Skip to content

Commit cdb8a1d

Browse files
authored
[Fizz] Add option to inject bootstrapping script tags after the shell is injected (#22594)
* Add option to inject bootstrap scripts These are emitted right after the shell as flushed. * Update ssr fixtures to use bootstrapScripts instead of manual script tag * Add option to FB renderer too
1 parent 3677c01 commit cdb8a1d

File tree

20 files changed

+5445
-21
lines changed

20 files changed

+5445
-21
lines changed

fixtures/ssr/server/render.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default function render(url, res) {
2121
});
2222
let didError = false;
2323
const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
24+
bootstrapScripts: [assets['main.js']],
2425
onCompleteShell() {
2526
// If something errored before we started streaming, we set the error code appropriately.
2627
res.statusCode = didError ? 500 : 200;

fixtures/ssr/src/components/Chrome.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ export default class Chrome extends Component {
4646
__html: `assetManifest = ${JSON.stringify(assets)};`,
4747
}}
4848
/>
49-
<script src={assets['main.js']} />
5049
</body>
5150
</html>
5251
);

fixtures/ssr/yarn.lock

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4265,7 +4265,7 @@ longest@^1.0.1:
42654265
resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
42664266
integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=
42674267

4268-
loose-envify@^1.0.0, loose-envify@^1.1.0:
4268+
loose-envify@^1.0.0:
42694269
version "1.4.0"
42704270
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
42714271
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -5945,14 +5945,6 @@ sax@^1.2.1, sax@~1.2.1:
59455945
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
59465946
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
59475947

5948-
scheduler@^0.20.1:
5949-
version "0.20.2"
5950-
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
5951-
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
5952-
dependencies:
5953-
loose-envify "^1.1.0"
5954-
object-assign "^4.1.1"
5955-
59565948
"semver@2 || 3 || 4 || 5", semver@^5.1.0, semver@^5.3.0:
59575949
version "5.7.1"
59585950
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"

fixtures/ssr2/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
"concurrently": "^5.3.0",
1616
"express": "^4.17.1",
1717
"nodemon": "^2.0.6",
18-
"react": "18.0.0-alpha-7ec4c5597",
19-
"react-dom": "18.0.0-alpha-7ec4c5597",
18+
"react": "link:../../build/node_modules/react",
19+
"react-dom": "link:../../build/node_modules/react-dom",
2020
"react-error-boundary": "^3.1.3",
2121
"resolve": "1.12.0",
2222
"rimraf": "^3.0.2",

