From c8c4d22321f70cc1ecea1ec9f487c07e65807194 Mon Sep 17 00:00:00 2001
From: Josh Story
Date: Fri, 5 Aug 2022 16:33:02 -0700
Subject: [PATCH 01/12] implement preamble and postambl for react-dom/server
---
.../src/__tests__/ReactDOMFizzServer-test.js | 137 +++++++++++++++++-
.../src/server/ReactDOMServerFormatConfig.js | 24 +++
.../src/ReactNoopServer.js | 2 +
packages/react-server/src/ReactFizzServer.js | 47 ++++--
4 files changed, 197 insertions(+), 13 deletions(-)
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 60a3a3938df3e..c18125a7b542a 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -128,6 +128,8 @@ describe('ReactDOMFizzServer', () => {
buffer = '';
const fakeBody = document.createElement('body');
fakeBody.innerHTML = bufferedContent;
+ const parent =
+ container.nodeName === '#document' ? container.body : container;
while (fakeBody.firstChild) {
const node = fakeBody.firstChild;
if (
@@ -137,13 +139,37 @@ describe('ReactDOMFizzServer', () => {
const script = document.createElement('script');
script.textContent = node.textContent;
fakeBody.removeChild(node);
- container.appendChild(script);
+ parent.appendChild(script);
} else {
- container.appendChild(node);
+ parent.appendChild(node);
}
}
}
+ async function actIntoEmptyDocument(callback) {
+ await callback();
+ // Await one turn around the event loop.
+ // This assumes that we'll flush everything we have so far.
+ await new Promise(resolve => {
+ setImmediate(resolve);
+ });
+ if (hasErrored) {
+ throw fatalError;
+ }
+ // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
+ // We also want to execute any scripts that are embedded.
+ // We assume that we have now received a proper fragment of HTML.
+ const bufferedContent = buffer;
+ // Test Environment
+ const jsdom = new JSDOM(bufferedContent, {
+ runScripts: 'dangerously',
+ });
+ window = jsdom.window;
+ document = jsdom.window.document;
+ container = document;
+ buffer = '';
+ }
+
function getVisibleChildren(element) {
const children = [];
let node = element.firstChild;
@@ -4194,6 +4220,113 @@ describe('ReactDOMFizzServer', () => {
);
});
+ it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+ <>
+ a title
+
+
+
a body
+
+ >,
+ );
+ pipe(writable);
+ });
+ expect(getVisibleChildren(document)).toEqual(
+
+
+ a title
+
+ a body
+ ,
+ );
+
+ // Hydrate the same thing on the client. We expect this to still fail because is not a Resource
+ // and is unmatched on hydration
+ const errors = [];
+ const root = ReactDOMClient.hydrateRoot(
+ document,
+ <>
+ a title
+
+
+ a body
+
+ >,
+ {
+ onRecoverableError: (err, errInfo) => {
+ errors.push(err.message);
+ },
+ },
+ );
+ expect(() => {
+ try {
+ expect(() => {
+ expect(Scheduler).toFlushWithoutYielding();
+ }).toThrow('Invalid insertion of HTML node in #document node.');
+ } catch (e) {
+ console.log('e', e);
+ }
+ }).toErrorDev(
+ [
+ 'Warning: Expected server HTML to contain a matching in <#document>.',
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
+ 'Warning: validateDOMNesting(...): cannot appear as a child of <#document>',
+ ],
+ {withoutStack: 1},
+ );
+ expect(errors).toEqual([
+ 'Hydration failed because the initial UI does not match what was rendered on the server.',
+ 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
+ ]);
+ });
+
+ it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => {
+ const chunks = [];
+ writable.on('data', chunk => {
+ chunks.push(chunk);
+ });
+
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+ first
+
+
+
+
+ ,
+ );
+ pipe(writable);
+ });
+
+ expect(getVisibleChildren(document)).toEqual(
+
+ {'first'}
+ ,
+ );
+
+ await act(() => {
+ resolveText('second');
+ });
+
+ expect(getVisibleChildren(document)).toEqual(
+
+
+ {'first'}
+ {'second'}
+
+ ,
+ );
+
+ expect(chunks.pop()).toEqual('');
+ });
+
describe('text separators', () => {
// To force performWork to start before resolving AsyncText but before piping we need to wait until
// after scheduleWork which currently uses setImmediate to delay performWork
diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
index 36c9469d60818..0dccd7ee34bd3 100644
--- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
@@ -242,6 +242,26 @@ export function getChildFormatContext(
return parentContext;
}
+export function isPreambleInsertion(type: string): boolean {
+ switch (type) {
+ case 'html':
+ case 'head': {
+ return true;
+ }
+ }
+ return false;
+}
+
+export function isPostambleInsertion(type: string): boolean {
+ switch (type) {
+ case 'body':
+ case 'html': {
+ return true;
+ }
+ }
+ return false;
+}
+
export type SuspenseBoundaryID = null | PrecomputedChunk;
export const UNINITIALIZED_SUSPENSE_BOUNDARY_ID: SuspenseBoundaryID = null;
@@ -1405,11 +1425,13 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('');
export function pushStartInstance(
target: Array,
+ preamble: Array,
type: string,
props: Object,
responseState: ResponseState,
formatContext: FormatContext,
): ReactNodeList {
+ target = isPreambleInsertion(type) ? preamble : target;
if (__DEV__) {
validateARIAProperties(type, props);
validateInputProperties(type, props);
@@ -1521,9 +1543,11 @@ const endTag2 = stringToPrecomputedChunk('>');
export function pushEndInstance(
target: Array,
+ postamble: Array,
type: string,
props: Object,
): void {
+ target = isPostambleInsertion(type) ? postamble : target;
switch (type) {
// Omitted close tags
// TODO: Instead of repeating this switch we could try to pass a flag from above.
diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js
index 14003b8291b37..e70de39ab3868 100644
--- a/packages/react-noop-renderer/src/ReactNoopServer.js
+++ b/packages/react-noop-renderer/src/ReactNoopServer.js
@@ -113,6 +113,7 @@ const ReactNoopServer = ReactFizzServer({
},
pushStartInstance(
target: Array,
+ preamble: Array,
type: string,
props: Object,
): ReactNodeList {
@@ -128,6 +129,7 @@ const ReactNoopServer = ReactFizzServer({
pushEndInstance(
target: Array,
+ postamble: Array,
type: string,
props: Object,
): void {
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index f2974e0507e1d..8b768601ddef0 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -200,6 +200,8 @@ export opaque type Request = {
clientRenderedBoundaries: Array, // Errored or client rendered but not yet flushed.
completedBoundaries: Array, // Completed but not yet fully flushed boundaries to show.
partialBoundaries: Array, // Partially completed boundaries that can flush its segments early.
+ +preamble: Array, // Chunks that need to be emitted before any segment chunks.
+ +postamble: Array, // Chunks that need to be emitted after segments, waiting for all pending root tasks to finish
// onError is called when an error happens anywhere in the tree. It might recover.
// The return string is used in production primarily to avoid leaking internals, secondarily to save bytes.
// Returning null/undefined will cause a defualt error message in production
@@ -272,6 +274,8 @@ export function createRequest(
clientRenderedBoundaries: [],
completedBoundaries: [],
partialBoundaries: [],
+ preamble: [],
+ postamble: [],
onError: onError === undefined ? defaultErrorHandler : onError,
onAllReady: onAllReady === undefined ? noop : onAllReady,
onShellReady: onShellReady === undefined ? noop : onShellReady,
@@ -632,6 +636,7 @@ function renderHostElement(
const segment = task.blockedSegment;
const children = pushStartInstance(
segment.chunks,
+ request.preamble,
type,
props,
request.responseState,
@@ -647,7 +652,7 @@ function renderHostElement(
// We expect that errors will fatal the whole task and that we don't need
// the correct context. Therefore this is not in a finally.
segment.formatContext = prevContext;
- pushEndInstance(segment.chunks, type, props);
+ pushEndInstance(segment.chunks, request.postamble, type, props);
segment.lastPushedText = false;
popComponentStackInDEV(task);
}
@@ -2054,6 +2059,7 @@ function flushCompletedQueues(
request: Request,
destination: Destination,
): void {
+ let allComplete = false;
beginWriting(destination);
try {
// The structure of this is to go through each queue one by one and write
@@ -2063,20 +2069,30 @@ function flushCompletedQueues(
// TODO: Emit preloading.
- // TODO: It's kind of unfortunate to keep checking this array after we've already
- // emitted the root.
+ let i;
const completedRootSegment = request.completedRootSegment;
- if (completedRootSegment !== null && request.pendingRootTasks === 0) {
- flushSegment(request, destination, completedRootSegment);
- request.completedRootSegment = null;
- writeCompletedRoot(destination, request.responseState);
+ if (completedRootSegment !== null) {
+ if (request.pendingRootTasks === 0) {
+ const preamble = request.preamble;
+ for (i = 0; i < preamble.length; i++) {
+ // we expect the preamble to be tiny and will ignore backpressure
+ writeChunk(destination, preamble[i]);
+ }
+ preamble.length = 0;
+
+ flushSegment(request, destination, completedRootSegment);
+ request.completedRootSegment = null;
+ writeCompletedRoot(destination, request.responseState);
+ } else {
+ // We haven't flushed the root yet so we don't need to check boundaries further down
+ return;
+ }
}
// We emit client rendering instructions for already emitted boundaries first.
// This is so that we can signal to the client to start client rendering them as
// soon as possible.
const clientRenderedBoundaries = request.clientRenderedBoundaries;
- let i;
for (i = 0; i < clientRenderedBoundaries.length; i++) {
const boundary = clientRenderedBoundaries[i];
if (!flushClientRenderedBoundary(request, destination, boundary)) {
@@ -2138,9 +2154,7 @@ function flushCompletedQueues(
}
}
largeBoundaries.splice(0, i);
- } finally {
- completeWriting(destination);
- flushBuffered(destination);
+
if (
request.allPendingTasks === 0 &&
request.pingedTasks.length === 0 &&
@@ -2149,6 +2163,17 @@ function flushCompletedQueues(
// We don't need to check any partially completed segments because
// either they have pending task or they're complete.
) {
+ allComplete = true;
+ const postamble = request.postamble;
+ for (i = 0; i < postamble.length; i++) {
+ writeChunk(destination, postamble[i]);
+ }
+ postamble.length = 0;
+ }
+ } finally {
+ completeWriting(destination);
+ flushBuffered(destination);
+ if (allComplete) {
if (__DEV__) {
if (request.abortableTasks.size !== 0) {
console.error(
From eb34ba299ce3e12ad6e2cd97665d56e93d27d683 Mon Sep 17 00:00:00 2001
From: Josh Story
Date: Mon, 8 Aug 2022 13:37:57 -0700
Subject: [PATCH 02/12] support hydrating resource stylesheets outside of
normal flow
---
.../src/__tests__/ReactDOMFizzServer-test.js | 121 +++++++++++++++++-
.../react-dom/src/client/ReactDOMComponent.js | 14 +-
.../src/client/ReactDOMHostConfig.js | 69 +++++++++-
.../src/server/ReactDOMServerFormatConfig.js | 61 ++++++++-
.../server/ReactNativeServerFormatConfig.js | 2 +
.../ReactFiberHostConfigWithNoHydration.js | 2 +
.../src/ReactFiberHydrationContext.new.js | 23 ++++
.../src/ReactFiberHydrationContext.old.js | 23 ++++
.../src/forks/ReactFiberHostConfig.custom.js | 3 +
packages/react-server/src/ReactFizzServer.js | 35 +++--
packages/shared/ReactFeatureFlags.js | 2 +
.../forks/ReactFeatureFlags.native-fb.js | 1 +
.../forks/ReactFeatureFlags.native-oss.js | 1 +
.../forks/ReactFeatureFlags.test-renderer.js | 1 +
.../ReactFeatureFlags.test-renderer.native.js | 1 +
.../ReactFeatureFlags.test-renderer.www.js | 1 +
.../shared/forks/ReactFeatureFlags.testing.js | 1 +
.../forks/ReactFeatureFlags.testing.www.js | 1 +
.../forks/ReactFeatureFlags.www-dynamic.js | 1 +
.../shared/forks/ReactFeatureFlags.www.js | 1 +
scripts/error-codes/codes.json | 4 +-
21 files changed, 344 insertions(+), 24 deletions(-)
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index c18125a7b542a..8ba29d37c5f2f 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -4220,6 +4220,7 @@ describe('ReactDOMFizzServer', () => {
);
});
+ // @gate enableFloat
it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
@@ -4245,7 +4246,7 @@ describe('ReactDOMFizzServer', () => {
// Hydrate the same thing on the client. We expect this to still fail because is not a Resource
// and is unmatched on hydration
const errors = [];
- const root = ReactDOMClient.hydrateRoot(
+ ReactDOMClient.hydrateRoot(
document,
<>
a title
@@ -4280,8 +4281,13 @@ describe('ReactDOMFizzServer', () => {
'Hydration failed because the initial UI does not match what was rendered on the server.',
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
]);
+ expect(getVisibleChildren(document)).toEqual();
+ expect(() => {
+ expect(Scheduler).toFlushWithoutYielding();
+ }).toThrow('The node to be removed is not a child of this node.');
});
+ // @gate enableFloat
it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => {
const chunks = [];
writable.on('data', chunk => {
@@ -4327,6 +4333,119 @@ describe('ReactDOMFizzServer', () => {
expect(chunks.pop()).toEqual('