Skip to content

Commit 59185b1

Browse files
committed
determine preamble while flushing and make postamble resilient to async render order
1 parent 35a7d75 commit 59185b1

File tree

7 files changed

+201
-124
lines changed

7 files changed

+201
-124
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 112 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4320,70 +4320,129 @@ describe('ReactDOMFizzServer', () => {
43204320
});
43214321

43224322
// @gate enableFloat
4323-
it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => {
4323+
it('can emit the preamble even if the head renders asynchronously', async () => {
4324+
function AsyncNoOutput() {
4325+
readText('nooutput');
4326+
return null;
4327+
}
4328+
function AsyncHead() {
4329+
readText('head');
4330+
return (
4331+
<head data-foo="foo">
4332+
<title>a title</title>
4333+
</head>
4334+
);
4335+
}
4336+
function AsyncBody() {
4337+
readText('body');
4338+
return (
4339+
<body data-bar="bar">
4340+
<link rel="preload" as="style" href="foo" />
4341+
hello
4342+
</body>
4343+
);
4344+
}
43244345
await actIntoEmptyDocument(() => {
43254346
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4326-
<>
4327-
<title data-baz="baz">a title</title>
4328-
<html data-foo="foo">
4329-
<head data-bar="bar" />
4330-
<body>a body</body>
4331-
</html>
4332-
</>,
4347+
<html data-html="html">
4348+
<AsyncNoOutput />
4349+
<AsyncHead />
4350+
<AsyncBody />
4351+
</html>,
43334352
);
43344353
pipe(writable);
43354354
});
4355+
await actIntoEmptyDocument(() => {
4356+
resolveText('body');
4357+
});
4358+
await actIntoEmptyDocument(() => {
4359+
resolveText('nooutput');
4360+
});
4361+
// We need to use actIntoEmptyDocument because act assumes that buffered
4362+
// content should be fake streamed into the body which is normally true
4363+
// but in this test the entire shell was delayed and we need the initial
4364+
// construction to be done to get the parsing right
4365+
await actIntoEmptyDocument(() => {
4366+
resolveText('head');
4367+
});
43364368
expect(getVisibleChildren(document)).toEqual(
4337-
<html data-foo="foo">
4338-
<head data-bar="bar">
4339-
<title data-baz="baz">a title</title>
4369+
<html data-html="html">
4370+
<head data-foo="foo">
4371+
<link rel="preload" as="style" href="foo" />
4372+
<title>a title</title>
43404373
</head>
4341-
<body>a body</body>
4374+
<body data-bar="bar">hello</body>
43424375
</html>,
43434376
);
4377+
});
43444378

