@samitouri / QOS-React-2 / commits / 38a1aedb49

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