@samitouri / QOS-React-2 / commits / 1d1e49cfa4

[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;