Skip to content

Commit 88402c6

Browse files
committed
implement preamble and postambl for react-dom/server
1 parent ca990e9 commit 88402c6

File tree

4 files changed

+197
-13
lines changed

4 files changed

+197
-13
lines changed

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

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ describe('ReactDOMFizzServer', () => {
128128
buffer = '';
129129
const fakeBody = document.createElement('body');
130130
fakeBody.innerHTML = bufferedContent;
131+
const parent =
132+
container.nodeName === '#document' ? container.body : container;
131133
while (fakeBody.firstChild) {
132134
const node = fakeBody.firstChild;
133135
if (
@@ -137,13 +139,37 @@ describe('ReactDOMFizzServer', () => {
137139
const script = document.createElement('script');
138140
script.textContent = node.textContent;
139141
fakeBody.removeChild(node);
140-
container.appendChild(script);
142+
parent.appendChild(script);
141143
} else {
142-
container.appendChild(node);
144+
parent.appendChild(node);
143145
}
144146
}
145147
}
146148

149+
async function actIntoEmptyDocument(callback) {
150+
await callback();
151+
// Await one turn around the event loop.
152+
// This assumes that we'll flush everything we have so far.
153+
await new Promise(resolve => {
154+
setImmediate(resolve);
155+
});
156+
if (hasErrored) {
157+
throw fatalError;
158+
}
159+
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
160+
// We also want to execute any scripts that are embedded.
161+
// We assume that we have now received a proper fragment of HTML.
162+
const bufferedContent = buffer;
163+
// Test Environment
164+
const jsdom = new JSDOM(bufferedContent, {
165+
runScripts: 'dangerously',
166+
});
167+
window = jsdom.window;
168+
document = jsdom.window.document;
169+
container = document;
170+
buffer = '';
171+
}
172+
147173
function getVisibleChildren(element) {
148174
const children = [];
149175
let node = element.firstChild;
@@ -4194,6 +4220,113 @@ describe('ReactDOMFizzServer', () => {
41944220
);
41954221
});
41964222

4223+
it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => {
4224+
await actIntoEmptyDocument(() => {
4225+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4226+
<>
4227+
<title data-baz="baz">a title</title>
4228+
<html data-foo="foo">
4229+
<head data-bar="bar" />
4230+
<body>a body</body>
4231+
</html>
4232+
</>,
4233+
);
4234+
pipe(writable);
4235+
});
4236+
expect(getVisibleChildren(document)).toEqual(
4237+
<html data-foo="foo">
4238+
<head data-bar="bar">
4239+
<title data-baz="baz">a title</title>
4240+
</head>
4241+
<body>a body</body>
4242+
</html>,
4243+
);
4244+
4245+
// Hydrate the same thing on the client. We expect this to still fail because <title> is not a Resource
4246+
// and is unmatched on hydration
4247+
const errors = [];
4248+
const root = ReactDOMClient.hydrateRoot(
4249+
document,
4250+
<>
4251+
<title data-baz="baz">a title</title>
4252+
<html data-foo="foo">
4253+
<head data-bar="bar" />
4254+
<body>a body</body>
4255+
</html>
4256+
</>,
4257+
{
4258+
onRecoverableError: (err, errInfo) => {
4259+
errors.push(err.message);
4260+
},
4261+
},
4262+
);
4263+
expect(() => {
4264+
try {
4265+
expect(() => {
4266+
expect(Scheduler).toFlushWithoutYielding();
4267+
}).toThrow('Invalid insertion of HTML node in #document node.');
4268+
} catch (e) {
4269+
console.log('e', e);
4270+
}
4271+
}).toErrorDev(
4272+
[
4273+
'Warning: Expected server HTML to contain a matching <title> in <#document>.',
4274+
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
4275+
'Warning: validateDOMNesting(...): <title> cannot appear as a child of <#document>',
4276+
],
4277+
{withoutStack: 1},
4278+
);
4279+
expect(errors).toEqual([
4280+
'Hydration failed because the initial UI does not match what was rendered on the server.',
4281+
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
4282+
]);
4283+
});
4284+
4285+
it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => {
4286+
const chunks = [];
4287+
writable.on('data', chunk => {
4288+
chunks.push(chunk);
4289+
});
4290+
4291+
await actIntoEmptyDocument(() => {
4292+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4293+
<html>
4294+
<head />
4295+
<body>
4296+
first
4297+
<Suspense>
4298+
<AsyncText text="second" />
4299+
</Suspense>
4300+
</body>
4301+
</html>,
4302+
);
4303+
pipe(writable);
4304+
});
4305+
4306+
expect(getVisibleChildren(document)).toEqual(
4307+
<html>
4308+
<head />
4309+
<body>{'first'}</body>
4310+
</html>,
4311+
);
4312+
4313+
await act(() => {
4314+
resolveText('second');
4315+
});
4316+
4317+
expect(getVisibleChildren(document)).toEqual(
4318+
<html>
4319+
<head />
4320+
<body>
4321+
{'first'}
4322+
{'second'}
4323+
</body>
4324+
</html>,
4325+
);
4326+
4327+
expect(chunks.pop()).toEqual('</body></html>');
4328+
});
4329+
41974330
describe('text separators', () => {
41984331
// To force performWork to start before resolving AsyncText but before piping we need to wait until
41994332
// after scheduleWork which currently uses setImmediate to delay performWork

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,26 @@ export function getChildFormatContext(
242242
return parentContext;
243243
}
244244

245+
export function isPreambleInsertion(type: string): boolean {
246+
switch (type) {
247+
case 'html':
248+
case 'head': {
249+
return true;
250+
}
251+
}
252+
return false;
253+
}
254+
255+
export function isPostambleInsertion(type: string): boolean {
256+
switch (type) {
257+
case 'body':
258+
case 'html': {
259+
return true;
260+
}
261+
}
262+
return false;
263+
}
264+
245265
export type SuspenseBoundaryID = null | PrecomputedChunk;
246266

247267
export const UNINITIALIZED_SUSPENSE_BOUNDARY_ID: SuspenseBoundaryID = null;
@@ -1405,11 +1425,13 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>');
14051425

14061426
export function pushStartInstance(
14071427
target: Array<Chunk | PrecomputedChunk>,
1428+
preamble: Array<Chunk | PrecomputedChunk>,
14081429
type: string,
14091430
props: Object,
14101431
responseState: ResponseState,
14111432
formatContext: FormatContext,
14121433
): ReactNodeList {
1434+
target = isPreambleInsertion(type) ? preamble : target;
14131435
if (__DEV__) {
14141436
validateARIAProperties(type, props);
14151437
validateInputProperties(type, props);
@@ -1521,9 +1543,11 @@ const endTag2 = stringToPrecomputedChunk('>');
15211543

15221544
export function pushEndInstance(
15231545
target: Array<Chunk | PrecomputedChunk>,
1546+
postamble: Array<Chunk | PrecomputedChunk>,
15241547
type: string,
15251548
props: Object,
15261549
): void {
1550+
target = isPostambleInsertion(type) ? postamble : target;
15271551
switch (type) {
15281552
// Omitted close tags
15291553
// TODO: Instead of repeating this switch we could try to pass a flag from above.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const ReactNoopServer = ReactFizzServer({
113113
},
114114
pushStartInstance(
115115
target: Array<Uint8Array>,
116+
preamble: Array<Uint8Array>,
116117
type: string,
117118
props: Object,
118119
): ReactNodeList {
@@ -128,6 +129,7 @@ const ReactNoopServer = ReactFizzServer({
128129

129130
pushEndInstance(
130131
target: Array<Uint8Array>,
132+
postamble: Array<Uint8Array>,
131133
type: string,
132134
props: Object,
133135
): void {

packages/react-server/src/ReactFizzServer.js

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ export opaque type Request = {
200200
clientRenderedBoundaries: Array<SuspenseBoundary>, // Errored or client rendered but not yet flushed.
201201
completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show.
202202
partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
203+
+preamble: Array<Chunk | PrecomputedChunk>, // Chunks that need to be emitted before any segment chunks.
204+
+postamble: Array<Chunk | PrecomputedChunk>, // Chunks that need to be emitted after segments, waiting for all pending root tasks to finish
203205
// onError is called when an error happens anywhere in the tree. It might recover.
204206
// The return string is used in production primarily to avoid leaking internals, secondarily to save bytes.
205207
// Returning null/undefined will cause a defualt error message in production
@@ -272,6 +274,8 @@ export function createRequest(
272274
clientRenderedBoundaries: [],
273275
completedBoundaries: [],
274276
partialBoundaries: [],
277+
preamble: [],
278+
postamble: [],
275279
onError: onError === undefined ? defaultErrorHandler : onError,
276280
onAllReady: onAllReady === undefined ? noop : onAllReady,
277281
onShellReady: onShellReady === undefined ? noop : onShellReady,
@@ -632,6 +636,7 @@ function renderHostElement(
632636
const segment = task.blockedSegment;
633637
const children = pushStartInstance(
634638
segment.chunks,
639+
request.preamble,
635640
type,
636641
props,
637642
request.responseState,
@@ -647,7 +652,7 @@ function renderHostElement(
647652
// We expect that errors will fatal the whole task and that we don't need
648653
// the correct context. Therefore this is not in a finally.
649654
segment.formatContext = prevContext;
650-
pushEndInstance(segment.chunks, type, props);
655+
pushEndInstance(segment.chunks, request.postamble, type, props);
651656
segment.lastPushedText = false;
652657
popComponentStackInDEV(task);
653658
}
@@ -2054,6 +2059,7 @@ function flushCompletedQueues(
20542059
request: Request,
20552060
destination: Destination,
20562061
): void {
2062+
let allComplete = false;
20572063
beginWriting(destination);
20582064
try {
20592065
// The structure of this is to go through each queue one by one and write
@@ -2063,20 +2069,30 @@ function flushCompletedQueues(
20632069

20642070
// TODO: Emit preloading.
20652071

2066-
// TODO: It's kind of unfortunate to keep checking this array after we've already
2067-
// emitted the root.
2072+
let i;
20682073
const completedRootSegment = request.completedRootSegment;
2069-
if (completedRootSegment !== null && request.pendingRootTasks === 0) {
2070-
flushSegment(request, destination, completedRootSegment);
2071-
request.completedRootSegment = null;
2072-
writeCompletedRoot(destination, request.responseState);
2074+
if (completedRootSegment !== null) {
2075+
if (request.pendingRootTasks === 0) {
2076+
const preamble = request.preamble;
2077+
for (i = 0; i < preamble.length; i++) {
2078+
// we expect the preamble to be tiny and will ignore backpressure
2079+
writeChunk(destination, preamble[i]);
2080+
}
2081+
preamble.length = 0;
2082+
2083+
flushSegment(request, destination, completedRootSegment);
2084+
request.completedRootSegment = null;
2085+
writeCompletedRoot(destination, request.responseState);
2086+
} else {
2087+
// We haven't flushed the root yet so we don't need to check boundaries further down
2088+
return;
2089+
}
20732090
}
20742091

20752092
// We emit client rendering instructions for already emitted boundaries first.
20762093
// This is so that we can signal to the client to start client rendering them as
20772094
// soon as possible.
20782095
const clientRenderedBoundaries = request.clientRenderedBoundaries;
2079-
let i;
20802096
for (i = 0; i < clientRenderedBoundaries.length; i++) {
20812097
const boundary = clientRenderedBoundaries[i];
20822098
if (!flushClientRenderedBoundary(request, destination, boundary)) {
@@ -2138,9 +2154,7 @@ function flushCompletedQueues(
21382154
}
21392155
}
21402156
largeBoundaries.splice(0, i);
2141-
} finally {
2142-
completeWriting(destination);
2143-
flushBuffered(destination);
2157+
21442158
if (
21452159
request.allPendingTasks === 0 &&
21462160
request.pingedTasks.length === 0 &&
@@ -2149,6 +2163,17 @@ function flushCompletedQueues(
21492163
// We don't need to check any partially completed segments because
21502164
// either they have pending task or they're complete.
21512165
) {
2166+
allComplete = true;
2167+
const postamble = request.postamble;
2168+
for (i = 0; i < postamble.length; i++) {
2169+
writeChunk(destination, postamble[i]);
2170+
}
2171+
postamble.length = 0;
2172+
}
2173+
} finally {
2174+
completeWriting(destination);
2175+
flushBuffered(destination);
2176+
if (allComplete) {
21522177
if (__DEV__) {
21532178
if (request.abortableTasks.size !== 0) {
21542179
console.error(

0 commit comments

Comments
 (0)