@samitouri / QOS-React-2 / commits / b70a0d7022

[Fizz] Track postponed holes in the prerender pass (#27317)

This is basically the implementation for the prerender pass. Instead of forking basically the whole implementation for prerender, I just add a conditional field on the request. If it's `null` it behaves like before. If it's non-`null` then instead of triggering client rendered boundaries it triggers those into a "postponed" state which is basically just a variant of "pending". It's supposed to be filled in later. It also builds up a serializable tree of which path can be followed to find the holes. This is basically a reverse `KeyPath` tree. It is unfortunate that this approach adds more code to the regular Fizz builds but in practice. It seems like this side is not going to add much code and we might instead just want to merge the builds so that it's smaller when you have `prerender` and `resume` in the same bundle - which I think will be common in practice. This just implements the prerender side, and not the resume side, which is why the tests have a TODO. That's in a follow up PR.

Sebastian Markbåge committed Aug 31, 2023 at 12:23 UTC b70a0d70224ceb4e277bd8ac535a2caafa5c075a
24 files changed +588 -147
packages/react-dom/npm/server.node.js
+2 -2
@@ -15,6 +15,6 @@ exports.renderToStaticMarkup = l.renderToStaticMarkup;
15 exports.renderToNodeStream = l.renderToNodeStream;
16 exports.renderToStaticNodeStream = l.renderToStaticNodeStream;
17 exports.renderToPipeableStream = s.renderToPipeableStream;
18 -if (s.resume) {
19 - exports.resume = s.resume;
18 +if (s.resumeToPipeableStream) {
19 + exports.resumeToPipeableStream = s.resumeToPipeableStream;
20 }
packages/react-dom/server.node.js
+2 -2
@@ -43,8 +43,8 @@ export function renderToPipeableStream() {
43 );
44 }
45
46 -export function resume() {
47 - return require('./src/server/react-dom-server.node').resume.apply(
46 +export function resumeToPipeableStream() {
47 + return require('./src/server/react-dom-server.node').resumeToPipeableStream.apply(
48 this,
49 arguments,
50 );
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+61 -40
@@ -14,6 +14,7 @@ import {
14 mergeOptions,
15 stripExternalRuntimeInNodes,
16 withLoadingReadyState,
17 + getVisibleChildren,
18 } from '../test-utils/FizzTestUtils';
19
20 let JSDOM;
@@ -23,6 +24,7 @@ let React;
24 let ReactDOM;
25 let ReactDOMClient;
26 let ReactDOMFizzServer;
27 +let ReactDOMFizzStatic;
28 let Suspense;
29 let SuspenseList;
30 let useSyncExternalStore;
@@ -77,6 +79,9 @@ describe('ReactDOMFizzServer', () => {
79 ReactDOM = require('react-dom');
80 ReactDOMClient = require('react-dom/client');
81 ReactDOMFizzServer = require('react-dom/server');
82 + if (__EXPERIMENTAL__) {
83 + ReactDOMFizzStatic = require('react-dom/static');
84 + }
85 Stream = require('stream');
86 Suspense = React.Suspense;
87 use = React.use;
@@ -289,46 +294,6 @@ describe('ReactDOMFizzServer', () => {
294 }, document);
295 }
296
292 - function getVisibleChildren(element) {
293 - const children = [];
294 - let node = element.firstChild;
295 - while (node) {
296 - if (node.nodeType === 1) {
297 - if (
298 - node.tagName !== 'SCRIPT' &&
299 - node.tagName !== 'script' &&
300 - node.tagName !== 'TEMPLATE' &&
301 - node.tagName !== 'template' &&
302 - !node.hasAttribute('hidden') &&
303 - !node.hasAttribute('aria-hidden')
304 - ) {
305 - const props = {};
306 - const attributes = node.attributes;
307 - for (let i = 0; i < attributes.length; i++) {
308 - if (
309 - attributes[i].name === 'id' &&
310 - attributes[i].value.includes(':')
311 - ) {
312 - // We assume this is a React added ID that's a non-visual implementation detail.
313 - continue;
314 - }
315 - props[attributes[i].name] = attributes[i].value;
316 - }
317 - props.children = getVisibleChildren(node);
318 - children.push(React.createElement(node.tagName.toLowerCase(), props));
319 - }
320 - } else if (node.nodeType === 3) {
321 - children.push(node.data);
322 - }
323 - node = node.nextSibling;
324 - }
325 - return children.length === 0
326 - ? undefined
327 - : children.length === 1
328 - ? children[0]
329 - : children;
330 - }
331 -
297 function resolveText(text) {
298 const record = textCache.get(text);
299 if (record === undefined) {
@@ -6227,4 +6192,60 @@ describe('ReactDOMFizzServer', () => {
6192 );
6193 },
6194 );
6195 +
6196 + // @gate enablePostpone
6197 + it('supports postponing in prerender and resuming later', async () => {
6198 + let prerendering = true;
6199 + function Postpone() {
6200 + if (prerendering) {
6201 + React.unstable_postpone();
6202 + }
6203 + return 'Hello';
6204 + }
6205 +
6206 + function App() {
6207 + return (
6208 + <div>
6209 + <Suspense fallback="Loading...">
6210 + <Postpone />
6211 + </Suspense>
6212 + </div>
6213 + );
6214 + }
6215 +
6216 + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(<App />);
6217 + expect(prerendered.postponed).not.toBe(null);
6218 +
6219 + prerendering = false;
6220 +
6221 + const resumed = ReactDOMFizzServer.resumeToPipeableStream(
6222 + <App />,
6223 + prerendered.postponed,
6224 + );
6225 +
6226 + // Create a separate stream so it doesn't close the writable. I.e. simple concat.
6227 + const preludeWritable = new Stream.PassThrough();
6228 + preludeWritable.setEncoding('utf8');
6229 + preludeWritable.on('data', chunk => {
6230 + writable.write(chunk);
6231 + });
6232 +
6233 + await act(() => {
6234 + prerendered.prelude.pipe(preludeWritable);
6235 + });
6236 +
6237 + expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
6238 +
6239 + const b = new Stream.PassThrough();
6240 + b.setEncoding('utf8');
6241 + b.on('data', chunk => {
6242 + writable.write(chunk);
6243 + });
6244 +
6245 + await act(() => {
6246 + resumed.pipe(writable);
6247 + });
6248 +
6249 + // TODO: expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
6250 + });
6251 });
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+137
@@ -9,23 +9,37 @@
9
10 'use strict';
11
12 +import {
13 + getVisibleChildren,
14 + insertNodesAndExecuteScripts,
15 +} from '../test-utils/FizzTestUtils';
16 +
17 // Polyfills for test environment
18 global.ReadableStream =
19 require('web-streams-polyfill/ponyfill/es6').ReadableStream;
20 global.TextEncoder = require('util').TextEncoder;
21
22 let React;
23 +let ReactDOMFizzServer;
24 let ReactDOMFizzStatic;
25 let Suspense;
26 +let container;
27
28 describe('ReactDOMFizzStaticBrowser', () => {
29 beforeEach(() => {
30 jest.resetModules();
31 React = require('react');
32 + ReactDOMFizzServer = require('react-dom/server.browser');
33 if (__EXPERIMENTAL__) {
34 ReactDOMFizzStatic = require('react-dom/static.browser');
35 }
36 Suspense = React.Suspense;
37 + container = document.createElement('div');
38 + document.body.appendChild(container);
39 + });
40 +
41 + afterEach(() => {
42 + document.body.removeChild(container);
43 });
44
45 const theError = new Error('This is an error');
@@ -37,6 +51,36 @@ describe('ReactDOMFizzStaticBrowser', () => {
51 throw theInfinitePromise;
52 }
53
54 + function concat(streamA, streamB) {
55 + const readerA = streamA.getReader();
56 + const readerB = streamB.getReader();
57 + return new ReadableStream({
58 + start(controller) {
59 + function readA() {
60 + readerA.read().then(({done, value}) => {
61 + if (done) {
62 + readB();
63 + return;
64 + }
65 + controller.enqueue(value);
66 + readA();
67 + });
68 + }
69 + function readB() {
70 + readerB.read().then(({done, value}) => {
71 + if (done) {
72 + controller.close();
73 + return;
74 + }
75 + controller.enqueue(value);
76 + readB();
77 + });
78 + }
79 + readA();
80 + },
81 + });
82 + }
83 +
84 async function readContent(stream) {
85 const reader = stream.getReader();
86 let content = '';
@@ -49,6 +93,21 @@ describe('ReactDOMFizzStaticBrowser', () => {
93 }
94 }
95
96 + async function readIntoContainer(stream) {
97 + const reader = stream.getReader();
98 + let result = '';
99 + while (true) {
100 + const {done, value} = await reader.read();
101 + if (done) {
102 + break;
103 + }
104 + result += Buffer.from(value).toString('utf8');
105 + }
106 + const temp = document.createElement('div');
107 + temp.innerHTML = result;
108 + insertNodesAndExecuteScripts(temp, container, null);
109 + }
110 +
111 // @gate experimental
112 it('should call prerender', async () => {
113 const result = await ReactDOMFizzStatic.prerender(<div>hello world</div>);
@@ -394,4 +453,82 @@ describe('ReactDOMFizzStaticBrowser', () => {
453
454 expect(errors).toEqual(['uh oh', 'uh oh']);
455 });
456 +
457 + // @gate enablePostpone
458 + it('supports postponing in prerender and resuming later', async () => {
459 + let prerendering = true;
460 + function Postpone() {
461 + if (prerendering) {
462 + React.unstable_postpone();
463 + }
464 + return 'Hello';
465 + }
466 +
467 + function App() {
468 + return (
469 + <div>
470 + <Suspense fallback="Loading...">
471 + <Postpone />
472 + </Suspense>
473 + </div>
474 + );
475 + }
476 +
477 + const prerendered = await ReactDOMFizzStatic.prerender(<App />);
478 + expect(prerendered.postponed).not.toBe(null);
479 +
480 + prerendering = false;
481 +
482 + const resumed = await ReactDOMFizzServer.resume(
483 + <App />,
484 + prerendered.postponed,
485 + );
486 +
487 + await readIntoContainer(prerendered.prelude);
488 +
489 + expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
490 +
491 + await readIntoContainer(resumed);
492 +
493 + // TODO: expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
494 + });
495 +
496 + // @gate enablePostpone
497 + it('only emits end tags once when resuming', async () => {
498 + let prerendering = true;
499 + function Postpone() {
500 + if (prerendering) {
501 + React.unstable_postpone();
502 + }
503 + return 'Hello';
504 + }
505 +
506 + function App() {
507 + return (
508 + <html>
509 + <body>
510 + <Suspense fallback="Loading...">
511 + <Postpone />
512 + </Suspense>
513 + </body>
514 + </html>
515 + );
516 + }
517 +
518 + const prerendered = await ReactDOMFizzStatic.prerender(<App />);
519 + expect(prerendered.postponed).not.toBe(null);
520 +
521 + prerendering = false;
522 +
523 + const content = await ReactDOMFizzServer.resume(
524 + <App />,
525 + prerendered.postponed,
526 + );
527 +
528 + const html = await readContent(concat(prerendered.prelude, content));
529 + const htmlEndTags = /<\/html\s*>/gi;
530 + const bodyEndTags = /<\/body\s*>/gi;
531 + expect(Array.from(html.matchAll(htmlEndTags)).length).toBe(1);
532 + expect(Array.from(html.matchAll(bodyEndTags)).length).toBe(1);
533 + });
534 });
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+3 -3
@@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion';
16
17 import {
18 createRequest,
19 - startWork,
19 + startRender,
20 startFlowing,
21 abort,
22 } from 'react-server/src/ReactFizzServer';
@@ -129,7 +129,7 @@ function renderToReadableStream(
129 signal.addEventListener('abort', listener);
130 }
131 }
132 - startWork(request);
132 + startRender(request);
133 });
134 }
135
@@ -200,7 +200,7 @@ function resume(
200 signal.addEventListener('abort', listener);
201 }
202 }
203 - startWork(request);
203 + startRender(request);
204 });
205 }
206
packages/react-dom/src/server/ReactDOMFizzServerBun.js
+2 -2
@@ -15,7 +15,7 @@ import ReactVersion from 'shared/ReactVersion';
15
16 import {
17 createRequest,
18 - startWork,
18 + startRender,
19 startFlowing,
20 abort,
21 } from 'react-server/src/ReactFizzServer';
@@ -121,7 +121,7 @@ function renderToReadableStream(
121 signal.addEventListener('abort', listener);
122 }
123 }
124 - startWork(request);
124 + startRender(request);
125 });
126 }
127
packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+3 -3
@@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion';
16
17 import {
18 createRequest,
19 - startWork,
19 + startRender,
20 startFlowing,
21 abort,
22 } from 'react-server/src/ReactFizzServer';
@@ -129,7 +129,7 @@ function renderToReadableStream(
129 signal.addEventListener('abort', listener);
130 }
131 }
132 - startWork(request);
132 + startRender(request);
133 });
134 }
135
@@ -200,7 +200,7 @@ function resume(
200 signal.addEventListener('abort', listener);
201 }
202 }
203 - startWork(request);
203 + startRender(request);
204 });
205 }
206
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+3 -3
@@ -18,7 +18,7 @@ import ReactVersion from 'shared/ReactVersion';
18
19 import {
20 createRequest,
21 - startWork,
21 + startRender,
22 startFlowing,
23 abort,
24 } from 'react-server/src/ReactFizzServer';
@@ -105,7 +105,7 @@ function renderToPipeableStream(
105 ): PipeableStream {
106 const request = createRequestImpl(children, options);
107 let hasStartedFlowing = false;
108 - startWork(request);
108 + startRender(request);
109 return {
110 pipe<T: Writable>(destination: T): T {
111 if (hasStartedFlowing) {
@@ -166,7 +166,7 @@ function resumeToPipeableStream(
166 ): PipeableStream {
167 const request = resumeRequestImpl(children, postponedState, options);
168 let hasStartedFlowing = false;
169 - startWork(request);
169 + startRender(request);
170 return {
171 pipe<T: Writable>(destination: T): T {
172 if (hasStartedFlowing) {
packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
+2 -2
@@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion';
16
17 import {
18 createRequest,
19 - startWork,
19 + startPrerender,
20 startFlowing,
21 abort,
22 getPostponedState,
@@ -109,7 +109,7 @@ function prerender(
109 signal.addEventListener('abort', listener);
110 }
111 }
112 - startWork(request);
112 + startPrerender(request);
113 });
114 }
115
packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
+2 -2
@@ -16,7 +16,7 @@ import ReactVersion from 'shared/ReactVersion';
16
17 import {
18 createRequest,
19 - startWork,
19 + startPrerender,
20 startFlowing,
21 abort,
22 getPostponedState,
@@ -109,7 +109,7 @@ function prerender(
109 signal.addEventListener('abort', listener);
110 }
111 }
112 - startWork(request);
112 + startPrerender(request);
113 });
114 }
115
packages/react-dom/src/server/ReactDOMFizzStaticNode.js
+2 -2
@@ -18,7 +18,7 @@ import ReactVersion from 'shared/ReactVersion';
18
19 import {
20 createRequest,
21 - startWork,
21 + startPrerender,
22 startFlowing,
23 abort,
24 getPostponedState,
@@ -123,7 +123,7 @@ function prerenderToNodeStream(
123 signal.addEventListener('abort', listener);
124 }
125 }
126 - startWork(request);
126 + startPrerender(request);
127 });
128 }
129
packages/react-dom/src/server/ReactDOMLegacyServerImpl.js
+2 -2
@@ -13,7 +13,7 @@ import type {ReactNodeList} from 'shared/ReactTypes';
13
14 import {
15 createRequest,
16 - startWork,
16 + startRender,
17 startFlowing,
18 abort,
19 } from 'react-server/src/ReactFizzServer';
@@ -81,7 +81,7 @@ function renderToStringImpl(
81 undefined,
82 undefined,
83 );
84 - startWork(request);
84 + startRender(request);
85 // If anything suspended and is still pending, we'll abort it before writing.
86 // That way we write only client-rendered boundaries from the start.
87 abort(request, abortReason);
packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js
+2 -2
@@ -13,7 +13,7 @@ import type {Request} from 'react-server/src/ReactFizzServer';
13
14 import {
15 createRequest,
16 - startWork,
16 + startRender,
17 startFlowing,
18 abort,
19 } from 'react-server/src/ReactFizzServer';
@@ -92,7 +92,7 @@ function renderToNodeStreamImpl(
92 undefined,
93 );
94 destination.request = request;
95 - startWork(request);
95 + startRender(request);
96 return destination;
97 }
98
packages/react-dom/src/test-utils/FizzTestUtils.js
+43
@@ -171,9 +171,52 @@ async function withLoadingReadyState<T>(
171 return result;
172 }
173
174 +function getVisibleChildren(element: Element): React$Node {
175 + const children = [];
176 + let node: any = element.firstChild;
177 + while (node) {
178 + if (node.nodeType === 1) {
179 + if (
180 + node.tagName !== 'SCRIPT' &&
181 + node.tagName !== 'script' &&
182 + node.tagName !== 'TEMPLATE' &&
183 + node.tagName !== 'template' &&
184 + !node.hasAttribute('hidden') &&
185 + !node.hasAttribute('aria-hidden')
186 + ) {
187 + const props: any = {};
188 + const attributes = node.attributes;
189 + for (let i = 0; i < attributes.length; i++) {
190 + if (
191 + attributes[i].name === 'id' &&
192 + attributes[i].value.includes(':')
193 + ) {
194 + // We assume this is a React added ID that's a non-visual implementation detail.
195 + continue;
196 + }
197 + props[attributes[i].name] = attributes[i].value;
198 + }
199 + props.children = getVisibleChildren(node);
200 + children.push(
201 + require('react').createElement(node.tagName.toLowerCase(), props),
202 + );
203 + }
204 + } else if (node.nodeType === 3) {
205 + children.push(node.data);
206 + }
207 + node = node.nextSibling;
208 + }
209 + return children.length === 0
210 + ? undefined
211 + : children.length === 1
212 + ? children[0]
213 + : children;
214 +}
215 +
216 export {
217 insertNodesAndExecuteScripts,
218 mergeOptions,
219 stripExternalRuntimeInNodes,
220 withLoadingReadyState,
221 + getVisibleChildren,
222 };
packages/react-noop-renderer/src/ReactNoopFlightServer.js
+1 -1
@@ -84,7 +84,7 @@ function render(model: ReactClientValue, options?: Options): Destination {
84 options ? options.context : undefined,
85 options ? options.identifierPrefix : undefined,
86 );
87 - ReactNoopFlightServer.startWork(request);
87 + ReactNoopFlightServer.startRender(request);
88 ReactNoopFlightServer.startFlowing(request, destination);
89 return destination;
90 }
packages/react-noop-renderer/src/ReactNoopServer.js
+1 -1
@@ -304,7 +304,7 @@ function render(children: React$Element<any>, options?: Options): Destination {
304 options ? options.onAllReady : undefined,
305 options ? options.onShellReady : undefined,
306 );
307 - ReactNoopServer.startWork(request);
307 + ReactNoopServer.startRender(request);
308 ReactNoopServer.startFlowing(request, destination);
309 return destination;
310 }
packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
+2 -2
@@ -20,7 +20,7 @@ import type {ServerContextJSONValue, Thenable} from 'shared/ReactTypes';
20
21 import {
22 createRequest,
23 - startWork,
23 + startRender,
24 startFlowing,
25 abort,
26 } from 'react-server/src/ReactFlightServer';
@@ -73,7 +73,7 @@ function renderToPipeableStream(
73 options ? options.onPostpone : undefined,
74 );
75 let hasStartedFlowing = false;
76 - startWork(request);
76 + startRender(request);
77 return {
78 pipe<T: Writable>(destination: T): T {
79 if (hasStartedFlowing) {
packages/react-server-dom-fb/src/ReactDOMServerFB.js
+2 -2
@@ -16,7 +16,7 @@ import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/Reac
16
17 import {
18 createRequest,
19 - startWork,
19 + startRender,
20 performWork,
21 startFlowing,
22 abort,
@@ -68,7 +68,7 @@ function renderToStream(children: ReactNodeList, options: Options): Stream {
68 undefined,
69 undefined,
70 );
71 - startWork(request);
71 + startRender(request);
72 if (destination.fatal) {
73 throw destination.error;
74 }
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+2 -2
@@ -14,7 +14,7 @@ import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig';
14
15 import {
16 createRequest,
17 - startWork,
17 + startRender,
18 startFlowing,
19 abort,
20 } from 'react-server/src/ReactFlightServer';
@@ -70,7 +70,7 @@ function renderToReadableStream(
70 {
71 type: 'bytes',
72 start: (controller): ?Promise<void> => {
73 - startWork(request);
73 + startRender(request);
74 },
75 pull: (controller): ?Promise<void> => {
76 startFlowing(request, controller);
packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+2 -2
@@ -14,7 +14,7 @@ import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig';
14
15 import {
16 createRequest,
17 - startWork,
17 + startRender,
18 startFlowing,
19 abort,
20 } from 'react-server/src/ReactFlightServer';
@@ -70,7 +70,7 @@ function renderToReadableStream(
70 {
71 type: 'bytes',
72 start: (controller): ?Promise<void> => {
73 - startWork(request);
73 + startRender(request);
74 },
75 pull: (controller): ?Promise<void> => {
76 startFlowing(request, controller);
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+2 -2
@@ -20,7 +20,7 @@ import type {ServerContextJSONValue, Thenable} from 'shared/ReactTypes';
20
21 import {
22 createRequest,
23 - startWork,
23 + startRender,
24 startFlowing,
25 abort,
26 } from 'react-server/src/ReactFlightServer';
@@ -74,7 +74,7 @@ function renderToPipeableStream(
74 options ? options.onPostpone : undefined,
75 );
76 let hasStartedFlowing = false;
77 - startWork(request);
77 + startRender(request);
78 return {
79 pipe<T: Writable>(destination: T): T {
80 if (hasStartedFlowing) {
packages/react-server/src/ReactFizzServer.js
+307 -68
@@ -154,27 +154,64 @@ const ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
154 // Linked list representing the identity of a component given the component/tag name and key.
155 // The name might be minified but we assume that it's going to be the same generated name. Typically
156 // because it's just the same compiled output in practice.
157 -type KeyNode =
158 - | null
159 - | [KeyNode /* parent */, string | null /* name */, string | number /* key */];
157 +type KeyNode = [
158 + Root | KeyNode /* parent */,
159 + string | null /* name */,
160 + string | number /* key */,
161 +];
162 +
163 +const REPLAY_NODE = 0;
164 +const REPLAY_SUSPENSE_BOUNDARY = 1;
165 +const RESUME_SEGMENT = 2;
166 +
167 +type ResumableParentNode =
168 + | [
169 + 0, // REPLAY_NODE
170 + string | null /* name */,
171 + string | number /* key */,
172 + Array<ResumableNode> /* children */,
173 + ]
174 + | [
175 + 1, // REPLAY_SUSPENSE_BOUNDARY
176 + string | null /* name */,
177 + string | number /* key */,
178 + Array<ResumableNode> /* children */,
179 + SuspenseBoundaryID,
180 + ];
181 +type ResumableNode =
182 + | ResumableParentNode
183 + | [
184 + 2, // RESUME_SEGMENT
185 + string | null /* name */,
186 + string | number /* key */,
187 + number /* segment id */,
188 + ];
189 +
190 +type PostponedHoles = {
191 + workingMap: Map<KeyNode, ResumableParentNode>,
192 + root: Array<ResumableNode>,
193 +};
194
195 type LegacyContext = {
196 [key: string]: any,
197 };
198
199 +const CLIENT_RENDERED = 4; // if it errors or infinitely suspends
200 +
201 type SuspenseBoundary = {
202 + status: 0 | 1 | 4 | 5,
203 id: SuspenseBoundaryID,
204 rootSegmentID: number,
205 errorDigest: ?string, // the error hash if it errors
206 errorMessage?: string, // the error string if it errors
207 errorComponentStack?: string, // the error component stack if it errors
171 - forceClientRender: boolean, // if it errors or infinitely suspends
208 parentFlushed: boolean,
209 pendingTasks: number, // when it reaches zero we can show this boundary's content
210 completedSegments: Array<Segment>, // completed but not yet flushed segments.
211 byteSize: number, // used to determine whether to inline children boundaries.
212 fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled.
213 resources: BoundaryResources,
214 + keyPath: Root | KeyNode,
215 };
216
217 export type Task = {
@@ -183,7 +220,7 @@ export type Task = {
220 blockedBoundary: Root | SuspenseBoundary,
221 blockedSegment: Segment, // the segment we'll write to
222 abortSet: Set<Task>, // the abortable set that this task belongs to
186 - keyPath: KeyNode, // the path of all parent keys currently rendering
223 + keyPath: Root | KeyNode, // the path of all parent keys currently rendering
224 legacyContext: LegacyContext, // the current legacy context that this task is executing in
225 context: ContextSnapshot, // the current new context that this task is executing in
226 treeContext: TreeContext, // the current tree context that this task is executing in
@@ -196,11 +233,12 @@ const COMPLETED = 1;
233 const FLUSHED = 2;
234 const ABORTED = 3;
235 const ERRORED = 4;
236 +const POSTPONED = 5;
237
238 type Root = null;
239
240 type Segment = {
203 - status: 0 | 1 | 2 | 3 | 4,
241 + status: 0 | 1 | 2 | 3 | 4 | 5,
242 parentFlushed: boolean, // typically a segment will be flushed by its parent, except if its parent was already flushed
243 id: number, // starts as 0 and is lazily assigned if the parent flushes early
244 +index: number, // the index within the parent's chunks or 0 at the root
@@ -224,6 +262,7 @@ export opaque type Request = {
262 flushScheduled: boolean,
263 +resumableState: ResumableState,
264 +renderState: RenderState,
265 + +rootFormatContext: FormatContext,
266 +progressiveChunkSize: number,
267 status: 0 | 1 | 2,
268 fatalError: mixed,
@@ -237,6 +276,7 @@ export opaque type Request = {
276 clientRenderedBoundaries: Array<SuspenseBoundary>, // Errored or client rendered but not yet flushed.
277 completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show.
278 partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
279 + trackedPostpones: null | PostponedHoles, // Gets set to non-null while we want to track postponed holes. I.e. during a prerender.
280 // onError is called when an error happens anywhere in the tree. It might recover.
281 // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes.
282 // Returning null/undefined will cause a defualt error message in production
@@ -302,6 +342,7 @@ export function createRequest(
342 flushScheduled: false,
343 resumableState,
344 renderState,
345 + rootFormatContext,
346 progressiveChunkSize:
347 progressiveChunkSize === undefined
348 ? DEFAULT_PROGRESSIVE_CHUNK_SIZE
@@ -317,6 +358,7 @@ export function createRequest(
358 clientRenderedBoundaries: ([]: Array<SuspenseBoundary>),
359 completedBoundaries: ([]: Array<SuspenseBoundary>),
360 partialBoundaries: ([]: Array<SuspenseBoundary>),
361 + trackedPostpones: null,
362 onError: onError === undefined ? defaultErrorHandler : onError,
363 onPostpone: onPostpone === undefined ? noop : onPostpone,
364 onAllReady: onAllReady === undefined ? noop : onAllReady,
@@ -375,18 +417,20 @@ function pingTask(request: Request, task: Task): void {
417 function createSuspenseBoundary(
418 request: Request,
419 fallbackAbortableTasks: Set<Task>,
420 + keyPath: Root | KeyNode,
421 ): SuspenseBoundary {
422 return {
423 + status: PENDING,
424 id: UNINITIALIZED_SUSPENSE_BOUNDARY_ID,
425 rootSegmentID: -1,
426 parentFlushed: false,
427 pendingTasks: 0,
384 - forceClientRender: false,
428 completedSegments: [],
429 byteSize: 0,
430 fallbackAbortableTasks,
431 errorDigest: null,
432 resources: createBoundaryResources(),
433 + keyPath,
434 };
435 }
436
@@ -397,7 +441,7 @@ function createTask(
441 blockedBoundary: Root | SuspenseBoundary,
442 blockedSegment: Segment,
443 abortSet: Set<Task>,
400 - keyPath: KeyNode,
444 + keyPath: Root | KeyNode,
445 legacyContext: LegacyContext,
446 context: ContextSnapshot,
447 treeContext: TreeContext,
@@ -580,7 +624,11 @@ function renderSuspenseBoundary(
624 const content: ReactNodeList = props.children;
625
626 const fallbackAbortSet: Set<Task> = new Set();
583 - const newBoundary = createSuspenseBoundary(request, fallbackAbortSet);
627 + const newBoundary = createSuspenseBoundary(
628 + request,
629 + fallbackAbortSet,
630 + task.keyPath,
631 + );
632 const insertionIndex = parentSegment.chunks.length;
633 // The children of the boundary segment is actually the fallback.
634 const boundarySegment = createPendingSegment(
@@ -637,7 +685,8 @@ function renderSuspenseBoundary(
685 );
686 contentRootSegment.status = COMPLETED;
687 queueCompletedSegment(newBoundary, contentRootSegment);
640 - if (newBoundary.pendingTasks === 0) {
688 + if (newBoundary.pendingTasks === 0 && newBoundary.status === PENDING) {
689 + newBoundary.status = COMPLETED;
690 // This must have been the last segment we were waiting on. This boundary is now complete.
691 // Therefore we won't need the fallback. We early return so that we don't have to create
692 // the fallback.
@@ -646,7 +695,7 @@ function renderSuspenseBoundary(
695 }
696 } catch (error) {
697 contentRootSegment.status = ERRORED;
649 - newBoundary.forceClientRender = true;
698 + newBoundary.status = CLIENT_RENDERED;
699 let errorDigest;
700 if (
701 enablePostpone &&
@@ -1624,6 +1673,85 @@ function renderChildrenArray(
1673 }
1674 }
1675
1676 +function trackPostpone(
1677 + request: Request,
1678 + trackedPostpones: PostponedHoles,
1679 + task: Task,
1680 + segment: Segment,
1681 +): void {
1682 + segment.status = POSTPONED;
1683 + // We know that this will leave a hole so we might as well assign an ID now.
1684 + segment.id = request.nextSegmentId++;
1685 +
1686 + const boundary = task.blockedBoundary;
1687 + if (boundary !== null && boundary.status === PENDING) {
1688 + boundary.status = POSTPONED;
1689 + // We need to eagerly assign it an ID because we'll need to refer to
1690 + // it before flushing and we know that we can't inline it.
1691 + boundary.id = assignSuspenseBoundaryID(
1692 + request.renderState,
1693 + request.resumableState,
1694 + );
1695 +
1696 + const boundaryKeyPath = boundary.keyPath;
1697 + if (boundaryKeyPath === null) {
1698 + throw new Error(
1699 + 'It should not be possible to postpone at the root. This is a bug in React.',
1700 + );
1701 + }
1702 + const children: Array<ResumableNode> = [];
1703 + const boundaryNode: ResumableParentNode = [
1704 + REPLAY_SUSPENSE_BOUNDARY,
1705 + boundaryKeyPath[1],
1706 + boundaryKeyPath[2],
1707 + children,
1708 + boundary.id,
1709 + ];
1710 + trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode);
1711 + addToResumableParent(boundaryNode, boundaryKeyPath, trackedPostpones);
1712 + }
1713 +
1714 + const keyPath = task.keyPath;
1715 + if (keyPath === null) {
1716 + throw new Error(
1717 + 'It should not be possible to postpone at the root. This is a bug in React.',
1718 + );
1719 + }
1720 +
1721 + const segmentNode: ResumableNode = [
1722 + RESUME_SEGMENT,
1723 + keyPath[1],
1724 + keyPath[2],
1725 + segment.id,
1726 + ];
1727 + addToResumableParent(segmentNode, keyPath, trackedPostpones);
1728 +}
1729 +
1730 +function injectPostponedHole(
1731 + request: Request,
1732 + task: Task,
1733 + reason: string,
1734 +): Segment {
1735 + logPostpone(request, reason);
1736 + // Something suspended, we'll need to create a new segment and resolve it later.
1737 + const segment = task.blockedSegment;
1738 + const insertionIndex = segment.chunks.length;
1739 + const newSegment = createPendingSegment(
1740 + request,
1741 + insertionIndex,
1742 + null,
1743 + segment.formatContext,
1744 + // Adopt the parent segment's leading text embed
1745 + segment.lastPushedText,
1746 + // Assume we are text embedded at the trailing edge
1747 + true,
1748 + );
1749 + segment.children.push(newSegment);
1750 + // Reset lastPushedText for current Segment since the new Segment "consumed" it
1751 + segment.lastPushedText = false;
1752 + return segment;
1753 +}
1754 +
1755 function spawnNewSuspendedTask(
1756 request: Request,
1757 task: Task,
@@ -1713,40 +1841,73 @@ function renderNode(
1841 getSuspendedThenable()
1842 : thrownValue;
1843
1716 - // $FlowFixMe[method-unbinding]
1717 - if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
1718 - const wakeable: Wakeable = (x: any);
1719 - const thenableState = getThenableStateAfterSuspending();
1720 - spawnNewSuspendedTask(request, task, thenableState, wakeable);
1721 -
1722 - // Restore the context. We assume that this will be restored by the inner
1723 - // functions in case nothing throws so we don't use "finally" here.
1724 - task.blockedSegment.formatContext = previousFormatContext;
1725 - task.legacyContext = previousLegacyContext;
1726 - task.context = previousContext;
1727 - task.keyPath = previousKeyPath;
1728 - // Restore all active ReactContexts to what they were before.
1729 - switchContext(previousContext);
1730 - if (__DEV__) {
1731 - task.componentStack = previousComponentStack;
1844 + if (typeof x === 'object' && x !== null) {
1845 + // $FlowFixMe[method-unbinding]
1846 + if (typeof x.then === 'function') {
1847 + const wakeable: Wakeable = (x: any);
1848 + const thenableState = getThenableStateAfterSuspending();
1849 + spawnNewSuspendedTask(request, task, thenableState, wakeable);
1850 +
1851 + // Restore the context. We assume that this will be restored by the inner
1852 + // functions in case nothing throws so we don't use "finally" here.
1853 + task.blockedSegment.formatContext = previousFormatContext;
1854 + task.legacyContext = previousLegacyContext;
1855 + task.context = previousContext;
1856 + task.keyPath = previousKeyPath;
1857 + // Restore all active ReactContexts to what they were before.
1858 + switchContext(previousContext);
1859 + if (__DEV__) {
1860 + task.componentStack = previousComponentStack;
1861 + }
1862 + return;
1863 }
1733 - return;
1734 - } else {
1735 - // Restore the context. We assume that this will be restored by the inner
1736 - // functions in case nothing throws so we don't use "finally" here.
1737 - task.blockedSegment.formatContext = previousFormatContext;
1738 - task.legacyContext = previousLegacyContext;
1739 - task.context = previousContext;
1740 - task.keyPath = previousKeyPath;
1741 - // Restore all active ReactContexts to what they were before.
1742 - switchContext(previousContext);
1743 - if (__DEV__) {
1744 - task.componentStack = previousComponentStack;
1864 + if (
1865 + enablePostpone &&
1866 + request.trackedPostpones !== null &&
1867 + x.$$typeof === REACT_POSTPONE_TYPE &&
1868 + task.blockedBoundary !== null // TODO: Support holes in the shell
1869 + ) {
1870 + // If we're tracking postpones, we inject a hole here and continue rendering
1871 + // sibling. Similar to suspending. If we're not tracking, we treat it more like
1872 + // an error. Notably this doesn't spawn a new task since nothing will fill it
1873 + // in during this prerender.
1874 + const postponeInstance: Postpone = (x: any);
1875 + const trackedPostpones = request.trackedPostpones;
1876 + const postponedSegment = injectPostponedHole(
1877 + request,
1878 + task,
1879 + postponeInstance.message,
1880 + );
1881 + trackPostpone(request, trackedPostpones, task, postponedSegment);
1882 +
1883 + // Restore the context. We assume that this will be restored by the inner
1884 + // functions in case nothing throws so we don't use "finally" here.
1885 + task.blockedSegment.formatContext = previousFormatContext;
1886 + task.legacyContext = previousLegacyContext;
1887 + task.context = previousContext;
1888 + task.keyPath = previousKeyPath;
1889 + // Restore all active ReactContexts to what they were before.
1890 + switchContext(previousContext);
1891 + if (__DEV__) {
1892 + task.componentStack = previousComponentStack;
1893 + }
1894 + return;
1895 }
1746 - // We assume that we don't need the correct context.
1747 - // Let's terminate the rest of the tree and don't render any siblings.
1748 - throw x;
1896 }
1897 + // Restore the context. We assume that this will be restored by the inner
1898 + // functions in case nothing throws so we don't use "finally" here.
1899 + task.blockedSegment.formatContext = previousFormatContext;
1900 + task.legacyContext = previousLegacyContext;
1901 + task.context = previousContext;
1902 + task.keyPath = previousKeyPath;
1903 + // Restore all active ReactContexts to what they were before.
1904 + switchContext(previousContext);
1905 + if (__DEV__) {
1906 + task.componentStack = previousComponentStack;
1907 + }
1908 + // We assume that we don't need the correct context.
1909 + // Let's terminate the rest of the tree and don't render any siblings.
1910 + throw x;
1911 }
1912 }
1913
@@ -1775,8 +1936,8 @@ function erroredTask(
1936 fatalError(request, error);
1937 } else {
1938 boundary.pendingTasks--;
1778 - if (!boundary.forceClientRender) {
1779 - boundary.forceClientRender = true;
1939 + if (boundary.status !== CLIENT_RENDERED) {
1940 + boundary.status = CLIENT_RENDERED;
1941 boundary.errorDigest = errorDigest;
1942 if (__DEV__) {
1943 captureBoundaryErrorDetailsDev(boundary, error);
@@ -1829,9 +1990,8 @@ function abortTask(task: Task, request: Request, error: mixed): void {
1990 }
1991 } else {
1992 boundary.pendingTasks--;
1832 -
1833 - if (!boundary.forceClientRender) {
1834 - boundary.forceClientRender = true;
1993 + if (boundary.status !== CLIENT_RENDERED) {
1994 + boundary.status = CLIENT_RENDERED;
1995 boundary.errorDigest = request.onError(error);
1996 if (__DEV__) {
1997 const errorPrefix =
@@ -1918,9 +2078,12 @@ function finishedTask(
2078 }
2079 } else {
2080 boundary.pendingTasks--;
1921 - if (boundary.forceClientRender) {
2081 + if (boundary.status === CLIENT_RENDERED) {
2082 // This already errored.
2083 } else if (boundary.pendingTasks === 0) {
2084 + if (boundary.status === PENDING) {
2085 + boundary.status = COMPLETED;
2086 + }
2087 // This must have been the last segment we were waiting on. This boundary is now complete.
2088 if (segment.parentFlushed) {
2089 // Our parent segment already flushed, so we need to schedule this segment to be emitted.
@@ -2034,17 +2197,35 @@ function retryTask(request: Request, task: Task): void {
2197 getSuspendedThenable()
2198 : thrownValue;
2199
2037 - // $FlowFixMe[method-unbinding]
2038 - if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
2039 - // Something suspended again, let's pick it back up later.
2040 - const ping = task.ping;
2041 - x.then(ping, ping);
2042 - task.thenableState = getThenableStateAfterSuspending();
2043 - } else {
2044 - task.abortSet.delete(task);
2045 - segment.status = ERRORED;
2046 - erroredTask(request, task.blockedBoundary, segment, x);
2200 + if (typeof x === 'object' && x !== null) {
2201 + // $FlowFixMe[method-unbinding]
2202 + if (typeof x.then === 'function') {
2203 + // Something suspended again, let's pick it back up later.
2204 + const ping = task.ping;
2205 + x.then(ping, ping);
2206 + task.thenableState = getThenableStateAfterSuspending();
2207 + return;
2208 + } else if (
2209 + enablePostpone &&
2210 + request.trackedPostpones !== null &&
2211 + x.$$typeof === REACT_POSTPONE_TYPE &&
2212 + task.blockedBoundary !== null // TODO: Support holes in the shell
2213 + ) {
2214 + // If we're tracking postpones, we mark this segment as postponed and finish
2215 + // the task without filling it in. If we're not tracking, we treat it more like
2216 + // an error.
2217 + const trackedPostpones = request.trackedPostpones;
2218 + task.abortSet.delete(task);
2219 + const postponeInstance: Postpone = (x: any);
2220 + logPostpone(request, postponeInstance.message);
2221 + trackPostpone(request, trackedPostpones, task, segment);
2222 + finishedTask(request, task.blockedBoundary, segment);
2223 + }
2224 }
2225 + task.abortSet.delete(task);
2226 + segment.status = ERRORED;
2227 + erroredTask(request, task.blockedBoundary, segment, x);
2228 + return;
2229 } finally {
2230 if (enableFloat) {
2231 setCurrentlyRenderingBoundaryResourcesTarget(request.renderState, null);
@@ -2126,7 +2307,11 @@ function flushSubtree(
2307 case PENDING: {
2308 // We're emitting a placeholder for this segment to be filled in later.
2309 // Therefore we'll need to assign it an ID - to refer to it by.
2129 - const segmentID = (segment.id = request.nextSegmentId++);
2310 + segment.id = request.nextSegmentId++;
2311 + // Fallthrough
2312 + }
2313 + case POSTPONED: {
2314 + const segmentID = segment.id;
2315 // When this segment finally completes it won't be embedded in text since it will flush separately
2316 segment.lastPushedText = false;
2317 segment.textEmbedded = false;
@@ -2174,10 +2359,11 @@ function flushSegment(
2359 // Not a suspense boundary.
2360 return flushSubtree(request, destination, segment);
2361 }
2362 +
2363 boundary.parentFlushed = true;
2364 // This segment is a Suspense boundary. We need to decide whether to
2365 // emit the content or the fallback now.
2180 - if (boundary.forceClientRender) {
2366 + if (boundary.status === CLIENT_RENDERED) {
2367 // Emit a client rendered suspense boundary wrapper.
2368 // We never queue the inner boundary so we'll never emit its content or partial segments.
2369
@@ -2195,7 +2381,13 @@ function flushSegment(
2381 destination,
2382 request.renderState,
2383 );
2198 - } else if (boundary.pendingTasks > 0) {
2384 + } else if (boundary.status !== COMPLETED) {
2385 + if (boundary.status === PENDING) {
2386 + boundary.id = assignSuspenseBoundaryID(
2387 + request.renderState,
2388 + request.resumableState,
2389 + );
2390 + }
2391 // This boundary is still loading. Emit a pending suspense boundary wrapper.
2392
2393 // Assign an ID to refer to the future content by.
@@ -2206,10 +2398,7 @@ function flushSegment(
2398 }
2399
2400 /// This is the first time we should have referenced this ID.
2209 - const id = (boundary.id = assignSuspenseBoundaryID(
2210 - request.renderState,
2211 - request.resumableState,
2212 - ));
2401 + const id = boundary.id;
2402
2403 writeStartPendingSuspenseBoundary(destination, request.renderState, id);
2404
@@ -2522,7 +2711,15 @@ function flushCompletedQueues(
2711 ) {
2712 request.flushScheduled = false;
2713 if (enableFloat) {
2525 - writePostamble(destination, request.resumableState);
2714 + // We write the trailing tags but only if don't have any data to resume.
2715 + // If we need to resume we'll write the postamble in the resume instead.
2716 + if (
2717 + !enablePostpone ||
2718 + request.trackedPostpones === null ||
2719 + request.trackedPostpones.root.length === 0
2720 + ) {
2721 + writePostamble(destination, request.resumableState);
2722 + }
2723 }
2724 completeWriting(destination);
2725 flushBuffered(destination);
@@ -2542,7 +2739,7 @@ function flushCompletedQueues(
2739 }
2740 }
2741
2545 -export function startWork(request: Request): void {
2742 +export function startRender(request: Request): void {
2743 request.flushScheduled = request.destination !== null;
2744 if (supportsRequestStorage) {
2745 scheduleWork(() => requestStorage.run(request, performWork, request));
@@ -2551,6 +2748,12 @@ export function startWork(request: Request): void {
2748 }
2749 }
2750
2751 +export function startPrerender(request: Request): void {
2752 + // Start tracking postponed holes during this render.
2753 + request.trackedPostpones = {workingMap: new Map(), root: []};
2754 + startRender(request);
2755 +}
2756 +
2757 function enqueueFlush(request: Request): void {
2758 if (
2759 request.flushScheduled === false &&
@@ -2617,14 +2820,50 @@ export function getResumableState(request: Request): ResumableState {
2820 return request.resumableState;
2821 }
2822
2823 +function addToResumableParent(
2824 + node: ResumableNode,
2825 + keyPath: KeyNode,
2826 + trackedPostpones: PostponedHoles,
2827 +): void {
2828 + const parentKeyPath = keyPath[0];
2829 + if (parentKeyPath === null) {
2830 + trackedPostpones.root.push(node);
2831 + } else {
2832 + const workingMap = trackedPostpones.workingMap;
2833 + let parentNode = workingMap.get(parentKeyPath);
2834 + if (parentNode === undefined) {
2835 + parentNode = ([
2836 + REPLAY_NODE,
2837 + parentKeyPath[1],
2838 + parentKeyPath[2],
2839 + ([]: Array<ResumableNode>),
2840 + ]: ResumableParentNode);
2841 + workingMap.set(parentKeyPath, parentNode);
2842 + addToResumableParent(parentNode, parentKeyPath, trackedPostpones);
2843 + }
2844 + parentNode[3].push(node);
2845 + }
2846 +}
2847 +
2848 export type PostponedState = {
2849 nextSegmentId: number,
2850 rootFormatContext: FormatContext,
2851 progressiveChunkSize: number,
2852 resumableState: ResumableState,
2853 + resumablePath: Array<ResumableNode>,
2854 };
2855
2856 // Returns the state of a postponed request or null if nothing was postponed.
2857 export function getPostponedState(request: Request): null | PostponedState {
2629 - return null;
2858 + const trackedPostpones = request.trackedPostpones;
2859 + if (trackedPostpones === null || trackedPostpones.root.length === 0) {
2860 + return null;
2861 + }
2862 + return {
2863 + nextSegmentId: request.nextSegmentId,
2864 + rootFormatContext: request.rootFormatContext,
2865 + progressiveChunkSize: request.progressiveChunkSize,
2866 + resumableState: request.resumableState,
2867 + resumablePath: trackedPostpones.root,
2868 + };
2869 }
packages/react-server/src/ReactFlightServer.js
+1 -1
@@ -1519,7 +1519,7 @@ function flushCompletedChunks(
1519 }
1520 }
1521
1522 -export function startWork(request: Request): void {
1522 +export function startRender(request: Request): void {
1523 request.flushScheduled = request.destination !== null;
1524 if (supportsRequestStorage) {
1525 scheduleWork(() => requestStorage.run(request, performWork, request));
scripts/error-codes/codes.json
+2 -1
@@ -470,5 +470,6 @@
470 "482": "async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.",
471 "483": "Hooks are not supported inside an async component. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.",
472 "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details.",
473 - "485": "Cannot update form state while rendering."
473 + "485": "Cannot update form state while rendering.",
474 + "486": "It should not be possible to postpone at the root. This is a bug in React."
475 }
\ No newline at end of file