@samitouri / QOS-React-1 / commits / 1e241f9d6c

Add renderToMarkup for Client Components (#30121)

Follow up to #30105. This supports `renderToMarkup` in a non-RSC environment (not the `react-server` condition). This is just a Fizz renderer but it errors at runtime when you use state, effects or event handlers that would require hydration - like the RSC version would. (Except RSC can give early errors too.) To do this I have to move the `react-html` builds to a new `markup` dimension out of the `dom-legacy` dimension so that we can configure this differently from `renderToString`/`renderToStaticMarkup`. Eventually that dimension can go away though if deprecated. That also helps us avoid dynamic configuration and we can just compile in the right configuration so the split helps anyway. One consideration is that if a compiler strips out useEffects or inlines initial state from useState, then it would not get called an the error wouldn't happen. Therefore to preserve semantics, a compiler would need to inject some call that can check the current renderer and whether it should throw. There is an argument that it could be useful to not error for these because it's possible to write components that works with SSR but are just optionally hydrated. However, there's also an argument that doing that silently is too easy to lead to mistakes and it's better to error - especially for the e-mail use case where you can't take it back but you can replay a queue that had failures. There are other ways to conditionally branch components intentionally. Besides if you want it to be silent you can still use renderToString (or better yet renderToReadableStream). The primary mechanism is the RSC environment and the client-environment is really the secondary one that's only there to support legacy environments. So this also ensures parity with the primary environment.

Sebastian Markbåge committed Jun 28, 2024 at 15:25 UTC 1e241f9d6c5f7d0e875b19a99c83cd6197fa62f7
22 files changed +826 -181
packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js
+14 -76
@@ -7,82 +7,20 @@
7 * @flow
8 */
9
10 -import type {Thenable} from 'shared/ReactTypes';
10 +export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
11 +export * from 'react-client/src/ReactClientConsoleConfigBrowser';
12
12 -export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js';
13 -export * from 'react-client/src/ReactClientConsoleConfigPlain';
14 -
15 -export type ModuleLoading = null;
16 -export type SSRModuleMap = null;
17 -export opaque type ServerManifest = null;
13 +export type Response = any;
14 +export opaque type ModuleLoading = mixed;
15 +export opaque type SSRModuleMap = mixed;
16 +export opaque type ServerManifest = mixed;
17 export opaque type ServerReferenceId = string;
19 -export opaque type ClientReferenceMetadata = null;
20 -export opaque type ClientReference<T> = null; // eslint-disable-line no-unused-vars
21 -
22 -export function prepareDestinationForModule(
23 - moduleLoading: ModuleLoading,
24 - nonce: ?string,
25 - metadata: ClientReferenceMetadata,
26 -) {
27 - throw new Error(
28 - 'renderToMarkup should not have emitted Client References. This is a bug in React.',
29 - );
30 -}
31 -
32 -export function resolveClientReference<T>(
33 - bundlerConfig: SSRModuleMap,
34 - metadata: ClientReferenceMetadata,
35 -): ClientReference<T> {
36 - throw new Error(
37 - 'renderToMarkup should not have emitted Client References. This is a bug in React.',
38 - );
39 -}
40 -
41 -export function resolveServerReference<T>(
42 - config: ServerManifest,
43 - id: ServerReferenceId,
44 -): ClientReference<T> {
45 - throw new Error(
46 - 'renderToMarkup should not have emitted Server References. This is a bug in React.',
47 - );
48 -}
49 -
50 -export function preloadModule<T>(
51 - metadata: ClientReference<T>,
52 -): null | Thenable<T> {
53 - return null;
54 -}
55 -
56 -export function requireModule<T>(metadata: ClientReference<T>): T {
57 - throw new Error(
58 - 'renderToMarkup should not have emitted Client References. This is a bug in React.',
59 - );
60 -}
61 -
18 +export opaque type ClientReferenceMetadata = mixed;
19 +export opaque type ClientReference<T> = mixed; // eslint-disable-line no-unused-vars
20 +export const resolveClientReference: any = null;
21 +export const resolveServerReference: any = null;
22 +export const preloadModule: any = null;
23 +export const requireModule: any = null;
24 +export const dispatchHint: any = null;
25 +export const prepareDestinationForModule: any = null;
26 export const usedWithSSR = true;
63 -
64 -type HintCode = string;
65 -type HintModel<T: HintCode> = null; // eslint-disable-line no-unused-vars
66 -
67 -export function dispatchHint<Code: HintCode>(
68 - code: Code,
69 - model: HintModel<Code>,
70 -): void {
71 - // Should never happen.
72 -}
73 -
74 -export function preinitModuleForSSR(
75 - href: string,
76 - nonce: ?string,
77 - crossOrigin: ?string,
78 -) {
79 - // Should never happen.
80 -}
81 -
82 -export function preinitScriptForSSR(
83 - href: string,
84 - nonce: ?string,
85 - crossOrigin: ?string,
86 -) {
87 - // Should never happen.
88 -}
packages/react-client/src/forks/ReactFlightClientConfig.markup.js new
+88
@@ -0,0 +1,88 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {Thenable} from 'shared/ReactTypes';
11 +
12 +export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js';
13 +export * from 'react-client/src/ReactClientConsoleConfigPlain';
14 +
15 +export type ModuleLoading = null;
16 +export type SSRModuleMap = null;
17 +export opaque type ServerManifest = null;
18 +export opaque type ServerReferenceId = string;
19 +export opaque type ClientReferenceMetadata = null;
20 +export opaque type ClientReference<T> = null; // eslint-disable-line no-unused-vars
21 +
22 +export function prepareDestinationForModule(
23 + moduleLoading: ModuleLoading,
24 + nonce: ?string,
25 + metadata: ClientReferenceMetadata,
26 +) {
27 + throw new Error(
28 + 'renderToMarkup should not have emitted Client References. This is a bug in React.',
29 + );
30 +}
31 +
32 +export function resolveClientReference<T>(
33 + bundlerConfig: SSRModuleMap,
34 + metadata: ClientReferenceMetadata,
35 +): ClientReference<T> {
36 + throw new Error(
37 + 'renderToMarkup should not have emitted Client References. This is a bug in React.',
38 + );
39 +}
40 +
41 +export function resolveServerReference<T>(
42 + config: ServerManifest,
43 + id: ServerReferenceId,
44 +): ClientReference<T> {
45 + throw new Error(
46 + 'renderToMarkup should not have emitted Server References. This is a bug in React.',
47 + );
48 +}
49 +
50 +export function preloadModule<T>(
51 + metadata: ClientReference<T>,
52 +): null | Thenable<T> {
53 + return null;
54 +}
55 +
56 +export function requireModule<T>(metadata: ClientReference<T>): T {
57 + throw new Error(
58 + 'renderToMarkup should not have emitted Client References. This is a bug in React.',
59 + );
60 +}
61 +
62 +export const usedWithSSR = true;
63 +
64 +type HintCode = string;
65 +type HintModel<T: HintCode> = null; // eslint-disable-line no-unused-vars
66 +
67 +export function dispatchHint<Code: HintCode>(
68 + code: Code,
69 + model: HintModel<Code>,
70 +): void {
71 + // Should never happen.
72 +}
73 +
74 +export function preinitModuleForSSR(
75 + href: string,
76 + nonce: ?string,
77 + crossOrigin: ?string,
78 +) {
79 + // Should never happen.
80 +}
81 +
82 +export function preinitScriptForSSR(
83 + href: string,
84 + nonce: ?string,
85 + crossOrigin: ?string,
86 +) {
87 + // Should never happen.
88 +}
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+2
@@ -108,6 +108,8 @@ export type HeadersDescriptor = {
108 // E.g. this can be used to distinguish legacy renderers from this modern one.
109 export const isPrimaryRenderer = true;
110
111 +export const supportsClientAPIs = true;
112 +
113 export type StreamingFormat = 0 | 1;
114 const ScriptStreamingFormat: StreamingFormat = 0;
115 const DataStreamingFormat: StreamingFormat = 1;
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+1
@@ -166,6 +166,7 @@ export {
166 resetResumableState,
167 completeResumableState,
168 emitEarlyPreloads,
169 + supportsClientAPIs,
170 } from './ReactFizzConfigDOM';
171
172 import escapeTextForBrowser from './escapeTextForBrowser';
packages/react-html/index.js
+9 -4
@@ -1,5 +1,10 @@
1 -'use strict';
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9
3 -throw new Error(
4 - 'react-html is not supported outside a React Server Components environment.',
5 -);
10 +export * from './src/ReactHTMLClient';
packages/react-html/npm/index.js
+5 -3
@@ -1,5 +1,7 @@
1 'use strict';
2
3 -throw new Error(
4 - 'react-html is not supported outside a React Server Components environment.'
5 -);
3 +if (process.env.NODE_ENV === 'production') {
4 + module.exports = require('./cjs/react-html.production.js');
5 +} else {
6 + module.exports = require('./cjs/react-html.development.js');
7 +}
packages/react-html/src/ReactFizzConfigHTML.js new
+188
@@ -0,0 +1,188 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {ReactNodeList} from 'shared/ReactTypes';
11 +
12 +import type {
13 + RenderState,
14 + ResumableState,
15 + HoistableState,
16 + FormatContext,
17 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
18 +
19 +import {pushStartInstance as pushStartInstanceImpl} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
20 +
21 +import type {
22 + Destination,
23 + Chunk,
24 + PrecomputedChunk,
25 +} from 'react-server/src/ReactServerStreamConfig';
26 +
27 +import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
28 +
29 +import {NotPending} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
30 +
31 +import hasOwnProperty from 'shared/hasOwnProperty';
32 +
33 +// Allow embedding inside another Fizz render.
34 +export const isPrimaryRenderer = false;
35 +
36 +// Disable Client Hooks
37 +export const supportsClientAPIs = false;
38 +
39 +import {
40 + stringToChunk,
41 + stringToPrecomputedChunk,
42 +} from 'react-server/src/ReactServerStreamConfig';
43 +
44 +// this chunk is empty on purpose because we do not want to emit the DOCTYPE
45 +// when markup is rendering HTML
46 +export const doctypeChunk: PrecomputedChunk = stringToPrecomputedChunk('');
47 +
48 +export type {
49 + RenderState,
50 + ResumableState,
51 + HoistableState,
52 + FormatContext,
53 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
54 +
55 +export {
56 + getChildFormatContext,
57 + makeId,
58 + pushEndInstance,
59 + pushStartCompletedSuspenseBoundary,
60 + pushEndCompletedSuspenseBoundary,
61 + pushFormStateMarkerIsMatching,
62 + pushFormStateMarkerIsNotMatching,
63 + writeStartSegment,
64 + writeEndSegment,
65 + writeCompletedSegmentInstruction,
66 + writeCompletedBoundaryInstruction,
67 + writeClientRenderBoundaryInstruction,
68 + writeStartPendingSuspenseBoundary,
69 + writeEndPendingSuspenseBoundary,
70 + writeHoistablesForBoundary,
71 + writePlaceholder,
72 + writeCompletedRoot,
73 + createRootFormatContext,
74 + createRenderState,
75 + createResumableState,
76 + createHoistableState,
77 + writePreamble,
78 + writeHoistables,
79 + writePostamble,
80 + hoistHoistables,
81 + resetResumableState,
82 + completeResumableState,
83 + emitEarlyPreloads,
84 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
85 +
86 +import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser';
87 +
88 +export function pushStartInstance(
89 + target: Array<Chunk | PrecomputedChunk>,
90 + type: string,
91 + props: Object,
92 + resumableState: ResumableState,
93 + renderState: RenderState,
94 + hoistableState: null | HoistableState,
95 + formatContext: FormatContext,
96 + textEmbedded: boolean,
97 + isFallback: boolean,
98 +): ReactNodeList {
99 + for (const propKey in props) {
100 + if (hasOwnProperty.call(props, propKey)) {
101 + const propValue = props[propKey];
102 + if (propKey === 'ref' && propValue != null) {
103 + throw new Error(
104 + 'Cannot pass ref in renderToMarkup because they will never be hydrated.',
105 + );
106 + }
107 + if (typeof propValue === 'function') {
108 + throw new Error(
109 + 'Cannot pass event handlers (' +
110 + propKey +
111 + ') in renderToMarkup because ' +
112 + 'the HTML will never be hydrated so they can never get called.',
113 + );
114 + }
115 + }
116 + }
117 +
118 + return pushStartInstanceImpl(
119 + target,
120 + type,
121 + props,
122 + resumableState,
123 + renderState,
124 + hoistableState,
125 + formatContext,
126 + textEmbedded,
127 + isFallback,
128 + );
129 +}
130 +
131 +export function pushTextInstance(
132 + target: Array<Chunk | PrecomputedChunk>,
133 + text: string,
134 + renderState: RenderState,
135 + textEmbedded: boolean,
136 +): boolean {
137 + // Markup doesn't need any termination.
138 + target.push(stringToChunk(escapeTextForBrowser(text)));
139 + return false;
140 +}
141 +
142 +export function pushSegmentFinale(
143 + target: Array<Chunk | PrecomputedChunk>,
144 + renderState: RenderState,
145 + lastPushedText: boolean,
146 + textEmbedded: boolean,
147 +): void {
148 + // Markup doesn't need any termination.
149 + return;
150 +}
151 +
152 +export function writeStartCompletedSuspenseBoundary(
153 + destination: Destination,
154 + renderState: RenderState,
155 +): boolean {
156 + // Markup doesn't have any instructions.
157 + return true;
158 +}
159 +export function writeStartClientRenderedSuspenseBoundary(
160 + destination: Destination,
161 + renderState: RenderState,
162 + // flushing these error arguments are not currently supported in this legacy streaming format.
163 + errorDigest: ?string,
164 + errorMessage: ?string,
165 + errorStack: ?string,
166 + errorComponentStack: ?string,
167 +): boolean {
168 + // Markup doesn't have any instructions.
169 + return true;
170 +}
171 +
172 +export function writeEndCompletedSuspenseBoundary(
173 + destination: Destination,
174 + renderState: RenderState,
175 +): boolean {
176 + // Markup doesn't have any instructions.
177 + return true;
178 +}
179 +export function writeEndClientRenderedSuspenseBoundary(
180 + destination: Destination,
181 + renderState: RenderState,
182 +): boolean {
183 + // Markup doesn't have any instructions.
184 + return true;
185 +}
186 +
187 +export type TransitionStatus = FormStatus;
188 +export const NotPendingTransition: TransitionStatus = NotPending;
packages/react-html/src/ReactHTMLClient.js new
+100
@@ -0,0 +1,100 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {ReactNodeList} from 'shared/ReactTypes';
11 +
12 +import ReactVersion from 'shared/ReactVersion';
13 +
14 +import {
15 + createRequest as createFizzRequest,
16 + startWork as startFizzWork,
17 + startFlowing as startFizzFlowing,
18 + abort as abortFizz,
19 +} from 'react-server/src/ReactFizzServer';
20 +
21 +import {
22 + createResumableState,
23 + createRenderState,
24 + createRootFormatContext,
25 +} from './ReactFizzConfigHTML';
26 +
27 +type MarkupOptions = {
28 + identifierPrefix?: string,
29 + signal?: AbortSignal,
30 +};
31 +
32 +export function renderToMarkup(
33 + children: ReactNodeList,
34 + options?: MarkupOptions,
35 +): Promise<string> {
36 + return new Promise((resolve, reject) => {
37 + let buffer = '';
38 + const fizzDestination = {
39 + push(chunk: string | null): boolean {
40 + if (chunk !== null) {
41 + buffer += chunk;
42 + } else {
43 + // null indicates that we finished
44 + resolve(buffer);
45 + }
46 + return true;
47 + },
48 + destroy(error: mixed) {
49 + reject(error);
50 + },
51 + };
52 + function onError(error: mixed) {
53 + // Any error rejects the promise, regardless of where it happened.
54 + // Unlike other React SSR we don't want to put Suspense boundaries into
55 + // client rendering mode because there's no client rendering here.
56 + reject(error);
57 + }
58 + const resumableState = createResumableState(
59 + options ? options.identifierPrefix : undefined,
60 + undefined,
61 + );
62 + const fizzRequest = createFizzRequest(
63 + children,
64 + resumableState,
65 + createRenderState(
66 + resumableState,
67 + undefined,
68 + undefined,
69 + undefined,
70 + undefined,
71 + undefined,
72 + ),
73 + createRootFormatContext(),
74 + Infinity,
75 + onError,
76 + undefined,
77 + undefined,
78 + undefined,
79 + undefined,
80 + undefined,
81 + undefined,
82 + );
83 + if (options && options.signal) {
84 + const signal = options.signal;
85 + if (signal.aborted) {
86 + abortFizz(fizzRequest, (signal: any).reason);
87 + } else {
88 + const listener = () => {
89 + abortFizz(fizzRequest, (signal: any).reason);
90 + signal.removeEventListener('abort', listener);
91 + };
92 + signal.addEventListener('abort', listener);
93 + }
94 + }
95 + startFizzWork(fizzRequest);
96 + startFizzFlowing(fizzRequest, fizzDestination);
97 + });
98 +}
99 +
100 +export {ReactVersion as version};
packages/react-html/src/ReactHTMLServer.js
+9 -2
@@ -37,7 +37,7 @@ import {
37 createResumableState,
38 createRenderState,
39 createRootFormatContext,
40 -} from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy';
40 +} from './ReactFizzConfigHTML';
41
42 type ReactMarkupNodeList =
43 // This is the intersection of ReactNodeList and ReactClientValue minus
@@ -143,7 +143,14 @@ export function renderToMarkup(
143 // $FlowFixMe: Thenables as children are supported.
144 root,
145 resumableState,
146 - createRenderState(resumableState, true),
146 + createRenderState(
147 + resumableState,
148 + undefined,
149 + undefined,
150 + undefined,
151 + undefined,
152 + undefined,
153 + ),
154 createRootFormatContext(),
155 Infinity,
156 onError,
packages/react-html/src/__tests__/ReactHTMLClient-test.js new
+141
@@ -0,0 +1,141 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +let React;
13 +let ReactHTML;
14 +
15 +describe('ReactHTML', () => {
16 + beforeEach(() => {
17 + jest.resetModules();
18 + React = require('react');
19 + ReactHTML = require('react-html');
20 + });
21 +
22 + it('should be able to render a simple component', async () => {
23 + function Component() {
24 + return <div>hello world</div>;
25 + }
26 +
27 + const html = await ReactHTML.renderToMarkup(<Component />);
28 + expect(html).toBe('<div>hello world</div>');
29 + });
30 +
31 + it('should error on useState', async () => {
32 + function Component() {
33 + const [state] = React.useState('hello');
34 + return <div>{state}</div>;
35 + }
36 +
37 + await expect(async () => {
38 + await ReactHTML.renderToMarkup(<Component />);
39 + }).rejects.toThrow();
40 + });
41 +
42 + it('should error on refs passed to host components', async () => {
43 + function Component() {
44 + const ref = React.createRef();
45 + return <div ref={ref} />;
46 + }
47 +
48 + await expect(async () => {
49 + await ReactHTML.renderToMarkup(<Component />);
50 + }).rejects.toThrow();
51 + });
52 +
53 + it('should error on callbacks passed to event handlers', async () => {
54 + function Component() {
55 + function onClick() {
56 + // This won't be able to be called.
57 + }
58 + return <div onClick={onClick} />;
59 + }
60 +
61 + await expect(async () => {
62 + await ReactHTML.renderToMarkup(<Component />);
63 + }).rejects.toThrow();
64 + });
65 +
66 + it('supports the useId Hook', async () => {
67 + function Component() {
68 + const firstNameId = React.useId();
69 + const lastNameId = React.useId();
70 + return React.createElement(
71 + 'div',
72 + null,
73 + React.createElement(
74 + 'h2',
75 + {
76 + id: firstNameId,
77 + },
78 + 'First',
79 + ),
80 + React.createElement(
81 + 'p',
82 + {
83 + 'aria-labelledby': firstNameId,
84 + },
85 + 'Sebastian',
86 + ),
87 + React.createElement(
88 + 'h2',
89 + {
90 + id: lastNameId,
91 + },
92 + 'Last',
93 + ),
94 + React.createElement(
95 + 'p',
96 + {
97 + 'aria-labelledby': lastNameId,
98 + },
99 + 'Smith',
100 + ),
101 + );
102 + }
103 +
104 + const html = await ReactHTML.renderToMarkup(<Component />);
105 + const container = document.createElement('div');
106 + container.innerHTML = html;
107 +
108 + expect(container.getElementsByTagName('h2')[0].id).toBe(
109 + container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'),
110 + );
111 + expect(container.getElementsByTagName('h2')[1].id).toBe(
112 + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'),
113 + );
114 +
115 + // It's not the same id between them.
116 + expect(container.getElementsByTagName('h2')[0].id).not.toBe(
117 + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'),
118 + );
119 + });
120 +
121 + // @gate disableClientCache
122 + it('does NOT support cache yet because it is a client component', async () => {
123 + let counter = 0;
124 + const getCount = React.cache(() => {
125 + return counter++;
126 + });
127 + function Component() {
128 + const a = getCount();
129 + const b = getCount();
130 + return (
131 + <div>
132 + {a}
133 + {b}
134 + </div>
135 + );
136 + }
137 +
138 + const html = await ReactHTML.renderToMarkup(<Component />);
139 + expect(html).toBe('<div>01</div>');
140 + });
141 +});
packages/react-html/src/__tests__/ReactHTMLServer-test.js
+38
@@ -38,6 +38,44 @@ describe('ReactHTML', () => {
38 expect(html).toBe('<div>hello world</div>');
39 });
40
41 + it('should error on useState', async () => {
42 + function Component() {
43 + const [state] = React.useState('hello');
44 + // We can't use JSX because that's client-JSX in our tests.
45 + return React.createElement('div', null, state);
46 + }
47 +
48 + await expect(async () => {
49 + await ReactHTML.renderToMarkup(React.createElement(Component));
50 + }).rejects.toThrow();
51 + });
52 +
53 + it('should error on refs passed to host components', async () => {
54 + function Component() {
55 + const ref = React.createRef();
56 + // We can't use JSX because that's client-JSX in our tests.
57 + return React.createElement('div', {ref});
58 + }
59 +
60 + await expect(async () => {
61 + await ReactHTML.renderToMarkup(React.createElement(Component));
62 + }).rejects.toThrow();
63 + });
64 +
65 + it('should error on callbacks passed to event handlers', async () => {
66 + function Component() {
67 + function onClick() {
68 + // This won't be able to be called.
69 + }
70 + // We can't use JSX because that's client-JSX in our tests.
71 + return React.createElement('div', {onClick});
72 + }
73 +
74 + await expect(async () => {
75 + await ReactHTML.renderToMarkup(React.createElement(Component));
76 + }).rejects.toThrow();
77 + });
78 +
79 it('supports the useId Hook', async () => {
80 function Component() {
81 const firstNameId = React.useId();
packages/react-reconciler/src/forks/ReactFiberConfig.markup.js new
+16
@@ -0,0 +1,16 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +// Re-exported just because we always type check react-reconciler even in
11 +// dimensions where it's not used.
12 +export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM';
13 +export * from 'react-client/src/ReactClientConsoleConfigBrowser';
14 +
15 +// eslint-disable-next-line react-internal/prod-error-codes
16 +throw new Error('Fiber is not used in react-html');
packages/react-server/src/ReactFizzHooks.js
+55 -24
@@ -31,7 +31,11 @@ import {
31 readPreviousThenable,
32 } from './ReactFizzThenable';
33
34 -import {makeId, NotPendingTransition} from './ReactFizzConfig';
34 +import {
35 + makeId,
36 + NotPendingTransition,
37 + supportsClientAPIs,
38 +} from './ReactFizzConfig';
39 import {createFastHash} from './ReactServerStreamConfig';
40
41 import {
@@ -803,29 +807,56 @@ function useMemoCache(size: number): Array<any> {
807
808 function noop(): void {}
809
806 -export const HooksDispatcher: Dispatcher = {
807 - readContext,
808 - use,
809 - useContext,
810 - useMemo,
811 - useReducer,
812 - useRef,
813 - useState,
814 - useInsertionEffect: noop,
815 - useLayoutEffect: noop,
816 - useCallback,
817 - // useImperativeHandle is not run in the server environment
818 - useImperativeHandle: noop,
819 - // Effects are not run in the server environment.
820 - useEffect: noop,
821 - // Debugging effect
822 - useDebugValue: noop,
823 - useDeferredValue,
824 - useTransition,
825 - useId,
826 - // Subscriptions are not setup in a server environment.
827 - useSyncExternalStore,
828 -};
810 +function clientHookNotSupported() {
811 + throw new Error(
812 + 'Cannot use state or effect Hooks in renderToMarkup because ' +
813 + 'this component will never be hydrated.',
814 + );
815 +}
816 +
817 +export const HooksDispatcher: Dispatcher = supportsClientAPIs
818 + ? {
819 + readContext,
820 + use,
821 + useContext,
822 + useMemo,
823 + useReducer,
824 + useRef,
825 + useState,
826 + useInsertionEffect: noop,
827 + useLayoutEffect: noop,
828 + useCallback,
829 + // useImperativeHandle is not run in the server environment
830 + useImperativeHandle: noop,
831 + // Effects are not run in the server environment.
832 + useEffect: noop,
833 + // Debugging effect
834 + useDebugValue: noop,
835 + useDeferredValue,
836 + useTransition,
837 + useId,
838 + // Subscriptions are not setup in a server environment.
839 + useSyncExternalStore,
840 + }
841 + : {
842 + readContext,
843 + use,
844 + useContext,
845 + useMemo,
846 + useReducer: clientHookNotSupported,
847 + useRef: clientHookNotSupported,
848 + useState: clientHookNotSupported,
849 + useInsertionEffect: clientHookNotSupported,
850 + useLayoutEffect: clientHookNotSupported,
851 + useCallback,
852 + useImperativeHandle: clientHookNotSupported,
853 + useEffect: clientHookNotSupported,
854 + useDebugValue: noop,
855 + useDeferredValue: clientHookNotSupported,
856 + useTransition: clientHookNotSupported,
857 + useId,
858 + useSyncExternalStore: clientHookNotSupported,
859 + };
860
861 if (enableCache) {
862 HooksDispatcher.useCacheRefresh = useCacheRefresh;
packages/react-server/src/forks/ReactFizzConfig.custom.js
+2
@@ -37,6 +37,8 @@ export type {TransitionStatus};
37
38 export const isPrimaryRenderer = false;
39
40 +export const supportsClientAPIs = true;
41 +
42 export const supportsRequestStorage = false;
43 export const requestStorage: AsyncLocalStorage<Request | void> = (null: any);
44
packages/react-server/src/forks/ReactFizzConfig.markup.js new
+16
@@ -0,0 +1,16 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +import type {Request} from 'react-server/src/ReactFizzServer';
10 +
11 +export * from 'react-html/src/ReactFizzConfigHTML.js';
12 +
13 +export * from 'react-client/src/ReactClientConsoleConfigPlain';
14 +
15 +export const supportsRequestStorage = false;
16 +export const requestStorage: AsyncLocalStorage<Request | void> = (null: any);
packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js
+9 -68
@@ -9,15 +9,15 @@
9
10 import type {Request} from 'react-server/src/ReactFlightServer';
11 import type {ReactComponentInfo} from 'shared/ReactTypes';
12 -import type {ReactClientValue} from 'react-server/src/ReactFlightServer';
12
14 -export type HintCode = string;
15 -export type HintModel<T: HintCode> = null; // eslint-disable-line no-unused-vars
16 -export type Hints = null;
13 +export * from '../ReactFlightServerConfigBundlerCustom';
14
18 -export function createHints(): Hints {
19 - return null;
20 -}
15 +export * from '../ReactFlightServerConfigDebugNoop';
16 +
17 +export type Hints = any;
18 +export type HintCode = any;
19 +// eslint-disable-next-line no-unused-vars
20 +export type HintModel<T: any> = any;
21
22 export const supportsRequestStorage = false;
23 export const requestStorage: AsyncLocalStorage<Request | void> = (null: any);
@@ -26,65 +26,6 @@ export const supportsComponentStorage = false;
26 export const componentStorage: AsyncLocalStorage<ReactComponentInfo | void> =
27 (null: any);
28
29 -export * from '../ReactFlightServerConfigDebugNoop';
30 -
31 -export type ClientManifest = null;
32 -export opaque type ClientReference<T> = null; // eslint-disable-line no-unused-vars
33 -export opaque type ServerReference<T> = null; // eslint-disable-line no-unused-vars
34 -export opaque type ClientReferenceMetadata: any = null;
35 -export opaque type ServerReferenceId: string = string;
36 -export opaque type ClientReferenceKey: any = string;
37 -
38 -const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference');
39 -const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference');
40 -
41 -export function isClientReference(reference: Object): boolean {
42 - return reference.$$typeof === CLIENT_REFERENCE_TAG;
43 -}
44 -
45 -export function isServerReference(reference: Object): boolean {
46 - return reference.$$typeof === SERVER_REFERENCE_TAG;
47 -}
48 -
49 -export function getClientReferenceKey(
50 - reference: ClientReference<any>,
51 -): ClientReferenceKey {
52 - throw new Error(
53 - 'Attempted to render a Client Component from renderToMarkup. ' +
54 - 'This is not supported since it will never hydrate. ' +
55 - 'Only render Server Components with renderToMarkup.',
56 - );
57 -}
58 -
59 -export function resolveClientReferenceMetadata<T>(
60 - config: ClientManifest,
61 - clientReference: ClientReference<T>,
62 -): ClientReferenceMetadata {
63 - throw new Error(
64 - 'Attempted to render a Client Component from renderToMarkup. ' +
65 - 'This is not supported since it will never hydrate. ' +
66 - 'Only render Server Components with renderToMarkup.',
67 - );
68 -}
69 -
70 -export function getServerReferenceId<T>(
71 - config: ClientManifest,
72 - serverReference: ServerReference<T>,
73 -): ServerReferenceId {
74 - throw new Error(
75 - 'Attempted to render a Server Action from renderToMarkup. ' +
76 - 'This is not supported since it varies by version of the app. ' +
77 - 'Use a fixed URL for any forms instead.',
78 - );
79 -}
80 -
81 -export function getServerReferenceBoundArguments<T>(
82 - config: ClientManifest,
83 - serverReference: ServerReference<T>,
84 -): null | Array<ReactClientValue> {
85 - throw new Error(
86 - 'Attempted to render a Server Action from renderToMarkup. ' +
87 - 'This is not supported since it varies by version of the app. ' +
88 - 'Use a fixed URL for any forms instead.',
89 - );
29 +export function createHints(): any {
30 + return null;
31 }
packages/react-server/src/forks/ReactFlightServerConfig.markup.js new
+90
@@ -0,0 +1,90 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {Request} from 'react-server/src/ReactFlightServer';
11 +import type {ReactComponentInfo} from 'shared/ReactTypes';
12 +import type {ReactClientValue} from 'react-server/src/ReactFlightServer';
13 +
14 +export type HintCode = string;
15 +export type HintModel<T: HintCode> = null; // eslint-disable-line no-unused-vars
16 +export type Hints = null;
17 +
18 +export function createHints(): Hints {
19 + return null;
20 +}
21 +
22 +export const supportsRequestStorage = false;
23 +export const requestStorage: AsyncLocalStorage<Request | void> = (null: any);
24 +
25 +export const supportsComponentStorage = false;
26 +export const componentStorage: AsyncLocalStorage<ReactComponentInfo | void> =
27 + (null: any);
28 +
29 +export * from '../ReactFlightServerConfigDebugNoop';
30 +
31 +export type ClientManifest = null;
32 +export opaque type ClientReference<T> = null; // eslint-disable-line no-unused-vars
33 +export opaque type ServerReference<T> = null; // eslint-disable-line no-unused-vars
34 +export opaque type ClientReferenceMetadata: any = null;
35 +export opaque type ServerReferenceId: string = string;
36 +export opaque type ClientReferenceKey: any = string;
37 +
38 +const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference');
39 +const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference');
40 +
41 +export function isClientReference(reference: Object): boolean {
42 + return reference.$$typeof === CLIENT_REFERENCE_TAG;
43 +}
44 +
45 +export function isServerReference(reference: Object): boolean {
46 + return reference.$$typeof === SERVER_REFERENCE_TAG;
47 +}
48 +
49 +export function getClientReferenceKey(
50 + reference: ClientReference<any>,
51 +): ClientReferenceKey {
52 + throw new Error(
53 + 'Attempted to render a Client Component from renderToMarkup. ' +
54 + 'This is not supported since it will never hydrate. ' +
55 + 'Only render Server Components with renderToMarkup.',
56 + );
57 +}
58 +
59 +export function resolveClientReferenceMetadata<T>(
60 + config: ClientManifest,
61 + clientReference: ClientReference<T>,
62 +): ClientReferenceMetadata {
63 + throw new Error(
64 + 'Attempted to render a Client Component from renderToMarkup. ' +
65 + 'This is not supported since it will never hydrate. ' +
66 + 'Only render Server Components with renderToMarkup.',
67 + );
68 +}
69 +
70 +export function getServerReferenceId<T>(
71 + config: ClientManifest,
72 + serverReference: ServerReference<T>,
73 +): ServerReferenceId {
74 + throw new Error(
75 + 'Attempted to render a Server Action from renderToMarkup. ' +
76 + 'This is not supported since it varies by version of the app. ' +
77 + 'Use a fixed URL for any forms instead.',
78 + );
79 +}
80 +
81 +export function getServerReferenceBoundArguments<T>(
82 + config: ClientManifest,
83 + serverReference: ServerReference<T>,
84 +): null | Array<ReactClientValue> {
85 + throw new Error(
86 + 'Attempted to render a Server Action from renderToMarkup. ' +
87 + 'This is not supported since it varies by version of the app. ' +
88 + 'Use a fixed URL for any forms instead.',
89 + );
90 +}
packages/react-server/src/forks/ReactServerStreamConfig.markup.js new
+10
@@ -0,0 +1,10 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +export * from 'react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig';
scripts/error-codes/codes.json
+4 -1
@@ -520,5 +520,8 @@
520 "532": "Attempted to render a Client Component from renderToMarkup. This is not supported since it will never hydrate. Only render Server Components with renderToMarkup.",
521 "533": "Attempted to render a Server Action from renderToMarkup. This is not supported since it varies by version of the app. Use a fixed URL for any forms instead.",
522 "534": "renderToMarkup should not have emitted Client References. This is a bug in React.",
523 - "535": "renderToMarkup should not have emitted Server References. This is a bug in React."
523 + "535": "renderToMarkup should not have emitted Server References. This is a bug in React.",
524 + "536": "Cannot pass ref in renderToMarkup because they will never be hydrated.",
525 + "537": "Cannot pass event handlers (%s) in renderToMarkup because the HTML will never be hydrated so they can never get called.",
526 + "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated."
527 }
scripts/rollup/bundles.js
+13 -1
@@ -363,7 +363,7 @@ const bundles = [
363 externals: [],
364 },
365
366 - /******* React HTML *******/
366 + /******* React HTML RSC *******/
367 {
368 bundleTypes: [NODE_DEV, NODE_PROD],
369 moduleType: RENDERER,
@@ -376,6 +376,18 @@ const bundles = [
376 externals: ['react'],
377 },
378
379 + /******* React HTML Client *******/
380 + {
381 + bundleTypes: [NODE_DEV, NODE_PROD],
382 + moduleType: RENDERER,
383 + entry: 'react-html/src/ReactHTMLClient.js',
384 + name: 'react-html',
385 + global: 'ReactHTML',
386 + minifyWithProdErrorCodes: false,
387 + wrapWithModuleBoundaries: false,
388 + externals: ['react'],
389 + },
390 +
391 /******* React Server DOM Webpack Server *******/
392 {
393 bundleTypes: [NODE_DEV, NODE_PROD],
scripts/rollup/forks.js
+1
@@ -100,6 +100,7 @@ const forks = Object.freeze({
100 entry === 'react-dom/src/ReactDOMFB.js' ||
101 entry === 'react-dom/src/ReactDOMTestingFB.js' ||
102 entry === 'react-dom/src/ReactDOMServer.js' ||
103 + entry === 'react-html/src/ReactHTMLClient.js' ||
104 entry === 'react-html/src/ReactHTMLServer.js'
105 ) {
106 if (
scripts/shared/inlinedHostConfigs.js
+15 -2
@@ -428,16 +428,29 @@ module.exports = [
428 entryPoints: [
429 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser
430 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node
431 - 'react-html/src/ReactHTMLServer.js',
431 ],
432 paths: [
433 'react-dom',
434 'react-dom/src/ReactDOMReactServer.js',
435 'react-dom-bindings',
437 - 'react-server-dom-webpack',
436 'react-dom/src/server/ReactDOMLegacyServerImpl.js', // not an entrypoint, but only usable in *Browser and *Node files
437 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser
438 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node
439 + 'shared/ReactDOMSharedInternals',
440 + ],
441 + isFlowTyped: true,
442 + isServerSupported: true,
443 + },
444 + {
445 + shortName: 'markup',
446 + entryPoints: [
447 + 'react-html/src/ReactHTMLClient.js', // react-html
448 + 'react-html/src/ReactHTMLServer.js', // react-html/react-html.react-server
449 + ],
450 + paths: [
451 + 'react-dom',
452 + 'react-dom/src/ReactDOMReactServer.js',
453 + 'react-dom-bindings',
454 'react-html',
455 'shared/ReactDOMSharedInternals',
456 ],