4345-
// Hydrate the same thing on the client. We expect this to still fail because <title> is not a Resource
4346-
// and is unmatched on hydration
4347-
const errors = [];
4348-
ReactDOMClient.hydrateRoot(
4349-
document,
4350-
<>
4351-
<title data-baz="baz">a title</title>
4352-
<html data-foo="foo">
4353-
<head data-bar="bar" />
4354-
<body>a body</body>
4355-
</html>
4356-
</>,
4357-
{
4358-
onRecoverableError: (err, errInfo) => {
4359-
errors.push(err.message);
4360-
},
4361-
},
4362-
);
4363-
expect(() => {
4364-
try {
4365-
expect(() => {
4366-
expect(Scheduler).toFlushWithoutYielding();
4367-
}).toThrow('Invalid insertion of HTML node in #document node.');
4368-
} catch (e) {
4369-
console.log('e', e);
4370-
}
4371-
}).toErrorDev(
4372-
[
4373-
'Warning: Expected server HTML to contain a matching <title> in <#document>.',
4374-
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
4375-
'Warning: validateDOMNesting(...): <title> cannot appear as a child of <#document>',
4376-
],
4377-
{withoutStack: 1},
4379+
// @gate enableFloat
4380+
it('does not emit as preamble after the first non-preamble chunk', async () => {
4381+
function AsyncNoOutput() {
4382+
readText('nooutput');
4383+
return null;
4384+
}
4385+
function AsyncHead() {
4386+
readText('head');
4387+
return (
4388+
<head data-foo="foo">
4389+
<title>a title</title>
4390+
</head>
4391+
);
4392+
}
4393+
function AsyncBody() {
4394+
readText('body');
4395+
return (
4396+
<body data-bar="bar">
4397+
<link rel="preload" as="style" href="foo" />
4398+
hello
4399+
</body>
4400+
);
4401+
}
4402+
await actIntoEmptyDocument(() => {
4403+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4404+
<html data-html="html">
4405+
<AsyncNoOutput />
4406+
<AsyncBody />
4407+
<AsyncHead />
4408+
</html>,
4409+
);
4410+
pipe(writable);
4411+
});
4412+
await actIntoEmptyDocument(() => {
4413+
resolveText('body');
4414+
});
4415+
await actIntoEmptyDocument(() => {
4416+
resolveText('nooutput');
4417+
});
4418+
// We need to use actIntoEmptyDocument because act assumes that buffered
4419+
// content should be fake streamed into the body which is normally true
4420+
// but in this test the entire shell was delayed and we need the initial
4421+
// construction to be done to get the parsing right
4422+
await actIntoEmptyDocument(() => {
4423+
resolveText('head');
4424+
});
4425+
// This assertion is a little strange. The html open tag is part of the preamble
4426+
// but since the next chunk will be the body open tag which is not preamble it
4427+
// emits resources. The browser understands that the link is part of the head and
4428+
// constructs the head implicitly which is why it does not have the data-foo attribute.
4429+
// When the head finally streams in it is inside the body rather than after it because the
4430+
// body closing tag is part of the postamble which stays open until the entire request
4431+
// has flushed. This is how the browser would interpret a late head arriving after the
4432+
// the body closing tag so while strange it is the expected behavior. One other oddity
4433+
// is that <head> in body is elided by html parsers so we end up with just an inlined
4434+
// style tag.
4435+
expect(getVisibleChildren(document)).toEqual(
4436+
<html data-html="html">
4437+
<head>
4438+
<link rel="preload" as="style" href="foo" />
4439+
</head>
4440+
<body data-bar="bar">
4441+
hello
4442+
<title>a title</title>
4443+
</body>
4444+
</html>,
43784445
);
4379-
expect(errors).toEqual([
4380-
'Hydration failed because the initial UI does not match what was rendered on the server.',
4381-
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
4382-
]);
4383-
expect(getVisibleChildren(document)).toEqual();
4384-
expect(() => {
4385-
expect(Scheduler).toFlushWithoutYielding();
4386-
}).toThrow('The node to be removed is not a child of this node.');
43874446
});
43884447

43894448
// @gate enableFloat

packages/react-dom/src/server/ReactDOMServerFormatConfig.js

Lines changed: 28 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* @flow
88
*/
99

10+
import type {ArrayWithPreamble} from 'react-server/src/ReactFizzServer';
1011
import type {ReactNodeList} from 'shared/ReactTypes';
1112
import type {Resources, BoundaryResources} from './ReactDOMFloatServer';
1213
export type {Resources, BoundaryResources};
@@ -93,6 +94,8 @@ export type ResponseState = {
9394
...
9495
};
9596

97+
export const emptyChunk = stringToPrecomputedChunk('');
98+
9699
const startInlineScript = stringToPrecomputedChunk('<script>');
97100
const endInlineScript = stringToPrecomputedChunk('</script>');
98101

@@ -283,25 +286,6 @@ export function getChildFormatContext(
283286
return createFormatContext(HTML_MODE, null);
284287
}
285288
if (parentContext.insertionMode === ROOT_HTML_MODE) {
286-
switch (type) {
287-
case 'html': {
288-
return parentContext;
289-
}
290-
case 'head':
291-
case 'title':
292-
case 'base':
293-
case 'link':
294-
case 'style':
295-
case 'meta':
296-
case 'script':
297-
case 'noscript':
298-
case 'template': {
299-
break;
300-
}
301-
default: {
302-
parentContext.preambleOpen = false;
303-
}
304-
}
305289
// We've emitted the root and is now in plain HTML mode.
306290
return createFormatContext(HTML_MODE, null);
307291
}
@@ -1316,40 +1300,33 @@ function pushStartTitle(
13161300
}
13171301

13181302
function pushStartHead(
1319-
target: Array<Chunk | PrecomputedChunk>,
1320-
preamble: Array<Chunk | PrecomputedChunk>,
1303+
target: ArrayWithPreamble<Chunk | PrecomputedChunk>,
13211304
props: Object,
13221305
tag: string,
13231306
responseState: ResponseState,
13241307
formatContext: FormatContext,
13251308
): ReactNodeList {
1326-
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
1327-
target =
1328-
enableFloat &&
1329-
formatContext.insertionMode === ROOT_HTML_MODE &&
1330-
formatContext.preambleOpen
1331-
? preamble
1332-
: target;
1333-
1334-
return pushStartGenericElement(target, props, tag, responseState);
1309+
const children = pushStartGenericElement(target, props, tag, responseState);
1310+
target._preambleIndex = target.length;
1311+
return children;
13351312
}
13361313

