Skip to content

Commit

Permalink
[Fizz] Add option to inject bootstrapping script tags after the shell…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
sebmarkbage authored Oct 20, 2021
1 parent 3677c01 commit cdb8a1d
Show file tree
Hide file tree
Showing 20 changed files with 5,445 additions and 21 deletions.
1 change: 1 addition & 0 deletions fixtures/ssr/server/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function render(url, res) {
});
let didError = false;
const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
bootstrapScripts: [assets['main.js']],
onCompleteShell() {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
Expand Down
1 change: 0 additions & 1 deletion fixtures/ssr/src/components/Chrome.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ export default class Chrome extends Component {
__html: `assetManifest = ${JSON.stringify(assets)};`,
}}
/>
<script src={assets['main.js']} />
</body>
</html>
);
Expand Down
10 changes: 1 addition & 9 deletions fixtures/ssr/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4265,7 +4265,7 @@ longest@^1.0.1:
resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=

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

scheduler@^0.20.1:
version "0.20.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"

"semver@2 || 3 || 4 || 5", semver@^5.1.0, semver@^5.3.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
Expand Down
4 changes: 2 additions & 2 deletions fixtures/ssr2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"concurrently": "^5.3.0",
"express": "^4.17.1",
"nodemon": "^2.0.6",
"react": "18.0.0-alpha-7ec4c5597",
"react-dom": "18.0.0-alpha-7ec4c5597",
"react": "link:../../build/node_modules/react",
"react-dom": "link:../../build/node_modules/react-dom",
"react-error-boundary": "^3.1.3",
"resolve": "1.12.0",
"rimraf": "^3.0.2",
Expand Down
1 change: 1 addition & 0 deletions fixtures/ssr2/server/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ module.exports = function render(url, res) {
<App assets={assets} />
</DataProvider>,
{
bootstrapScripts: [assets['main.js']],
onCompleteShell() {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
Expand Down
1 change: 0 additions & 1 deletion fixtures/ssr2/src/Html.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export default function Html({assets, children, title}) {
__html: `assetManifest = ${JSON.stringify(assets)};`,
}}
/>
<script async src={assets['main.js']} />
</body>
</html>
);
Expand Down
5,277 changes: 5,277 additions & 0 deletions fixtures/ssr2/yarn.lock

Large diffs are not rendered by default.

31 changes: 24 additions & 7 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ let useSyncExternalStore;
let useSyncExternalStoreExtra;
let PropTypes;
let textCache;
let window;
let document;
let writable;
let CSPnonce = null;
Expand Down Expand Up @@ -56,6 +57,7 @@ describe('ReactDOMFizzServer', () => {
runScripts: 'dangerously',
},
);
window = jsdom.window;
document = jsdom.window.document;
container = document.getElementById('container');

Expand Down Expand Up @@ -338,11 +340,18 @@ describe('ReactDOMFizzServer', () => {
);
}

let bootstrapped = false;
window.__INIT__ = function() {
bootstrapped = true;
// Attempt to hydrate the content.
ReactDOM.hydrateRoot(container, <App isClient={true} />);
};

await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<App isClient={false} />,

{
bootstrapScriptContent: '__INIT__();',
onError(x) {
loggedErrors.push(x);
},
Expand All @@ -351,10 +360,8 @@ describe('ReactDOMFizzServer', () => {
pipe(writable);
});
expect(loggedErrors).toEqual([]);
expect(bootstrapped).toBe(true);

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

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

let bootstrapped = false;
window.__INIT__ = function() {
bootstrapped = true;
// Attempt to hydrate the content.
ReactDOM.hydrateRoot(container, <App />);
};

await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
bootstrapScriptContent: '__INIT__();',
});
pipe(writable);
});

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

// We already bootstrapped.
expect(bootstrapped).toBe(true);

// Attempt to hydrate the content.
const root = ReactDOM.createRoot(container, {hydrate: true});
root.render(<App />);
Scheduler.unstable_flushAll();

