Skip to content
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
36 changes: 35 additions & 1 deletion fixtures/ssr/server/render.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import {renderToPipeableStream} from 'react-dom/server';
import {Writable} from 'stream';

import App from '../src/components/App';

Expand All @@ -14,19 +15,52 @@ if (process.env.NODE_ENV === 'development') {
assets = require('../build/asset-manifest.json');
}

class ThrottledWritable extends Writable {
constructor(destination) {
super();
this.destination = destination;
this.delay = 150;
}

_write(chunk, encoding, callback) {
let o = 0;
const write = () => {
this.destination.write(chunk.slice(o, o + 100), encoding, x => {
o += 100;
if (o < chunk.length) {
setTimeout(write, this.delay);
} else {
callback(x);
}
});
};
setTimeout(write, this.delay);
}

_final(callback) {
setTimeout(() => {
this.destination.end(callback);
}, this.delay);
}
}

export default function render(url, res) {
res.socket.on('error', error => {
// Log fatal errors
console.error('Fatal', error);
});
console.log('hello');
let didError = false;
const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
bootstrapScripts: [assets['main.js']],
onShellReady() {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
pipe(res);
// To test the actual chunks taking time to load over the network, we throttle
// the stream a bit.
const throttledResponse = new ThrottledWritable(res);
pipe(throttledResponse);
},
onShellError(x) {
// Something errored before we could complete the shell so we emit an alternative shell.
Expand Down
1 change: 1 addition & 0 deletions fixtures/ssr/src/components/Chrome.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default class Chrome extends Component {
</div>
</Theme.Provider>
</Suspense>
<p>This should appear in the first paint.</p>
<script
dangerouslySetInnerHTML={{
__html: `assetManifest = ${JSON.stringify(assets)};`,
Expand Down
136 changes: 117 additions & 19 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,13 @@ const ScriptStreamingFormat: StreamingFormat = 0;
const DataStreamingFormat: StreamingFormat = 1;

export type InstructionState = number;
const NothingSent /* */ = 0b00000;
const SentCompleteSegmentFunction /* */ = 0b00001;
const SentCompleteBoundaryFunction /* */ = 0b00010;
const SentClientRenderFunction /* */ = 0b00100;
const SentStyleInsertionFunction /* */ = 0b01000;
const SentFormReplayingRuntime /* */ = 0b10000;
const NothingSent /* */ = 0b000000;
const SentCompleteSegmentFunction /* */ = 0b000001;
const SentCompleteBoundaryFunction /* */ = 0b000010;
const SentClientRenderFunction /* */ = 0b000100;
const SentStyleInsertionFunction /* */ = 0b001000;
const SentFormReplayingRuntime /* */ = 0b010000;
const SentCompletedShellId /* */ = 0b100000;

// Per request, global state that is not contextual to the rendering subtree.
// This cannot be resumed and therefore should only contain things that are
Expand Down Expand Up @@ -289,15 +290,15 @@ export type ResumableState = {

const dataElementQuotedEnd = stringToPrecomputedChunk('"></template>');

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

const startScriptSrc = stringToPrecomputedChunk('<script src="');
const startModuleSrc = stringToPrecomputedChunk('<script type="module" src="');
const scriptNonce = stringToPrecomputedChunk('" nonce="');
const scriptIntegirty = stringToPrecomputedChunk('" integrity="');
const scriptCrossOrigin = stringToPrecomputedChunk('" crossorigin="');
const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
const scriptNonce = stringToPrecomputedChunk(' nonce="');
const scriptIntegirty = stringToPrecomputedChunk(' integrity="');
const scriptCrossOrigin = stringToPrecomputedChunk(' crossorigin="');
const endAsyncScript = stringToPrecomputedChunk(' async=""></script>');

/**
* This escaping function is designed to work with with inline scripts where the entire
Expand Down Expand Up @@ -367,7 +368,7 @@ export function createRenderState(
nonce === undefined
? startInlineScript
: stringToPrecomputedChunk(
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
'<script nonce="' + escapeTextForBrowser(nonce) + '"',
);
const idPrefix = resumableState.idPrefix;

Expand All @@ -376,8 +377,10 @@ export function createRenderState(
const {bootstrapScriptContent, bootstrapScripts, bootstrapModules} =
resumableState;
if (bootstrapScriptContent !== undefined) {
bootstrapChunks.push(inlineScriptWithNonce);
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(
inlineScriptWithNonce,
endOfStartTag,
stringToChunk(escapeEntireInlineScriptContent(bootstrapScriptContent)),
endInlineScript,
);
Expand Down Expand Up @@ -527,25 +530,30 @@ export function createRenderState(
bootstrapChunks.push(
startScriptSrc,
stringToChunk(escapeTextForBrowser(src)),
attributeEnd,
);
if (nonce) {
bootstrapChunks.push(
scriptNonce,
stringToChunk(escapeTextForBrowser(nonce)),
attributeEnd,
);
}
if (typeof integrity === 'string') {
bootstrapChunks.push(
scriptIntegirty,
stringToChunk(escapeTextForBrowser(integrity)),
attributeEnd,
);
}
if (typeof crossOrigin === 'string') {
bootstrapChunks.push(
scriptCrossOrigin,
stringToChunk(escapeTextForBrowser(crossOrigin)),
attributeEnd,
);
}
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(endAsyncScript);
}
}
Expand Down Expand Up @@ -579,26 +587,30 @@ export function createRenderState(
bootstrapChunks.push(
startModuleSrc,
stringToChunk(escapeTextForBrowser(src)),
attributeEnd,
);

if (nonce) {
bootstrapChunks.push(
scriptNonce,
stringToChunk(escapeTextForBrowser(nonce)),
attributeEnd,
);
}
if (typeof integrity === 'string') {
bootstrapChunks.push(
scriptIntegirty,
stringToChunk(escapeTextForBrowser(integrity)),
attributeEnd,
);
}
if (typeof crossOrigin === 'string') {
bootstrapChunks.push(
scriptCrossOrigin,
stringToChunk(escapeTextForBrowser(crossOrigin)),
attributeEnd,
);
}
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(endAsyncScript);
}
}
Expand Down Expand Up @@ -1960,11 +1972,32 @@ function injectFormReplayingRuntime(
(!enableFizzExternalRuntime || !renderState.externalRuntimeScript)
) {
resumableState.instructions |= SentFormReplayingRuntime;
renderState.bootstrapChunks.unshift(
renderState.startInlineScript,
formReplayingRuntimeScript,
endInlineScript,
);
const preamble = renderState.preamble;
const bootstrapChunks = renderState.bootstrapChunks;
if (
(preamble.htmlChunks || preamble.headChunks) &&
bootstrapChunks.length === 0
) {
// If we rendered the whole document, then we emitted a rel="expect" that needs a
// matching target. If we haven't emitted that yet, we need to include it in this
// script tag.
bootstrapChunks.push(renderState.startInlineScript);
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(
endOfStartTag,
formReplayingRuntimeScript,
endInlineScript,
);
} else {
// Otherwise we added to the beginning of the scripts. This will mean that it
// appears before the shell ID unfortunately.
bootstrapChunks.unshift(
renderState.startInlineScript,
endOfStartTag,
formReplayingRuntimeScript,
endInlineScript,
);
}
}
}

Expand Down Expand Up @@ -4075,8 +4108,21 @@ function writeBootstrap(

export function writeCompletedRoot(
destination: Destination,
resumableState: ResumableState,
renderState: RenderState,
): boolean {
const preamble = renderState.preamble;
if (preamble.htmlChunks || preamble.headChunks) {
// If we rendered the whole document, then we emitted a rel="expect" that needs a
// matching target. Normally we use one of the bootstrap scripts for this but if
// there are none, then we need to emit a tag to complete the shell.
if ((resumableState.instructions & SentCompletedShellId) === NothingSent) {
const bootstrapChunks = renderState.bootstrapChunks;
bootstrapChunks.push(startChunkForTag('template'));
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(endOfStartTag, endChunkForTag('template'));
}
}
return writeBootstrap(destination, renderState);
}

Expand Down Expand Up @@ -4400,6 +4446,7 @@ export function writeCompletedSegmentInstruction(
resumableState.streamingFormat === ScriptStreamingFormat;
if (scriptFormat) {
writeChunk(destination, renderState.startInlineScript);
writeChunk(destination, endOfStartTag);
if (
(resumableState.instructions & SentCompleteSegmentFunction) ===
NothingSent
Expand Down Expand Up @@ -4481,6 +4528,7 @@ export function writeCompletedBoundaryInstruction(
resumableState.streamingFormat === ScriptStreamingFormat;
if (scriptFormat) {
writeChunk(destination, renderState.startInlineScript);
writeChunk(destination, endOfStartTag);
if (requiresStyleInsertion) {
if (
(resumableState.instructions & SentCompleteBoundaryFunction) ===
Expand Down Expand Up @@ -4591,6 +4639,7 @@ export function writeClientRenderBoundaryInstruction(
resumableState.streamingFormat === ScriptStreamingFormat;
if (scriptFormat) {
writeChunk(destination, renderState.startInlineScript);
writeChunk(destination, endOfStartTag);
if (
(resumableState.instructions & SentClientRenderFunction) ===
NothingSent
Expand Down Expand Up @@ -4933,6 +4982,44 @@ function preloadLateStyles(this: Destination, styleQueue: StyleQueue) {
styleQueue.sheets.clear();
}

const blockingRenderChunkStart = stringToPrecomputedChunk(
'<link rel="expect" href="#',
);
const blockingRenderChunkEnd = stringToPrecomputedChunk(
'" blocking="render"/>',
);

function writeBlockingRenderInstruction(
destination: Destination,
resumableState: ResumableState,
renderState: RenderState,
): void {
const idPrefix = resumableState.idPrefix;
const shellId = '\u00AB' + idPrefix + 'R\u00BB';
writeChunk(destination, blockingRenderChunkStart);
writeChunk(destination, stringToChunk(escapeTextForBrowser(shellId)));
writeChunk(destination, blockingRenderChunkEnd);
}

const completedShellIdAttributeStart = stringToPrecomputedChunk(' id="');

function pushCompletedShellIdAttribute(
target: Array<Chunk | PrecomputedChunk>,
resumableState: ResumableState,
): void {
if ((resumableState.instructions & SentCompletedShellId) !== NothingSent) {
return;
}
resumableState.instructions |= SentCompletedShellId;
const idPrefix = resumableState.idPrefix;
const shellId = '\u00AB' + idPrefix + 'R\u00BB';
target.push(
completedShellIdAttributeStart,
stringToChunk(escapeTextForBrowser(shellId)),
attributeEnd,
);
}

// We don't bother reporting backpressure at the moment because we expect to
// flush the entire preamble in a single pass. This probably should be modified
// in the future to be backpressure sensitive but that requires a larger refactor
Expand All @@ -4942,6 +5029,7 @@ export function writePreambleStart(
resumableState: ResumableState,
renderState: RenderState,
willFlushAllSegments: boolean,
skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
): void {
// This function must be called exactly once on every request
if (
Expand Down Expand Up @@ -5027,6 +5115,16 @@ export function writePreambleStart(
renderState.bulkPreloads.forEach(flushResource, destination);
renderState.bulkPreloads.clear();

if ((htmlChunks || headChunks) && !skipExpect) {
// If we have any html or head chunks we know that we're rendering a full document.
// A full document should block display until the full shell has downloaded.
// Therefore we insert a render blocking instruction referring to the last body
// element that's considered part of the shell. We do this after the important loads
// have already been emitted so we don't do anything to delay them but early so that
// the browser doesn't risk painting too early.
writeBlockingRenderInstruction(destination, resumableState, renderState);
}

// Write embedding hoistableChunks
const hoistableChunks = renderState.hoistableChunks;
for (i = 0; i < hoistableChunks.length; i++) {
Expand Down
11 changes: 6 additions & 5 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3580,7 +3580,8 @@ describe('ReactDOMFizzServer', () => {
expect(document.head.innerHTML).toBe(
'<script type="importmap">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>',
'</script><script async="" src="foo"></script>' +
'<link rel="expect" href="#«R»" blocking="render">',
);
});

Expand Down Expand Up @@ -4189,7 +4190,7 @@ describe('ReactDOMFizzServer', () => {
renderOptions.unstable_externalRuntimeSrc,
).map(n => n.outerHTML),
).toEqual([
'<script src="foo" async=""></script>',
'<script src="foo" id="«R»" async=""></script>',
'<script src="bar" async=""></script>',
'<script src="baz" integrity="qux" async=""></script>',
'<script type="module" src="quux" async=""></script>',
Expand Down Expand Up @@ -4276,7 +4277,7 @@ describe('ReactDOMFizzServer', () => {
renderOptions.unstable_externalRuntimeSrc,
).map(n => n.outerHTML),
).toEqual([
'<script src="foo" async=""></script>',
'<script src="foo" id="«R»" async=""></script>',
'<script src="bar" async=""></script>',
'<script src="baz" crossorigin="" async=""></script>',
'<script src="qux" crossorigin="" async=""></script>',
Expand Down Expand Up @@ -4512,7 +4513,7 @@ describe('ReactDOMFizzServer', () => {

// the html should be as-is
expect(document.documentElement.innerHTML).toEqual(
'<head></head><body><p>hello world!</p></body>',
'<head><link rel="expect" href="#«R»" blocking="render"></head><body><p>hello world!</p><template id="«R»"></template></body>',
);
});

Expand Down Expand Up @@ -6492,7 +6493,7 @@ describe('ReactDOMFizzServer', () => {
});

expect(document.documentElement.outerHTML).toEqual(
'<html><head></head><body><script>try { foo() } catch (e) {} ;</script></body></html>',
'<html><head><link rel="expect" href="#«R»" blocking="render"></head><body><script>try { foo() } catch (e) {} ;</script><template id="«R»"></template></body></html>',
);
});

Expand Down
Loading
Loading