From 45a1577c2b8b26fa1dce68337edc0b809c5d8ca2 Mon Sep 17 00:00:00 2001
From: Josh Story
Date: Tue, 10 Jan 2023 13:25:35 -0800
Subject: [PATCH] ensure fallback bootstrap scripts get used in fallback cases
---
.../src/server/ReactDOMServerFormatConfig.js | 50 ++-
.../ReactDOMServerLegacyFormatConfig.js | 4 +
.../src/__tests__/ReactDOMFizzServer-test.js | 402 ++++++++++++++++++
.../ReactDOMFizzServerBrowser-test.js | 22 +
.../__tests__/ReactDOMFizzServerNode-test.js | 16 +
.../src/server/ReactDOMFizzServerNode.js | 2 +-
packages/react-server/src/ReactFizzServer.js | 10 +-
7 files changed, 480 insertions(+), 26 deletions(-)
diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
index e5f39dfe0b168..1d3a9913517d3 100644
--- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
+++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
@@ -2442,27 +2442,29 @@ export function writeEarlyPreamble(
// If we emitted a preamble early it will have flushed and .
// We check that we haven't flushed anything yet which is equivalent
// to checking whether we have not flushed an or
- if (responseState.flushed === NONE && responseState.rendered !== NONE) {
- let i = 0;
- const {htmlChunks, headChunks} = responseState;
- if (htmlChunks.length) {
- for (i = 0; i < htmlChunks.length; i++) {
- writeChunk(destination, htmlChunks[i]);
+ if (responseState.rendered !== NONE) {
+ if (responseState.flushed === NONE) {
+ let i = 0;
+ const {htmlChunks, headChunks} = responseState;
+ if (htmlChunks.length) {
+ for (i = 0; i < htmlChunks.length; i++) {
+ writeChunk(destination, htmlChunks[i]);
+ }
+ } else {
+ writeChunk(destination, DOCTYPE);
+ writeChunk(destination, startChunkForTag('html'));
+ writeChunk(destination, endOfStartTag);
}
- } else {
- writeChunk(destination, DOCTYPE);
- writeChunk(destination, startChunkForTag('html'));
- writeChunk(destination, endOfStartTag);
- }
- if (headChunks.length) {
- for (i = 0; i < headChunks.length; i++) {
- writeChunk(destination, headChunks[i]);
+ if (headChunks.length) {
+ for (i = 0; i < headChunks.length; i++) {
+ writeChunk(destination, headChunks[i]);
+ }
+ } else {
+ writeChunk(destination, startChunkForTag('head'));
+ writeChunk(destination, endOfStartTag);
}
- } else {
- writeChunk(destination, startChunkForTag('head'));
- writeChunk(destination, endOfStartTag);
+ responseState.flushed |= HTML | HEAD;
}
- responseState.flushed |= HTML | HEAD;
return writeEarlyResources(
destination,
@@ -2587,14 +2589,16 @@ export function prepareForFallback(responseState: ResponseState): void {
if (__DEV__) {
(responseState: any).inFallbackDEV = true;
}
- // This function would ideally check somethign to see whether embedding
- // was required however at the moment the only time we use a Request Fallback
- // is when we use renderIntoDocument which is the only variant where which
- // utilizes fallback children. We assume we're in that mode if this function
- // is called and update the requirement accordingly
+ // Reset rendered states
responseState.htmlChunks = [];
responseState.headChunks = [];
responseState.rendered = NONE;
+
+ // Move fallback bootstrap to bootstrap if configured
+ const fallbackBootstrapChunks = responseState.fallbackBootstrapChunks;
+ if (fallbackBootstrapChunks && fallbackBootstrapChunks.length) {
+ responseState.bootstrapChunks = fallbackBootstrapChunks;
+ }
}
export function writeCompletedRoot(
diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js
index 84dd47e706f94..cfc0d3140c435 100644
--- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js
+++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js
@@ -44,6 +44,8 @@ export type ResponseState = {
requiresEmbedding: boolean,
rendered: DocumentStructureTag,
flushed: DocumentStructureTag,
+ charsetChunks: Array,
+ hoistableChunks: Array,
placeholderPrefix: PrecomputedChunk,
segmentPrefix: PrecomputedChunk,
boundaryPrefix: string,
@@ -87,6 +89,8 @@ export function createResponseState(
requiresEmbedding: false,
rendered: NONE,
flushed: NONE,
+ charsetChunks: [],
+ hoistableChunks: [],
placeholderPrefix: responseState.placeholderPrefix,
segmentPrefix: responseState.segmentPrefix,
boundaryPrefix: responseState.boundaryPrefix,
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 6510c8fea2a35..335e4b8842af2 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -212,6 +212,12 @@ describe('ReactDOMFizzServer', () => {
while (true) {
const bufferedContent = buffer;
+ document.__headOpen =
+ document.__headOpen ||
+ ((bufferedContent.includes('') ||
+ bufferedContent.includes(''));
+
let parent;
let temporaryHostElement;
if (bufferedContent.startsWith('')) {
@@ -255,6 +261,12 @@ describe('ReactDOMFizzServer', () => {
temporaryHostElement = document.createElement('head');
temporaryHostElement.innerHTML = headContent;
buffer = bodyContent;
+ document.__headOpen = false;
+ } else if (document.__headOpen) {
+ parent = document.head;
+ temporaryHostElement = document.createElement('head');
+ temporaryHostElement.innerHTML = bufferedContent;
+ buffer = '';
} else {
parent = document.body;
temporaryHostElement = document.createElement('body');
@@ -6154,6 +6166,7 @@ describe('ReactDOMFizzServer', () => {
});
describe('renderIntoDocument', () => {
+ // @gate enableFloat && enableFizzIntoDocument
it('can render arbitrary HTML into a Document', async () => {
let content = '';
writable.on('data', chunk => (content += chunk));
@@ -6178,6 +6191,7 @@ describe('ReactDOMFizzServer', () => {
);
});
+ // @gate enableFloat && enableFizzIntoDocument
it('can render into a Document', async () => {
let content = '';
writable.on('data', chunk => (content += chunk));
@@ -6200,6 +6214,7 @@ describe('ReactDOMFizzServer', () => {
);
});
+ // @gate enableFloat && enableFizzIntoDocument
it('can render into a Document', async () => {
let content = '';
writable.on('data', chunk => (content += chunk));
@@ -6233,6 +6248,7 @@ describe('ReactDOMFizzServer', () => {
);
});
+ // @gate enableFloat && enableFizzIntoDocument
it('inserts an empty head when rendering if no is provided', async () => {
let content = '';
writable.on('data', chunk => (content += chunk));
@@ -6257,6 +6273,7 @@ describe('ReactDOMFizzServer', () => {
);
});
+ // @gate enableFloat && enableFizzIntoDocument
it('can render a fallback if the shell errors', async () => {
function Throw() {
throw new Error('uh oh');
@@ -6299,6 +6316,7 @@ describe('ReactDOMFizzServer', () => {
);
});
+ // @gate enableFloat && enableFizzIntoDocument
it('can render a fallback if the shell errors even if the preamble has already been flushed', async () => {
function Throw() {
throw new Error('uh oh');
@@ -6370,5 +6388,389 @@ describe('ReactDOMFizzServer', () => {
,
);
});
+
+ // @gate enableFloat && enableFizzIntoDocument
+ it('can render an empty fallback', async () => {
+ function Throw() {
+ throw new Error('uh oh');
+ }
+
+ let content = '';
+ writable.on('data', chunk => (content += chunk));
+
+ let didBootstrap = false;
+ function bootstrap() {
+ didBootstrap = true;
+ }
+ window.__INIT__ = bootstrap;
+
+ const errors = [];
+ await act(() => {
+ const {pipe} = renderIntoDocumentAsPipeableStream(
+
+
+
+
+
+
+ ,
+ undefined,
+ {
+ onError(err) {
+ errors.push(err.message);
+ },
+ fallbackBootstrapScriptContent: '__INIT__()',
+ },
+ );
+ pipe(writable);
+ });
+
+ expect(errors).toEqual(['uh oh']);
+ expect(didBootstrap).toBe(true);
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ // @gate enableFloat && enableFizzIntoDocument
+ it('emits fallback bootstrap scripts if configured when rendering the fallback shell', async () => {
+ function Throw() {
+ throw new Error('uh oh');
+ }
+
+ let didBootstrap = false;
+ function bootstrap() {
+ didBootstrap = true;
+ }
+ window.__INIT__ = bootstrap;
+
+ let didFallback = false;
+ function fallback() {
+ didFallback = true;
+ }
+ window.__FALLBACK_INIT__ = fallback;
+
+ const errors = [];
+ await act(() => {
+ const {pipe} = renderIntoDocumentAsPipeableStream(
+
+
+ hello world
+
+
+ ,
+ fallback
,
+ {
+ onError(err) {
+ errors.push(err.message);
+ },
+ bootstrapScriptContent: '__INIT__();',
+ fallbackBootstrapScriptContent: '__FALLBACK_INIT__();',
+ },
+ );
+ pipe(writable);
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+
+ fallback
+
+ ,
+ );
+
+ expect(didBootstrap).toBe(false);
+ expect(didFallback).toBe(true);
+ });
+
+ // @gate enableFloat && enableFizzIntoDocument
+ it('emits bootstrap scripts if no fallback bootstrap scripts are configured when rendering the fallback shell', async () => {
+ function Throw() {
+ throw new Error('uh oh');
+ }
+
+ let didBootstrap = false;
+ function bootstrap() {
+ didBootstrap = true;
+ }
+ window.__INIT__ = bootstrap;
+
+ const errors = [];
+ await act(() => {
+ const {pipe} = renderIntoDocumentAsPipeableStream(
+
+
+
+ hello world
+
+ ,
+ fallback
,
+ {
+ onError(err) {
+ errors.push(err.message);
+ },
+ bootstrapScriptContent: '__INIT__();',
+ },
+ );
+ pipe(writable);
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+ fallback
+
+ ,
+ );
+
+ expect(didBootstrap).toBe(true);
+ });
+
+ // @gate enableFloat && enableFizzIntoDocument
+ it('does not work on the fallback unless the primary children error in the shell', async () => {
+ function Throw() {
+ throw new Error('uh oh');
+ }
+
+ const logs = [];
+ function BlockOn({value, children}) {
+ readText(value);
+ logs.push(value);
+ return children;
+ }
+
+ const errors = [];
+ await act(() => {
+ const {pipe} = renderIntoDocumentAsPipeableStream(
+
+
+
+
+
+
+
+
+ hello world
+
+ ,
+
+ fallback
+
,
+ {
+ onError(err) {
+ errors.push(err.message);
+ },
+ },
+ );
+ pipe(writable);
+ });
+
+ expect(logs).toEqual([]);
+ logs.length = 0;
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+ fallback
+
+ ,
+ );
+ });
+
+ // @gate enableFloat && enableFizzIntoDocument
+ it('only emits stylesheets up to the first precedence during the early preamble', async () => {
+ function BlockOn({value, children}) {
+ readText(value);
+ return children;
+ }
+
+ await act(() => {
+ const {pipe} = renderIntoDocumentAsPipeableStream(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ hello world
+
+ ,
+ );
+ pipe(writable);
+ });
+
+ expect(getMeaningfulChildren(document)).toEqual(
+
+ hello world
+ ,
+ );
+ });
+
+ // @gate enableFloat && enableFizzIntoDocument
+ it('errors fatally if the fallback shell errors', async () => {
+ let content = '';
+ writable.on('data', chunk => (content += chunk));
+ function Throw({reason}) {
+ throw new Error(reason);
+ }
+
+ const errors = [];
+ try {
+ await act(() => {
+ const {pipe} = renderIntoDocumentAsPipeableStream(
+
+
+
+ hello world
+
+ ,
+
+
+ ,
+ {
+ onError(error) {
+ errors.push(error.message);
+ },
+ },
+ );
+ pipe(writable);
+ });
+ } catch (fatal) {}
+
+ expect(hasErrored).toBe(true);
+ expect(fatalError.message).toBe(
+ 'The fallback App failed for some reason',
+ );
+ });
});
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
index 6ccc1cc3db380..628f6a2d2ca54 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
@@ -645,4 +645,26 @@ describe('ReactDOMFizzServerBrowser', () => {
);
});
});
+
+ describe('renderIntoDocument', () => {
+ // @gate enableFloat && enableFizzIntoDocument
+ it('can render into a container', async () => {
+ let content = '';
+ await act(async () => {
+ const stream = ReactDOMFizzServer.renderIntoDocument(foo
);
+ const reader = stream.getReader();
+ while (true) {
+ const {done, value} = await reader.read();
+ if (done) {
+ return;
+ }
+ content += Buffer.from(value).toString('utf8');
+ }
+ });
+
+ expect(content).toEqual(
+ 'foo
',
+ );
+ });
+ });
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
index 2e92ae7cd750d..3f8c62dc27547 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
@@ -648,4 +648,20 @@ describe('ReactDOMFizzServerNode', () => {
).toBe(true);
});
});
+
+ describe('renderIntoDocumentAsPipeableStream', () => {
+ // @gate enableFloat && enableFizzIntoDocument
+ it('can render into a container', async () => {
+ const {writable, output} = getTestWritable();
+ const {pipe} = ReactDOMFizzServer.renderIntoDocumentAsPipeableStream(
+ foo
,
+ );
+ pipe(writable);
+ jest.runAllTimers();
+
+ expect(output.result).toEqual(
+ 'foo
',
+ );
+ });
+ });
});
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js
index 9604544f8ef5f..1ee26c542eb79 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js
@@ -283,7 +283,7 @@ function renderIntoDocumentAsPipeableStream(
let renderIntoDocumentAsPipeableStreamExport:
| void
| typeof renderIntoDocumentAsPipeableStream;
-if (enableFizzIntoContainer) {
+if (enableFizzIntoDocument) {
renderIntoDocumentAsPipeableStreamExport = renderIntoDocumentAsPipeableStream;
}
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 319eec5484d31..571aeb0cd5a64 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -344,7 +344,10 @@ export function createRequest(
);
pingedTasks.push(rootTask);
- if (fallback) {
+ // null is a valid fallback so we distinguish between undefined and null here
+ // If you use renderIntoDocument without a fallback argument the Request still
+ // has a null fallback and will exhibit fallback behavior
+ if (fallback !== undefined) {
const fallbackRootSegment = createPendingSegment(
request,
0,
@@ -1841,7 +1844,10 @@ function finishedTask(
abortTaskSoft.call(request, fallbackTask);
}
- if (segment.parentFlushed) {
+ // When the fallbackTask is aborted it still gets finished. We need to ensure we only
+ // set the completedRootSegment if the segment is not aborted to avoid having more than
+ // one set at a time.
+ if (segment.parentFlushed && segment.status !== ABORTED) {
if (request.completedRootSegment !== null) {
throw new Error(
'There can only be one root segment. This is a bug in React.',
+ ,
+ );
+
+ // Even though we unblock fallback since the task is not scheduled no log is observed
+ await act(() => {
+ resolveText('fallback');
+ });
+ expect(logs).toEqual([]);
+ logs.length = 0;
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+ ,
+ );
+
+ // When we resolve the resource it is emitted in the open preamble.
+ await act(() => {
+ resolveText('resource');
+ });
+ expect(logs).toEqual(['resource']);
+ logs.length = 0;
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+ ,
+ );
+
+ // When we resolve the resource it is emitted in the open preamble.
+ await act(() => {
+ resolveText('error');
+ });
+ expect(logs).toEqual(['error', 'fallback']);
+ logs.length = 0;
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+ ,
+ );
+
+ // We emit all resources that are unblocked which is most types except stylesheets.
+ // For stylesheets we can only emit up to the first precedence since we may discover
+ // additional stylesheets with this precedence level after this flush and would violate
+ // stylesheet order. For stylesheets we cannot emit we can emit a preload instead so the
+ // browser can start downloading the resource as soon as possible
+ await act(() => {
+ resolveText('one');
+ });
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+ ,
+ );
+
+ // We can continue to emit early resources according to these rules
+ await act(() => {
+ resolveText('two');
+ });
+ expect(getMeaningfulChildren(document)).toEqual(
+
+
+ ,
+ );
+
+ // Once the Shell is unblocked we can now emit the entire preamble as well as
+ // the main content
+ await act(() => {
+ resolveText('three');
+ });
+ expect(getMeaningfulChildren(document)).toEqual(
+
+