[Fizz] Assign an ID to the first DOM element in a fallback or insert a dummy (and testing infra) (#21020)
* Patches * Add Fizz testing infra structure * Assign an ID to the first DOM node in a fallback or insert a dummy * unstable_createRoot
Sebastian Markbåge committed
Mar 16, 2021 at 17:05 UTC
1d1e49cfa453b58769e87c3c8d321024d58c948f
5 files changed
+389
-21
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
new
+269
@@ -0,0 +1,269 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @emails react-core
8
+ */
9
+
10
+'use strict';
11
+
12
+let JSDOM;
13
+let Stream;
14
+let Scheduler;
15
+let React;
16
+let ReactDOM;
17
+let ReactDOMFizzServer;
18
+let Suspense;
19
+let textCache;
20
+let document;
21
+let writable;
22
+let buffer = '';
23
+let hasErrored = false;
24
+let fatalError = undefined;
25
+
26
+describe('ReactDOMFizzServer', () => {
27
+ beforeEach(() => {
28
+ jest.resetModules();
29
+ JSDOM = require('jsdom').JSDOM;
30
+ Scheduler = require('scheduler');
31
+ React = require('react');
32
+ ReactDOM = require('react-dom');
33
+ if (__EXPERIMENTAL__) {
34
+ ReactDOMFizzServer = require('react-dom/unstable-fizz');
35
+ }
36
+ Stream = require('stream');
37
+ Suspense = React.Suspense;
38
+ textCache = new Map();
39
+
40
+ // Test Environment
41
+ const jsdom = new JSDOM('<!DOCTYPE html><html><head></head><body>', {
42
+ runScripts: 'dangerously',
43
+ });
44
+ document = jsdom.window.document;
45
+
46
+ buffer = '';
47
+ hasErrored = false;
48
+
49
+ writable = new Stream.PassThrough();
50
+ writable.setEncoding('utf8');
51
+ writable.on('data', chunk => {
52
+ buffer += chunk;
53
+ });
54
+ writable.on('error', error => {
55
+ hasErrored = true;
56
+ fatalError = error;
57
+ });
58
+ });
59
+
60
+ async function act(callback) {
61
+ await callback();
62
+ // Await one turn around the event loop.
63
+ // This assumes that we'll flush everything we have so far.
64
+ await new Promise(resolve => {
65
+ setImmediate(resolve);
66
+ });
67
+ if (hasErrored) {
68
+ throw fatalError;
69
+ }
70
+ // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
71
+ // We also want to execute any scripts that are embedded.
72
+ // We assume that we have now received a proper fragment of HTML.
73
+ const bufferedContent = buffer;
74
+ buffer = '';
75
+ const fakeBody = document.createElement('body');
76
+ fakeBody.innerHTML = bufferedContent;
77
+ while (fakeBody.firstChild) {
78
+ const node = fakeBody.firstChild;
79
+ if (node.nodeName === 'SCRIPT') {
80
+ const script = document.createElement('script');
81
+ script.textContent = node.textContent;
82
+ fakeBody.removeChild(node);
83
+ document.body.appendChild(script);
84
+ } else {
85
+ document.body.appendChild(node);
86
+ }
87
+ }
88
+ }
89
+
90
+ function getVisibleChildren(element) {
91
+ const children = [];
92
+ let node = element.firstChild;
93
+ while (node) {
94
+ if (node.nodeType === 1) {
95
+ if (node.tagName !== 'SCRIPT' && !node.hasAttribute('hidden')) {
96
+ const props = {};
97
+ const attributes = node.attributes;
98
+ for (let i = 0; i < attributes.length; i++) {
99
+ props[attributes[i].name] = attributes[i].value;
100
+ }
101
+ props.children = getVisibleChildren(node);
102
+ children.push(React.createElement(node.tagName.toLowerCase(), props));
103
+ }
104
+ } else if (node.nodeType === 3) {
105
+ children.push(node.data);
106
+ }
107
+ node = node.nextSibling;
108
+ }
109
+ return children.length === 0
110
+ ? null
111
+ : children.length === 1
112
+ ? children[0]
113
+ : children;
114
+ }
115
+
116
+ function resolveText(text) {
117
+ const record = textCache.get(text);
118
+ if (record === undefined) {
119
+ const newRecord = {
120
+ status: 'resolved',
121
+ value: text,
122
+ };
123
+ textCache.set(text, newRecord);
124
+ } else if (record.status === 'pending') {
125
+ const thenable = record.value;
126
+ record.status = 'resolved';
127
+ record.value = text;
128
+ thenable.pings.forEach(t => t());
129
+ }
130
+ }
131
+
132
+ /*
133
+ function rejectText(text, error) {
134
+ const record = textCache.get(text);
135
+ if (record === undefined) {
136
+ const newRecord = {
137
+ status: 'rejected',
138
+ value: error,
139
+ };
140
+ textCache.set(text, newRecord);
141
+ } else if (record.status === 'pending') {
142
+ const thenable = record.value;
143
+ record.status = 'rejected';
144
+ record.value = error;
145
+ thenable.pings.forEach(t => t());
146
+ }
147
+ }
148
+ */
149
+
150
+ function readText(text) {
151
+ const record = textCache.get(text);
152
+ if (record !== undefined) {
153
+ switch (record.status) {
154
+ case 'pending':
155
+ throw record.value;
156
+ case 'rejected':
157
+ throw record.value;
158
+ case 'resolved':
159
+ return record.value;
160
+ }
161
+ } else {
162
+ const thenable = {
163
+ pings: [],
164
+ then(resolve) {
165
+ if (newRecord.status === 'pending') {
166
+ thenable.pings.push(resolve);
167
+ } else {
168
+ Promise.resolve().then(() => resolve(newRecord.value));
169
+ }
170
+ },
171
+ };
172
+
173
+ const newRecord = {
174
+ status: 'pending',
175
+ value: thenable,
176
+ };
177
+ textCache.set(text, newRecord);
178
+
179
+ throw thenable;
180
+ }
181
+ }
182
+
183
+ function Text({text}) {
184
+ return text;
185
+ }
186
+
187
+ function AsyncText({text}) {
188
+ return readText(text);
189
+ }
190
+
191
+ // @gate experimental
192
+ it('should asynchronously load the suspense boundary', async () => {
193
+ await act(async () => {
194
+ ReactDOMFizzServer.pipeToNodeWritable(
195
+ <div>
196
+ <Suspense fallback={<Text text="Loading..." />}>
197
+ <AsyncText text="Hello World" />
198
+ </Suspense>
199
+ </div>,
200
+ writable,
201
+ );
202
+ });
203
+ expect(getVisibleChildren(document.body)).toEqual(<div>Loading...</div>);
204
+ await act(async () => {
205
+ resolveText('Hello World');
206
+ });
207
+ expect(getVisibleChildren(document.body)).toEqual(<div>Hello World</div>);
208
+ });
209
+
210
+ // @gate experimental
211
+ it('waits for pending content to come in from the server and then hydrates it', async () => {
212
+ const ref = React.createRef();
213
+
214
+ function App() {
215
+ return (
216
+ <div>
217
+ <Suspense fallback="Loading...">
218
+ <h1 ref={ref}>
219
+ <AsyncText text="Hello" />
220
+ </h1>
221
+ </Suspense>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ await act(async () => {
227
+ ReactDOMFizzServer.pipeToNodeWritable(
228
+ // We currently have to wrap the server node in a container because
229
+ // otherwise the Fizz nodes get deleted during hydration.
230
+ <div id="container">
231
+ <App />
232
+ </div>,
233
+ writable,
234
+ );
235
+ });
236
+
237
+ // We're still showing a fallback.
238
+
239
+ // Attempt to hydrate the content.
240
+ const container = document.body.firstChild;
241
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
242
+ root.render(<App />);
243
+ Scheduler.unstable_flushAll();
244
+
245
+ // We're still loading because we're waiting for the server to stream more content.
246
+ expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
247
+
248
+ // The server now updates the content in place in the fallback.
249
+ await act(async () => {
250
+ resolveText('Hello');
251
+ });
252
+
253
+ // The final HTML is now in place.
254
+ expect(getVisibleChildren(container)).toEqual(
255
+ <div>
256
+ <h1>Hello</h1>
257
+ </div>,
258
+ );
259
+ const h1 = container.getElementsByTagName('h1')[0];
260
+
261
+ // But it is not yet hydrated.
262
+ expect(ref.current).toBe(null);
263
+
264
+ Scheduler.unstable_flushAll();
265
+
266
+ // Now it's hydrated.
267
+ expect(ref.current).toBe(h1);
268
+ });
269
+});
packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+76
-15
@@ -24,6 +24,7 @@ import invariant from 'shared/invariant';
24
25
// Per response,
26
export type ResponseState = {
27
+ nextSuspenseID: number,
28
sentCompleteSegmentFunction: boolean,
29
sentCompleteBoundaryFunction: boolean,
30
sentClientRenderFunction: boolean,
@@ -32,6 +33,7 @@ export type ResponseState = {
33
// Allows us to keep track of what we've already written so we can refer back to it.
34
export function createResponseState(): ResponseState {
35
return {
36
+ nextSuspenseID: 0,
37
sentCompleteSegmentFunction: false,
38
sentCompleteBoundaryFunction: false,
39
sentClientRenderFunction: false,
@@ -42,13 +44,13 @@ export function createResponseState(): ResponseState {
44
// We can't assign an ID up front because the node we're attaching it to might already
45
// have one. So we need to lazily use that if it's available.
46
export type SuspenseBoundaryID = {
45
- id: null | string,
47
+ formattedID: null | PrecomputedChunk,
48
};
49
50
export function createSuspenseBoundaryID(
51
responseState: ResponseState,
52
): SuspenseBoundaryID {
51
- return {id: null};
53
+ return {formattedID: null};
54
}
55
56
function encodeHTMLIDAttribute(value: string): string {
@@ -59,23 +61,86 @@ function encodeHTMLTextNode(text: string): string {
61
return escapeTextForBrowser(text);
62
}
63
64
+function assignAnID(
65
+ responseState: ResponseState,
66
+ id: SuspenseBoundaryID,
67
+): PrecomputedChunk {
68
+ // TODO: This approach doesn't yield deterministic results since this is assigned during render.
69
+ const generatedID = responseState.nextSuspenseID++;
70
+ return (id.formattedID = stringToPrecomputedChunk(
71
+ 'B:' + generatedID.toString(16),
72
+ ));
73
+}
74
+
75
+const dummyNode1 = stringToPrecomputedChunk('<span hidden id="');
76
+const dummyNode2 = stringToPrecomputedChunk('"></span>');
77
+
78
+function pushDummyNodeWithID(
79
+ target: Array<Chunk | PrecomputedChunk>,
80
+ responseState: ResponseState,
81
+ assignID: SuspenseBoundaryID,
82
+): void {
83
+ const id = assignAnID(responseState, assignID);
84
+ target.push(dummyNode1, id, dummyNode2);
85
+}
86
+
87
+export function pushEmpty(
88
+ target: Array<Chunk | PrecomputedChunk>,
89
+ responseState: ResponseState,
90
+ assignID: null | SuspenseBoundaryID,
91
+): void {
92
+ if (assignID !== null) {
93
+ pushDummyNodeWithID(target, responseState, assignID);
94
+ }
95
+}
96
+
97
export function pushTextInstance(
98
target: Array<Chunk | PrecomputedChunk>,
99
text: string,
100
+ responseState: ResponseState,
101
+ assignID: null | SuspenseBoundaryID,
102
): void {
103
+ if (assignID !== null) {
104
+ pushDummyNodeWithID(target, responseState, assignID);
105
+ }
106
target.push(stringToChunk(encodeHTMLTextNode(text)));
107
}
108
109
const startTag1 = stringToPrecomputedChunk('<');
110
const startTag2 = stringToPrecomputedChunk('>');
111
112
+const idAttr = stringToPrecomputedChunk(' id="');
113
+const attrEnd = stringToPrecomputedChunk('"');
114
+
115
export function pushStartInstance(
116
target: Array<Chunk | PrecomputedChunk>,
117
type: string,
118
props: Object,
119
+ responseState: ResponseState,
120
+ assignID: null | SuspenseBoundaryID,
121
): void {
122
// TODO: Figure out if it's self closing and everything else.
78
- target.push(startTag1, stringToChunk(type), startTag2);
123
+ if (assignID !== null) {
124
+ let encodedID;
125
+ if (typeof props.id === 'string') {
126
+ // We can reuse the existing ID for our purposes.
127
+ encodedID = assignID.formattedID = stringToPrecomputedChunk(
128
+ encodeHTMLIDAttribute(props.id),
129
+ );
130
+ } else {
131
+ encodedID = assignAnID(responseState, assignID);
132
+ }
133
+ target.push(
134
+ startTag1,
135
+ stringToChunk(type),
136
+ idAttr,
137
+ encodedID,
138
+ attrEnd,
139
+ startTag2,
140
+ );
141
+ } else {
142
+ target.push(startTag1, stringToChunk(type), startTag2);
143
+ }
144
}
145
146
const endTag1 = stringToPrecomputedChunk('</');
@@ -144,7 +209,7 @@ export function writeEndSuspenseBoundary(destination: Destination): boolean {
209
const startSegment = stringToPrecomputedChunk('<div hidden id="');
210
const startSegment2 = stringToPrecomputedChunk('S:');
211
const startSegment3 = stringToPrecomputedChunk('">');
147
-const endSegment = stringToPrecomputedChunk('"></div>');
212
+const endSegment = stringToPrecomputedChunk('</div>');
213
export function writeStartSegment(
214
destination: Destination,
215
id: number,
@@ -297,7 +362,7 @@ export function writeCompletedSegmentInstruction(
362
responseState: ResponseState,
363
contentSegmentID: number,
364
): boolean {
300
- if (responseState.sentCompleteSegmentFunction) {
365
+ if (!responseState.sentCompleteSegmentFunction) {
366
// The first time we write this, we'll need to include the full implementation.
367
responseState.sentCompleteSegmentFunction = true;
368
writeChunk(destination, completeSegmentScript1Full);
@@ -328,7 +393,7 @@ export function writeCompletedBoundaryInstruction(
393
boundaryID: SuspenseBoundaryID,
394
contentSegmentID: number,
395
): boolean {
331
- if (responseState.sentCompleteBoundaryFunction) {
396
+ if (!responseState.sentCompleteBoundaryFunction) {
397
// The first time we write this, we'll need to include the full implementation.
398
responseState.sentCompleteBoundaryFunction = true;
399
writeChunk(destination, completeBoundaryScript1Full);
@@ -337,13 +402,11 @@ export function writeCompletedBoundaryInstruction(
402
writeChunk(destination, completeBoundaryScript1Partial);
403
}
404
// TODO: Use the identifierPrefix option to make the prefix configurable.
405
+ const formattedBoundaryID = boundaryID.formattedID;
406
invariant(
341
- boundaryID.id !== null,
407
+ formattedBoundaryID !== null,
408
'An ID must have been assigned before we can complete the boundary.',
409
);
344
- const formattedBoundaryID = stringToChunk(
345
- encodeHTMLIDAttribute(boundaryID.id),
346
- );
410
const formattedContentID = stringToChunk(contentSegmentID.toString(16));
411
writeChunk(destination, formattedBoundaryID);
412
writeChunk(destination, completeBoundaryScript2);
@@ -362,7 +425,7 @@ export function writeClientRenderBoundaryInstruction(
425
responseState: ResponseState,
426
boundaryID: SuspenseBoundaryID,
427
): boolean {
365
- if (responseState.sentClientRenderFunction) {
428
+ if (!responseState.sentClientRenderFunction) {
429
// The first time we write this, we'll need to include the full implementation.
430
responseState.sentClientRenderFunction = true;
431
writeChunk(destination, clientRenderScript1Full);
@@ -370,13 +433,11 @@ export function writeClientRenderBoundaryInstruction(
433
// Future calls can just reuse the same function.
434
writeChunk(destination, clientRenderScript1Partial);
435
}
436
+ const formattedBoundaryID = boundaryID.formattedID;
437
invariant(
374
- boundaryID.id !== null,
438
+ formattedBoundaryID !== null,
439
'An ID must have been assigned before we can complete the boundary.',
440
);
377
- const formattedBoundaryID = stringToPrecomputedChunk(
378
- encodeHTMLIDAttribute(boundaryID.id),
379
- );
441
writeChunk(destination, formattedBoundaryID);
442
return writeChunk(destination, clientRenderScript2);
443
}
packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+13
@@ -73,14 +73,25 @@ export type SuspenseBoundaryID = number;
73
export function createSuspenseBoundaryID(
74
responseState: ResponseState,
75
): SuspenseBoundaryID {
76
+ // TODO: This is not deterministic since it's created during render.
77
return responseState.nextSuspenseID++;
78
}
79
80
const RAW_TEXT = stringToPrecomputedChunk('RCTRawText');
81
82
+export function pushEmpty(
83
+ target: Array<Chunk | PrecomputedChunk>,
84
+ responseState: ResponseState,
85
+ assignID: null | SuspenseBoundaryID,
86
+): void {
87
+ // This is not used since we don't need to assign any IDs.
88
+}
89
+
90
export function pushTextInstance(
91
target: Array<Chunk | PrecomputedChunk>,
92
text: string,
93
+ responseState: ResponseState,
94
+ assignID: null | SuspenseBoundaryID,
95
): void {
96
target.push(
97
INSTANCE,
@@ -95,6 +106,8 @@ export function pushStartInstance(
106
target: Array<Chunk | PrecomputedChunk>,
107
type: string,
108
props: Object,
109
+ responseState: ResponseState,
110
+ assignID: null | SuspenseBoundaryID,
111
): void {
112
target.push(
113
INSTANCE,
packages/react-server/src/ReactFizzServer.js
+30
-6
@@ -39,6 +39,7 @@ import {
39
writeClientRenderBoundaryInstruction,
40
writeCompletedBoundaryInstruction,
41
writeCompletedSegmentInstruction,
42
+ pushEmpty,
43
pushTextInstance,
44
pushStartInstance,
45
pushEndInstance,
@@ -218,11 +219,26 @@ function renderNode(
219
parentBoundary: Root | SuspenseBoundary,
220
segment: Segment,
221
node: ReactNodeList,
222
+ assignID: null | SuspenseBoundaryID,
223
): void {
224
if (typeof node === 'string') {
223
- pushTextInstance(segment.chunks, node);
225
+ pushTextInstance(segment.chunks, node, request.responseState, assignID);
226
+ return;
227
+ }
228
+
229
+ if (Array.isArray(node)) {
230
+ if (node.length > 0) {
231
+ // Only the first node gets assigned an ID.
232
+ renderNode(request, parentBoundary, segment, node[0], assignID);
233
+ for (let i = 1; i < node.length; i++) {
234
+ renderNode(request, parentBoundary, segment, node[i], null);
235
+ }
236
+ } else {
237
+ pushEmpty(segment.chunks, request.responseState, assignID);
238
+ }
239
return;
240
}
241
+
242
if (
243
typeof node !== 'object' ||
244
!node ||
@@ -236,7 +252,7 @@ function renderNode(
252
if (typeof type === 'function') {
253
try {
254
const result = type(props);
239
- renderNode(request, parentBoundary, segment, result);
255
+ renderNode(request, parentBoundary, segment, result, assignID);
256
} catch (x) {
257
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
258
// Something suspended, we'll need to create a new segment and resolve it later.
@@ -248,7 +264,7 @@ function renderNode(
264
node,
265
parentBoundary,
266
newSegment,
251
- null,
267
+ assignID,
268
);
269
const ping = suspendedWork.ping;
270
x.then(ping, ping);
@@ -259,10 +275,18 @@ function renderNode(
275
}
276
}
277
} else if (typeof type === 'string') {
262
- pushStartInstance(segment.chunks, type, props);
263
- renderNode(request, parentBoundary, segment, props.children);
278
+ pushStartInstance(
279
+ segment.chunks,
280
+ type,
281
+ props,
282
+ request.responseState,
283
+ assignID,
284
+ );
285
+ renderNode(request, parentBoundary, segment, props.children, null);
286
pushEndInstance(segment.chunks, type, props);
287
} else if (type === REACT_SUSPENSE_TYPE) {
288
+ // We need to push an "empty" thing here to identify the parent suspense boundary.
289
+ pushEmpty(segment.chunks, request.responseState, assignID);
290
// Each time we enter a suspense boundary, we split out into a new segment for
291
// the fallback so that we can later replace that segment with the content.
292
// This also lets us split out the main content even if it doesn't suspend,
@@ -418,7 +442,7 @@ function retryWork(request: Request, work: SuspendedWork): void {
442
node = element.type(element.props);
443
}
444
421
- renderNode(request, boundary, segment, node);
445
+ renderNode(request, boundary, segment, node, work.assignID);
446
447
completeWork(request, boundary, segment);
448
} catch (x) {
packages/react-server/src/forks/ReactServerFormatConfig.custom.js
+1
@@ -30,6 +30,7 @@ export opaque type SuspenseBoundaryID = mixed;
30
31
export const createResponseState = $$$hostConfig.createResponseState;
32
export const createSuspenseBoundaryID = $$$hostConfig.createSuspenseBoundaryID;
33
+export const pushEmpty = $$$hostConfig.pushEmpty;
34
export const pushTextInstance = $$$hostConfig.pushTextInstance;
35
export const pushStartInstance = $$$hostConfig.pushStartInstance;
36
export const pushEndInstance = $$$hostConfig.pushEndInstance;