[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
This commit is contained in:
Sebastian Markbåge 2021-10-19 22:36:10 -04:00 committed by GitHub
parent 3677c019af
commit cdb8a1d19d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 5445 additions and 21 deletions

View File

@ -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;

View File

@ -46,7 +46,6 @@ export default class Chrome extends Component {
__html: `assetManifest = ${JSON.stringify(assets)};`,
}}
/>
<script src={assets['main.js']} />
</body>
</html>
);

View File

@ -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==
@ -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"

View File

@ -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",

View File

@ -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;

View File

@ -28,7 +28,6 @@ export default function Html({assets, children, title}) {
__html: `assetManifest = ${JSON.stringify(assets)};`,
}}
/>
<script async src={assets['main.js']} />
</body>
</html>
);

5277
fixtures/ssr2/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@ let useSyncExternalStore;
let useSyncExternalStoreExtra;
let PropTypes;
let textCache;
let window;
let document;
let writable;
let CSPnonce = null;
@ -56,6 +57,7 @@ describe('ReactDOMFizzServer', () => {
runScripts: 'dangerously',
},
);
window = jsdom.window;
document = jsdom.window.document;
container = document.getElementById('container');
@ -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);
},
@ -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.
@ -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.

View File

@ -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;

View File

@ -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();

View File

@ -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,
@ -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,

View File

@ -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,
@ -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,

View File

@ -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,
@ -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 =
@ -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:'),
@ -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

View File

@ -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,
@ -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,
@ -95,6 +97,7 @@ export {
writeStartPendingSuspenseBoundary,
writeEndPendingSuspenseBoundary,
writePlaceholder,
writeCompletedRoot,
} from './ReactDOMServerFormatConfig';
import {stringToChunk} from 'react-server/src/ReactServerStreamConfig';

View File

@ -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) {

View File

@ -126,6 +126,13 @@ const ReactNoopServer = ReactFizzServer({
target.push(POP);
},
writeCompletedRoot(
destination: Destination,
responseState: ResponseState,
): boolean {
return true;
},
writePlaceholder(
destination: Destination,
responseState: ResponseState,

View File

@ -28,6 +28,9 @@ import {
type Options = {
identifierPrefix?: string,
bootstrapScriptContent?: string,
bootstrapScripts: Array<string>,
bootstrapModules: Array<string>,
progressiveChunkSize?: number,
onError: (error: mixed) => void,
};
@ -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,

View File

@ -48,6 +48,21 @@ describe('ReactDOMServerFB', () => {
expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
});
it('should emit bootstrap script src at the end', () => {
const stream = ReactDOMServer.renderToStream(<div>hello world</div>, {
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
onError(x) {
console.error(x);
},
});
const result = 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>"`,
);
});
it('emits all HTML as one unit if we wait until the end to start', async () => {
let hasLoaded = false;
let resolve;

View File

@ -36,6 +36,7 @@ import {
closeWithError,
} from './ReactServerStreamConfig';
import {
writeCompletedRoot,
writePlaceholder,
writeStartCompletedSuspenseBoundary,
writeStartPendingSuspenseBoundary,
@ -1779,6 +1780,7 @@ function flushCompletedQueues(
if (completedRootSegment !== null && request.pendingRootTasks === 0) {
flushSegment(request, destination, completedRootSegment);
request.completedRootSegment = null;
writeCompletedRoot(destination, request.responseState);
}
// We emit client rendering instructions for already emitted boundaries first.

View File

@ -44,6 +44,7 @@ export const pushStartCompletedSuspenseBoundary =
$$$hostConfig.pushStartCompletedSuspenseBoundary;
export const pushEndCompletedSuspenseBoundary =
$$$hostConfig.pushEndCompletedSuspenseBoundary;
export const writeCompletedRoot = $$$hostConfig.writeCompletedRoot;
export const writePlaceholder = $$$hostConfig.writePlaceholder;
export const writeStartCompletedSuspenseBoundary =
$$$hostConfig.writeStartCompletedSuspenseBoundary;