@samitouri / QOS-React-2 / commits / fa11bd6ecc

[ServerRenderer] Add option to send instructions as data attributes (#25437)

### Changes made: - Running with enableFizzExternalRuntime (feature flag) and unstable_externalRuntimeSrc (param) will generate html nodes with data attributes that encode Fizz instructions. ``` <div hidden data-rxi="" data-bid="param0" data-dgst="param1" ></div> ``` - Added an external runtime browser script `ReactDOMServerExternalRuntime`, which processes and removes these nodes - This runtime should be passed as to renderInto[...] via `unstable_externalRuntimeSrc` - Since this runtime is render blocking (for all streamed suspense boundaries and segments), we want this to reach the client as early as possible. By default, Fizz will send this script at the end of the shell when it detects dynamic content (e.g. suspenseful pending tasks), but it can be sent even earlier by calling `preinit(...)` inside a component. - The current implementation relies on Float to dedupe sending `unstable_externalRuntimeSrc`, so `enableFizzExternalRuntime` is only valid when `enableFloat` is also set.

mofeiZ committed Nov 30, 2022 at 13:22 UTC fa11bd6ecca57d8ddbc728df881f0e165225c04f
12 files changed +845 -151
packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
+12 -2
@@ -275,7 +275,7 @@ type PreinitOptions = {
275 crossOrigin?: string,
276 integrity?: string,
277 };
278 -function preinit(href: string, options: PreinitOptions) {
278 +function preinit(href: string, options: PreinitOptions): void {
279 if (!currentResources) {
280 // While we expect that preinit calls are primarily going to be observed
281 // during render because effects and events don't run on the server it is
@@ -285,7 +285,17 @@ function preinit(href: string, options: PreinitOptions) {
285 // simply return and do not warn.
286 return;
287 }
288 - const resources = currentResources;
288 + preinitImpl(currentResources, href, options);
289 +}
290 +
291 +// On the server, preinit may be called outside of render when sending an
292 +// external SSR runtime as part of the initial resources payload. Since this
293 +// is an internal React call, we do not need to use the resources stack.
294 +export function preinitImpl(
295 + resources: Resources,
296 + href: string,
297 + options: PreinitOptions,
298 +): void {
299 if (__DEV__) {
300 validatePreinitArguments(href, options);
301 }
packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js
+92 -10
@@ -3,6 +3,7 @@
3 * clients. Therefore, it should be fast and not have many external dependencies.
4 * @flow
5 */
6 +/* eslint-disable dot-notation */
7
8 // Imports are resolved statically by the closure compiler in release bundles
9 // and by rollup in jest unit tests
@@ -13,13 +14,94 @@ import {
14 completeSegment,
15 } from './fizz-instruction-set/ReactDOMFizzInstructionSet';
16
16 -// Intentionally does nothing. Implementation will be added in future PR.
17 -// eslint-disable-next-line no-unused-vars
18 -const observer = new MutationObserver(mutations => {
19 - // These are only called so I can check what the module output looks like. The
20 - // code is unreachable.
21 - clientRenderBoundary();
22 - completeBoundaryWithStyles();
23 - completeBoundary();
24 - completeSegment();
25 -});
17 +if (!window.$RC) {
18 + // TODO: Eventually remove, we currently need to set these globals for
19 + // compatibility with ReactDOMFizzInstructionSet
20 + window.$RC = completeBoundary;
21 + window.$RM = new Map();
22 +}
23 +
24 +if (document.readyState === 'loading') {
25 + if (document.body != null) {
26 + installFizzInstrObserver(document.body);
27 + } else {
28 + // body may not exist yet if the fizz runtime is sent in <head>
29 + // (e.g. as a preinit resource)
30 + const domBodyObserver = new MutationObserver(() => {
31 + // We expect the body node to be stable once parsed / created
32 + if (document.body) {
33 + if (document.readyState === 'loading') {
34 + installFizzInstrObserver(document.body);
35 + }
36 + handleExistingNodes();
37 + domBodyObserver.disconnect();
38 + }
39 + });
40 + // documentElement must already exist at this point
41 + // $FlowFixMe[incompatible-call]
42 + domBodyObserver.observe(document.documentElement, {childList: true});
43 + }
44 +}
45 +
46 +handleExistingNodes();
47 +
48 +function handleExistingNodes() {
49 + const existingNodes = document.getElementsByTagName('template');
50 + for (let i = 0; i < existingNodes.length; i++) {
51 + handleNode(existingNodes[i]);
52 + }
53 +}
54 +
55 +function installFizzInstrObserver(target /*: Node */) {
56 + const fizzInstrObserver = new MutationObserver(mutations => {
57 + for (let i = 0; i < mutations.length; i++) {
58 + const addedNodes = mutations[i].addedNodes;
59 + for (let j = 0; j < addedNodes.length; j++) {
60 + if (addedNodes.item(j).parentNode) {
61 + handleNode(addedNodes.item(j));
62 + }
63 + }
64 + }
65 + });
66 + // We assume that instruction data nodes are eventually appended to the
67 + // body, even if Fizz is streaming to a shell / subtree.
68 + fizzInstrObserver.observe(target, {
69 + childList: true,
70 + });
71 + window.addEventListener('DOMContentLoaded', () => {
72 + fizzInstrObserver.disconnect();
73 + });
74 +}
75 +
76 +function handleNode(node_ /*: Node */) {
77 + // $FlowFixMe[incompatible-cast]
78 + if (node_.nodeType !== 1 || !(node_ /*: HTMLElement*/).dataset) {
79 + return;
80 + }
81 + // $FlowFixMe[incompatible-cast]
82 + const node = (node_ /*: HTMLElement*/);
83 + const dataset = node.dataset;
84 + if (dataset['rxi'] != null) {
85 + clientRenderBoundary(
86 + dataset['bid'],
87 + dataset['dgst'],
88 + dataset['msg'],
89 + dataset['stck'],
90 + );
91 + node.remove();
92 + } else if (dataset['rri'] != null) {
93 + // Convert styles here, since its type is Array<Array<string>>
94 + completeBoundaryWithStyles(
95 + dataset['bid'],
96 + dataset['sid'],
97 + JSON.parse(dataset['sty']),
98 + );
99 + node.remove();
100 + } else if (dataset['rci'] != null) {
101 + completeBoundary(dataset['bid'], dataset['sid']);
102 + node.remove();
103 + } else if (dataset['rsi'] != null) {
104 + completeSegment(dataset['sid'], dataset['pid']);
105 + node.remove();
106 + }
107 +}
packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
+436 -88
@@ -63,6 +63,7 @@ import sanitizeURL from '../shared/sanitizeURL';
63 import isArray from 'shared/isArray';
64
65 import {
66 + preinitImpl,
67 prepareToRenderResources,
68 finishRenderingResources,
69 resourcesFromElement,
@@ -105,22 +106,33 @@ export function cleanupAfterRender(previousDispatcher: mixed) {
106 // E.g. this can be used to distinguish legacy renderers from this modern one.
107 export const isPrimaryRenderer = true;
108
109 +export type StreamingFormat = 0 | 1;
110 +const ScriptStreamingFormat: StreamingFormat = 0;
111 +const DataStreamingFormat: StreamingFormat = 1;
112 +
113 // Per response, global state that is not contextual to the rendering subtree.
114 export type ResponseState = {
115 bootstrapChunks: Array<Chunk | PrecomputedChunk>,
111 - startInlineScript: PrecomputedChunk,
116 placeholderPrefix: PrecomputedChunk,
117 segmentPrefix: PrecomputedChunk,
118 boundaryPrefix: string,
119 idPrefix: string,
120 nextSuspenseID: number,
121 + streamingFormat: StreamingFormat,
122 + // state for script streaming format, unused if using external runtime / data
123 + startInlineScript: PrecomputedChunk,
124 sentCompleteSegmentFunction: boolean,
125 sentCompleteBoundaryFunction: boolean,
126 sentClientRenderFunction: boolean,
120 - sentStyleInsertionFunction: boolean, // We allow the legacy renderer to extend this object.
127 + sentStyleInsertionFunction: boolean,
128 + // state for data streaming format
129 + externalRuntimeConfig: BootstrapScriptDescriptor | null,
130 + // We allow the legacy renderer to extend this object.
131 ...
132 };
133
134 +const dataElementQuotedEnd = stringToPrecomputedChunk('"></template>');
135 +
136 const startInlineScript = stringToPrecomputedChunk('<script>');
137 const endInlineScript = stringToPrecomputedChunk('</script>');
138
@@ -154,6 +166,8 @@ export type BootstrapScriptDescriptor = {
166 integrity?: string,
167 };
168 // Allows us to keep track of what we've already written so we can refer back to it.
169 +// if passed externalRuntimeConfig and the enableFizzExternalRuntime feature flag
170 +// is set, the server will send instructions via data attributes (instead of inline scripts)
171 export function createResponseState(
172 identifierPrefix: string | void,
173 nonce: string | void,
@@ -170,6 +184,8 @@ export function createResponseState(
184 '<script nonce="' + escapeTextForBrowser(nonce) + '">',
185 );
186 const bootstrapChunks = [];
187 + let externalRuntimeDesc = null;
188 + let streamingFormat = ScriptStreamingFormat;
189 if (bootstrapScriptContent !== undefined) {
190 bootstrapChunks.push(
191 inlineScriptWithNonce,
@@ -178,26 +194,21 @@ export function createResponseState(
194 );
195 }
196 if (enableFizzExternalRuntime) {
181 - if (externalRuntimeConfig !== undefined) {
182 - const src =
183 - typeof externalRuntimeConfig === 'string'
184 - ? externalRuntimeConfig
185 - : externalRuntimeConfig.src;
186 - const integrity =
187 - typeof externalRuntimeConfig === 'string'
188 - ? undefined
189 - : externalRuntimeConfig.integrity;
190 - bootstrapChunks.push(
191 - startScriptSrc,
192 - stringToChunk(escapeTextForBrowser(src)),
197 + if (!enableFloat) {
198 + throw new Error(
199 + 'enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle.',
200 );
194 - if (integrity) {
195 - bootstrapChunks.push(
196 - scriptIntegirty,
197 - stringToChunk(escapeTextForBrowser(integrity)),
198 - );
201 + }
202 + if (externalRuntimeConfig !== undefined) {
203 + streamingFormat = DataStreamingFormat;
204 + if (typeof externalRuntimeConfig === 'string') {
205 + externalRuntimeDesc = {
206 + src: externalRuntimeConfig,
207 + integrity: undefined,
208 + };
209 + } else {
210 + externalRuntimeDesc = externalRuntimeConfig;
211 }
200 - bootstrapChunks.push(endAsyncScript);
212 }
213 }
214 if (bootstrapScripts !== undefined) {
@@ -242,16 +253,18 @@ export function createResponseState(
253 }
254 return {
255 bootstrapChunks: bootstrapChunks,
245 - startInlineScript: inlineScriptWithNonce,
256 placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
257 segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
258 boundaryPrefix: idPrefix + 'B:',
259 idPrefix: idPrefix,
260 nextSuspenseID: 0,
261 + streamingFormat,
262 + startInlineScript: inlineScriptWithNonce,
263 sentCompleteSegmentFunction: false,
264 sentCompleteBoundaryFunction: false,
265 sentClientRenderFunction: false,
266 sentStyleInsertionFunction: false,
267 + externalRuntimeConfig: externalRuntimeDesc,
268 };
269 }
270
@@ -2384,29 +2397,53 @@ const completeSegmentScript1Full = stringToPrecomputedChunk(
2397 );
2398 const completeSegmentScript1Partial = stringToPrecomputedChunk('$RS("');
2399 const completeSegmentScript2 = stringToPrecomputedChunk('","');
2387 -const completeSegmentScript3 = stringToPrecomputedChunk('")</script>');
2400 +const completeSegmentScriptEnd = stringToPrecomputedChunk('")</script>');
2401 +
2402 +const completeSegmentData1 = stringToPrecomputedChunk(
2403 + '<template data-rsi="" data-sid="',
2404 +);
2405 +const completeSegmentData2 = stringToPrecomputedChunk('" data-pid="');
2406 +const completeSegmentDataEnd = dataElementQuotedEnd;
2407
2408 export function writeCompletedSegmentInstruction(
2409 destination: Destination,
2410 responseState: ResponseState,
2411 contentSegmentID: number,
2412 ): boolean {
2394 - writeChunk(destination, responseState.startInlineScript);
2395 - if (!responseState.sentCompleteSegmentFunction) {
2396 - // The first time we write this, we'll need to include the full implementation.
2397 - responseState.sentCompleteSegmentFunction = true;
2398 - writeChunk(destination, completeSegmentScript1Full);
2413 + const scriptFormat =
2414 + !enableFizzExternalRuntime ||
2415 + responseState.streamingFormat === ScriptStreamingFormat;
2416 + if (scriptFormat) {
2417 + writeChunk(destination, responseState.startInlineScript);
2418 + if (!responseState.sentCompleteSegmentFunction) {
2419 + // The first time we write this, we'll need to include the full implementation.
2420 + responseState.sentCompleteSegmentFunction = true;
2421 + writeChunk(destination, completeSegmentScript1Full);
2422 + } else {
2423 + // Future calls can just reuse the same function.
2424 + writeChunk(destination, completeSegmentScript1Partial);
2425 + }
2426 } else {
2400 - // Future calls can just reuse the same function.
2401 - writeChunk(destination, completeSegmentScript1Partial);
2427 + writeChunk(destination, completeSegmentData1);
2428 }
2429 +
2430 + // Write function arguments, which are string literals
2431 writeChunk(destination, responseState.segmentPrefix);
2432 const formattedID = stringToChunk(contentSegmentID.toString(16));
2433 writeChunk(destination, formattedID);
2406 - writeChunk(destination, completeSegmentScript2);
2434 + if (scriptFormat) {
2435 + writeChunk(destination, completeSegmentScript2);
2436 + } else {
2437 + writeChunk(destination, completeSegmentData2);
2438 + }
2439 writeChunk(destination, responseState.placeholderPrefix);
2440 writeChunk(destination, formattedID);
2409 - return writeChunkAndReturn(destination, completeSegmentScript3);
2441 +
2442 + if (scriptFormat) {
2443 + return writeChunkAndReturn(destination, completeSegmentScriptEnd);
2444 + } else {
2445 + return writeChunkAndReturn(destination, completeSegmentDataEnd);
2446 + }
2447 }
2448
2449 const completeBoundaryScript1Full = stringToPrecomputedChunk(
@@ -2424,9 +2461,19 @@ const completeBoundaryWithStylesScript1Partial = stringToPrecomputedChunk(
2461 '$RR("',
2462 );
2463 const completeBoundaryScript2 = stringToPrecomputedChunk('","');
2427 -const completeBoundaryScript2a = stringToPrecomputedChunk('",');
2428 -const completeBoundaryScript3 = stringToPrecomputedChunk('"');
2429 -const completeBoundaryScript4 = stringToPrecomputedChunk(')</script>');
2464 +const completeBoundaryScript3a = stringToPrecomputedChunk('",');
2465 +const completeBoundaryScript3b = stringToPrecomputedChunk('"');
2466 +const completeBoundaryScriptEnd = stringToPrecomputedChunk(')</script>');
2467 +
2468 +const completeBoundaryData1 = stringToPrecomputedChunk(
2469 + '<template data-rci="" data-bid="',
2470 +);
2471 +const completeBoundaryWithStylesData1 = stringToPrecomputedChunk(
2472 + '<template data-rri="" data-bid="',
2473 +);
2474 +const completeBoundaryData2 = stringToPrecomputedChunk('" data-sid="');
2475 +const completeBoundaryData3a = stringToPrecomputedChunk('" data-sty="');
2476 +const completeBoundaryDataEnd = dataElementQuotedEnd;
2477
2478 export function writeCompletedBoundaryInstruction(
2479 destination: Destination,
@@ -2439,27 +2486,38 @@ export function writeCompletedBoundaryInstruction(
2486 if (enableFloat) {
2487 hasStyleDependencies = hasStyleResourceDependencies(boundaryResources);
2488 }
2442 - writeChunk(destination, responseState.startInlineScript);
2443 - if (enableFloat && hasStyleDependencies) {
2444 - if (!responseState.sentCompleteBoundaryFunction) {
2445 - responseState.sentCompleteBoundaryFunction = true;
2446 - responseState.sentStyleInsertionFunction = true;
2447 - writeChunk(
2448 - destination,
2449 - clonePrecomputedChunk(completeBoundaryWithStylesScript1FullBoth),
2450 - );
2451 - } else if (!responseState.sentStyleInsertionFunction) {
2452 - responseState.sentStyleInsertionFunction = true;
2453 - writeChunk(destination, completeBoundaryWithStylesScript1FullPartial);
2489 + const scriptFormat =
2490 + !enableFizzExternalRuntime ||
2491 + responseState.streamingFormat === ScriptStreamingFormat;
2492 + if (scriptFormat) {
2493 + writeChunk(destination, responseState.startInlineScript);
2494 + if (enableFloat && hasStyleDependencies) {
2495 + if (!responseState.sentCompleteBoundaryFunction) {
2496 + responseState.sentCompleteBoundaryFunction = true;
2497 + responseState.sentStyleInsertionFunction = true;
2498 + writeChunk(
2499 + destination,
2500 + clonePrecomputedChunk(completeBoundaryWithStylesScript1FullBoth),
2501 + );
2502 + } else if (!responseState.sentStyleInsertionFunction) {
2503 + responseState.sentStyleInsertionFunction = true;
2504 + writeChunk(destination, completeBoundaryWithStylesScript1FullPartial);
2505 + } else {
2506 + writeChunk(destination, completeBoundaryWithStylesScript1Partial);
2507 + }
2508 } else {
2455 - writeChunk(destination, completeBoundaryWithStylesScript1Partial);
2509 + if (!responseState.sentCompleteBoundaryFunction) {
2510 + responseState.sentCompleteBoundaryFunction = true;
2511 + writeChunk(destination, completeBoundaryScript1Full);
2512 + } else {
2513 + writeChunk(destination, completeBoundaryScript1Partial);
2514 + }
2515 }
2516 } else {
2458 - if (!responseState.sentCompleteBoundaryFunction) {
2459 - responseState.sentCompleteBoundaryFunction = true;
2460 - writeChunk(destination, completeBoundaryScript1Full);
2517 + if (enableFloat && hasStyleDependencies) {
2518 + writeChunk(destination, completeBoundaryWithStylesData1);
2519 } else {
2462 - writeChunk(destination, completeBoundaryScript1Partial);
2520 + writeChunk(destination, completeBoundaryData1);
2521 }
2522 }
2523
@@ -2469,18 +2527,40 @@ export function writeCompletedBoundaryInstruction(
2527 );
2528 }
2529
2530 + // Write function arguments, which are string and array literals
2531 const formattedContentID = stringToChunk(contentSegmentID.toString(16));
2532 writeChunk(destination, boundaryID);
2474 - writeChunk(destination, completeBoundaryScript2);
2533 + if (scriptFormat) {
2534 + writeChunk(destination, completeBoundaryScript2);
2535 + } else {
2536 + writeChunk(destination, completeBoundaryData2);
2537 + }
2538 writeChunk(destination, responseState.segmentPrefix);
2539 writeChunk(destination, formattedContentID);
2540 if (enableFloat && hasStyleDependencies) {
2478 - writeChunk(destination, completeBoundaryScript2a);
2479 - writeStyleResourceDependencies(destination, boundaryResources);
2541 + // Script and data writers must format this differently:
2542 + // - script writer emits an array literal, whose string elements are
2543 + // escaped for javascript e.g. ["A", "B"]
2544 + // - data writer emits a string literal, which is escaped as html
2545 + // e.g. [&#34;A&#34;, &#34;B&#34;]
2546 + if (scriptFormat) {
2547 + writeChunk(destination, completeBoundaryScript3a);
2548 + // boundaryResources encodes an array literal
2549 + writeStyleResourceDependenciesInJS(destination, boundaryResources);
2550 + } else {
2551 + writeChunk(destination, completeBoundaryData3a);
2552 + writeStyleResourceDependenciesInAttr(destination, boundaryResources);
2553 + }
2554 + } else {
2555 + if (scriptFormat) {
2556 + writeChunk(destination, completeBoundaryScript3b);
2557 + }
2558 + }
2559 + if (scriptFormat) {
2560 + return writeChunkAndReturn(destination, completeBoundaryScriptEnd);
2561 } else {
2481 - writeChunk(destination, completeBoundaryScript3);
2562 + return writeChunkAndReturn(destination, completeBoundaryDataEnd);
2563 }
2483 - return writeChunkAndReturn(destination, completeBoundaryScript4);
2564 }
2565
2566 const clientRenderScript1Full = stringToPrecomputedChunk(
@@ -2488,8 +2568,16 @@ const clientRenderScript1Full = stringToPrecomputedChunk(
2568 );
2569 const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("');
2570 const clientRenderScript1A = stringToPrecomputedChunk('"');
2491 -const clientRenderScript2 = stringToPrecomputedChunk(')</script>');
2571 const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk(',');
2572 +const clientRenderScriptEnd = stringToPrecomputedChunk(')</script>');
2573 +
2574 +const clientRenderData1 = stringToPrecomputedChunk(
2575 + '<template data-rxi="" data-bid="',
2576 +);
2577 +const clientRenderData2 = stringToPrecomputedChunk('" data-dgst="');
2578 +const clientRenderData3 = stringToPrecomputedChunk('" data-msg="');
2579 +const clientRenderData4 = stringToPrecomputedChunk('" data-stck="');
2580 +const clientRenderDataEnd = dataElementQuotedEnd;
2581
2582 export function writeClientRenderBoundaryInstruction(
2583 destination: Destination,
@@ -2499,14 +2587,22 @@ export function writeClientRenderBoundaryInstruction(
2587 errorMessage?: string,
2588 errorComponentStack?: string,
2589 ): boolean {
2502 - writeChunk(destination, responseState.startInlineScript);
2503 - if (!responseState.sentClientRenderFunction) {
2504 - // The first time we write this, we'll need to include the full implementation.
2505 - responseState.sentClientRenderFunction = true;
2506 - writeChunk(destination, clientRenderScript1Full);
2590 + const scriptFormat =
2591 + !enableFizzExternalRuntime ||
2592 + responseState.streamingFormat === ScriptStreamingFormat;
2593 + if (scriptFormat) {
2594 + writeChunk(destination, responseState.startInlineScript);
2595 + if (!responseState.sentClientRenderFunction) {
2596 + // The first time we write this, we'll need to include the full implementation.
2597 + responseState.sentClientRenderFunction = true;
2598 + writeChunk(destination, clientRenderScript1Full);
2599 + } else {
2600 + // Future calls can just reuse the same function.
2601 + writeChunk(destination, clientRenderScript1Partial);
2602 + }
2603 } else {
2508 - // Future calls can just reuse the same function.
2509 - writeChunk(destination, clientRenderScript1Partial);
2604 + // <template data-rxi="" data-bid="
2605 + writeChunk(destination, clientRenderData1);
2606 }
2607
2608 if (boundaryID === null) {
@@ -2516,29 +2612,73 @@ export function writeClientRenderBoundaryInstruction(
2612 }
2613
2614 writeChunk(destination, boundaryID);
2519 - writeChunk(destination, clientRenderScript1A);
2615 + if (scriptFormat) {
2616 + // " needs to be inserted for scripts, since ArgInterstitual does not contain
2617 + // leading or trailing quotes
2618 + writeChunk(destination, clientRenderScript1A);
2619 + }
2620 +
2621 if (errorDigest || errorMessage || errorComponentStack) {
2521 - writeChunk(destination, clientRenderErrorScriptArgInterstitial);
2522 - writeChunk(
2523 - destination,
2524 - stringToChunk(escapeJSStringsForInstructionScripts(errorDigest || '')),
2525 - );
2622 + if (scriptFormat) {
2623 + // ,"JSONString"
2624 + writeChunk(destination, clientRenderErrorScriptArgInterstitial);
2625 + writeChunk(
2626 + destination,
2627 + stringToChunk(escapeJSStringsForInstructionScripts(errorDigest || '')),
2628 + );
2629 + } else {
2630 + // " data-dgst="HTMLString
2631 + writeChunk(destination, clientRenderData2);
2632 + writeChunk(
2633 + destination,
2634 + stringToChunk(escapeTextForBrowser(errorDigest || '')),
2635 + );
2636 + }
2637 }
2638 if (errorMessage || errorComponentStack) {
2528 - writeChunk(destination, clientRenderErrorScriptArgInterstitial);
2529 - writeChunk(
2530 - destination,
2531 - stringToChunk(escapeJSStringsForInstructionScripts(errorMessage || '')),
2532 - );
2639 + if (scriptFormat) {
2640 + // ,"JSONString"
2641 + writeChunk(destination, clientRenderErrorScriptArgInterstitial);
2642 + writeChunk(
2643 + destination,
2644 + stringToChunk(escapeJSStringsForInstructionScripts(errorMessage || '')),
2645 + );
2646 + } else {
2647 + // " data-msg="HTMLString
2648 + writeChunk(destination, clientRenderData3);
2649 + writeChunk(
2650 + destination,
2651 + stringToChunk(escapeTextForBrowser(errorMessage || '')),
2652 + );
2653 + }
2654 }
2655 if (errorComponentStack) {
2535 - writeChunk(destination, clientRenderErrorScriptArgInterstitial);
2536 - writeChunk(
2537 - destination,
2538 - stringToChunk(escapeJSStringsForInstructionScripts(errorComponentStack)),
2539 - );
2656 + // ,"JSONString"
2657 + if (scriptFormat) {
2658 + writeChunk(destination, clientRenderErrorScriptArgInterstitial);
2659 + writeChunk(
2660 + destination,
2661 + stringToChunk(
2662 + escapeJSStringsForInstructionScripts(errorComponentStack),
2663 + ),
2664 + );
2665 + } else {
2666 + // " data-stck="HTMLString
2667 + writeChunk(destination, clientRenderData4);
2668 + writeChunk(
2669 + destination,
2670 + stringToChunk(escapeTextForBrowser(errorComponentStack)),
2671 + );
2672 + }
2673 + }
2674 +
2675 + if (scriptFormat) {
2676 + // ></script>
2677 + return writeChunkAndReturn(destination, clientRenderScriptEnd);
2678 + } else {
2679 + // "></template>
2680 + return writeChunkAndReturn(destination, clientRenderDataEnd);
2681 }
2541 - return writeChunkAndReturn(destination, clientRenderScript2);
2682 }
2683
2684 const regexForJSStringsInInstructionScripts = /[<\u2028\u2029]/g;
@@ -2598,7 +2738,22 @@ export function writeInitialResources(
2738 destination: Destination,
2739 resources: Resources,
2740 responseState: ResponseState,
2741 + willFlushAllSegments: boolean,
2742 ): boolean {
2743 + // Write initially discovered resources after the shell completes
2744 + if (
2745 + enableFizzExternalRuntime &&
2746 + !willFlushAllSegments &&
2747 + responseState.externalRuntimeConfig
2748 + ) {
2749 + // If the root segment is incomplete due to suspended tasks
2750 + // (e.g. willFlushAllSegments = false) and we are using data
2751 + // streaming format, ensure the external runtime is sent.
2752 + // (User code could choose to send this even earlier by calling
2753 + // preinit(...), if they know they will suspend).
2754 + const {src, integrity} = responseState.externalRuntimeConfig;
2755 + preinitImpl(resources, src, {as: 'script', integrity});
2756 + }
2757 function flushLinkResource(resource) {
2758 if (!resource.flushed) {
2759 pushLinkImpl(target, resource.props, responseState);
@@ -2839,7 +2994,10 @@ const arraySubsequentOpenBracket = stringToPrecomputedChunk(',[');
2994 const arrayInterstitial = stringToPrecomputedChunk(',');
2995 const arrayCloseBracket = stringToPrecomputedChunk(']');
2996
2842 -function writeStyleResourceDependencies(
2997 +// This function writes a 2D array of strings to be embedded in javascript.
2998 +// E.g.
2999 +// [["JS_escaped_string1", "JS_escaped_string2"]]
3000 +function writeStyleResourceDependenciesInJS(
3001 destination: Destination,
3002 boundaryResources: BoundaryResources,
3003 ): void {
@@ -2852,12 +3010,12 @@ function writeStyleResourceDependencies(
3010 // should be ready before content is shown on the client
3011 } else if (resource.flushed) {
3012 writeChunk(destination, nextArrayOpenBrackChunk);
2855 - writeStyleResourceDependencyHrefOnly(destination, resource.href);
3013 + writeStyleResourceDependencyHrefOnlyInJS(destination, resource.href);
3014 writeChunk(destination, arrayCloseBracket);
3015 nextArrayOpenBrackChunk = arraySubsequentOpenBracket;
3016 } else {
3017 writeChunk(destination, nextArrayOpenBrackChunk);
2860 - writeStyleResourceDependency(
3018 + writeStyleResourceDependencyInJS(
3019 destination,
3020 resource.href,
3021 resource.precedence,
@@ -2873,7 +3031,8 @@ function writeStyleResourceDependencies(
3031 writeChunk(destination, arrayCloseBracket);
3032 }
3033
2876 -function writeStyleResourceDependencyHrefOnly(
3034 +/* Helper functions */
3035 +function writeStyleResourceDependencyHrefOnlyInJS(
3036 destination: Destination,
3037 href: string,
3038 ) {
@@ -2889,7 +3048,7 @@ function writeStyleResourceDependencyHrefOnly(
3048 );
3049 }
3050
2892 -function writeStyleResourceDependency(
3051 +function writeStyleResourceDependencyInJS(
3052 destination: Destination,
3053 href: string,
3054 precedence: string,
@@ -2936,7 +3095,7 @@ function writeStyleResourceDependency(
3095 );
3096 // eslint-disable-next-line-no-fallthrough
3097 default:
2939 - writeStyleResourceAttribute(destination, propKey, propValue);
3098 + writeStyleResourceAttributeInJS(destination, propKey, propValue);
3099 break;
3100 }
3101 }
@@ -2944,7 +3103,7 @@ function writeStyleResourceDependency(
3103 return null;
3104 }
3105
2947 -function writeStyleResourceAttribute(
3106 +function writeStyleResourceAttributeInJS(
3107 destination: Destination,
3108 name: string,
3109 value: string | boolean | number | Function | Object, // not null or undefined
@@ -3022,3 +3181,192 @@ function writeStyleResourceAttribute(
3181 stringToChunk(escapeJSObjectForInstructionScripts(attributeValue)),
3182 );
3183 }
3184 +
3185 +// This function writes a 2D array of strings to be embedded in an attribute
3186 +// value and read with JSON.parse in ReactDOMServerExternalRuntime.js
3187 +// E.g.
3188 +// [[&quot;JSON_escaped_string1&quot;, &quot;JSON_escaped_string2&quot;]]
3189 +function writeStyleResourceDependenciesInAttr(
3190 + destination: Destination,
3191 + boundaryResources: BoundaryResources,
3192 +): void {
3193 + writeChunk(destination, arrayFirstOpenBracket);
3194 +
3195 + let nextArrayOpenBrackChunk = arrayFirstOpenBracket;
3196 + boundaryResources.forEach(resource => {
3197 + if (resource.inShell) {
3198 + // We can elide this dependency because it was flushed in the shell and
3199 + // should be ready before content is shown on the client
3200 + } else if (resource.flushed) {
3201 + writeChunk(destination, nextArrayOpenBrackChunk);
3202 + writeStyleResourceDependencyHrefOnlyInAttr(destination, resource.href);
3203 + writeChunk(destination, arrayCloseBracket);
3204 + nextArrayOpenBrackChunk = arraySubsequentOpenBracket;
3205 + } else {
3206 + writeChunk(destination, nextArrayOpenBrackChunk);
3207 + writeStyleResourceDependencyInAttr(
3208 + destination,
3209 + resource.href,
3210 + resource.precedence,
3211 + resource.props,
3212 + );
3213 + writeChunk(destination, arrayCloseBracket);
3214 + nextArrayOpenBrackChunk = arraySubsequentOpenBracket;
3215 +
3216 + resource.flushed = true;
3217 + resource.hint.flushed = true;
3218 + }
3219 + });
3220 + writeChunk(destination, arrayCloseBracket);
3221 +}
3222 +
3223 +/* Helper functions */
3224 +function writeStyleResourceDependencyHrefOnlyInAttr(
3225 + destination: Destination,
3226 + href: string,
3227 +) {
3228 + // We should actually enforce this earlier when the resource is created but for
3229 + // now we make sure we are actually dealing with a string here.
3230 + if (__DEV__) {
3231 + checkAttributeStringCoercion(href, 'href');
3232 + }
3233 + const coercedHref = '' + (href: any);
3234 + writeChunk(
3235 + destination,
3236 + stringToChunk(escapeTextForBrowser(JSON.stringify(coercedHref))),
3237 + );
3238 +}
3239 +
3240 +function writeStyleResourceDependencyInAttr(
3241 + destination: Destination,
3242 + href: string,
3243 + precedence: string,
3244 + props: Object,
3245 +) {
3246 + if (__DEV__) {
3247 + checkAttributeStringCoercion(href, 'href');
3248 + }
3249 + const coercedHref = '' + (href: any);
3250 + sanitizeURL(coercedHref);
3251 + writeChunk(
3252 + destination,
3253 + stringToChunk(escapeTextForBrowser(JSON.stringify(coercedHref))),
3254 + );
3255 +
3256 + if (__DEV__) {
3257 + checkAttributeStringCoercion(precedence, 'precedence');
3258 + }
3259 + const coercedPrecedence = '' + (precedence: any);
3260 + writeChunk(destination, arrayInterstitial);
3261 + writeChunk(
3262 + destination,
3263 + stringToChunk(escapeTextForBrowser(JSON.stringify(coercedPrecedence))),
3264 + );
3265 +
3266 + for (const propKey in props) {
3267 + if (hasOwnProperty.call(props, propKey)) {
3268 + const propValue = props[propKey];
3269 + if (propValue == null) {
3270 + continue;
3271 + }
3272 + switch (propKey) {
3273 + case 'href':
3274 + case 'rel':
3275 + case 'precedence':
3276 + case 'data-precedence': {
3277 + break;
3278 + }
3279 + case 'children':
3280 + case 'dangerouslySetInnerHTML':
3281 + throw new Error(
3282 + `${'link'} is a self-closing tag and must neither have \`children\` nor ` +
3283 + 'use `dangerouslySetInnerHTML`.',
3284 + );
3285 + // eslint-disable-next-line-no-fallthrough
3286 + default:
3287 + writeStyleResourceAttributeInAttr(destination, propKey, propValue);
3288 + break;
3289 + }
3290 + }
3291 + }
3292 + return null;
3293 +}
3294 +
3295 +function writeStyleResourceAttributeInAttr(
3296 + destination: Destination,
3297 + name: string,
3298 + value: string | boolean | number | Function | Object, // not null or undefined
3299 +): void {
3300 + let attributeName = name.toLowerCase();
3301 + let attributeValue;
3302 + switch (typeof value) {
3303 + case 'function':
3304 + case 'symbol':
3305 + return;
3306 + }
3307 +
3308 + switch (name) {
3309 + // Reserved names
3310 + case 'innerHTML':
3311 + case 'dangerouslySetInnerHTML':
3312 + case 'suppressContentEditableWarning':
3313 + case 'suppressHydrationWarning':
3314 + case 'style':
3315 + // Ignored
3316 + return;
3317 +
3318 + // Attribute renames
3319 + case 'className':
3320 + attributeName = 'class';
3321 + break;
3322 +
3323 + // Booleans
3324 + case 'hidden':
3325 + if (value === false) {
3326 + return;
3327 + }
3328 + attributeValue = '';
3329 + break;
3330 +
3331 + // Santized URLs
3332 + case 'src':
3333 + case 'href': {
3334 + if (__DEV__) {
3335 + checkAttributeStringCoercion(value, attributeName);
3336 + }
3337 + attributeValue = '' + (value: any);
3338 + sanitizeURL(attributeValue);
3339 + break;
3340 + }
3341 + default: {
3342 + if (!isAttributeNameSafe(name)) {
3343 + return;
3344 + }
3345 + }
3346 + }
3347 +
3348 + if (
3349 + // shouldIgnoreAttribute
3350 + // We have already filtered out null/undefined and reserved words.
3351 + name.length > 2 &&
3352 + (name[0] === 'o' || name[0] === 'O') &&
3353 + (name[1] === 'n' || name[1] === 'N')
3354 + ) {
3355 + return;
3356 + }
3357 +
3358 + if (__DEV__) {
3359 + checkAttributeStringCoercion(value, attributeName);
3360 + }
3361 + attributeValue = '' + (value: any);
3362 + writeChunk(destination, arrayInterstitial);
3363 + writeChunk(
3364 + destination,
3365 + stringToChunk(escapeTextForBrowser(JSON.stringify(attributeName))),
3366 + );
3367 + writeChunk(destination, arrayInterstitial);
3368 + writeChunk(
3369 + destination,
3370 + stringToChunk(escapeTextForBrowser(JSON.stringify(attributeValue))),
3371 + );
3372 +}
packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js
+20 -4
@@ -7,7 +7,11 @@
7 * @flow
8 */
9
10 -import type {FormatContext} from './ReactDOMServerFormatConfig';
10 +import type {
11 + BootstrapScriptDescriptor,
12 + FormatContext,
13 + StreamingFormat,
14 +} from './ReactDOMServerFormatConfig';
15
16 import {
17 createResponseState as createResponseStateImpl,
@@ -31,16 +35,18 @@ export const isPrimaryRenderer = false;
35 export type ResponseState = {
36 // Keep this in sync with ReactDOMServerFormatConfig
37 bootstrapChunks: Array<Chunk | PrecomputedChunk>,
34 - startInlineScript: PrecomputedChunk,
38 placeholderPrefix: PrecomputedChunk,
39 segmentPrefix: PrecomputedChunk,
40 boundaryPrefix: string,
41 idPrefix: string,
42 nextSuspenseID: number,
43 + streamingFormat: StreamingFormat,
44 + startInlineScript: PrecomputedChunk,
45 sentCompleteSegmentFunction: boolean,
46 sentCompleteBoundaryFunction: boolean,
47 sentClientRenderFunction: boolean,
48 sentStyleInsertionFunction: boolean,
49 + externalRuntimeConfig: BootstrapScriptDescriptor | null,
50 // This is an extra field for the legacy renderer
51 generateStaticMarkup: boolean,
52 };
@@ -48,21 +54,31 @@ export type ResponseState = {
54 export function createResponseState(
55 generateStaticMarkup: boolean,
56 identifierPrefix: string | void,
57 + externalRuntimeConfig: string | BootstrapScriptDescriptor | void,
58 ): ResponseState {
52 - const responseState = createResponseStateImpl(identifierPrefix, undefined);
59 + const responseState = createResponseStateImpl(
60 + identifierPrefix,
61 + undefined,
62 + undefined,
63 + undefined,
64 + undefined,
65 + externalRuntimeConfig,
66 + );
67 return {
68 // Keep this in sync with ReactDOMServerFormatConfig
69 bootstrapChunks: responseState.bootstrapChunks,
56 - startInlineScript: responseState.startInlineScript,
70 placeholderPrefix: responseState.placeholderPrefix,
71 segmentPrefix: responseState.segmentPrefix,
72 boundaryPrefix: responseState.boundaryPrefix,
73 idPrefix: responseState.idPrefix,
74 nextSuspenseID: responseState.nextSuspenseID,
75 + streamingFormat: responseState.streamingFormat,
76 + startInlineScript: responseState.startInlineScript,
77 sentCompleteSegmentFunction: responseState.sentCompleteSegmentFunction,
78 sentCompleteBoundaryFunction: responseState.sentCompleteBoundaryFunction,
79 sentClientRenderFunction: responseState.sentClientRenderFunction,
80 sentStyleInsertionFunction: responseState.sentStyleInsertionFunction,
81 + externalRuntimeConfig: responseState.externalRuntimeConfig,
82 // This is an extra field for the legacy renderer
83 generateStaticMarkup,
84 };
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+126 -30
@@ -8,7 +8,12 @@
8 */
9
10 'use strict';
11 -import {replaceScriptsAndMove, mergeOptions} from '../test-utils/FizzTestUtils';
11 +import {
12 + replaceScriptsAndMove,
13 + mergeOptions,
14 + stripExternalRuntimeInNodes,
15 + withLoadingReadyState,
16 +} from '../test-utils/FizzTestUtils';
17
18 let JSDOM;
19 let Stream;
@@ -31,7 +36,7 @@ let container;
36 let buffer = '';
37 let hasErrored = false;
38 let fatalError = undefined;
34 -const renderOptions = {};
39 +let renderOptions;
40
41 describe('ReactDOMFizzServer', () => {
42 beforeEach(() => {
@@ -89,6 +94,12 @@ describe('ReactDOMFizzServer', () => {
94 hasErrored = true;
95 fatalError = error;
96 });
97 +
98 + renderOptions = {};
99 + if (gate(flags => flags.enableFizzExternalRuntime)) {
100 + renderOptions.unstable_externalRuntimeSrc =
101 + 'react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js';
102 + }
103 });
104
105 function expectErrors(errorsArr, toBeDevArr, toBeProdArr) {
@@ -134,10 +145,13 @@ describe('ReactDOMFizzServer', () => {
145 fakeBody.innerHTML = bufferedContent;
146 const parent =
147 container.nodeName === '#document' ? container.body : container;
137 - while (fakeBody.firstChild) {
138 - const node = fakeBody.firstChild;
139 - await replaceScriptsAndMove(window, CSPnonce, node, parent);
140 - }
148 +
149 + await withLoadingReadyState(async () => {
150 + while (fakeBody.firstChild) {
151 + const node = fakeBody.firstChild;
152 + await replaceScriptsAndMove(window, CSPnonce, node, parent);
153 + }
154 + }, document);
155 }
156
157 async function actIntoEmptyDocument(callback) {
@@ -162,7 +176,9 @@ describe('ReactDOMFizzServer', () => {
176 document = jsdom.window.document;
177 container = document;
178 buffer = '';
165 - await replaceScriptsAndMove(window, CSPnonce, document.documentElement);
179 + await withLoadingReadyState(async () => {
180 + await replaceScriptsAndMove(window, CSPnonce, document.documentElement);
181 + }, document);
182 }
183
184 function getVisibleChildren(element) {
@@ -595,7 +611,12 @@ describe('ReactDOMFizzServer', () => {
611 // Because there is no content inside the Suspense boundary that could've
612 // been written, we expect to not see any additional partial data flushed
613 // yet.
598 - expect(container.childNodes.length).toBe(1);
614 + expect(
615 + stripExternalRuntimeInNodes(
616 + container.childNodes,
617 + renderOptions.unstable_externalRuntimeSrc,
618 + ).length,
619 + ).toBe(1);
620 await act(async () => {
621 resolveElement({default: <Text text="Hello" />});
622 });
@@ -3490,7 +3511,10 @@ describe('ReactDOMFizzServer', () => {
3511 </html>,
3512 );
3513 expect(
3493 - Array.from(document.getElementsByTagName('script')).map(n => n.outerHTML),
3514 + stripExternalRuntimeInNodes(
3515 + document.getElementsByTagName('script'),
3516 + renderOptions.unstable_externalRuntimeSrc,
3517 + ).map(n => n.outerHTML),
3518 ).toEqual([
3519 '<script src="foo" async=""></script>',
3520 '<script src="bar" async=""></script>',
@@ -3566,7 +3590,9 @@ describe('ReactDOMFizzServer', () => {
3590 <html>
3591 <head />
3592 <body>
3569 - <div>hello world</div>
3593 + <Suspense fallback={'loading...'}>
3594 + <AsyncText text="Hello" />
3595 + </Suspense>
3596 </body>
3597 </html>,
3598 {
@@ -3576,17 +3602,68 @@ describe('ReactDOMFizzServer', () => {
3602 pipe(writable);
3603 });
3604
3605 + // We want the external runtime to be sent in <head> so the script can be
3606 + // fetched and executed as early as possible. For SSR pages using Suspense,
3607 + // this script execution would be render blocking.
3608 + expect(
3609 + Array.from(document.head.getElementsByTagName('script')).map(
3610 + n => n.outerHTML,
3611 + ),
3612 + ).toEqual(['<script src="src-of-external-runtime" async=""></script>']);
3613 +
3614 expect(getVisibleChildren(document)).toEqual(
3615 <html>
3616 <head />
3582 - <body>
3583 - <div>hello world</div>
3584 - </body>
3617 + <body>loading...</body>
3618 </html>,
3619 );
3587 - expect(
3588 - Array.from(document.getElementsByTagName('script')).map(n => n.outerHTML),
3589 - ).toEqual(['<script src="src-of-external-runtime" async=""></script>']);
3620 + });
3621 +
3622 + // @gate enableFizzExternalRuntime
3623 + it('does not send script tags for SSR instructions when using the external runtime', async () => {
3624 + function App() {
3625 + return (
3626 + <div>
3627 + <Suspense fallback="Loading...">
3628 + <div>
3629 + <AsyncText text="Hello" />
3630 + </div>
3631 + </Suspense>
3632 + </div>
3633 + );
3634 + }
3635 + await actIntoEmptyDocument(() => {
3636 + const {pipe} = renderToPipeableStream(<App />);
3637 + pipe(writable);
3638 + });
3639 + await act(async () => {
3640 + resolveText('Hello');
3641 + });
3642 +
3643 + // The only script elements sent should be from unstable_externalRuntimeSrc
3644 + expect(document.getElementsByTagName('script').length).toEqual(1);
3645 + });
3646 +
3647 + it('does not send the external runtime for static pages', async () => {
3648 + await actIntoEmptyDocument(() => {
3649 + const {pipe} = renderToPipeableStream(
3650 + <html>
3651 + <head />
3652 + <body>
3653 + <p>hello world!</p>
3654 + </body>
3655 + </html>,
3656 + );
3657 + pipe(writable);
3658 + });
3659 +
3660 + // no scripts should be sent
3661 + expect(document.getElementsByTagName('script').length).toEqual(0);
3662 +
3663 + // the html should be as-is
3664 + expect(document.documentElement.innerHTML).toEqual(
3665 + '<head></head><body><p>hello world!</p></body>',
3666 + );
3667 });
3668
3669 it('#24384: Suspending should halt hydration warnings and not emit any if hydration completes successfully after unsuspending', async () => {
@@ -4527,12 +4604,21 @@ describe('ReactDOMFizzServer', () => {
4604
4605 await act(() => resolveText('Foo'));
4606
4530 - expect(container.firstElementChild.outerHTML).toEqual(
4607 + const div = stripExternalRuntimeInNodes(
4608 + container.children,
4609 + renderOptions.unstable_externalRuntimeSrc,
4610 + )[0];
4611 + expect(div.outerHTML).toEqual(
4612 '<div id="app-div">hello<b>world, Foo</b>!</div>',
4613 );
4533 - // there are extra script nodes at the end of container
4534 - expect(container.childNodes.length).toBe(5);
4535 - const div = container.childNodes[1];
4614 + // there may be either:
4615 + // - an external runtime script and deleted nodes with data attributes
4616 + // - extra script nodes containing fizz instructions at the end of container
4617 + expect(
4618 + Array.from(container.childNodes).filter(e => e.tagName !== 'SCRIPT')
4619 + .length,
4620 + ).toBe(3);
4621 +
4622 expect(div.childNodes.length).toBe(3);
4623 const b = div.childNodes[1];
4624 expect(b.childNodes.length).toBe(2);
@@ -4582,9 +4668,12 @@ describe('ReactDOMFizzServer', () => {
4668 );
4669
4670 await act(() => resolveText('ello'));
4585 - expect(container.firstElementChild.outerHTML).toEqual(
4586 - '<div id="app-div">helloworld</div>',
4587 - );
4671 + expect(
4672 + stripExternalRuntimeInNodes(
4673 + container.children,
4674 + renderOptions.unstable_externalRuntimeSrc,
4675 + )[0].outerHTML,
4676 + ).toEqual('<div id="app-div">helloworld</div>');
4677
4678 const errors = [];
4679 ReactDOMClient.hydrateRoot(container, <App name="Foo" />, {
@@ -4624,9 +4713,12 @@ describe('ReactDOMFizzServer', () => {
4713
4714 await act(() => resolveText('orld'));
4715
4627 - expect(container.firstElementChild.outerHTML).toEqual(
4628 - '<div id="app-div">h<!-- -->ello<!-- -->world</div>',
4629 - );
4716 + expect(
4717 + stripExternalRuntimeInNodes(
4718 + container.children,
4719 + renderOptions.unstable_externalRuntimeSrc,
4720 + )[0].outerHTML,
4721 + ).toEqual('<div id="app-div">h<!-- -->ello<!-- -->world</div>');
4722
4723 const errors = [];
4724 ReactDOMClient.hydrateRoot(container, <App />, {
@@ -4784,7 +4876,12 @@ describe('ReactDOMFizzServer', () => {
4876 resolveText('second suspended');
4877 });
4878
4787 - expect(container.firstElementChild.outerHTML).toEqual(
4879 + expect(
4880 + stripExternalRuntimeInNodes(
4881 + container.children,
4882 + renderOptions.unstable_externalRuntimeSrc,
4883 + )[0].outerHTML,
4884 + ).toEqual(
4885 '<div id="app-div">start<!--$-->firststartfirst suspendedfirstend<!--/$--><!--$-->secondstart<b>second suspended</b><!--/$-->end</div>',
4886 );
4887
@@ -4834,7 +4931,6 @@ describe('ReactDOMFizzServer', () => {
4931 pipe(writable);
4932 });
4933
4837 - // strip inserted external runtime
4934 expect(container.innerHTML).toEqual(
4935 '<div><!--$-->hello<!-- -->world<!-- --><!--/$--><!--$-->world<!-- --><!--/$--><!--$-->hello<!-- -->world<!-- --><br><!--/$--><!--$-->world<!-- --><br><!--/$--></div>',
4936 );
@@ -5462,7 +5558,7 @@ describe('ReactDOMFizzServer', () => {
5558
5559 it('can render scripts with simple children', async () => {
5560 await actIntoEmptyDocument(async () => {
5465 - const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
5561 + const {pipe} = renderToPipeableStream(
5562 <html>
5563 <body>
5564 <script>{'try { foo() } catch (e) {} ;'}</script>
@@ -5490,7 +5586,7 @@ describe('ReactDOMFizzServer', () => {
5586
5587 try {
5588 await actIntoEmptyDocument(async () => {
5493 - const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
5589 + const {pipe} = renderToPipeableStream(
5590 <html>
5591 <body>
5592 <script>{2}</script>
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+65 -13
@@ -8,7 +8,11 @@
8 */
9
10 'use strict';
11 -import {replaceScriptsAndMove, mergeOptions} from '../test-utils/FizzTestUtils';
11 +import {
12 + replaceScriptsAndMove,
13 + mergeOptions,
14 + withLoadingReadyState,
15 +} from '../test-utils/FizzTestUtils';
16
17 let JSDOM;
18 let Stream;
@@ -26,7 +30,7 @@ let container;
30 let buffer = '';
31 let hasErrored = false;
32 let fatalError = undefined;
29 -const renderOptions = {};
33 +let renderOptions;
34
35 describe('ReactDOMFloat', () => {
36 beforeEach(() => {
@@ -64,6 +68,12 @@ describe('ReactDOMFloat', () => {
68 hasErrored = true;
69 fatalError = error;
70 });
71 +
72 + renderOptions = {};
73 + if (gate(flags => flags.enableFizzExternalRuntime)) {
74 + renderOptions.unstable_externalRuntimeSrc =
75 + 'react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js';
76 + }
77 });
78
79 function normalizeCodeLocInfo(str) {
@@ -100,10 +110,17 @@ describe('ReactDOMFloat', () => {
110 fakeBody.innerHTML = bufferedContent;
111 const parent =
112 container.nodeName === '#document' ? container.body : container;
103 - while (fakeBody.firstChild) {
104 - const node = fakeBody.firstChild;
105 - await replaceScriptsAndMove(document.defaultView, CSPnonce, node, parent);
106 - }
113 + await withLoadingReadyState(async () => {
114 + while (fakeBody.firstChild) {
115 + const node = fakeBody.firstChild;
116 + await replaceScriptsAndMove(
117 + document.defaultView,
118 + CSPnonce,
119 + node,
120 + parent,
121 + );
122 + }
123 + }, document);
124 }
125
126 async function actIntoEmptyDocument(callback) {
@@ -127,7 +144,9 @@ describe('ReactDOMFloat', () => {
144 document = jsdom.window.document;
145 container = document;
146 buffer = '';
130 - await replaceScriptsAndMove(jsdom.window, null, document.documentElement);
147 + await withLoadingReadyState(async () => {
148 + await replaceScriptsAndMove(jsdom.window, null, document.documentElement);
149 + }, document);
150 }
151
152 function getMeaningfulChildren(element) {
@@ -287,7 +306,7 @@ describe('ReactDOMFloat', () => {
306 // @gate enableFloat
307 it('can hydrate non Resources in head when Resources are also inserted there', async () => {
308 await actIntoEmptyDocument(() => {
290 - const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
309 + const {pipe} = renderToPipeableStream(
310 <html>
311 <head>
312 <meta property="foo" content="bar" />
@@ -589,6 +608,39 @@ describe('ReactDOMFloat', () => {
608 ]);
609 });
610
611 + it('dedupes if the external runtime is explicitly loaded using preinit', async () => {
612 + const unstable_externalRuntimeSrc = 'src-of-external-runtime';
613 + function App() {
614 + ReactDOM.preinit(unstable_externalRuntimeSrc, {as: 'script'});
615 + return (
616 + <div>
617 + <Suspense fallback={<h1>Loading...</h1>}>
618 + <AsyncText text="Hello" />
619 + </Suspense>
620 + </div>
621 + );
622 + }
623 +
624 + await actIntoEmptyDocument(() => {
625 + const {pipe} = renderToPipeableStream(
626 + <html>
627 + <head />
628 + <body>
629 + <App />
630 + </body>
631 + </html>,
632 + {
633 + unstable_externalRuntimeSrc,
634 + },
635 + );
636 + pipe(writable);
637 + });
638 +
639 + expect(
640 + Array.from(document.getElementsByTagName('script')).map(n => n.outerHTML),
641 + ).toEqual(['<script src="src-of-external-runtime" async=""></script>']);
642 + });
643 +
644 describe('HostResource', () => {
645 // @gate enableFloat
646 it('warns when you update props to an invalid type', async () => {
@@ -5392,7 +5444,7 @@ describe('ReactDOMFloat', () => {
5444 // @gate enableFloat
5445 it('allows resources inside foreignobject within an svg context', async () => {
5446 await actIntoEmptyDocument(() => {
5395 - const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
5447 + const {pipe} = renderToPipeableStream(
5448 <html>
5449 <body>
5450 <svg>
@@ -5499,7 +5551,7 @@ describe('ReactDOMFloat', () => {
5551 // @gate enableFloat
5552 it('should support non-title resources in svg context', async () => {
5553 await actIntoEmptyDocument(() => {
5502 - const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
5554 + const {pipe} = renderToPipeableStream(
5555 <html>
5556 <body>
5557 <svg>
@@ -5632,7 +5684,7 @@ describe('ReactDOMFloat', () => {
5684 // @gate enableFloat
5685 it('should not treat title descendants of svg into resources', async () => {
5686 await actIntoEmptyDocument(() => {
5635 - const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
5687 + const {pipe} = renderToPipeableStream(
5688 <html>
5689 <body>
5690 <svg>
@@ -5758,7 +5810,7 @@ describe('ReactDOMFloat', () => {
5810 );
5811 }
5812 await actIntoEmptyDocument(() => {
5761 - const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
5813 + const {pipe} = renderToPipeableStream(<App />);
5814 pipe(writable);
5815 });
5816
@@ -5855,7 +5907,7 @@ describe('ReactDOMFloat', () => {
5907 }
5908
5909 await actIntoEmptyDocument(() => {
5858 - const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
5910 + const {pipe} = renderToPipeableStream(<App />);
5911 pipe(writable);
5912 });
5913 expect(getMeaningfulChildren(container)).toEqual(
packages/react-dom/src/server/ReactDOMLegacyServerImpl.js
+3
@@ -10,6 +10,7 @@
10 import ReactVersion from 'shared/ReactVersion';
11
12 import type {ReactNodeList} from 'shared/ReactTypes';
13 +import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactDOMServerFormatConfig';
14
15 import {
16 createRequest,
@@ -36,6 +37,7 @@ function renderToStringImpl(
37 options: void | ServerOptions,
38 generateStaticMarkup: boolean,
39 abortReason: string,
40 + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41 ): string {
42 let didFatal = false;
43 let fatalError = null;
@@ -62,6 +64,7 @@ function renderToStringImpl(
64 createResponseState(
65 generateStaticMarkup,
66 options ? options.identifierPrefix : undefined,
67 + unstable_externalRuntimeSrc,
68 ),
69 createRootFormatContext(),
70 Infinity,
packages/react-dom/src/test-utils/FizzTestUtils.js
+77 -2
@@ -11,7 +11,9 @@
11 import * as tmp from 'tmp';
12 import * as fs from 'fs';
13 import replace from 'rollup-plugin-replace';
14 +import resolve from 'rollup-plugin-node-resolve';
15 import {rollup} from 'rollup';
16 +import path from 'path';
17
18 const rollupCache: Map<string, string | null> = new Map();
19
@@ -27,7 +29,12 @@ async function getRollupResult(scriptSrc: string): Promise<string | null> {
29 const rollupConfig = {
30 input: require.resolve(scriptSrc),
31 onwarn: console.warn,
30 - plugins: [replace({__DEV__: 'true'})],
32 + plugins: [
33 + replace({__DEV__: 'true'}),
34 + resolve({
35 + rootDir: path.join(__dirname, '..', '..', '..'),
36 + }),
37 + ],
38 output: {
39 externalLiveBindings: false,
40 freeze: false,
@@ -68,6 +75,7 @@ async function getRollupResult(scriptSrc: string): Promise<string | null> {
75 // 1. Matching nonce attributes and moving node into an existing
76 // parent container (if passed)
77 // 2. Resolving scripts with sources
78 +// 3. Moving data attribute nodes to the body
79 async function replaceScriptsAndMove(
80 window: any,
81 CSPnonce: string | null,
@@ -102,6 +110,18 @@ async function replaceScriptsAndMove(
110 } else {
111 element.parentNode?.replaceChild(script, element);
112 }
113 + } else if (
114 + node.nodeType === 1 &&
115 + // $FlowFixMe[prop-missing]
116 + node.dataset != null &&
117 + (node.dataset.rxi != null ||
118 + node.dataset.rri != null ||
119 + node.dataset.rci != null ||
120 + node.dataset.rsi != null)
121 + ) {
122 + // External runtime assumes that instruction data nodes are eventually
123 + // appended to the body
124 + window.document.body.appendChild(node);
125 } else {
126 for (let i = 0; i < node.childNodes.length; i++) {
127 const inner = node.childNodes[i];
@@ -120,4 +140,59 @@ function mergeOptions(options: Object, defaultOptions: Object): Object {
140 };
141 }
142
123 -export {replaceScriptsAndMove, mergeOptions};
143 +function stripExternalRuntimeInNodes(
144 + nodes: HTMLElement[] | HTMLCollection<HTMLElement>,
145 + externalRuntimeSrc: string | null,
146 +): HTMLElement[] {
147 + if (!Array.isArray(nodes)) {
148 + nodes = Array.from(nodes);
149 + }
150 + if (externalRuntimeSrc == null) {
151 + return nodes;
152 + }
153 + return nodes.filter(
154 + n =>
155 + (n.tagName !== 'SCRIPT' && n.tagName !== 'script') ||
156 + n.getAttribute('src') !== externalRuntimeSrc,
157 + );
158 +}
159 +
160 +// Since JSDOM doesn't implement a streaming HTML parser, we manually overwrite
161 +// readyState here (currently read by ReactDOMServerExternalRuntime). This does
162 +// not trigger event callbacks, but we do not rely on any right now.
163 +async function withLoadingReadyState<T>(
164 + fn: () => T,
165 + document: Document,
166 +): Promise<T> {
167 + // JSDOM implements readyState in document's direct prototype, but this may
168 + // change in later versions
169 + let prevDescriptor = null;
170 + let proto: Object = document;
171 + while (proto != null) {
172 + prevDescriptor = Object.getOwnPropertyDescriptor(proto, 'readyState');
173 + if (prevDescriptor != null) {
174 + break;
175 + }
176 + proto = Object.getPrototypeOf(proto);
177 + }
178 + Object.defineProperty(document, 'readyState', {
179 + get() {
180 + return 'loading';
181 + },
182 + configurable: true,
183 + });
184 + const result = await fn();
185 + // $FlowFixMe[incompatible-type]
186 + delete document.readyState;
187 + if (prevDescriptor) {
188 + Object.defineProperty(proto, 'readyState', prevDescriptor);
189 + }
190 + return result;
191 +}
192 +
193 +export {
194 + replaceScriptsAndMove,
195 + mergeOptions,
196 + stripExternalRuntimeInNodes,
197 + withLoadingReadyState,
198 +};
packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+1
@@ -319,6 +319,7 @@ export function writeInitialResources(
319 destination: Destination,
320 resources: Resources,
321 responseState: ResponseState,
322 + willFlushAllSegments: boolean,
323 ): boolean {
324 return true;
325 }
packages/react-server-dom-relay/src/ReactDOMServerFB.js
+3
@@ -12,6 +12,7 @@ import type {ReactNodeList} from 'shared/ReactTypes';
12 import type {Request} from 'react-server/src/ReactFizzServer';
13
14 import type {Destination} from 'react-server/src/ReactServerStreamConfig';
15 +import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactDOMServerFormatConfig';
16
17 import {
18 createRequest,
@@ -33,6 +34,7 @@ type Options = {
34 bootstrapModules: Array<string>,
35 progressiveChunkSize?: number,
36 onError: (error: mixed) => void,
37 + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
38 };
39
40 opaque type Stream = {
@@ -55,6 +57,7 @@ function renderToStream(children: ReactNodeList, options: Options): Stream {
57 options ? options.bootstrapScriptContent : undefined,
58 options ? options.bootstrapScripts : undefined,
59 options ? options.bootstrapModules : undefined,
60 + options ? options.unstable_externalRuntimeSrc : undefined,
61 ),
62 createRootFormatContext(undefined),
63 options ? options.progressiveChunkSize : undefined,
packages/react-server/src/ReactFizzServer.js
+8 -1
@@ -2126,8 +2126,14 @@ function flushInitialResources(
2126 destination: Destination,
2127 resources: Resources,
2128 responseState: ResponseState,
2129 + willFlushAllSegments: boolean,
2130 ): void {
2130 - writeInitialResources(destination, resources, responseState);
2131 + writeInitialResources(
2132 + destination,
2133 + resources,
2134 + responseState,
2135 + willFlushAllSegments,
2136 + );
2137 }
2138
2139 function flushImmediateResources(
@@ -2288,6 +2294,7 @@ function flushCompletedQueues(
2294 destination,
2295 request.resources,
2296 request.responseState,
2297 + request.allPendingTasks === 0,
2298 );
2299 }
2300
scripts/error-codes/codes.json
+2 -1
@@ -449,5 +449,6 @@
449 "461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.",
450 "462": "Unexpected SuspendedReason. This is a bug in React.",
451 "463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
452 - "464": "ReactDOMServer.renderToStaticNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead."
452 + "464": "ReactDOMServer.renderToStaticNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
453 + "465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle."
454 }