// We're still loading because we're waiting for the server to stream more content.
Expand Down
16 changes: 16 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ describe('ReactDOMFizzServer', () => {
);
});

// @gate experimental
it('should emit bootstrap script src at the end', async () => {
const stream = ReactDOMFizzServer.renderToReadableStream(
<div>hello world</div>,
{
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
},
);
const result = await readResult(stream);
expect(result).toMatchInlineSnapshot(
`"<div>hello world</div><script>INIT();</script><script src=\\"init.js\\" async=\\"\\"></script><script type=\\"module\\" src=\\"init.mjs\\" async=\\"\\"></script>"`,
);
});

// @gate experimental
it('emits all HTML as one unit if we wait until the end to start', async () => {
let hasLoaded = false;
Expand Down
18 changes: 18 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ describe('ReactDOMFizzServer', () => {
);
});

// @gate experimental
it('should emit bootstrap script src at the end', () => {
const {writable, output} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>hello world</div>,
{
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
},
);
pipe(writable);
jest.runAllTimers();
expect(output.result).toMatchInlineSnapshot(
`"<div>hello world</div><script>INIT();</script><script src=\\"init.js\\" async=\\"\\"></script><script type=\\"module\\" src=\\"init.mjs\\" async=\\"\\"></script>"`,
);
});

