diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index bb65ef4b659a7..1434d17015a54 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -20,12 +20,15 @@ import type {Thenable} from 'shared/ReactTypes'; import {Readable} from 'stream'; +import {enableHalt} from 'shared/ReactFeatureFlags'; + import { createRequest, startWork, startFlowing, stopFlowing, abort, + halt, } from 'react-server/src/ReactFlightServer'; import { @@ -187,10 +190,20 @@ function prerenderToNodeStream( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js index ef980764942d7..56c3d5b71f432 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js @@ -12,12 +12,15 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import {enableHalt} from 'shared/ReactFeatureFlags'; + import { createRequest, startWork, startFlowing, stopFlowing, abort, + halt, } from 'react-server/src/ReactFlightServer'; import { @@ -146,10 +149,20 @@ function prerender( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js index ef980764942d7..56c3d5b71f432 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js @@ -12,12 +12,15 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import {enableHalt} from 'shared/ReactFeatureFlags'; + import { createRequest, startWork, startFlowing, stopFlowing, abort, + halt, } from 'react-server/src/ReactFlightServer'; import { @@ -146,10 +149,20 @@ function prerender( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index e484d4b7e77d5..f9b0c163b2154 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -20,12 +20,15 @@ import type {Thenable} from 'shared/ReactTypes'; import {Readable} from 'stream'; +import {enableHalt} from 'shared/ReactFeatureFlags'; + import { createRequest, startWork, startFlowing, stopFlowing, abort, + halt, } from 'react-server/src/ReactFlightServer'; import { @@ -189,10 +192,20 @@ function prerenderToNodeStream( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index faaf8aef01b0d..6a0ce0152b704 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -2722,4 +2722,138 @@ describe('ReactFlightDOM', () => { await readInto(container, fizzReadable); expect(getMeaningfulChildren(container)).toEqual(
hello world
); }); + + // @gate enableHalt + it('serializes unfinished tasks with infinite promises when aborting a prerender', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( +
+ + + +
+ ); + } + + async function Greeting() { + await greetingPromise; + return 'hello world'; + } + + const controller = new AbortController(); + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream( + , + webpackMap, + { + signal: controller.signal, + }, + ), + }; + }); + + controller.abort(); + resolveGreeting(); + const {prelude} = await pendingResult; + + const preludeWeb = Readable.toWeb(prelude); + const response = ReactServerDOMClient.createFromReadableStream(preludeWeb); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const errors = []; + let abortFizz; + await serverAct(async () => { + const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onError(error) { + errors.push(error); + }, + }, + ); + pipe(fizzWritable); + abortFizz = abort; + }); + + await serverAct(() => { + abortFizz('boom'); + }); + + expect(errors).toEqual(['boom']); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual(
loading...
); + }); + + // @gate enableHalt + it('will leave async iterables in an incomplete state when halting', async () => { + let resolve; + const wait = new Promise(r => (resolve = r)); + const errors = []; + + const multiShotIterable = { + async *[Symbol.asyncIterator]() { + yield {hello: 'A'}; + await wait; + yield {hi: 'B'}; + return 'C'; + }, + }; + + const controller = new AbortController(); + const {pendingResult} = await serverAct(() => { + return { + pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream( + { + multiShotIterable, + }, + {}, + { + onError(x) { + errors.push(x); + }, + signal: controller.signal, + }, + ), + }; + }); + + controller.abort(); + await serverAct(() => resolve()); + + const {prelude} = await pendingResult; + + const result = await ReactServerDOMClient.createFromReadableStream( + Readable.toWeb(prelude), + ); + + const iterator = result.multiShotIterable[Symbol.asyncIterator](); + + expect(await iterator.next()).toEqual({ + value: {hello: 'A'}, + done: false, + }); + + const race = Promise.race([ + iterator.next(), + new Promise(r => setTimeout(() => r('timeout'), 10)), + ]); + + await 1; + jest.advanceTimersByTime('100'); + expect(await race).toBe('timeout'); + }); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index db8edf7ad6831..7bbfea1484bed 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -29,6 +29,7 @@ let ReactDOM; let ReactDOMClient; let ReactDOMFizzServer; let ReactServerDOMServer; +let ReactServerDOMStaticServer; let ReactServerDOMClient; let Suspense; let use; @@ -60,7 +61,13 @@ describe('ReactFlightDOMBrowser', () => { serverExports = WebpackMock.serverExports; webpackMap = WebpackMock.webpackMap; webpackServerMap = WebpackMock.webpackServerMap; - ReactServerDOMServer = require('react-server-dom-webpack/server.browser'); + ReactServerDOMServer = require('react-server-dom-webpack/server'); + if (__EXPERIMENTAL__) { + jest.mock('react-server-dom-webpack/static', () => + require('react-server-dom-webpack/static.browser'), + ); + ReactServerDOMStaticServer = require('react-server-dom-webpack/static'); + } __unmockReact(); jest.resetModules(); @@ -2332,4 +2339,110 @@ describe('ReactFlightDOMBrowser', () => { expect(error.digest).toBe('aborted'); expect(errors).toEqual([reason]); }); + + // @gate experimental + it('can prerender', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( +
+ +
+ ); + } + + async function Greeting() { + await greetingPromise; + return 'hello world'; + } + + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerender( + , + webpackMap, + ), + }; + }); + + resolveGreeting(); + const {prelude} = await pendingResult; + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromReadableStream( + passThrough(prelude), + ); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render(); + }); + expect(container.innerHTML).toBe('
hello world
'); + }); + + // @gate enableHalt + it('serializes unfinished tasks with infinite promises when aborting a prerender', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( +
+ + + +
+ ); + } + + async function Greeting() { + await greetingPromise; + return 'hello world'; + } + + const controller = new AbortController(); + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerender( + , + webpackMap, + { + signal: controller.signal, + }, + ), + }; + }); + + controller.abort(); + resolveGreeting(); + const {prelude} = await pendingResult; + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromReadableStream( + passThrough(prelude), + ); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render(); + }); + + expect(container.innerHTML).toBe('
loading...
'); + }); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index ffef621e9761b..b38c33dc7761b 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -23,9 +23,9 @@ if (typeof File === 'undefined' || typeof FormData === 'undefined') { // Patch for Edge environments for global scope global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setTimeout = cb => cb(); +const { + patchMessageChannel, +} = require('../../../../scripts/jest/patchMessageChannel'); let serverExports; let clientExports; @@ -36,8 +36,11 @@ let React; let ReactServer; let ReactDOMServer; let ReactServerDOMServer; +let ReactServerDOMStaticServer; let ReactServerDOMClient; let use; +let ReactServerScheduler; +let reactServerAct; function normalizeCodeLocInfo(str) { return ( @@ -52,6 +55,10 @@ describe('ReactFlightDOMEdge', () => { beforeEach(() => { jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchMessageChannel(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-webpack/server', () => @@ -68,6 +75,12 @@ describe('ReactFlightDOMEdge', () => { ReactServer = require('react'); ReactServerDOMServer = require('react-server-dom-webpack/server'); + if (__EXPERIMENTAL__) { + jest.mock('react-server-dom-webpack/static', () => + require('react-server-dom-webpack/static.edge'), + ); + ReactServerDOMStaticServer = require('react-server-dom-webpack/static'); + } jest.resetModules(); __unmockReact(); @@ -81,6 +94,17 @@ describe('ReactFlightDOMEdge', () => { use = React.use; }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function passThrough(stream) { // Simulate more realistic network by splitting up and rejoining some chunks. // This lets us test that we don't accidentally rely on particular bounds of the chunks. @@ -174,9 +198,8 @@ describe('ReactFlightDOMEdge', () => { return ; } - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(, webpackMap), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { ssrManifest: { @@ -189,8 +212,8 @@ describe('ReactFlightDOMEdge', () => { return use(response); } - const ssrStream = await ReactDOMServer.renderToReadableStream( - , + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), ); const result = await readResult(ssrStream); expect(result).toEqual('Client Component'); @@ -200,10 +223,12 @@ describe('ReactFlightDOMEdge', () => { const testString = '"\n\t'.repeat(500) + '🙃'; const testString2 = 'hello'.repeat(400); - const stream = ReactServerDOMServer.renderToReadableStream({ - text: testString, - text2: testString2, - }); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream({ + text: testString, + text2: testString2, + }), + ); const [stream1, stream2] = passThrough(stream).tee(); const serializedContent = await readResult(stream1); @@ -234,7 +259,9 @@ describe('ReactFlightDOMEdge', () => { with: {many: 'properties in it'}, }; const props = {root:
{new Array(30).fill(obj)}
}; - const stream = ReactServerDOMServer.renderToReadableStream(props); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(props), + ); const [stream1, stream2] = passThrough(stream).tee(); const serializedContent = await readResult(stream1); @@ -302,7 +329,9 @@ describe('ReactFlightDOMEdge', () => { ); const resolvedChildren = new Array(30).fill(str); - const stream = ReactServerDOMServer.renderToReadableStream(children); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(children), + ); const [stream1, stream2] = passThrough(stream).tee(); const serializedContent = await readResult(stream1); @@ -318,7 +347,9 @@ describe('ReactFlightDOMEdge', () => { }); // Use the SSR render to resolve any lazy elements - const ssrStream = await ReactDOMServer.renderToReadableStream(model); + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(model), + ); // Should still match the result when parsed const result = await readResult(ssrStream); expect(result).toEqual(resolvedChildren.join('')); @@ -370,22 +401,28 @@ describe('ReactFlightDOMEdge', () => { const resolvedChildren = new Array(30).fill( '
this is a long return value
', ); - const stream = ReactServerDOMServer.renderToReadableStream(children); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(children), + ); const [stream1, stream2] = passThrough(stream).tee(); const serializedContent = await readResult(stream1); expect(serializedContent.length).toBeLessThan(__DEV__ ? 605 : 400); expect(timesRendered).toBeLessThan(5); - const model = await ReactServerDOMClient.createFromReadableStream(stream2, { - ssrManifest: { - moduleMap: null, - moduleLoading: null, - }, - }); + const model = await serverAct(() => + ReactServerDOMClient.createFromReadableStream(stream2, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }), + ); // Use the SSR render to resolve any lazy elements - const ssrStream = await ReactDOMServer.renderToReadableStream(model); + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(model), + ); // Should still match the result when parsed const result = await readResult(ssrStream); expect(result).toEqual(resolvedChildren.join('')); @@ -398,8 +435,10 @@ describe('ReactFlightDOMEdge', () => { } return
Fin
; } - const stream = ReactServerDOMServer.renderToReadableStream( - , + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + ), ); const serializedContent = await readResult(stream); const expectedDebugInfoSize = __DEV__ ? 300 * 20 : 0; @@ -426,8 +465,8 @@ describe('ReactFlightDOMEdge', () => { new BigUint64Array(buffer, 0), new DataView(buffer, 3), ]; - const stream = passThrough( - ReactServerDOMServer.renderToReadableStream(buffers), + const stream = await serverAct(() => + passThrough(ReactServerDOMServer.renderToReadableStream(buffers)), ); const result = await ReactServerDOMClient.createFromReadableStream(stream, { ssrManifest: { @@ -446,8 +485,8 @@ describe('ReactFlightDOMEdge', () => { const blob = new Blob([bytes, bytes], { type: 'application/x-test', }); - const stream = passThrough( - ReactServerDOMServer.renderToReadableStream(blob), + const stream = await serverAct(() => + passThrough(ReactServerDOMServer.renderToReadableStream(blob)), ); const result = await ReactServerDOMClient.createFromReadableStream(stream, { ssrManifest: { @@ -476,8 +515,8 @@ describe('ReactFlightDOMEdge', () => { expect(formData.get('file') instanceof File).toBe(true); expect(formData.get('file').name).toBe('filename.test'); - const stream = passThrough( - ReactServerDOMServer.renderToReadableStream(formData), + const stream = await serverAct(() => + passThrough(ReactServerDOMServer.renderToReadableStream(formData)), ); const result = await ReactServerDOMClient.createFromReadableStream(stream, { ssrManifest: { @@ -507,8 +546,8 @@ describe('ReactFlightDOMEdge', () => { const map = new Map(); map.set('value', awaitedValue); - const stream = passThrough( - ReactServerDOMServer.renderToReadableStream(map, webpackMap), + const stream = await serverAct(() => + passThrough(ReactServerDOMServer.renderToReadableStream(map, webpackMap)), ); // Parsing the root blocks because the module hasn't loaded yet @@ -549,16 +588,18 @@ describe('ReactFlightDOMEdge', () => { }, }); - const stream = passThrough( - ReactServerDOMServer.renderToReadableStream(s, webpackMap), + const stream = await serverAct(() => + passThrough(ReactServerDOMServer.renderToReadableStream(s, webpackMap)), ); - const result = await ReactServerDOMClient.createFromReadableStream(stream, { - ssrManifest: { - moduleMap: null, - moduleLoading: null, - }, - }); + const result = await serverAct(() => + ReactServerDOMClient.createFromReadableStream(stream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }), + ); const reader = result.getReader(); @@ -589,20 +630,24 @@ describe('ReactFlightDOMEdge', () => { }, }; - const stream = passThrough( - ReactServerDOMServer.renderToReadableStream( - multiShotIterable, - webpackMap, + const stream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream( + multiShotIterable, + webpackMap, + ), ), ); // Parsing the root blocks because the module hasn't loaded yet - const result = await ReactServerDOMClient.createFromReadableStream(stream, { - ssrManifest: { - moduleMap: null, - moduleLoading: null, - }, - }); + const result = await serverAct(() => + ReactServerDOMClient.createFromReadableStream(stream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }), + ); const iterator = result[Symbol.asyncIterator](); @@ -635,9 +680,11 @@ describe('ReactFlightDOMEdge', () => { }, }; - const stream = ReactServerDOMServer.renderToReadableStream({ - iterable, - }); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream({ + iterable, + }), + ); const [stream1, stream2] = passThrough(stream).tee(); const serializedContent = await readResult(stream1); @@ -728,7 +775,9 @@ describe('ReactFlightDOMEdge', () => { }, }); - const stream = ReactServerDOMServer.renderToReadableStream(s, {}); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(s, {}), + ); const [stream1, stream2] = passThrough(stream).tee(); @@ -785,7 +834,9 @@ describe('ReactFlightDOMEdge', () => { }, }); - const stream = ReactServerDOMServer.renderToReadableStream(s, {}); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(s, {}), + ); const [stream1, stream2] = passThrough(stream).tee(); @@ -841,23 +892,21 @@ describe('ReactFlightDOMEdge', () => { greeting: ReactServer.createElement(Greeting, {firstName: 'Seb'}), }; - const stream = ReactServerDOMServer.renderToReadableStream( - model, - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(model, webpackMap), ); - const rootModel = await ReactServerDOMClient.createFromReadableStream( - stream, - { + const rootModel = await serverAct(() => + ReactServerDOMClient.createFromReadableStream(stream, { ssrManifest: { moduleMap: null, moduleLoading: null, }, - }, + }), ); - const ssrStream = await ReactDOMServer.renderToReadableStream( - rootModel.greeting, + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(rootModel.greeting), ); const result = await readResult(ssrStream); expect(result).toEqual('Hello, Seb'); @@ -916,13 +965,15 @@ describe('ReactFlightDOMEdge', () => { return ReactServer.createElement('span', null, 'hi'); } - const stream = ReactServerDOMServer.renderToReadableStream( - ReactServer.createElement( - 'div', - null, - ReactServer.createElement(Foo, null), + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + ReactServer.createElement( + 'div', + null, + ReactServer.createElement(Foo, null), + ), + webpackMap, ), - webpackMap, ); await readResult(stream); @@ -943,35 +994,31 @@ describe('ReactFlightDOMEdge', () => { root: ReactServer.createElement(Erroring), }; - const stream = ReactServerDOMServer.renderToReadableStream( - model, - webpackMap, - { + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(model, webpackMap, { onError() {}, - }, + }), ); - const rootModel = await ReactServerDOMClient.createFromReadableStream( - stream, - { + const rootModel = await serverAct(() => + ReactServerDOMClient.createFromReadableStream(stream, { ssrManifest: { moduleMap: null, moduleLoading: null, }, - }, + }), ); const errors = []; - const result = ReactDOMServer.renderToReadableStream( -
{rootModel.root}
, - { + const result = serverAct(() => + ReactDOMServer.renderToReadableStream(
{rootModel.root}
, { onError(error, {componentStack}) { errors.push({ error, componentStack: normalizeCodeLocInfo(componentStack), }); }, - }, + }), ); const theError = new Error('my error'); @@ -1000,4 +1047,130 @@ describe('ReactFlightDOMEdge', () => { }, ]); }); + + // @gate experimental + it('can prerender', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( +
+ +
+ ); + } + + async function Greeting() { + await greetingPromise; + return 'hello world'; + } + + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerender( + , + webpackMap, + ), + }; + }); + + resolveGreeting(); + const {prelude} = await pendingResult; + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromReadableStream(prelude, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + // Use the SSR render to resolve any lazy elements + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream( + React.createElement(ClientRoot, {response}), + ), + ); + // Should still match the result when parsed + const result = await readResult(ssrStream); + expect(result).toBe('
hello world
'); + }); + + // @gate enableHalt + it('serializes unfinished tasks with infinite promises when aborting a prerender', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( +
+ + + +
+ ); + } + + async function Greeting() { + await greetingPromise; + return 'hello world'; + } + + const controller = new AbortController(); + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerender( + , + webpackMap, + { + signal: controller.signal, + }, + ), + }; + }); + + controller.abort(); + resolveGreeting(); + const {prelude} = await pendingResult; + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromReadableStream(prelude, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const fizzController = new AbortController(); + const errors = []; + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream( + React.createElement(ClientRoot, {response}), + { + signal: fizzController.signal, + onError(error) { + errors.push(error); + }, + }, + ), + ); + fizzController.abort('boom'); + expect(errors).toEqual(['boom']); + // Should still match the result when parsed + const result = await readResult(ssrStream); + const div = document.createElement('div'); + div.innerHTML = result; + expect(div.textContent).toBe('loading...'); + }); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 2de34cc1c493f..fbe32d2f1f697 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -20,7 +20,9 @@ let webpackModules; let webpackModuleLoading; let React; let ReactDOMServer; +let ReactServer; let ReactServerDOMServer; +let ReactServerDOMStaticServer; let ReactServerDOMClient; let Stream; let use; @@ -45,7 +47,14 @@ describe('ReactFlightDOMNode', () => { jest.mock('react-server-dom-webpack/server', () => require('react-server-dom-webpack/server.node'), ); + ReactServer = require('react'); ReactServerDOMServer = require('react-server-dom-webpack/server'); + if (__EXPERIMENTAL__) { + jest.mock('react-server-dom-webpack/static', () => + require('react-server-dom-webpack/static.node'), + ); + ReactServerDOMStaticServer = require('react-server-dom-webpack/static'); + } const WebpackMock = require('./utils/WebpackMock'); clientExports = WebpackMock.clientExports; @@ -378,4 +387,128 @@ describe('ReactFlightDOMNode', () => { expect(error.digest).toBe('aborted'); expect(errors).toEqual([reason]); }); + + // @gate experimental + it('can prerender', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( +
+ +
+ ); + } + + async function Greeting() { + await greetingPromise; + return 'hello world'; + } + + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream( + , + webpackMap, + ), + }; + }); + + resolveGreeting(); + const {prelude} = await pendingResult; + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromNodeStream(prelude, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + // Use the SSR render to resolve any lazy elements + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream( + React.createElement(ClientRoot, {response}), + ), + ); + // Should still match the result when parsed + const result = await readResult(ssrStream); + expect(result).toBe('
hello world
'); + }); + + // @gate enableHalt + it('serializes unfinished tasks with infinite promises when aborting a prerender', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( +
+ + + +
+ ); + } + + async function Greeting() { + await greetingPromise; + return 'hello world'; + } + + const controller = new AbortController(); + const {pendingResult} = await serverAct(async () => { + // destructure trick to avoid the act scope from awaiting the returned value + return { + pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream( + , + webpackMap, + { + signal: controller.signal, + }, + ), + }; + }); + + controller.abort(); + resolveGreeting(); + const {prelude} = await pendingResult; + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromNodeStream(prelude, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const errors = []; + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream( + React.createElement(ClientRoot, {response}), + { + onError(error) { + errors.push(error); + }, + }, + ), + ); + ssrStream.abort('boom'); + expect(errors).toEqual(['boom']); + // Should still match the result when parsed + const result = await readResult(ssrStream); + const div = document.createElement('div'); + div.innerHTML = result; + expect(div.textContent).toBe('loading...'); + }); }); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js index a4e0c3bef693b..95e7f770428a3 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js @@ -12,12 +12,15 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import {enableHalt} from 'shared/ReactFeatureFlags'; + import { createRequest, startWork, startFlowing, stopFlowing, abort, + halt, } from 'react-server/src/ReactFlightServer'; import { @@ -146,10 +149,20 @@ function prerender( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js index a4e0c3bef693b..95e7f770428a3 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js @@ -12,12 +12,15 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import {enableHalt} from 'shared/ReactFeatureFlags'; + import { createRequest, startWork, startFlowing, stopFlowing, abort, + halt, } from 'react-server/src/ReactFlightServer'; import { @@ -146,10 +149,20 @@ function prerender( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 1506259476703..1d8d6ea9ef743 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -20,12 +20,15 @@ import type {Thenable} from 'shared/ReactTypes'; import {Readable} from 'stream'; +import {enableHalt} from 'shared/ReactFeatureFlags'; + import { createRequest, startWork, startFlowing, stopFlowing, abort, + halt, } from 'react-server/src/ReactFlightServer'; import { @@ -189,10 +192,20 @@ function prerenderToNodeStream( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } } else { const listener = () => { - abort(request, (signal: any).reason); + const reason = (signal: any).reason; + if (enableHalt) { + halt(request, reason); + } else { + abort(request, reason); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index d8ba106d37e72..cd2bfda57efd7 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -16,6 +16,7 @@ import type {TemporaryReferenceSet} from './ReactFlightServerTemporaryReferences import { enableBinaryFlight, enablePostpone, + enableHalt, enableTaint, enableRefAsProp, enableServerComponentLogs, @@ -611,11 +612,15 @@ function serializeThenable( default: { if (request.status === ABORTING) { // We can no longer accept any resolved values - newTask.status = ABORTED; - const errorId: number = (request.fatalError: any); - const model = stringify(serializeByValueID(errorId)); - emitModelChunk(request, newTask.id, model); request.abortableTasks.delete(newTask); + newTask.status = ABORTED; + if (enableHalt && request.fatalError === haltSymbol) { + emitModelChunk(request, newTask.id, reusableInfinitePromiseModel); + } else { + const errorId: number = (request.fatalError: any); + const model = stringify(serializeByValueID(errorId)); + emitModelChunk(request, newTask.id, model); + } return newTask.id; } if (typeof thenable.status === 'string') { @@ -748,23 +753,32 @@ function serializeReadableStream( } aborted = true; request.abortListeners.delete(error); - if ( + + let cancelWith: mixed; + if (enableHalt && request.fatalError === haltSymbol) { + cancelWith = reason; + } else if ( enablePostpone && typeof reason === 'object' && reason !== null && (reason: any).$$typeof === REACT_POSTPONE_TYPE ) { + cancelWith = reason; const postponeInstance: Postpone = (reason: any); logPostpone(request, postponeInstance.message, streamTask); emitPostponeChunk(request, streamTask.id, postponeInstance); + enqueueFlush(request); } else { + cancelWith = reason; const digest = logRecoverableError(request, reason, streamTask); emitErrorChunk(request, streamTask.id, digest, reason); + enqueueFlush(request); } - enqueueFlush(request); + // $FlowFixMe should be able to pass mixed - reader.cancel(reason).then(error, error); + reader.cancel(cancelWith).then(error, error); } + request.abortListeners.add(error); reader.read().then(progress, error); return serializeByValueID(streamTask.id); @@ -866,24 +880,30 @@ function serializeAsyncIterable( } aborted = true; request.abortListeners.delete(error); - if ( + let throwWith: mixed; + if (enableHalt && request.fatalError === haltSymbol) { + throwWith = reason; + } else if ( enablePostpone && typeof reason === 'object' && reason !== null && (reason: any).$$typeof === REACT_POSTPONE_TYPE ) { + throwWith = reason; const postponeInstance: Postpone = (reason: any); logPostpone(request, postponeInstance.message, streamTask); emitPostponeChunk(request, streamTask.id, postponeInstance); + enqueueFlush(request); } else { + throwWith = reason; const digest = logRecoverableError(request, reason, streamTask); emitErrorChunk(request, streamTask.id, digest, reason); + enqueueFlush(request); } - enqueueFlush(request); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + iterator.throw(throwWith).then(error, error); } } request.abortListeners.add(error); @@ -1798,6 +1818,7 @@ function serializeLazyID(id: number): string { function serializeInfinitePromise(): string { return '$@'; } +const reusableInfinitePromiseModel = stringify(serializeInfinitePromise()); function serializePromiseID(id: number): string { return '$@' + id.toString(16); @@ -2066,12 +2087,18 @@ function serializeBlob(request: Request, blob: Blob): string { } aborted = true; request.abortListeners.delete(error); - const digest = logRecoverableError(request, reason, newTask); - emitErrorChunk(request, newTask.id, digest, reason); - request.abortableTasks.delete(newTask); - enqueueFlush(request); + let cancelWith: mixed; + if (enableHalt && request.fatalError === haltSymbol) { + cancelWith = reason; + } else { + cancelWith = reason; + const digest = logRecoverableError(request, reason, newTask); + emitErrorChunk(request, newTask.id, digest, reason); + request.abortableTasks.delete(newTask); + enqueueFlush(request); + } // $FlowFixMe should be able to pass mixed - reader.cancel(reason).then(error, error); + reader.cancel(cancelWith).then(error, error); } request.abortListeners.add(error); @@ -2149,6 +2176,9 @@ function renderModel( if (typeof x.then === 'function') { if (request.status === ABORTING) { task.status = ABORTED; + if (enableHalt && request.fatalError === haltSymbol) { + return serializeInfinitePromise(); + } const errorId: number = (request.fatalError: any); if (wasReactNode) { return serializeLazyID(errorId); @@ -2202,6 +2232,9 @@ function renderModel( if (request.status === ABORTING) { task.status = ABORTED; + if (enableHalt && request.fatalError === haltSymbol) { + return serializeInfinitePromise(); + } const errorId: number = (request.fatalError: any); if (wasReactNode) { return serializeLazyID(errorId); @@ -3691,9 +3724,13 @@ function retryTask(request: Request, task: Task): void { if (request.status === ABORTING) { request.abortableTasks.delete(task); task.status = ABORTED; - const errorId: number = (request.fatalError: any); - const model = stringify(serializeByValueID(errorId)); - emitModelChunk(request, task.id, model); + if (enableHalt && request.fatalError === haltSymbol) { + emitModelChunk(request, task.id, reusableInfinitePromiseModel); + } else { + const errorId: number = (request.fatalError: any); + const model = stringify(serializeByValueID(errorId)); + emitModelChunk(request, task.id, model); + } return; } // Something suspended again, let's pick it back up later. @@ -3715,9 +3752,13 @@ function retryTask(request: Request, task: Task): void { if (request.status === ABORTING) { request.abortableTasks.delete(task); task.status = ABORTED; - const errorId: number = (request.fatalError: any); - const model = stringify(serializeByValueID(errorId)); - emitModelChunk(request, task.id, model); + if (enableHalt && request.fatalError === haltSymbol) { + emitModelChunk(request, task.id, reusableInfinitePromiseModel); + } else { + const errorId: number = (request.fatalError: any); + const model = stringify(serializeByValueID(errorId)); + emitModelChunk(request, task.id, model); + } return; } @@ -3795,6 +3836,15 @@ function abortTask(task: Task, request: Request, errorId: number): void { request.completedErrorChunks.push(processedChunk); } +function haltTask(task: Task, request: Request): void { + if (task.status === RENDERING) { + // This task will be aborted by the render + return; + } + task.status = ABORTED; + emitModelChunk(request, task.id, reusableInfinitePromiseModel); +} + function flushCompletedChunks( request: Request, destination: Destination, @@ -4012,3 +4062,36 @@ export function abort(request: Request, reason: mixed): void { fatalError(request, error); } } + +const haltSymbol = Symbol('halt'); + +// This is called to stop rendering without erroring. All unfinished work is represented Promises +// that never resolve. +export function halt(request: Request, reason: mixed): void { + try { + if (request.status === OPEN) { + request.status = ABORTING; + } + request.fatalError = haltSymbol; + const abortableTasks = request.abortableTasks; + // We have tasks to abort. We'll emit one error row and then emit a reference + // to that row from every row that's still remaining. + if (abortableTasks.size > 0) { + request.pendingChunks++; + const refId = request.nextChunkId++; + abortableTasks.forEach(task => haltTask(task, request, refId)); + abortableTasks.clear(); + } + const abortListeners = request.abortListeners; + if (abortListeners.size > 0) { + abortListeners.forEach(callback => callback(reason)); + abortListeners.clear(); + } + if (request.destination !== null) { + flushCompletedChunks(request, request.destination); + } + } catch (error) { + logRecoverableError(request, error, null); + fatalError(request, error); + } +} diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index b0286405a6cca..c5351d6d92631 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -87,6 +87,8 @@ export const enableTaint = __EXPERIMENTAL__; export const enablePostpone = __EXPERIMENTAL__; +export const enableHalt = __EXPERIMENTAL__; + /** * Switches the Fabric API from doing layout in commit work instead of complete work. */ diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 4eda27d16cfcb..3618aa70e7d67 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -58,6 +58,7 @@ export const enableFilterEmptyStringAttributesDOM = true; export const enableFizzExternalRuntime = true; export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = true; +export const enableHalt = false; export const enableInfiniteRenderLoopDetection = true; export const enableContextProfiling = false; export const enableLegacyCache = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 2a4421f41da0a..2aae8bd3d1c65 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -49,6 +49,7 @@ export const enableFilterEmptyStringAttributesDOM = true; export const enableFizzExternalRuntime = true; export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = false; +export const enableHalt = false; export const enableInfiniteRenderLoopDetection = true; export const enableLazyContextPropagation = false; export const enableContextProfiling = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 8778bf6558cb4..c44e7014fc444 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -25,6 +25,7 @@ export const enableFlightReadableStream = true; export const enableAsyncIterableChildren = false; export const enableTaint = true; export const enablePostpone = false; +export const enableHalt = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; export const disableIEWorkarounds = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 3a8a0c1d44cec..bc7ddf85acc03 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -40,6 +40,7 @@ export const enableFilterEmptyStringAttributesDOM = true; export const enableFizzExternalRuntime = true; export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = false; +export const enableHalt = false; export const enableInfiniteRenderLoopDetection = true; export const enableLazyContextPropagation = false; export const enableContextProfiling = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index eb801d7bac4b6..57f60c24aef45 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -25,6 +25,7 @@ export const enableFlightReadableStream = true; export const enableAsyncIterableChildren = false; export const enableTaint = true; export const enablePostpone = false; +export const enableHalt = false; export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; export const disableIEWorkarounds = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 95cd1e5a6ebe6..465fa58590bcc 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -78,6 +78,8 @@ export const enableTaint = false; export const enablePostpone = false; +export const enableHalt = false; + export const enableContextProfiling = true; // TODO: www currently relies on this feature. It's disabled in open source.