fixtures/ssr2/server/render.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ module.exports = function render(url, res) {
4242
<App assets={assets} />
4343
</DataProvider>,
4444
{
45+
bootstrapScripts: [assets['main.js']],
4546
onCompleteShell() {
4647
// If something errored before we started streaming, we set the error code appropriately.
4748
res.statusCode = didError ? 500 : 200;

fixtures/ssr2/src/Html.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export default function Html({assets, children, title}) {
2828
__html: `assetManifest = ${JSON.stringify(assets)};`,
2929
}}
3030
/>
31-
<script async src={assets['main.js']} />
3231
</body>
3332
</html>
3433
);

fixtures/ssr2/yarn.lock

Lines changed: 5277 additions & 0 deletions
Large diffs are not rendered by default.

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ let useSyncExternalStore;
2121
let useSyncExternalStoreExtra;
2222
let PropTypes;
2323
let textCache;
24+
let window;
2425
let document;
2526
let writable;
2627
let CSPnonce = null;
@@ -56,6 +57,7 @@ describe('ReactDOMFizzServer', () => {
5657
runScripts: 'dangerously',
5758
},
5859
);
60+
window = jsdom.window;
5961
document = jsdom.window.document;
6062
container = document.getElementById('container');
6163

@@ -338,11 +340,18 @@ describe('ReactDOMFizzServer', () => {
338340
);
339341
}
340342

343+
let bootstrapped = false;
344+
window.__INIT__ = function() {
345+
bootstrapped = true;
346+
// Attempt to hydrate the content.
347+
ReactDOM.hydrateRoot(container, <App isClient={true} />);
348+
};
349+
341350
await act(async () => {
342351
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
343352
<App isClient={false} />,
344-
345353
{
354+
bootstrapScriptContent: '__INIT__();',
346355
onError(x) {
347356
loggedErrors.push(x);
348357
},
@@ -351,10 +360,8 @@ describe('ReactDOMFizzServer', () => {
351360
pipe(writable);
352361
});
353362
expect(loggedErrors).toEqual([]);
363+
expect(bootstrapped).toBe(true);
354364

355-
// Attempt to hydrate the content.
356-
const root = ReactDOM.createRoot(container, {hydrate: true});
357-
root.render(<App isClient={true} />);
358365
Scheduler.unstable_flushAll();
359366

360367
// We're still loading because we're waiting for the server to stream more content.
@@ -507,17 +514,27 @@ describe('ReactDOMFizzServer', () => {
507514
);
508515
}
509516

517+
let bootstrapped = false;
518+
window.__INIT__ = function() {
519+
bootstrapped = true;
520+
// Attempt to hydrate the content.
521+
ReactDOM.hydrateRoot(container, <App />);
522+
};
523+
510524
await act(async () => {
511-
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
525+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
526+
bootstrapScriptContent: '__INIT__();',
527+
});
512528
pipe(writable);
513529
});
514530

515531
// We're still showing a fallback.
516532
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
517533

534+
// We already bootstrapped.
535+
expect(bootstrapped).toBe(true);
536+
518537
// Attempt to hydrate the content.
519-
const root = ReactDOM.createRoot(container, {hydrate: true});
520-
root.render(<App />);
521538
Scheduler.unstable_flushAll();
522539

523540
// We're still loading because we're waiting for the server to stream more content.

packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ describe('ReactDOMFizzServer', () => {
7171
);
7272
});
7373

74+
// @gate experimental
75+
it('should emit bootstrap script src at the end', async () => {
76+
const stream = ReactDOMFizzServer.renderToReadableStream(
77+
<div>hello world</div>,
78+
{
79+
bootstrapScriptContent: 'INIT();',
80+
bootstrapScripts: ['init.js'],
81+
bootstrapModules: ['init.mjs'],
82+
},
83+
);
84+
const result = await readResult(stream);
85+
expect(result).toMatchInlineSnapshot(
86+
`"<div>hello world</div><script>INIT();</script><script src=\\"init.js\\" async=\\"\\"></script><script type=\\"module\\" src=\\"init.mjs\\" async=\\"\\"></script>"`,
87+
);
88+
});
89+
7490
// @gate experimental
7591
it('emits all HTML as one unit if we wait until the end to start', async () => {
7692
let hasLoaded = false;

packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,24 @@ describe('ReactDOMFizzServer', () => {
8282
);
8383
});
8484

85+
// @gate experimental
86+
it('should emit bootstrap script src at the end', () => {
87+
const {writable, output} = getTestWritable();
88+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
89+
<div>hello world</div>,
90+
{
91+
bootstrapScriptContent: 'INIT();',
92+
bootstrapScripts: ['init.js'],
93+
bootstrapModules: ['init.mjs'],
94+
},
95+
);
96+
pipe(writable);
97+
jest.runAllTimers();
98+
expect(output.result).toMatchInlineSnapshot(
99+
`"<div>hello world</div><script>INIT();</script><script src=\\"init.js\\" async=\\"\\"></script><script type=\\"module\\" src=\\"init.mjs\\" async=\\"\\"></script>"`,
100+
);
101+
});
102+
85103
// @gate experimental
86104
it('should start writing after pipe', () => {
87105
const {writable, output} = getTestWritable();

packages/react-dom/src/server/ReactDOMFizzServerBrowser.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ type Options = {|
2727
identifierPrefix?: string,
2828
namespaceURI?: string,
2929
nonce?: string,
30+
bootstrapScriptContent?: string,
31+
bootstrapScripts?: Array<string>,
32+
bootstrapModules?: Array<string>,
3033
progressiveChunkSize?: number,
3134
signal?: AbortSignal,
3235
onCompleteShell?: () => void,
@@ -43,6 +46,9 @@ function renderToReadableStream(
4346
createResponseState(
4447
options ? options.identifierPrefix : undefined,
4548
options ? options.nonce : undefined,
49+
options ? options.bootstrapScriptContent : undefined,
50+
options ? options.bootstrapScripts : undefined,
51+
options ? options.bootstrapModules : undefined,
4652
),
4753
createRootFormatContext(options ? options.namespaceURI : undefined),
4854
options ? options.progressiveChunkSize : undefined,

packages/react-dom/src/server/ReactDOMFizzServerNode.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ type Options = {|
3232
identifierPrefix?: string,
3333
namespaceURI?: string,
3434
nonce?: string,
35+
bootstrapScriptContent?: string,
36+
bootstrapScripts?: Array<string>,
37+
bootstrapModules?: Array<string>,
3538
progressiveChunkSize?: number,
3639
onCompleteShell?: () => void,
3740
onCompleteAll?: () => void,
@@ -51,6 +54,9 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
5154
createResponseState(
5255
options ? options.identifierPrefix : undefined,
5356
options ? options.nonce : undefined,
57+
options ? options.bootstrapScriptContent : undefined,
58+
options ? options.bootstrapScripts : undefined,
59+
options ? options.bootstrapModules : undefined,
5460
),
5561
createRootFormatContext(options ? options.namespaceURI : undefined),
5662
options ? options.progressiveChunkSize : undefined,

packages/react-dom/src/server/ReactDOMServerFormatConfig.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const isPrimaryRenderer = true;
5959

6060
// Per response, global state that is not contextual to the rendering subtree.
6161
export type ResponseState = {
62+
bootstrapChunks: Array<Chunk | PrecomputedChunk>,
6263
startInlineScript: PrecomputedChunk,
6364
placeholderPrefix: PrecomputedChunk,
6465
segmentPrefix: PrecomputedChunk,
@@ -73,11 +74,19 @@ export type ResponseState = {
7374
};
7475

7576
const startInlineScript = stringToPrecomputedChunk('<script>');
77+
const endInlineScript = stringToPrecomputedChunk('</script>');
78+
79+
const startScriptSrc = stringToPrecomputedChunk('<script src="');
80+
const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
81+
const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
7682

7783
// Allows us to keep track of what we've already written so we can refer back to it.
7884
export function createResponseState(
7985
identifierPrefix: string | void,
8086
nonce: string | void,
87+
bootstrapScriptContent: string | void,
88+
bootstrapScripts: Array<string> | void,
89+
bootstrapModules: Array<string> | void,
8190
): ResponseState {
8291
const idPrefix = identifierPrefix === undefined ? '' : identifierPrefix;
8392
const inlineScriptWithNonce =
@@ -86,7 +95,34 @@ export function createResponseState(
8695
: stringToPrecomputedChunk(
8796
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
8897
);
98+
const bootstrapChunks = [];
99+
if (bootstrapScriptContent !== undefined) {
100+
bootstrapChunks.push(
101+
inlineScriptWithNonce,
102+
stringToChunk(escapeTextForBrowser(bootstrapScriptContent)),
103+
endInlineScript,
104+
);
105+
}
106+
if (bootstrapScripts !== undefined) {
107+
for (let i = 0; i < bootstrapScripts.length; i++) {
108+
bootstrapChunks.push(
109+
startScriptSrc,
110+
stringToChunk(escapeTextForBrowser(bootstrapScripts[i])),
111+
endAsyncScript,
112+
);
113+
}
114+
}
115+
if (bootstrapModules !== undefined) {
116+
for (let i = 0; i < bootstrapModules.length; i++) {
117+
bootstrapChunks.push(
118+
startModuleSrc,
119+
stringToChunk(escapeTextForBrowser(bootstrapModules[i])),
120+
endAsyncScript,
121+
);
122+
}
123+
}
89124
return {
125+
bootstrapChunks: bootstrapChunks,
90126
startInlineScript: inlineScriptWithNonce,
91127
placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
92128
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
@@ -1370,6 +1406,18 @@ export function pushEndInstance(
13701406
}
13711407
}
13721408

1409+
export function writeCompletedRoot(
1410+
destination: Destination,
1411+
responseState: ResponseState,
1412+
): boolean {
1413+
const bootstrapChunks = responseState.bootstrapChunks;
1414+
let result = true;
1415+
for (let i = 0; i < bootstrapChunks.length; i++) {
1416+
result = writeChunk(destination, bootstrapChunks[i]);
1417+
}
1418+
return result;
1419+
}
1420+
13731421
// Structural Nodes
13741422

13751423
// A placeholder is a node inside a hidden partial tree that can be filled in later, but before

packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const isPrimaryRenderer = false;
2929

3030
export type ResponseState = {
3131
// Keep this in sync with ReactDOMServerFormatConfig
32+
bootstrapChunks: Array<Chunk | PrecomputedChunk>,
3233
startInlineScript: PrecomputedChunk,
3334
placeholderPrefix: PrecomputedChunk,
3435
segmentPrefix: PrecomputedChunk,
@@ -50,6 +51,7 @@ export function createResponseState(
5051
const responseState = createResponseStateImpl(identifierPrefix, undefined);
5152
return {
5253
// Keep this in sync with ReactDOMServerFormatConfig
54+
bootstrapChunks: responseState.bootstrapChunks,
5355
startInlineScript: responseState.startInlineScript,
5456
placeholderPrefix: responseState.placeholderPrefix,
5557
segmentPrefix: responseState.segmentPrefix,
@@ -95,6 +97,7 @@ export {
9597
writeStartPendingSuspenseBoundary,
9698
writeEndPendingSuspenseBoundary,
9799
writePlaceholder,
100+
writeCompletedRoot,
98101
} from './ReactDOMServerFormatConfig';
99102

100103
import {stringToChunk} from 'react-server/src/ReactServerStreamConfig';

packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,13 @@ export function pushEndInstance(
164164
target.push(END);
165165
}
166166

167+
export function writeCompletedRoot(
168+
destination: Destination,
169+
responseState: ResponseState,
170+
): boolean {
171+
return true;
172+
}
173+
167174
// IDs are formatted as little endian Uint16
168175
function formatID(id: number): Uint8Array {
169176
if (id > 0xffff) {

packages/react-noop-renderer/src/ReactNoopServer.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ const ReactNoopServer = ReactFizzServer({
126126
target.push(POP);
127127
},
128128

129+
writeCompletedRoot(
130+
destination: Destination,
131+
responseState: ResponseState,
132+
): boolean {
133+
return true;
134+
},
135+
129136
writePlaceholder(
130137
destination: Destination,
131138
responseState: ResponseState,

packages/react-server-dom-relay/src/ReactDOMServerFB.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import {
2828

2929
type Options = {
3030
identifierPrefix?: string,
31+
bootstrapScriptContent?: string,
32+
bootstrapScripts: Array<string>,
33+
bootstrapModules: Array<string>,
3134
progressiveChunkSize?: number,
3235
onError: (error: mixed) => void,
3336
};
@@ -46,7 +49,13 @@ function renderToStream(children: ReactNodeList, options: Options): Stream {
4649
};
4750
const request = createRequest(
4851
children,
49-
createResponseState(options ? options.identifierPrefix : undefined),
52+
createResponseState(
53+
options ? options.identifierPrefix : undefined,
54+
undefined,
55+
options ? options.bootstrapScriptContent : undefined,
56+
options ? options.bootstrapScripts : undefined,
57+
options ? options.bootstrapModules : undefined,
58+
),
5059
createRootFormatContext(undefined),
5160
options ? options.progressiveChunkSize : undefined,
5261
options.onError,

0 commit comments

Comments
 (0)