13371314
function pushStartHtml(
1338-
target: Array<Chunk | PrecomputedChunk>,
1339-
preamble: Array<Chunk | PrecomputedChunk>,
1315+
target: ArrayWithPreamble<Chunk | PrecomputedChunk>,
13401316
props: Object,
13411317
tag: string,
13421318
responseState: ResponseState,
13431319
formatContext: FormatContext,
13441320
): ReactNodeList {
13451321
if (formatContext.insertionMode === ROOT_HTML_MODE) {
1346-
target = enableFloat && formatContext.preambleOpen ? preamble : target;
13471322
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
13481323
// then we also emit the DOCTYPE as part of the root content as a convenience for
13491324
// rendering the whole document.
13501325
target.push(DOCTYPE);
13511326
}
1352-
return pushStartGenericElement(target, props, tag, responseState);
1327+
const children = pushStartGenericElement(target, props, tag, responseState);
1328+
target._preambleIndex = target.length;
1329+
return children;
13531330
}
13541331

13551332
function pushStartGenericElement(
@@ -1567,8 +1544,7 @@ function startChunkForTag(tag: string): PrecomputedChunk {
15671544
const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>');
15681545

15691546
export function pushStartInstance(
1570-
target: Array<Chunk | PrecomputedChunk>,
1571-
preamble: Array<Chunk | PrecomputedChunk>,
1547+
target: ArrayWithPreamble<Chunk | PrecomputedChunk>,
15721548
type: string,
15731549
props: Object,
15741550
responseState: ResponseState,
@@ -1663,23 +1639,9 @@ export function pushStartInstance(
16631639
}
16641640
// Preamble start tags
16651641
case 'head':
1666-
return pushStartHead(
1667-
target,
1668-
preamble,
1669-
props,
1670-
type,
1671-
responseState,
1672-
formatContext,
1673-
);
1642+
return pushStartHead(target, props, type, responseState, formatContext);
16741643
case 'html': {
1675-
return pushStartHtml(
1676-
target,
1677-
preamble,
1678-
props,
1679-
type,
1680-
responseState,
1681-
formatContext,
1682-
);
1644+
return pushStartHtml(target, props, type, responseState, formatContext);
16831645
}
16841646
default: {
16851647
if (type.indexOf('-') === -1 && typeof props.is !== 'string') {
@@ -1722,17 +1684,24 @@ export function pushEndInstance(
17221684
case 'track':
17231685
case 'wbr': {
17241686
// No close tag needed.
1725-
break;
1687+
return;
17261688
}
17271689
// Postamble end tags
1728-
case 'body':
1729-
case 'html':
1730-
target = enableFloat ? postamble : target;
1731-
// Intentional fallthrough
1732-
default: {
1733-
target.push(endTag1, stringToChunk(type), endTag2);
1690+
case 'body': {
1691+
if (enableFloat) {
1692+
postamble.unshift(endTag1, stringToChunk(type), endTag2);
1693+
return;
1694+
}
1695+
break;
17341696
}
1697+
case 'html':
1698+
if (enableFloat) {
1699+
postamble.push(endTag1, stringToChunk(type), endTag2);
1700+
return;
1701+
}
1702+
break;
17351703
}
1704+
target.push(endTag1, stringToChunk(type), endTag2);
17361705
}
17371706

17381707
export function writeCompletedRoot(

packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export {
110110
setCurrentlyRenderingBoundaryResources,
111111
prepareToRender,
112112
cleanupAfterRender,
113+
emptyChunk,
113114
} from './ReactDOMServerFormatConfig';
114115

115116
import {stringToChunk} from 'react-server/src/ReactServerStreamConfig';

packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ export type ResponseState = {
6565
nextSuspenseID: number,
6666
};
6767

68+
export const emptyChunk = stringToPrecomputedChunk('');
69+
6870
// Allows us to keep track of what we've already written so we can refer back to it.
6971
export function createResponseState(): ResponseState {
7072
return {
@@ -140,7 +142,6 @@ export function pushTextInstance(
140142

141143
export function pushStartInstance(
142144
target: Array<Chunk | PrecomputedChunk>,
143-
preamble: Array<Chunk | PrecomputedChunk>,
144145
type: string,
145146
props: Object,
146147
responseState: ResponseState,

packages/react-noop-renderer/src/ReactNoopServer.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ const ReactNoopServer = ReactFizzServer({
116116
},
117117
pushStartInstance(
118118
target: Array<Uint8Array>,
119-
preamble: Array<Uint8Array>,
120119
type: string,
121120
props: Object,
122121
): ReactNodeList {

0 commit comments

Comments
 (0)