Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

[Fizz] Add option to inject bootstrapping script tags after the shell is injected #22594

Merged
merged 3 commits into from
Oct 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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