// @gate experimental
it('should start writing after pipe', () => {
const {writable, output} = getTestWritable();
Expand Down
6 changes: 6 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ type Options = {|
identifierPrefix?: string,
namespaceURI?: string,
nonce?: string,
bootstrapScriptContent?: string,
bootstrapScripts?: Array<string>,
bootstrapModules?: Array<string>,
progressiveChunkSize?: number,
signal?: AbortSignal,
onCompleteShell?: () => void,
Expand All @@ -43,6 +46,9 @@ function renderToReadableStream(
createResponseState(
options ? options.identifierPrefix : undefined,
options ? options.nonce : undefined,
options ? options.bootstrapScriptContent : undefined,
options ? options.bootstrapScripts : undefined,
options ? options.bootstrapModules : undefined,
),
createRootFormatContext(options ? options.namespaceURI : undefined),
options ? options.progressiveChunkSize : undefined,
Expand Down
6 changes: 6 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzServerNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ type Options = {|
identifierPrefix?: string,
namespaceURI?: string,
nonce?: string,
bootstrapScriptContent?: string,
bootstrapScripts?: Array<string>,
bootstrapModules?: Array<string>,
progressiveChunkSize?: number,
onCompleteShell?: () => void,
onCompleteAll?: () => void,
Expand All @@ -51,6 +54,9 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
createResponseState(
options ? options.identifierPrefix : undefined,
options ? options.nonce : undefined,
options ? options.bootstrapScriptContent : undefined,
options ? options.bootstrapScripts : undefined,
options ? options.bootstrapModules : undefined,
),
createRootFormatContext(options ? options.namespaceURI : undefined),
options ? options.progressiveChunkSize : undefined,
Expand Down
48 changes: 48 additions & 0 deletions packages/react-dom/src/server/ReactDOMServerFormatConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const isPrimaryRenderer = true;

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

const startInlineScript = stringToPrecomputedChunk('<script>');
const endInlineScript = stringToPrecomputedChunk('</script>');

const startScriptSrc = stringToPrecomputedChunk('<script src="');
const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');

// Allows us to keep track of what we've already written so we can refer back to it.
export function createResponseState(
identifierPrefix: string | void,
nonce: string | void,
bootstrapScriptContent: string | void,
bootstrapScripts: Array<string> | void,
bootstrapModules: Array<string> | void,
): ResponseState {
const idPrefix = identifierPrefix === undefined ? '' : identifierPrefix;
const inlineScriptWithNonce =
Expand All @@ -86,7 +95,34 @@ export function createResponseState(
: stringToPrecomputedChunk(
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
);
const bootstrapChunks = [];
if (bootstrapScriptContent !== undefined) {
bootstrapChunks.push(
inlineScriptWithNonce,
stringToChunk(escapeTextForBrowser(bootstrapScriptContent)),
endInlineScript,
);
}
if (bootstrapScripts !== undefined) {
for (let i = 0; i < bootstrapScripts.length; i++) {
bootstrapChunks.push(
startScriptSrc,
stringToChunk(escapeTextForBrowser(bootstrapScripts[i])),
endAsyncScript,
);
}
}
if (bootstrapModules !== undefined) {
for (let i = 0; i < bootstrapModules.length; i++) {
bootstrapChunks.push(
startModuleSrc,
stringToChunk(escapeTextForBrowser(bootstrapModules[i])),
endAsyncScript,
);
}
}
return {
bootstrapChunks: bootstrapChunks,
startInlineScript: inlineScriptWithNonce,
placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
Expand Down Expand Up @@ -1370,6 +1406,18 @@ export function pushEndInstance(
}
}

export function writeCompletedRoot(
destination: Destination,
responseState: ResponseState,
): boolean {
const bootstrapChunks = responseState.bootstrapChunks;
let result = true;
for (let i = 0; i < bootstrapChunks.length; i++) {
result = writeChunk(destination, bootstrapChunks[i]);
}
return result;
}

// Structural Nodes

// A placeholder is a node inside a hidden partial tree that can be filled in later, but before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const isPrimaryRenderer = false;

export type ResponseState = {
// Keep this in sync with ReactDOMServerFormatConfig
bootstrapChunks: Array<Chunk | PrecomputedChunk>,
startInlineScript: PrecomputedChunk,
placeholderPrefix: PrecomputedChunk,
segmentPrefix: PrecomputedChunk,
Expand All @@ -50,6 +51,7 @@ export function createResponseState(
const responseState = createResponseStateImpl(identifierPrefix, undefined);
return {
// Keep this in sync with ReactDOMServerFormatConfig
bootstrapChunks: responseState.bootstrapChunks,
startInlineScript: responseState.startInlineScript,
placeholderPrefix: responseState.placeholderPrefix,
segmentPrefix: responseState.segmentPrefix,
Expand Down Expand Up @@ -95,6 +97,7 @@ export {
writeStartPendingSuspenseBoundary,
writeEndPendingSuspenseBoundary,
writePlaceholder,
writeCompletedRoot,
} from './ReactDOMServerFormatConfig';

import {stringToChunk} from 'react-server/src/ReactServerStreamConfig';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ export function pushEndInstance(
target.push(END);
}

export function writeCompletedRoot(
destination: Destination,
responseState: ResponseState,
): boolean {
return true;
}

// IDs are formatted as little endian Uint16
function formatID(id: number): Uint8Array {
if (id > 0xffff) {
Expand Down
7 changes: 7 additions & 0 deletions packages/react-noop-renderer/src/ReactNoopServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ const ReactNoopServer = ReactFizzServer({
target.push(POP);
},

writeCompletedRoot(
destination: Destination,
responseState: ResponseState,
): boolean {
return true;
},

writePlaceholder(
destination: Destination,
responseState: ResponseState,
Expand Down
11 changes: 10 additions & 1 deletion packages/react-server-dom-relay/src/ReactDOMServerFB.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import {

type Options = {
identifierPrefix?: string,
bootstrapScriptContent?: string,
bootstrapScripts: Array<string>,
bootstrapModules: Array<string>,
progressiveChunkSize?: number,
onError: (error: mixed) => void,
};
Expand All @@ -46,7 +49,13 @@ function renderToStream(children: ReactNodeList, options: Options): Stream {
};
const request = createRequest(
children,
createResponseState(options ? options.identifierPrefix : undefined),
createResponseState(
options ? options.identifierPrefix : undefined,
undefined,
options ? options.bootstrapScriptContent : undefined,
options ? options.bootstrapScripts : undefined,
options ? options.bootstrapModules : undefined,
),
createRootFormatContext(undefined),
options ? options.progressiveChunkSize : undefined,
options.onError,
Expand Down
Loading

0 comments on commit cdb8a1d

Please sign in to comment.