[Fizz] Add FormatContext and Refactor Work (#21103)
* Add format context * Let the Work node hold all working state for the recursive loop Stacks are nice and all but there's a cost to maintaining each frame both in terms of stack size usage and writing to it. * Move current format context into work * Synchronously render children of a Suspense boundary We don't have to spawn work and snapshot the context. Instead we can try to render the boundary immediately in case it works. * Lazily create the fallback work Instead of eagerly create the fallback work and then immediately abort it. We can just avoid creating it if we finish synchronously.
Sebastian Markbåge committed
Mar 25, 2021 at 21:38 UTC
38a1aedb4987142ff2cfca57593b91571a39d394
7 files changed
+188
-58
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+5
-1
@@ -16,7 +16,10 @@ import {
16
abort,
17
} from 'react-server/src/ReactFizzServer';
18
19
-import {createResponseState} from './ReactDOMServerFormatConfig';
19
+import {
20
+ createResponseState,
21
+ createRootFormatContext,
22
+} from './ReactDOMServerFormatConfig';
23
24
type Options = {
25
identifierPrefix?: string,
@@ -46,6 +49,7 @@ function renderToReadableStream(
49
children,
50
controller,
51
createResponseState(options ? options.identifierPrefix : undefined),
52
+ createRootFormatContext(), // We call this here in case we need options to initialize it.
53
options ? options.progressiveChunkSize : undefined,
54
options ? options.onError : undefined,
55
options ? options.onCompleteAll : undefined,
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+5
-1
@@ -17,7 +17,10 @@ import {
17
abort,
18
} from 'react-server/src/ReactFizzServer';
19
20
-import {createResponseState} from './ReactDOMServerFormatConfig';
20
+import {
21
+ createResponseState,
22
+ createRootFormatContext,
23
+} from './ReactDOMServerFormatConfig';
24
25
function createDrainHandler(destination, request) {
26
return () => startFlowing(request);
@@ -46,6 +49,7 @@ function pipeToNodeWritable(
49
children,
50
destination,
51
createResponseState(options ? options.identifierPrefix : undefined),
52
+ createRootFormatContext(), // We call this here in case we need options to initialize it.
53
options ? options.progressiveChunkSize : undefined,
54
options ? options.onError : undefined,
55
options ? options.onCompleteAll : undefined,
packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+51
-1
@@ -22,7 +22,7 @@ import {
22
import escapeTextForBrowser from './escapeTextForBrowser';
23
import invariant from 'shared/invariant';
24
25
-// Per response,
25
+// Per response, global state that is not contextual to the rendering subtree.
26
export type ResponseState = {
27
placeholderPrefix: PrecomputedChunk,
28
segmentPrefix: PrecomputedChunk,
@@ -50,6 +50,56 @@ export function createResponseState(
50
};
51
}
52
53
+// Constants for the namespace we use. We don't actually provide the namespace but conditionally
54
+// use different segment parents based on namespace. Therefore we use constants instead of the string.
55
+const ROOT_NAMESPACE = 0; // At the root we don't need to know which namespace it is. We just need to know that it's already the right one.
56
+const HTML_NAMESPACE = 1;
57
+const SVG_NAMESPACE = 2;
58
+const MATHML_NAMESPACE = 3;
59
+
60
+type NamespaceFlag = 0 | 1 | 2 | 3;
61
+
62
+// Lets us keep track of contextual state and pick it back up after suspending.
63
+export type FormatContext = {
64
+ namespace: NamespaceFlag, // root/svg/html/mathml
65
+ selectedValue: null | string, // the selected value(s) inside a <select>, or null outside <select>
66
+};
67
+
68
+function createFormatContext(
69
+ namespace: NamespaceFlag,
70
+ selectedValue: null | string,
71
+): FormatContext {
72
+ return {
73
+ namespace,
74
+ selectedValue,
75
+ };
76
+}
77
+
78
+export function createRootFormatContext(): FormatContext {
79
+ return createFormatContext(ROOT_NAMESPACE, null);
80
+}
81
+
82
+export function getChildFormatContext(
83
+ parentContext: FormatContext,
84
+ type: string,
85
+ props: Object,
86
+): FormatContext {
87
+ switch (type) {
88
+ case 'select':
89
+ return createFormatContext(
90
+ parentContext.namespace,
91
+ props.value != null ? props.value : props.defaultValue,
92
+ );
93
+ case 'svg':
94
+ return createFormatContext(SVG_NAMESPACE, null);
95
+ case 'math':
96
+ return createFormatContext(MATHML_NAMESPACE, null);
97
+ case 'foreignObject':
98
+ return createFormatContext(HTML_NAMESPACE, null);
99
+ }
100
+ return parentContext;
101
+}
102
+
103
// This object is used to lazily reuse the ID of the first generated node, or assign one.
104
// We can't assign an ID up front because the node we're attaching it to might already
105
// have one. So we need to lazily use that if it's available.
packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+27
@@ -66,6 +66,33 @@ export function createResponseState(): ResponseState {
66
};
67
}
68
69
+// isInAParentText
70
+export type FormatContext = boolean;
71
+
72
+export function createRootFormatContext(): FormatContext {
73
+ return false;
74
+}
75
+
76
+export function getChildFormatContext(
77
+ parentContext: FormatContext,
78
+ type: string,
79
+ props: Object,
80
+): FormatContext {
81
+ const prevIsInAParentText = parentContext;
82
+ const isInAParentText =
83
+ type === 'AndroidTextInput' || // Android
84
+ type === 'RCTMultilineTextInputView' || // iOS
85
+ type === 'RCTSinglelineTextInputView' || // iOS
86
+ type === 'RCTText' ||
87
+ type === 'RCTVirtualText';
88
+
89
+ if (prevIsInAParentText !== isInAParentText) {
90
+ return isInAParentText;
91
+ } else {
92
+ return parentContext;
93
+ }
94
+}
95
+
96
// This object is used to lazily reuse the ID of the first generated node, or assign one.
97
// This is very specific to DOM where we can't assign an ID to.
98
export type SuspenseBoundaryID = number;
packages/react-noop-renderer/src/ReactNoopServer.js
+5
@@ -82,6 +82,10 @@ const ReactNoopServer = ReactFizzServer({
82
return {state: 'pending', children: []};
83
},
84
85
+ getChildFormatContext(): null {
86
+ return null;
87
+ },
88
+
89
pushTextInstance(target: Array<Uint8Array>, text: string): void {
90
const textInstance: TextInstance = {
91
text,
@@ -236,6 +240,7 @@ function render(children: React$Element<any>, options?: Options): Destination {
240
children,
241
destination,
242
null,
243
+ null,
244
options ? options.progressiveChunkSize : undefined,
245
options ? options.onError : undefined,
246
options ? options.onCompleteAll : undefined,
packages/react-server/src/ReactFizzServer.js
+93
-55
@@ -17,6 +17,7 @@ import type {ReactNodeList} from 'shared/ReactTypes';
17
import type {
18
SuspenseBoundaryID,
19
ResponseState,
20
+ FormatContext,
21
} from './ReactServerFormatConfig';
22
23
import {
@@ -44,6 +45,7 @@ import {
45
pushStartInstance,
46
pushEndInstance,
47
createSuspenseBoundaryID,
48
+ getChildFormatContext,
49
} from './ReactServerFormatConfig';
50
import {REACT_ELEMENT_TYPE, REACT_SUSPENSE_TYPE} from 'shared/ReactSymbols';
51
import ReactSharedInternals from 'shared/ReactSharedInternals';
@@ -69,6 +71,7 @@ type SuspendedWork = {
71
blockedBoundary: Root | SuspenseBoundary,
72
blockedSegment: Segment, // the segment we'll write to
73
abortSet: Set<SuspendedWork>, // the abortable set that this work belongs to
74
+ formatContext: FormatContext,
75
assignID: null | SuspenseBoundaryID, // id to assign to the content
76
};
77
@@ -142,6 +145,7 @@ export function createRequest(
145
children: ReactNodeList,
146
destination: Destination,
147
responseState: ResponseState,
148
+ rootContext: FormatContext,
149
progressiveChunkSize: number = DEFAULT_PROGRESSIVE_CHUNK_SIZE,
150
onError: (error: mixed) => void = noop,
151
onCompleteAll: () => void = noop,
@@ -177,6 +181,7 @@ export function createRequest(
181
null,
182
rootSegment,
183
abortSet,
184
+ rootContext,
185
null,
186
);
187
pingedWork.push(rootWork);
@@ -213,6 +218,7 @@ function createSuspendedWork(
218
blockedBoundary: Root | SuspenseBoundary,
219
blockedSegment: Segment,
220
abortSet: Set<SuspendedWork>,
221
+ formatContext: FormatContext,
222
assignID: null | SuspenseBoundaryID,
223
): SuspendedWork {
224
request.allPendingWork++;
@@ -227,6 +233,7 @@ function createSuspendedWork(
233
blockedBoundary,
234
blockedSegment,
235
abortSet,
236
+ formatContext,
237
assignID,
238
};
239
abortSet.add(work);
@@ -265,26 +272,32 @@ function fatalError(request: Request, error: mixed): void {
272
273
function renderNode(
274
request: Request,
268
- parentBoundary: Root | SuspenseBoundary,
269
- segment: Segment,
275
+ work: SuspendedWork,
276
node: ReactNodeList,
271
- abortSet: Set<SuspendedWork>,
272
- assignID: null | SuspenseBoundaryID,
277
): void {
278
if (typeof node === 'string') {
275
- pushTextInstance(segment.chunks, node, request.responseState, assignID);
279
+ pushTextInstance(
280
+ work.blockedSegment.chunks,
281
+ node,
282
+ request.responseState,
283
+ work.assignID,
284
+ );
285
+ work.assignID = null;
286
return;
287
}
288
289
if (Array.isArray(node)) {
290
if (node.length > 0) {
281
- // Only the first node gets assigned an ID.
282
- renderNode(request, parentBoundary, segment, node[0], abortSet, assignID);
283
- for (let i = 1; i < node.length; i++) {
284
- renderNode(request, parentBoundary, segment, node[i], abortSet, null);
291
+ for (let i = 0; i < node.length; i++) {
292
+ renderNode(request, work, node[i]);
293
}
294
} else {
287
- pushEmpty(segment.chunks, request.responseState, assignID);
295
+ pushEmpty(
296
+ work.blockedSegment.chunks,
297
+ request.responseState,
298
+ work.assignID,
299
+ );
300
+ work.assignID = null;
301
}
302
return;
303
}
@@ -302,24 +315,27 @@ function renderNode(
315
if (typeof type === 'function') {
316
try {
317
const result = type(props);
305
- renderNode(request, parentBoundary, segment, result, abortSet, assignID);
318
+ renderNode(request, work, result);
319
} catch (x) {
320
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
321
// Something suspended, we'll need to create a new segment and resolve it later.
322
+ const segment = work.blockedSegment;
323
const insertionIndex = segment.chunks.length;
324
const newSegment = createPendingSegment(request, insertionIndex, null);
325
segment.children.push(newSegment);
326
const suspendedWork = createSuspendedWork(
327
request,
328
node,
315
- parentBoundary,
329
+ work.blockedBoundary,
330
newSegment,
317
- abortSet,
318
- assignID,
331
+ work.abortSet,
332
+ work.formatContext,
333
+ work.assignID,
334
);
335
+ // We've delegated the assignment.
336
+ work.assignID = null;
337
const ping = suspendedWork.ping;
338
x.then(ping, ping);
322
- // TODO: Emit place holder
339
} else {
340
// We can rethrow to terminate the rest of this tree.
341
throw x;
@@ -327,24 +343,28 @@ function renderNode(
343
}
344
} else if (typeof type === 'string') {
345
pushStartInstance(
330
- segment.chunks,
346
+ work.blockedSegment.chunks,
347
type,
348
props,
349
request.responseState,
334
- assignID,
335
- );
336
- renderNode(
337
- request,
338
- parentBoundary,
339
- segment,
340
- props.children,
341
- abortSet,
342
- null,
350
+ work.assignID,
351
);
344
- pushEndInstance(segment.chunks, type, props);
352
+ // We must have assigned it already above so we don't need this anymore.
353
+ work.assignID = null;
354
+ const prevContext = work.formatContext;
355
+ work.formatContext = getChildFormatContext(prevContext, type, props);
356
+ renderNode(request, work, props.children);
357
+ // We expect that errors will fatal the whole work and that we don't need
358
+ // the correct context. Therefore this is not in a finally.
359
+ work.formatContext = prevContext;
360
+ pushEndInstance(work.blockedSegment.chunks, type, props);
361
} else if (type === REACT_SUSPENSE_TYPE) {
362
+ const parentBoundary = work.blockedBoundary;
363
+ const parentSegment = work.blockedSegment;
364
+
365
// We need to push an "empty" thing here to identify the parent suspense boundary.
347
- pushEmpty(segment.chunks, request.responseState, assignID);
366
+ pushEmpty(parentSegment.chunks, request.responseState, work.assignID);
367
+ work.assignID = null;
368
// Each time we enter a suspense boundary, we split out into a new segment for
369
// the fallback so that we can later replace that segment with the content.
370
// This also lets us split out the main content even if it doesn't suspend,
@@ -354,15 +374,52 @@ function renderNode(
374
375
const fallbackAbortSet: Set<SuspendedWork> = new Set();
376
const newBoundary = createSuspenseBoundary(request, fallbackAbortSet);
357
-
358
- const insertionIndex = segment.chunks.length;
377
+ const insertionIndex = parentSegment.chunks.length;
378
// The children of the boundary segment is actually the fallback.
379
const boundarySegment = createPendingSegment(
380
request,
381
insertionIndex,
382
newBoundary,
383
);
365
- segment.children.push(boundarySegment);
384
+ parentSegment.children.push(boundarySegment);
385
+
386
+ // This segment is the actual child content. We can start rendering that immediately.
387
+ const contentRootSegment = createPendingSegment(request, 0, null);
388
+ // We mark the root segment as having its parent flushed. It's not really flushed but there is
389
+ // no parent segment so there's nothing to wait on.
390
+ contentRootSegment.parentFlushed = true;
391
+
392
+ // Currently this is running synchronously. We could instead schedule this to pingedWork.
393
+ // I suspect that there might be some efficiency benefits from not creating the suspended work
394
+ // and instead just using the stack if possible.
395
+ // TODO: Call this directly instead of messing with saving and restoring contexts.
396
+
397
+ // We can reuse the current context and work to render the content immediately without
398
+ // context switching. We just need to temporarily switch which boundary and which segment
399
+ // we're writing to. If something suspends, it'll spawn new suspended work with that context.
400
+ work.blockedBoundary = newBoundary;
401
+ work.blockedSegment = contentRootSegment;
402
+ try {
403
+ renderNode(request, work, content);
404
+ contentRootSegment.status = COMPLETED;
405
+ newBoundary.completedSegments.push(contentRootSegment);
406
+ if (newBoundary.pendingWork === 0) {
407
+ // This must have been the last segment we were waiting on. This boundary is now complete.
408
+ // Therefore we won't need the fallback. We early return so that we don't have to create
409
+ // the fallback.
410
+ return;
411
+ }
412
+ } catch (error) {
413
+ contentRootSegment.status = ERRORED;
414
+ reportError(request, error);
415
+ newBoundary.forceClientRender = true;
416
+ // We don't need to decrement any work numbers because we didn't spawn any new work.
417
+ // We don't need to schedule any work because we know the parent has written yet.
418
+ // We do need to fallthrough to create the fallback though.
419
+ } finally {
420
+ work.blockedBoundary = parentBoundary;
421
+ work.blockedSegment = parentSegment;
422
+ }
423
424
// We create suspended work for the fallback because we don't want to actually work
425
// on it yet in case we finish the main content, so we queue for later.
@@ -372,29 +429,12 @@ function renderNode(
429
parentBoundary,
430
boundarySegment,
431
fallbackAbortSet,
432
+ work.formatContext,
433
newBoundary.id, // This is the ID we want to give this fallback so we can replace it later.
434
);
435
// TODO: This should be queued at a separate lower priority queue so that we only work
436
// on preparing fallbacks if we don't have any more main content to work on.
437
request.pingedWork.push(suspendedFallbackWork);
380
-
381
- // This segment is the actual child content. We can start rendering that immediately.
382
- const contentRootSegment = createPendingSegment(request, 0, null);
383
- // We mark the root segment as having its parent flushed. It's not really flushed but there is
384
- // no parent segment so there's nothing to wait on.
385
- contentRootSegment.parentFlushed = true;
386
- // TODO: Currently this is running synchronously. We could instead schedule this to pingedWork.
387
- // I suspect that there might be some efficiency benefits from not creating the suspended work
388
- // and instead just using the stack if possible. Particularly when we add contexts.
389
- const contentWork = createSuspendedWork(
390
- request,
391
- content,
392
- newBoundary,
393
- contentRootSegment,
394
- abortSet,
395
- null,
396
- );
397
- retryWork(request, contentWork);
438
} else {
439
throw new Error('Not yet implemented element type.');
440
}
@@ -547,8 +587,6 @@ function retryWork(request: Request, work: SuspendedWork): void {
587
// We completed this by other means before we had a chance to retry it.
588
return;
589
}
550
- const boundary = work.blockedBoundary;
551
- const abortSet = work.abortSet;
590
try {
591
let node = work.node;
592
while (
@@ -565,20 +603,20 @@ function retryWork(request: Request, work: SuspendedWork): void {
603
node = element.type(element.props);
604
}
605
568
- renderNode(request, boundary, segment, node, abortSet, work.assignID);
606
+ renderNode(request, work, node);
607
570
- abortSet.delete(work);
608
+ work.abortSet.delete(work);
609
segment.status = COMPLETED;
572
- finishedWork(request, boundary, segment);
610
+ finishedWork(request, work.blockedBoundary, segment);
611
} catch (x) {
612
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
613
// Something suspended again, let's pick it back up later.
614
const ping = work.ping;
615
x.then(ping, ping);
616
} else {
579
- abortSet.delete(work);
617
+ work.abortSet.delete(work);
618
segment.status = ERRORED;
581
- erroredWork(request, boundary, segment, x);
619
+ erroredWork(request, work.blockedBoundary, segment, x);
620
}
621
}
622
}
packages/react-server/src/forks/ReactServerFormatConfig.custom.js
+2
@@ -26,8 +26,10 @@
26
declare var $$$hostConfig: any;
27
export opaque type Destination = mixed; // eslint-disable-line no-undef
28
export opaque type ResponseState = mixed;
29
+export opaque type FormatContext = mixed;
30
export opaque type SuspenseBoundaryID = mixed;
31
32
+export const getChildFormatContext = $$$hostConfig.getChildFormatContext;
33
export const createSuspenseBoundaryID = $$$hostConfig.createSuspenseBoundaryID;
34
export const pushEmpty = $$$hostConfig.pushEmpty;
35
export const pushTextInstance = $$$hostConfig.pushTextInstance;