Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Add option to inject bootstrap scripts
These are emitted right after the shell as flushed.
  • Loading branch information
sebmarkbage committed Oct 20, 2021
commit 9945e633cf70522b53b60299f994074ceaa2f2bf
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
2 changes: 2 additions & 0 deletions packages/react-server/src/ReactFizzServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
closeWithError,
} from './ReactServerStreamConfig';
import {
writeCompletedRoot,
writePlaceholder,
writeStartCompletedSuspenseBoundary,
writeStartPendingSuspenseBoundary,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down