@samitouri / QOS-React / commits / 473522093d

[Fizz] Add resumeAndPrerender to Static Rendering (#30950)

This is only in the same experimental exports as `resume`. Useful with Postpone/Halt. We already have `prerender()` to create a partial tree with postponed state. We also have `resume()` to dynamically resume such a tree. This lets you do a new prerender by resuming an already existing postponed state. Basically creating a chain of preludes. The next prelude would include the scripts to patch up the document. This mostly just works since both prerender and resume are already implemented using the same code so we just enable both at the root. I'm sure we'll find some edge cases since this wasn't considered when it was first written but so far I've only found an unrelated existing bug with `keyPath` fixed here.

Sebastian Markbåge committed Sep 12, 2024 at 10:51 UTC 473522093d3dd95582729d01cd5c0d15dcc9cd3b
14 files changed +349 -8
packages/react-dom/npm/static.browser.js
+1
@@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') {
9
10 exports.version = s.version;
11 exports.prerender = s.prerender;
12 +exports.resumeAndPrerender = s.resumeAndPrerender;
packages/react-dom/npm/static.edge.js
+1
@@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') {
9
10 exports.version = s.version;
11 exports.prerender = s.prerender;
12 +exports.resumeAndPrerender = s.resumeAndPrerender;
packages/react-dom/npm/static.node.js
+1
@@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') {
9
10 exports.version = s.version;
11 exports.prerenderToNodeStream = s.prerenderToNodeStream;
12 +exports.resumeAndPrerenderToNodeStream = s.resumeAndPrerenderToNodeStream;
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+86
@@ -1758,4 +1758,90 @@ describe('ReactDOMFizzStaticBrowser', () => {
1758 await readIntoContainer(dynamic);
1759 expect(getVisibleChildren(container)).toEqual('hello');
1760 });
1761 +
1762 + // @gate enableHalt
1763 + it('can resume render of a prerender', async () => {
1764 + const errors = [];
1765 +
1766 + let resolveA;
1767 + const promiseA = new Promise(r => (resolveA = r));
1768 + let resolveB;
1769 + const promiseB = new Promise(r => (resolveB = r));
1770 +
1771 + async function ComponentA() {
1772 + await promiseA;
1773 + return (
1774 + <Suspense fallback="Loading B">
1775 + <ComponentB />
1776 + </Suspense>
1777 + );
1778 + }
1779 +
1780 + async function ComponentB() {
1781 + await promiseB;
1782 + return 'Hello';
1783 + }
1784 +
1785 + function App() {
1786 + return (
1787 + <Suspense fallback="Loading A">
1788 + <ComponentA />
1789 + </Suspense>
1790 + );
1791 + }
1792 +
1793 + const controller = new AbortController();
1794 + let pendingResult;
1795 + await serverAct(async () => {
1796 + pendingResult = ReactDOMFizzStatic.prerender(<App />, {
1797 + signal: controller.signal,
1798 + onError(x) {
1799 + errors.push(x.message);
1800 + },
1801 + });
1802 + });
1803 +
1804 + controller.abort();
1805 +
1806 + const prerendered = await pendingResult;
1807 + const postponedState = JSON.stringify(prerendered.postponed);
1808 +
1809 + await readIntoContainer(prerendered.prelude);
1810 + expect(getVisibleChildren(container)).toEqual('Loading A');
1811 +
1812 + await resolveA();
1813 +
1814 + expect(prerendered.postponed).not.toBe(null);
1815 +
1816 + const controller2 = new AbortController();
1817 + await serverAct(async () => {
1818 + pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
1819 + <App />,
1820 + JSON.parse(postponedState),
1821 + {
1822 + signal: controller2.signal,
1823 + onError(x) {
1824 + errors.push(x.message);
1825 + },
1826 + },
1827 + );
1828 + });
1829 +
1830 + controller2.abort();
1831 +
1832 + const prerendered2 = await pendingResult;
1833 + const postponedState2 = JSON.stringify(prerendered2.postponed);
1834 +
1835 + await readIntoContainer(prerendered2.prelude);
1836 + expect(getVisibleChildren(container)).toEqual('Loading B');
1837 +
1838 + await resolveB();
1839 +
1840 + const dynamic = await serverAct(() =>
1841 + ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
1842 + );
1843 +
1844 + await readIntoContainer(dynamic);
1845 + expect(getVisibleChildren(container)).toEqual('Hello');
1846 + });
1847 });
packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
+72 -1
@@ -23,6 +23,7 @@ import ReactVersion from 'shared/ReactVersion';
23
24 import {
25 createPrerenderRequest,
26 + resumeAndPrerenderRequest,
27 startWork,
28 startFlowing,
29 stopFlowing,
@@ -33,6 +34,7 @@ import {
34 import {
35 createResumableState,
36 createRenderState,
37 + resumeRenderState,
38 createRootFormatContext,
39 } from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
40
@@ -141,4 +143,73 @@ function prerender(
143 });
144 }
145
144 -export {prerender, ReactVersion as version};
146 +type ResumeOptions = {
147 + nonce?: string,
148 + signal?: AbortSignal,
149 + onError?: (error: mixed) => ?string,
150 + onPostpone?: (reason: string) => void,
151 + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
152 +};
153 +
154 +function resumeAndPrerender(
155 + children: ReactNodeList,
156 + postponedState: PostponedState,
157 + options?: ResumeOptions,
158 +): Promise<StaticResult> {
159 + return new Promise((resolve, reject) => {
160 + const onFatalError = reject;
161 +
162 + function onAllReady() {
163 + const stream = new ReadableStream(
164 + {
165 + type: 'bytes',
166 + pull: (controller): ?Promise<void> => {
167 + startFlowing(request, controller);
168 + },
169 + cancel: (reason): ?Promise<void> => {
170 + stopFlowing(request);
171 + abort(request, reason);
172 + },
173 + },
174 + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
175 + {highWaterMark: 0},
176 + );
177 +
178 + const result = {
179 + postponed: getPostponedState(request),
180 + prelude: stream,
181 + };
182 + resolve(result);
183 + }
184 +
185 + const request = resumeAndPrerenderRequest(
186 + children,
187 + postponedState,
188 + resumeRenderState(
189 + postponedState.resumableState,
190 + options ? options.nonce : undefined,
191 + ),
192 + options ? options.onError : undefined,
193 + onAllReady,
194 + undefined,
195 + undefined,
196 + onFatalError,
197 + options ? options.onPostpone : undefined,
198 + );
199 + if (options && options.signal) {
200 + const signal = options.signal;
201 + if (signal.aborted) {
202 + abort(request, (signal: any).reason);
203 + } else {
204 + const listener = () => {
205 + abort(request, (signal: any).reason);
206 + signal.removeEventListener('abort', listener);
207 + };
208 + signal.addEventListener('abort', listener);
209 + }
210 + }
211 + startWork(request);
212 + });
213 +}
214 +
215 +export {prerender, resumeAndPrerender, ReactVersion as version};
packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
+72 -1
@@ -23,6 +23,7 @@ import ReactVersion from 'shared/ReactVersion';
23
24 import {
25 createPrerenderRequest,
26 + resumeAndPrerenderRequest,
27 startWork,
28 startFlowing,
29 stopFlowing,
@@ -33,6 +34,7 @@ import {
34 import {
35 createResumableState,
36 createRenderState,
37 + resumeRenderState,
38 createRootFormatContext,
39 } from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
40
@@ -140,4 +142,73 @@ function prerender(
142 });
143 }
144
143 -export {prerender, ReactVersion as version};
145 +type ResumeOptions = {
146 + nonce?: string,
147 + signal?: AbortSignal,
148 + onError?: (error: mixed) => ?string,
149 + onPostpone?: (reason: string) => void,
150 + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
151 +};
152 +
153 +function resumeAndPrerender(
154 + children: ReactNodeList,
155 + postponedState: PostponedState,
156 + options?: ResumeOptions,
157 +): Promise<StaticResult> {
158 + return new Promise((resolve, reject) => {
159 + const onFatalError = reject;
160 +
161 + function onAllReady() {
162 + const stream = new ReadableStream(
163 + {
164 + type: 'bytes',
165 + pull: (controller): ?Promise<void> => {
166 + startFlowing(request, controller);
167 + },
168 + cancel: (reason): ?Promise<void> => {
169 + stopFlowing(request);
170 + abort(request, reason);
171 + },
172 + },
173 + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
174 + {highWaterMark: 0},
175 + );
176 +
177 + const result = {
178 + postponed: getPostponedState(request),
179 + prelude: stream,
180 + };
181 + resolve(result);
182 + }
183 +
184 + const request = resumeAndPrerenderRequest(
185 + children,
186 + postponedState,
187 + resumeRenderState(
188 + postponedState.resumableState,
189 + options ? options.nonce : undefined,
190 + ),
191 + options ? options.onError : undefined,
192 + onAllReady,
193 + undefined,
194 + undefined,
195 + onFatalError,
196 + options ? options.onPostpone : undefined,
197 + );
198 + if (options && options.signal) {
199 + const signal = options.signal;
200 + if (signal.aborted) {
201 + abort(request, (signal: any).reason);
202 + } else {
203 + const listener = () => {
204 + abort(request, (signal: any).reason);
205 + signal.removeEventListener('abort', listener);
206 + };
207 + signal.addEventListener('abort', listener);
208 + }
209 + }
210 + startWork(request);
211 + });
212 +}
213 +
214 +export {prerender, resumeAndPrerender, ReactVersion as version};
packages/react-dom/src/server/ReactDOMFizzStaticNode.js
+66 -1
@@ -25,6 +25,7 @@ import ReactVersion from 'shared/ReactVersion';
25
26 import {
27 createPrerenderRequest,
28 + resumeAndPrerenderRequest,
29 startWork,
30 startFlowing,
31 abort,
@@ -34,6 +35,7 @@ import {
35 import {
36 createResumableState,
37 createRenderState,
38 + resumeRenderState,
39 createRootFormatContext,
40 } from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
41
@@ -141,4 +143,67 @@ function prerenderToNodeStream(
143 });
144 }
145
144 -export {prerenderToNodeStream, ReactVersion as version};
146 +type ResumeOptions = {
147 + nonce?: string,
148 + signal?: AbortSignal,
149 + onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
150 + onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void,
151 +};
152 +
153 +function resumeAndPrerenderToNodeStream(
154 + children: ReactNodeList,
155 + postponedState: PostponedState,
156 + options?: ResumeOptions,
157 +): Promise<StaticResult> {
158 + return new Promise((resolve, reject) => {
159 + const onFatalError = reject;
160 +
161 + function onAllReady() {
162 + const readable: Readable = new Readable({
163 + read() {
164 + startFlowing(request, writable);
165 + },
166 + });
167 + const writable = createFakeWritable(readable);
168 +
169 + const result = {
170 + postponed: getPostponedState(request),
171 + prelude: readable,
172 + };
173 + resolve(result);
174 + }
175 + const request = resumeAndPrerenderRequest(
176 + children,
177 + postponedState,
178 + resumeRenderState(
179 + postponedState.resumableState,
180 + options ? options.nonce : undefined,
181 + ),
182 + options ? options.onError : undefined,
183 + onAllReady,
184 + undefined,
185 + undefined,
186 + onFatalError,
187 + options ? options.onPostpone : undefined,
188 + );
189 + if (options && options.signal) {
190 + const signal = options.signal;
191 + if (signal.aborted) {
192 + abort(request, (signal: any).reason);
193 + } else {
194 + const listener = () => {
195 + abort(request, (signal: any).reason);
196 + signal.removeEventListener('abort', listener);
197 + };
198 + signal.addEventListener('abort', listener);
199 + }
200 + }
201 + startWork(request);
202 + });
203 +}
204 +
205 +export {
206 + prerenderToNodeStream,
207 + resumeAndPrerenderToNodeStream,
208 + ReactVersion as version,
209 +};
packages/react-dom/src/server/react-dom-server.browser.js
+1 -1
@@ -8,4 +8,4 @@
8 */
9
10 export * from './ReactDOMFizzServerBrowser.js';
11 -export {prerender} from './ReactDOMFizzStaticBrowser.js';
11 +export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticBrowser.js';
packages/react-dom/src/server/react-dom-server.edge.js
+1 -1
@@ -8,4 +8,4 @@
8 */
9
10 export * from './ReactDOMFizzServerEdge.js';
11 -export {prerender} from './ReactDOMFizzStaticEdge.js';
11 +export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticEdge.js';
packages/react-dom/src/server/react-dom-server.node.js
+4 -1
@@ -8,4 +8,7 @@
8 */
9
10 export * from './ReactDOMFizzServerNode.js';
11 -export {prerenderToNodeStream} from './ReactDOMFizzStaticNode.js';
11 +export {
12 + prerenderToNodeStream,
13 + resumeAndPrerenderToNodeStream,
14 +} from './ReactDOMFizzStaticNode.js';
packages/react-dom/static.browser.js
+5 -1
@@ -7,4 +7,8 @@
7 * @flow
8 */
9
10 -export {prerender, version} from './src/server/react-dom-server.browser';
10 +export {
11 + prerender,
12 + resumeAndPrerender,
13 + version,
14 +} from './src/server/react-dom-server.browser';
packages/react-dom/static.edge.js
+5 -1
@@ -7,4 +7,8 @@
7 * @flow
8 */
9
10 -export {prerender, version} from './src/server/react-dom-server.edge';
10 +export {
11 + prerender,
12 + resumeAndPrerender,
13 + version,
14 +} from './src/server/react-dom-server.edge';
packages/react-dom/static.node.js
+1
@@ -9,5 +9,6 @@
9
10 export {
11 prerenderToNodeStream,
12 + resumeAndPrerenderToNodeStream,
13 version,
14 } from './src/server/react-dom-server.node';
packages/react-server/src/ReactFizzServer.js
+33
@@ -641,6 +641,37 @@ export function resumeRequest(
641 return request;
642 }
643
644 +export function resumeAndPrerenderRequest(
645 + children: ReactNodeList,
646 + postponedState: PostponedState,
647 + renderState: RenderState,
648 + onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string),
649 + onAllReady: void | (() => void),
650 + onShellReady: void | (() => void),
651 + onShellError: void | ((error: mixed) => void),
652 + onFatalError: void | ((error: mixed) => void),
653 + onPostpone: void | ((reason: string, postponeInfo: PostponeInfo) => void),
654 +): Request {
655 + const request = resumeRequest(
656 + children,
657 + postponedState,
658 + renderState,
659 + onError,
660 + onAllReady,
661 + onShellReady,
662 + onShellError,
663 + onFatalError,
664 + onPostpone,
665 + );
666 + // Start tracking postponed holes during this render.
667 + request.trackedPostpones = {
668 + workingMap: new Map(),
669 + rootNodes: [],
670 + rootSlots: null,
671 + };
672 + return request;
673 +}
674 +
675 let currentRequest: null | Request = null;
676
677 export function resolveRequest(): null | Request {
@@ -1349,6 +1380,7 @@ function replaySuspenseBoundary(
1380 // we're writing to. If something suspends, it'll spawn new suspended task with that context.
1381 task.blockedBoundary = resumedBoundary;
1382 task.hoistableState = resumedBoundary.contentState;
1383 + task.keyPath = keyPath;
1384 task.replay = {nodes: childNodes, slots: childSlots, pendingTasks: 1};
1385
1386 try {
@@ -5066,6 +5098,7 @@ export function abort(request: Request, reason: mixed): void {
5098 if (request.status === OPEN) {
5099 request.status = ABORTING;
5100 }
5101 +
5102 try {
5103 const abortableTasks = request.abortableTasks;
5104 if (abortableTasks.size > 0) {