[Static][Fizz] bootstrap scripts should only emit once (#27674)
I introduced a bug in a recent change to how bootstrap scripts are handled. Rather than clearing out the bootstrap script state from ResumableState on completion of the prerender I did it during the flushing phase which comes later after the postponed state has likely been serialized. We should freeze these objects in dev so this is not possible to do easily in test (nor in actual code in real systems). This fixes the bug by eliminating the bootstrap config during getPostponedState which is before the state can be serialized.
Josh Story committed
Nov 8, 2023 at 17:51 UTC
746890329452cbec8685eb3466847c5f17d9dc77
6 files changed
+92
-12
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+9
-7
@@ -690,6 +690,13 @@ export function resetResumableState(
690
resumableState.moduleScriptResources = {};
691
}
692
693
+export function completeResumableState(resumableState: ResumableState): void {
694
+ // This function is called when we have completed a prerender and there is a shell.
695
+ resumableState.bootstrapScriptContent = undefined;
696
+ resumableState.bootstrapScripts = undefined;
697
+ resumableState.bootstrapModules = undefined;
698
+}
699
+
700
// Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion
701
// modes. We only include the variants as they matter for the sake of our purposes.
702
// We don't actually provide the namespace therefore we use constants instead of the string.
@@ -3723,11 +3730,7 @@ export function pushEndInstance(
3730
function writeBootstrap(
3731
destination: Destination,
3732
renderState: RenderState,
3726
- resumableState: ResumableState,
3733
): boolean {
3728
- resumableState.bootstrapScriptContent = undefined;
3729
- resumableState.bootstrapScripts = undefined;
3730
- resumableState.bootstrapModules = undefined;
3734
const bootstrapChunks = renderState.bootstrapChunks;
3735
let i = 0;
3736
for (; i < bootstrapChunks.length - 1; i++) {
@@ -3744,9 +3747,8 @@ function writeBootstrap(
3747
export function writeCompletedRoot(
3748
destination: Destination,
3749
renderState: RenderState,
3747
- resumableState: ResumableState,
3750
): boolean {
3749
- return writeBootstrap(destination, renderState, resumableState);
3751
+ return writeBootstrap(destination, renderState);
3752
}
3753
3754
// Structural Nodes
@@ -4211,7 +4213,7 @@ export function writeCompletedBoundaryInstruction(
4213
} else {
4214
writeMore = writeChunkAndReturn(destination, completeBoundaryDataEnd);
4215
}
4214
- return writeBootstrap(destination, renderState, resumableState) && writeMore;
4216
+ return writeBootstrap(destination, renderState) && writeMore;
4217
}
4218
4219
const clientRenderScript1Full = stringToPrecomputedChunk(
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+1
@@ -171,6 +171,7 @@ export {
171
setCurrentlyRenderingBoundaryResourcesTarget,
172
prepareHostDispatcher,
173
resetResumableState,
174
+ completeResumableState,
175
emitEarlyPreloads,
176
} from './ReactFizzConfigDOM';
177
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+76
@@ -1419,4 +1419,80 @@ describe('ReactDOMFizzStaticBrowser', () => {
1419
'<!DOCTYPE html><html><head></head><body>hello<!--$?--><template id="B:1"></template><!--/$--><div hidden id="S:1">world<!-- --></div><script>$RC',
1420
);
1421
});
1422
+
1423
+ // @gate enablePostpone
1424
+ it('does not bootstrap again in a resume if it bootstraps', async () => {
1425
+ let prerendering = true;
1426
+
1427
+ function Postpone() {
1428
+ if (prerendering) {
1429
+ React.unstable_postpone();
1430
+ }
1431
+ return null;
1432
+ }
1433
+
1434
+ function App() {
1435
+ return (
1436
+ <html>
1437
+ <body>
1438
+ <Suspense fallback="loading...">
1439
+ <Postpone />
1440
+ hello
1441
+ </Suspense>
1442
+ </body>
1443
+ </html>
1444
+ );
1445
+ }
1446
+
1447
+ let inits = 0;
1448
+ jest.mock(
1449
+ 'init.js',
1450
+ () => {
1451
+ inits++;
1452
+ },
1453
+ {virtual: true},
1454
+ );
1455
+
1456
+ const prerendered = await ReactDOMFizzStatic.prerender(<App />, {
1457
+ bootstrapScripts: ['init.js'],
1458
+ });
1459
+
1460
+ const postponedSerializedState = JSON.stringify(prerendered.postponed);
1461
+
1462
+ expect(prerendered.postponed).not.toBe(null);
1463
+
1464
+ await readIntoContainer(prerendered.prelude);
1465
+
1466
+ expect(getVisibleChildren(container)).toEqual([
1467
+ <link rel="preload" href="init.js" fetchpriority="low" as="script" />,
1468
+ 'loading...',
1469
+ ]);
1470
+
1471
+ expect(inits).toBe(1);
1472
+
1473
+ jest.resetModules();
1474
+ jest.mock(
1475
+ 'init.js',
1476
+ () => {
1477
+ inits++;
1478
+ },
1479
+ {virtual: true},
1480
+ );
1481
+
1482
+ prerendering = false;
1483
+
1484
+ const content = await ReactDOMFizzServer.resume(
1485
+ <App />,
1486
+ JSON.parse(postponedSerializedState),
1487
+ );
1488
+
1489
+ await readIntoContainer(content);
1490
+
1491
+ expect(inits).toBe(1);
1492
+
1493
+ expect(getVisibleChildren(container)).toEqual([
1494
+ <link rel="preload" href="init.js" fetchpriority="low" as="script" />,
1495
+ 'hello',
1496
+ ]);
1497
+ });
1498
});
packages/react-noop-renderer/src/ReactNoopServer.js
+1
@@ -95,6 +95,7 @@ const ReactNoopServer = ReactFizzServer({
95
},
96
97
resetResumableState(): void {},
98
+ completeResumableState(): void {},
99
100
pushTextInstance(
101
target: Array<Uint8Array>,
packages/react-server/src/ReactFizzServer.js
+4
-5
@@ -77,6 +77,7 @@ import {
77
pushFormStateMarkerIsMatching,
78
pushFormStateMarkerIsNotMatching,
79
resetResumableState,
80
+ completeResumableState,
81
emitEarlyPreloads,
82
} from './ReactFizzConfig';
83
import {
@@ -3968,11 +3969,7 @@ function flushCompletedQueues(
3969
3970
flushSegment(request, destination, completedRootSegment);
3971
request.completedRootSegment = null;
3971
- writeCompletedRoot(
3972
- destination,
3973
- request.renderState,
3974
- request.resumableState,
3975
- );
3972
+ writeCompletedRoot(destination, request.renderState);
3973
} else {
3974
// We haven't flushed the root yet so we don't need to check any other branches further down
3975
return;
@@ -4283,6 +4280,8 @@ export function getPostponedState(request: Request): null | PostponedState {
4280
) {
4281
// We postponed the root so we didn't flush anything.
4282
resetResumableState(request.resumableState, request.renderState);
4283
+ } else {
4284
+ completeResumableState(request.resumableState);
4285
}
4286
return {
4287
nextSegmentId: request.nextSegmentId,
packages/react-server/src/forks/ReactFizzConfig.custom.js
+1
@@ -41,6 +41,7 @@ export const supportsRequestStorage = false;
41
export const requestStorage: AsyncLocalStorage<Request> = (null: any);
42
43
export const resetResumableState = $$$config.resetResumableState;
44
+export const completeResumableState = $$$config.completeResumableState;
45
export const getChildFormatContext = $$$config.getChildFormatContext;
46
export const makeId = $$$config.makeId;
47
export const pushTextInstance = $$$config.pushTextInstance;