From 43714eb4e970d0200fdc5eac887691df7fae53d5 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 5 Jun 2025 21:08:57 +0200 Subject: [PATCH 01/33] Do not notify Discord for draft pull requests (#33446) When I added the `ready_for_review` event in #32344, no notifications for opened draft PRs were sent due to some other condition. This is not the case anymore, so we need to exclude draft PRs from triggering a notification when the workflow is run because of an `opened` event. This event is still needed because the `ready_for_review` event only fires when an existing draft PR is converted to a non-draft state. It does not trigger for pull requests that are opened directly as ready-for-review. --- .github/workflows/compiler_discord_notify.yml | 1 + .github/workflows/runtime_discord_notify.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/compiler_discord_notify.yml b/.github/workflows/compiler_discord_notify.yml index 7a5f5db0fb988..5a57cf6a32c19 100644 --- a/.github/workflows/compiler_discord_notify.yml +++ b/.github/workflows/compiler_discord_notify.yml @@ -11,6 +11,7 @@ permissions: {} jobs: check_access: + if: ${{ github.event.pull_request.draft == false }} runs-on: ubuntu-latest outputs: is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }} diff --git a/.github/workflows/runtime_discord_notify.yml b/.github/workflows/runtime_discord_notify.yml index 69e4c3453f343..8d047e697640d 100644 --- a/.github/workflows/runtime_discord_notify.yml +++ b/.github/workflows/runtime_discord_notify.yml @@ -11,6 +11,7 @@ permissions: {} jobs: check_access: + if: ${{ github.event.pull_request.draft == false }} runs-on: ubuntu-latest outputs: is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }} From dddcae7a11b8241cbd6e2de55f9e68881baea458 Mon Sep 17 00:00:00 2001 From: Timothy Yung Date: Thu, 5 Jun 2025 14:22:35 -0700 Subject: [PATCH 02/33] Enable the `enableEagerAlternateStateNodeCleanup` Feature Flag (#33447) ## Summary Enables the `enableEagerAlternateStateNodeCleanup` feature flag for all variants, while maintaining the `__VARIANT__` for the internal React Native flavor for backtesting reasons. ## How did you test this change? ``` $ yarn test ``` --- packages/shared/forks/ReactFeatureFlags.native-oss.js | 2 +- packages/shared/forks/ReactFeatureFlags.test-renderer.js | 2 +- .../shared/forks/ReactFeatureFlags.test-renderer.native-fb.js | 2 +- packages/shared/forks/ReactFeatureFlags.test-renderer.www.js | 2 +- packages/shared/forks/ReactFeatureFlags.www.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index e142ef2865df3..f514d53195678 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -48,7 +48,7 @@ export const enableRetryLaneExpiration = false; export const enableSchedulingProfiler = __PROFILE__; export const enableComponentPerformanceTrack = false; export const enableScopeAPI = false; -export const enableEagerAlternateStateNodeCleanup = false; +export const enableEagerAlternateStateNodeCleanup = true; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseCallback = false; export const enableTaint = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 811f777f2b80b..e2e2bf1c861a9 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -61,7 +61,7 @@ export const disableClientCache = true; export const enableInfiniteRenderLoopDetection = false; export const renameElementSymbol = true; -export const enableEagerAlternateStateNodeCleanup = false; +export const enableEagerAlternateStateNodeCleanup = true; export const enableYieldingBeforePassive = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 61d6017642ea8..410eff7f3436e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -46,7 +46,7 @@ export const enableRetryLaneExpiration = false; export const enableSchedulingProfiler = __PROFILE__; export const enableComponentPerformanceTrack = false; export const enableScopeAPI = false; -export const enableEagerAlternateStateNodeCleanup = false; +export const enableEagerAlternateStateNodeCleanup = true; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseCallback = false; export const enableTaint = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index d17c23524f76d..f5772dd7aaa8b 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -70,7 +70,7 @@ export const disableDefaultPropsExceptForClasses = true; export const renameElementSymbol = false; export const enableObjectFiber = false; -export const enableEagerAlternateStateNodeCleanup = false; +export const enableEagerAlternateStateNodeCleanup = true; export const enableHydrationLaneScheduling = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index e22e6d2aff362..afe652ce3e030 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -104,7 +104,7 @@ export const enableReactTestRendererWarning = false; export const disableLegacyMode = true; -export const enableEagerAlternateStateNodeCleanup = false; +export const enableEagerAlternateStateNodeCleanup = true; export const enableLazyPublicInstanceInFabric = false; From b1759882c0b8045aff27fa9e41600534d396f69c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 6 Jun 2025 06:42:58 +0200 Subject: [PATCH 03/33] [Flight] Bypass caches in Flight fixture if requested (#33445) --- fixtures/flight/server/global.js | 3 +++ fixtures/flight/server/region.js | 21 ++++++++++++--------- fixtures/flight/src/App.js | 12 ++++++------ 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js index a2fa737ae0f4d..d81dc2c038c12 100644 --- a/fixtures/flight/server/global.js +++ b/fixtures/flight/server/global.js @@ -101,6 +101,9 @@ async function renderApp(req, res, next) { } else if (req.get('Content-type')) { proxiedHeaders['Content-type'] = req.get('Content-type'); } + if (req.headers['cache-control']) { + proxiedHeaders['Cache-Control'] = req.get('cache-control'); + } const requestsPrerender = req.path === '/prerender'; diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index 6896713e41cbf..a352d34ee6b79 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -50,7 +50,7 @@ const {readFile} = require('fs').promises; const React = require('react'); -async function renderApp(res, returnValue, formState) { +async function renderApp(res, returnValue, formState, noCache) { const {renderToPipeableStream} = await import( 'react-server-dom-webpack/server' ); @@ -97,7 +97,7 @@ async function renderApp(res, returnValue, formState) { key: filename, }) ), - React.createElement(App) + React.createElement(App, {noCache}) ); // For client-invoked server actions we refresh the tree and return a return value. const payload = {root, returnValue, formState}; @@ -105,7 +105,7 @@ async function renderApp(res, returnValue, formState) { pipe(res); } -async function prerenderApp(res, returnValue, formState) { +async function prerenderApp(res, returnValue, formState, noCache) { const {unstable_prerenderToNodeStream: prerenderToNodeStream} = await import( 'react-server-dom-webpack/static' ); @@ -152,7 +152,7 @@ async function prerenderApp(res, returnValue, formState) { key: filename, }) ), - React.createElement(App, {prerender: true}) + React.createElement(App, {prerender: true, noCache}) ); // For client-invoked server actions we refresh the tree and return a return value. const payload = {root, returnValue, formState}; @@ -161,14 +161,17 @@ async function prerenderApp(res, returnValue, formState) { } app.get('/', async function (req, res) { + const noCache = req.get('cache-control') === 'no-cache'; + if ('prerender' in req.query) { - await prerenderApp(res, null, null); + await prerenderApp(res, null, null, noCache); } else { - await renderApp(res, null, null); + await renderApp(res, null, null, noCache); } }); app.post('/', bodyParser.text(), async function (req, res) { + const noCache = req.headers['cache-control'] === 'no-cache'; const {decodeReply, decodeReplyFromBusboy, decodeAction, decodeFormState} = await import('react-server-dom-webpack/server'); const serverReference = req.get('rsc-action'); @@ -201,7 +204,7 @@ app.post('/', bodyParser.text(), async function (req, res) { // We handle the error on the client } // Refresh the client and return the value - renderApp(res, result, null); + renderApp(res, result, null, noCache); } else { // This is the progressive enhancement case const UndiciRequest = require('undici').Request; @@ -217,11 +220,11 @@ app.post('/', bodyParser.text(), async function (req, res) { // Wait for any mutations const result = await action(); const formState = decodeFormState(result, formData); - renderApp(res, null, formState); + renderApp(res, null, formState, noCache); } catch (x) { const {setServerState} = await import('../src/ServerState.js'); setServerState('Error: ' + x.message); - renderApp(res, null, null); + renderApp(res, null, null, noCache); } } }); diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 08eaefc90f887..5f12956fd6fc4 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -47,8 +47,8 @@ async function ThirdPartyComponent() { // Using Web streams for tee'ing convenience here. let cachedThirdPartyReadableWeb; -function fetchThirdParty(Component) { - if (cachedThirdPartyReadableWeb) { +function fetchThirdParty(noCache) { + if (cachedThirdPartyReadableWeb && !noCache) { const [readableWeb1, readableWeb2] = cachedThirdPartyReadableWeb.tee(); cachedThirdPartyReadableWeb = readableWeb1; @@ -79,16 +79,16 @@ function fetchThirdParty(Component) { return result; } -async function ServerComponent() { +async function ServerComponent({noCache}) { await new Promise(resolve => setTimeout(() => resolve('deferred text'), 50)); - return await fetchThirdParty(); + return fetchThirdParty(noCache); } -export default async function App({prerender}) { +export default async function App({prerender, noCache}) { const res = await fetch('http://localhost:3001/todos'); const todos = await res.json(); - const dedupedChild = ; + const dedupedChild = ; const message = getServerState(); return ( From a3be6829c6425f306a8bef9f7dba72d1347a64b3 Mon Sep 17 00:00:00 2001 From: Ricky Date: Fri, 6 Jun 2025 09:16:58 -0400 Subject: [PATCH 04/33] [tests] remove pretest compiler script (#33452) This shouldn't be needed now that the lint rule was move --- .github/workflows/runtime_build_and_test.yml | 29 ++++++++++++++++++++ package.json | 1 - scripts/jest/config.base.js | 1 + 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/runtime_build_and_test.yml b/.github/workflows/runtime_build_and_test.yml index 1d0a896984e26..0d87776d0494a 100644 --- a/.github/workflows/runtime_build_and_test.yml +++ b/.github/workflows/runtime_build_and_test.yml @@ -280,6 +280,35 @@ jobs: if: steps.node_modules.outputs.cache-hit != 'true' - run: yarn test ${{ matrix.params }} --ci --shard=${{ matrix.shard }} + # Hardcoded to improve parallelism + test-linter: + name: Test eslint-plugin-react-hooks + needs: [runtime_compiler_node_modules_cache] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + cache-dependency-path: | + yarn.lock + compiler/yarn.lock + - name: Restore cached node_modules + uses: actions/cache@v4 + id: node_modules + with: + path: | + **/node_modules + key: runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} + restore-keys: | + runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}- + runtime-and-compiler-node_modules-v6- + - run: yarn install --frozen-lockfile + if: steps.node_modules.outputs.cache-hit != 'true' + - run: ./scripts/react-compiler/build-compiler.sh && ./scripts/react-compiler/link-compiler.sh + - run: yarn workspace eslint-plugin-react-hooks test + # ----- BUILD ----- build_and_lint: name: yarn build and lint diff --git a/package.json b/package.json index 6ad9211568541..9e7eb8dc5334d 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,6 @@ "lint-build": "node ./scripts/rollup/validate/index.js", "extract-errors": "node scripts/error-codes/extract-errors.js", "postinstall": "node ./scripts/flow/createFlowConfigs.js", - "pretest": "./scripts/react-compiler/build-compiler.sh && ./scripts/react-compiler/link-compiler.sh", "test": "node ./scripts/jest/jest-cli.js", "test-stable": "node ./scripts/jest/jest-cli.js --release-channel=stable", "test-www": "node ./scripts/jest/jest-cli.js --release-channel=www-modern", diff --git a/scripts/jest/config.base.js b/scripts/jest/config.base.js index 15401cbba012e..ba001e165ee04 100644 --- a/scripts/jest/config.base.js +++ b/scripts/jest/config.base.js @@ -5,6 +5,7 @@ module.exports = { modulePathIgnorePatterns: [ '/scripts/rollup/shims/', '/scripts/bench/', + '/packages/eslint-plugin-react-hooks/', ], transform: { '^.+babel-plugin-react-compiler/dist/index.js$': [ From 22b929156c325eaf52c375f0c62801831951814a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 6 Jun 2025 10:14:13 -0400 Subject: [PATCH 05/33] [Fizz] Suspensey Images for View Transition Reveals (#33433) Block the view transition on suspensey images Up to 500ms just like the client. We can't use `decode()` because a bug in Chrome where those are blocked on `startViewTransition` finishing we instead rely on sync decoding but also that the image is live when it's animating in and we assume it doesn't start visible. However, we can block the View Transition from starting on the `"load"` or `"error"` events. The nice thing about blocking inside `startViewTransition` is that we have already done the layout so we can only wait on images that are within the viewport at this point. We might want to do that in Fiber too. If many image doesn't have fixed size but need to load first, they can all end up in the viewport. We might consider only doing this for images that have a fixed size or only a max number that doesn't have a fixed size. --- .../src/server/ReactFizzConfigDOM.js | 5 ++- ...tDOMFizzInstructionSetInlineCodeStrings.js | 2 +- .../ReactDOMFizzInstructionSetShared.js | 44 ++++++++++++++++--- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 795a406690282..44d3f20a61e31 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -4860,8 +4860,9 @@ export function writeCompletedSegmentInstruction( const completeBoundaryScriptFunctionOnly = stringToPrecomputedChunk( completeBoundaryFunction, ); -const completeBoundaryUpgradeToViewTransitionsInstruction = - stringToPrecomputedChunk(upgradeToViewTransitionsInstruction); +const completeBoundaryUpgradeToViewTransitionsInstruction = stringToChunk( + upgradeToViewTransitionsInstruction, +); const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); const completeBoundaryWithStylesScript1FullPartial = stringToPrecomputedChunk( diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index eacd86aa0687d..72a5aba4fb332 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -8,7 +8,7 @@ export const clientRenderBoundary = export const completeBoundary = '$RB=[];$RV=function(c){$RT=performance.now();for(var a=0;a { - revealBoundaries( - batch, - // Force layout to trigger font loading, we pass the actual value to trick minifiers. + revealBoundaries(batch); + const blockingPromises = [ + // Force layout to trigger font loading, we stash the actual value to trick minifiers. document.documentElement.clientHeight, - ); - return Promise.race([ // Block on fonts finishing loading before revealing these boundaries. document.fonts.ready, - new Promise(resolve => setTimeout(resolve, SUSPENSEY_FONT_TIMEOUT)), + ]; + for (let i = 0; i < suspenseyImages.length; i++) { + const suspenseyImage = suspenseyImages[i]; + if (!suspenseyImage.complete) { + const rect = suspenseyImage.getBoundingClientRect(); + const inViewport = + rect.bottom > 0 && + rect.right > 0 && + rect.top < window.innerHeight && + rect.left < window.innerWidth; + if (inViewport) { + const loadingImage = new Promise(resolve => { + suspenseyImage.addEventListener('load', resolve); + suspenseyImage.addEventListener('error', resolve); + }); + blockingPromises.push(loadingImage); + } + } + } + return Promise.race([ + Promise.all(blockingPromises), + new Promise(resolve => + setTimeout(resolve, SUSPENSEY_FONT_AND_IMAGE_TIMEOUT), + ), ]); }, types: [], // TODO: Add a hard coded type for Suspense reveals. From d177272802b7f86a847312c23b7e60a6f56434de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 6 Jun 2025 10:29:48 -0400 Subject: [PATCH 06/33] [Fizz] Error and deopt from rel=expect for large documents without boundaries (#33454) We want to make sure that we can block the reveal of a well designed complete shell reliably. In the Suspense model, client transitions don't have any way to implicitly resolve. This means you need to use Suspense or SuspenseList to explicitly split the document. Relying on implicit would mean you can't add a Suspense boundary later where needed. So we highly encourage the use of them around large content. However, if you have constructed a too large shell (e.g. by not adding any Suspense boundaries at all) then that might take too long to render on the client. We shouldn't punish users (or overzealous metrics tracking tools like search engines) in that scenario. This opts out of render blocking if the shell ends up too large to be intentional and too slow to load. Instead it deopts to showing the content split up in arbitrary ways (browser default). It only does this for SSR, and not client navs so it's not reliable. In fact, we issue an error to `onError`. This error is recoverable in that the document is still produced. It's up to your framework to decide if this errors the build or just surface it for action later. What should be the limit though? There's a trade off here. If this limit is too low then you can't fit a reasonably well built UI within it without getting errors. If it's too high then things that accidentally fall below it might take too long to load. I came up with 512kB of uncompressed shell HTML. See the comment in code for the rationale for this number. TL;DR: Data and theory indicates that having this much content inside `rel="expect"` doesn't meaningfully change metrics. Research of above-the-fold content on various websites indicate that this can comfortable fit all of them which should be enough for any intentional initial paint. --- .../src/server/ReactFizzConfigDOM.js | 8 +- .../src/server/ReactFizzConfigDOMLegacy.js | 16 ++- .../__tests__/ReactDOMFizzServerEdge-test.js | 100 ++++++++++++++++++ .../src/__tests__/ReactDOMLegacyFloat-test.js | 6 -- .../src/__tests__/ReactRenderDocument-test.js | 61 ++--------- .../react-markup/src/ReactFizzConfigMarkup.js | 4 +- packages/react-server/src/ReactFizzServer.js | 65 +++++++++++- scripts/error-codes/codes.json | 3 +- 8 files changed, 199 insertions(+), 64 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 44d3f20a61e31..eb8c2786152d8 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -5465,7 +5465,7 @@ export function writePreambleStart( destination: Destination, resumableState: ResumableState, renderState: RenderState, - skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup + skipBlockingShell: boolean, ): void { // This function must be called exactly once on every request if (enableFizzExternalRuntime && renderState.externalRuntimeScript) { @@ -5549,7 +5549,7 @@ export function writePreambleStart( renderState.bulkPreloads.forEach(flushResource, destination); renderState.bulkPreloads.clear(); - if ((htmlChunks || headChunks) && !skipExpect) { + if ((htmlChunks || headChunks) && !skipBlockingShell) { // If we have any html or head chunks we know that we're rendering a full document. // A full document should block display until the full shell has downloaded. // Therefore we insert a render blocking instruction referring to the last body @@ -5557,6 +5557,10 @@ export function writePreambleStart( // have already been emitted so we don't do anything to delay them but early so that // the browser doesn't risk painting too early. writeBlockingRenderInstruction(destination, resumableState, renderState); + } else { + // We don't need to add the shell id so mark it as if sent. + // Currently it might still be sent if it was already added to a bootstrap script. + resumableState.instructions |= SentCompletedShellId; } // Write embedding hoistableChunks diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index 1489c35353725..6ab54af00f7b7 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -27,6 +27,7 @@ import { writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl, writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl, writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl, + writePreambleStart as writePreambleStartImpl, } from './ReactFizzConfigDOM'; import type { @@ -170,7 +171,6 @@ export { createResumableState, createPreambleState, createHoistableState, - writePreambleStart, writePreambleEnd, writeHoistables, writePostamble, @@ -311,5 +311,19 @@ export function writeEndClientRenderedSuspenseBoundary( return writeEndClientRenderedSuspenseBoundaryImpl(destination, renderState); } +export function writePreambleStart( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + skipBlockingShell: boolean, +): void { + return writePreambleStartImpl( + destination, + resumableState, + renderState, + true, // skipBlockingShell + ); +} + export type TransitionStatus = FormStatus; export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js index c5cbe8bb85dc2..91f0774aa661e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js @@ -18,12 +18,14 @@ global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage; let React; let ReactDOM; let ReactDOMFizzServer; +let Suspense; describe('ReactDOMFizzServerEdge', () => { beforeEach(() => { jest.resetModules(); jest.useRealTimers(); React = require('react'); + Suspense = React.Suspense; ReactDOM = require('react-dom'); ReactDOMFizzServer = require('react-dom/server.edge'); }); @@ -81,4 +83,102 @@ describe('ReactDOMFizzServerEdge', () => { ); } }); + + it('recoverably errors and does not add rel="expect" for large shells', async () => { + function Paragraph() { + return ( +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris + porttitor tortor ac lectus faucibus, eget eleifend elit hendrerit. + Integer porttitor nisi in leo congue rutrum. Morbi sed ante posuere, + aliquam lorem ac, imperdiet orci. Duis malesuada gravida pharetra. + Cras facilisis arcu diam, id dictum lorem imperdiet a. Suspendisse + aliquet tempus tortor et ultricies. Aliquam libero velit, posuere + tempus ante sed, pellentesque tincidunt lorem. Nullam iaculis, eros a + varius aliquet, tortor felis tempor metus, nec cursus felis eros + aliquam nulla. Vivamus ut orci sed mauris congue lacinia. Cras eget + blandit neque. Pellentesque a massa in turpis ullamcorper volutpat vel + at massa. Sed ante est, auctor non diam non, vulputate ultrices metus. + Maecenas dictum fermentum quam id aliquam. Donec porta risus vitae + pretium posuere. Fusce facilisis eros in lacus tincidunt congue. +

+ ); + } + + function App({suspense}) { + const paragraphs = []; + for (let i = 0; i < 600; i++) { + paragraphs.push(); + } + return ( + + + {suspense ? ( + // This is ok + {paragraphs} + ) : ( + // This is not + paragraphs + )} + + + ); + } + const errors = []; + const stream = await ReactDOMFizzServer.renderToReadableStream( + , + { + onError(error) { + errors.push(error); + }, + }, + ); + const result = await readResult(stream); + expect(result).not.toContain('rel="expect"'); + if (gate(flags => flags.enableFizzBlockingRender)) { + expect(errors.length).toBe(1); + expect(errors[0].message).toContain( + 'This rendered a large document (>512) without any Suspense boundaries around most of it.', + ); + } else { + expect(errors.length).toBe(0); + } + + // If we wrap in a Suspense boundary though, then it should be ok. + const errors2 = []; + const stream2 = await ReactDOMFizzServer.renderToReadableStream( + , + { + onError(error) { + errors2.push(error); + }, + }, + ); + const result2 = await readResult(stream2); + if (gate(flags => flags.enableFizzBlockingRender)) { + expect(result2).toContain('rel="expect"'); + } else { + expect(result2).not.toContain('rel="expect"'); + } + expect(errors2.length).toBe(0); + + // Or if we increase the progressiveChunkSize. + const errors3 = []; + const stream3 = await ReactDOMFizzServer.renderToReadableStream( + , + { + progressiveChunkSize: 100000, + onError(error) { + errors3.push(error); + }, + }, + ); + const result3 = await readResult(stream3); + if (gate(flags => flags.enableFizzBlockingRender)) { + expect(result3).toContain('rel="expect"'); + } else { + expect(result3).not.toContain('rel="expect"'); + } + expect(errors3.length).toBe(0); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js index 2c4972245cd5b..5b22c6364f48b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js @@ -35,13 +35,7 @@ describe('ReactDOMFloat', () => { expect(result).toEqual( '' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : '') + 'title' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : '') + '', ); }); diff --git a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js index 9a485f8ddd0f4..f53354073eae8 100644 --- a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js +++ b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js @@ -70,6 +70,7 @@ describe('rendering React components at document', () => { const markup = ReactDOMServer.renderToString(); expect(markup).not.toContain('DOCTYPE'); + expect(markup).not.toContain('rel="expect"'); const testDocument = getTestDocument(markup); const body = testDocument.body; @@ -77,22 +78,12 @@ describe('rendering React components at document', () => { await act(() => { root = ReactDOMClient.hydrateRoot(testDocument, ); }); - expect(testDocument.body.innerHTML).toBe( - 'Hello world' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : ''), - ); + expect(testDocument.body.innerHTML).toBe('Hello world'); await act(() => { root.render(); }); - expect(testDocument.body.innerHTML).toBe( - 'Hello moon' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : ''), - ); + expect(testDocument.body.innerHTML).toBe('Hello moon'); expect(body === testDocument.body).toBe(true); }); @@ -117,12 +108,7 @@ describe('rendering React components at document', () => { await act(() => { root = ReactDOMClient.hydrateRoot(testDocument, ); }); - expect(testDocument.body.innerHTML).toBe( - 'Hello world' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : ''), - ); + expect(testDocument.body.innerHTML).toBe('Hello world'); const originalDocEl = testDocument.documentElement; const originalHead = testDocument.head; @@ -133,16 +119,8 @@ describe('rendering React components at document', () => { expect(testDocument.firstChild).toBe(originalDocEl); expect(testDocument.head).toBe(originalHead); expect(testDocument.body).toBe(originalBody); - expect(originalBody.innerHTML).toBe( - gate(flags => flags.enableFizzBlockingRender) - ? '' - : '', - ); - expect(originalHead.innerHTML).toBe( - gate(flags => flags.enableFizzBlockingRender) - ? '' - : '', - ); + expect(originalBody.innerHTML).toBe(''); + expect(originalHead.innerHTML).toBe(''); }); it('should not be able to switch root constructors', async () => { @@ -180,22 +158,13 @@ describe('rendering React components at document', () => { root = ReactDOMClient.hydrateRoot(testDocument, ); }); - expect(testDocument.body.innerHTML).toBe( - 'Hello world' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : ''), - ); + expect(testDocument.body.innerHTML).toBe('Hello world'); await act(() => { root.render(); }); - expect(testDocument.body.innerHTML).toBe( - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : '') + 'Goodbye world', - ); + expect(testDocument.body.innerHTML).toBe('Goodbye world'); }); it('should be able to mount into document', async () => { @@ -224,12 +193,7 @@ describe('rendering React components at document', () => { ); }); - expect(testDocument.body.innerHTML).toBe( - 'Hello world' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : ''), - ); + expect(testDocument.body.innerHTML).toBe('Hello world'); }); it('cannot render over an existing text child at the root', async () => { @@ -362,12 +326,7 @@ describe('rendering React components at document', () => { : [], ); expect(testDocument.body.innerHTML).toBe( - favorSafetyOverHydrationPerf - ? 'Hello world' - : 'Goodbye world' + - (gate(flags => flags.enableFizzBlockingRender) - ? '' - : ''), + favorSafetyOverHydrationPerf ? 'Hello world' : 'Goodbye world', ); }); diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index bbca0d4ddf2b9..fee02f320fcb5 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -222,13 +222,13 @@ export function writePreambleStart( destination: Destination, resumableState: ResumableState, renderState: RenderState, - skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup + skipBlockingShell: boolean, ): void { return writePreambleStartImpl( destination, resumableState, renderState, - true, // skipExpect + true, // skipBlockingShell ); } diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 579edf25c9102..19860614ad450 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -182,6 +182,7 @@ import { disableDefaultPropsExceptForClasses, enableAsyncIterableChildren, enableViewTransition, + enableFizzBlockingRender, } from 'shared/ReactFeatureFlags'; import assign from 'shared/assign'; @@ -418,6 +419,41 @@ type Preamble = PreambleState; // 500 * 1024 / 8 * .8 * 0.5 / 2 const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800; +function getBlockingRenderMaxSize(request: Request): number { + // We want to make sure that we can block the reveal of a well designed complete + // shell but if you have constructed a too large shell (e.g. by not adding any + // Suspense boundaries) then that might take too long to render. We shouldn't + // punish users (or overzealous metrics tracking) in that scenario. + // There's a trade off here. If this limit is too low then you can't fit a + // reasonably well built UI within it without getting errors. If it's too high + // then things that accidentally fall below it might take too long to load. + // Web Vitals target 1.8 seconds for first paint and our goal to have the limit + // be fast enough to hit that. For this argument we assume that most external + // resources are already cached because it's a return visit, or inline styles. + // If it's not, then it's highly unlikely that any render blocking instructions + // we add has any impact what so ever on the paint. + // Assuming a first byte of about 600ms which is kind of bad but common with a + // decent static host. If it's longer e.g. due to dynamic rendering, then you + // are going to bound by dynamic production of the content and you're better off + // with Suspense boundaries anyway. This number doesn't matter much. Then you + // have about 1.2 seconds left for bandwidth. On 3G that gives you about 112.5kb + // worth of data. That's worth about 10x in terms of uncompressed bytes. Then we + // half that just to account for longer latency, slower bandwidth and CPU processing. + // Now we're down to about 500kb. In fact, looking at metrics we've collected with + // rel="expect" examples and other documents, the impact on documents smaller than + // that is within the noise. That's because there's enough happening within that + // start up to not make HTML streaming not significantly better. + // Content above the fold tends to be about 100-200kb tops. Therefore 500kb should + // be enough head room for a good loading state. After that you should use + // Suspense or SuspenseList to improve it. + // Since this is highly related to the reason you would adjust the + // progressiveChunkSize option, and always has to be higher, we define this limit + // in terms of it. So if you want to increase the limit because you have high + // bandwidth users, then you can adjust it up. If you are concerned about even + // slower bandwidth then you can adjust it down. + return request.progressiveChunkSize * 40; // 512kb by default. +} + function isEligibleForOutlining( request: Request, boundary: SuspenseBoundary, @@ -5476,9 +5512,15 @@ function flushPreamble( destination: Destination, rootSegment: Segment, preambleSegments: Array>, + skipBlockingShell: boolean, ) { // The preamble is ready. - writePreambleStart(destination, request.resumableState, request.renderState); + writePreambleStart( + destination, + request.resumableState, + request.renderState, + skipBlockingShell, + ); for (let i = 0; i < preambleSegments.length; i++) { const segments = preambleSegments[i]; for (let j = 0; j < segments.length; j++) { @@ -5888,11 +5930,32 @@ function flushCompletedQueues( flushedByteSize = request.byteSize; // Start counting bytes // TODO: Count the size of the preamble chunks too. + let skipBlockingShell = false; + if (enableFizzBlockingRender) { + const blockingRenderMaxSize = getBlockingRenderMaxSize(request); + if (flushedByteSize > blockingRenderMaxSize) { + skipBlockingShell = true; + const maxSizeKb = Math.round(blockingRenderMaxSize / 1000); + const error = new Error( + 'This rendered a large document (>' + + maxSizeKb + + ') without any Suspense ' + + 'boundaries around most of it. That can delay initial paint longer than ' + + 'necessary. To improve load performance, add a or ' + + 'around the content you expect to be below the header or below the fold. ' + + 'In the meantime, the content will deopt to paint arbitrary incomplete ' + + 'pieces of HTML.', + ); + const errorInfo: ThrownInfo = {}; + logRecoverableError(request, error, errorInfo, null); + } + } flushPreamble( request, destination, completedRootSegment, completedPreambleSegments, + skipBlockingShell, ); flushSegment(request, destination, completedRootSegment, null); request.completedRootSegment = null; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 3c64fcc4719a2..7e709903a841d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -545,5 +545,6 @@ "557": "Expected to have a hydrated activity instance. This error is likely caused by a bug in React. Please file an issue.", "558": "Client rendering an Activity suspended it again. This is a bug in React.", "559": "Expected to find a host node. This is a bug in React.", - "560": "Cannot use a startGestureTransition() with a comment node root." + "560": "Cannot use a startGestureTransition() with a comment node root.", + "561": "This rendered a large document (>%s) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a or around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML." } From e8d15fa19efcd18f0947fe4189652f5b64f74256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 6 Jun 2025 11:07:15 -0400 Subject: [PATCH 07/33] [Flight] Build node-webstreams version of bundled webpack server (#33456) Follow up to #33442. This is the bundled version. To keep type check passes from exploding and the maintainance of the annoying `paths: []` list small, this doesn't add this to flow type checks. We might miss some config but every combination should already be covered by other one passes. I also don't add any jest tests because to test these double export entry points we need conditional importing to cover builds and non-builds which turns out to be difficult for the Flight builds so these aren't covered by any basic build tests. This approach is what I'm going for, for the other bundlers too. --- ...lientConfig.dom-node-webstreams-webpack.js | 19 ++++++++++++++ .../npm/client.node.js | 25 +++++++++++++++++-- .../npm/server.node.js | 11 +++++--- ...react-flight-dom-client.node-webstreams.js | 10 ++++++++ ...react-flight-dom-server.node-webstreams.js | 21 ++++++++++++++++ scripts/jest/setupTests.js | 9 +++++-- scripts/rollup/bundles.js | 23 +++++++++++++++++ scripts/shared/inlinedHostConfigs.js | 13 ++++++++++ 8 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-webpack.js create mode 100644 packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.js create mode 100644 packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.js diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-webpack.js new file mode 100644 index 0000000000000..f328a3e2ed7b1 --- /dev/null +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-webpack.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-webpack'; + +export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; +export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack'; +export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackServer'; +export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigTargetWebpackServer'; +export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; +export const usedWithSSR = true; diff --git a/packages/react-server-dom-webpack/npm/client.node.js b/packages/react-server-dom-webpack/npm/client.node.js index 32b45503d142c..71a093f0cd5af 100644 --- a/packages/react-server-dom-webpack/npm/client.node.js +++ b/packages/react-server-dom-webpack/npm/client.node.js @@ -1,7 +1,28 @@ 'use strict'; +var n, w; if (process.env.NODE_ENV === 'production') { - module.exports = require('./cjs/react-server-dom-webpack-client.node.production.js'); + n = require('./cjs/react-server-dom-webpack-client.node.production.js'); + w = require('./cjs/react-server-dom-webpack-client.node-webstreams.production.js'); } else { - module.exports = require('./cjs/react-server-dom-webpack-client.node.development.js'); + n = require('./cjs/react-server-dom-webpack-client.node.development.js'); + w = require('./cjs/react-server-dom-webpack-client.node-webstreams.development.js'); } + +exports.registerServerReference = function (r, i, e) { + return w.registerServerReference(n.registerServerReference(r, i, e), i, e); +}; +exports.createServerReference = function (i, c, e, d, f) { + return w.registerServerReference( + n.createServerReference(i, c, e, d, f), + i, + e + ); +}; + +exports.createFromNodeStream = n.createFromNodeStream; +exports.createFromFetch = w.createFromFetch; +exports.createFromReadableStream = w.createFromReadableStream; + +exports.createTemporaryReferenceSet = w.createTemporaryReferenceSet; +exports.encodeReply = w.encodeReply; diff --git a/packages/react-server-dom-webpack/npm/server.node.js b/packages/react-server-dom-webpack/npm/server.node.js index 6885e43a44fc0..2d215c45d5fe0 100644 --- a/packages/react-server-dom-webpack/npm/server.node.js +++ b/packages/react-server-dom-webpack/npm/server.node.js @@ -1,10 +1,12 @@ 'use strict'; -var s; +var s, w; if (process.env.NODE_ENV === 'production') { - s = require('./cjs/react-server-dom-webpack-server.node.production.js'); + s = require('./cjs/react-server-dom-webpack-server.node.unbundled.production.js'); + w = require('./cjs/react-server-dom-webpack-server.node-webstreams.unbundled.production.js'); } else { - s = require('./cjs/react-server-dom-webpack-server.node.development.js'); + s = require('./cjs/react-server-dom-webpack-server.node.unbundled.development.js'); + w = require('./cjs/react-server-dom-webpack-server.node-webstreams.unbundled.development.js'); } exports.renderToPipeableStream = s.renderToPipeableStream; @@ -16,3 +18,6 @@ exports.registerServerReference = s.registerServerReference; exports.registerClientReference = s.registerClientReference; exports.createClientModuleProxy = s.createClientModuleProxy; exports.createTemporaryReferenceSet = s.createTemporaryReferenceSet; + +exports.renderToReadableStream = w.renderToReadableStream; +exports.decodeReplyFromAsyncIterable = w.decodeReplyFromAsyncIterable; diff --git a/packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.js b/packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.js new file mode 100644 index 0000000000000..14a8876953b2c --- /dev/null +++ b/packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.js new file mode 100644 index 0000000000000..9198f9913ed37 --- /dev/null +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToReadableStream, + prerender as unstable_prerender, + decodeReply, + decodeReplyFromAsyncIterable, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerEdge'; diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index 1e8d8e5276e5c..175e0cd7cab1c 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -296,14 +296,19 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { // We mock createHook so that we can automatically clean it up. let installedHook = null; +let outgoingHook = null; jest.mock('async_hooks', () => { const actual = jest.requireActual('async_hooks'); return { ...actual, createHook(config) { - if (installedHook) { - installedHook.disable(); + // We unmount when there's more than two hooks installed. + // We use two because the build of server.node actually installs two hooks. + // One in each build. + if (outgoingHook) { + outgoingHook.disable(); } + outgoingHook = installedHook; return (installedHook = actual.createHook(config)); }, }; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 6bb622c62baa5..78f633009becd 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -472,6 +472,18 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], }, + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: + 'react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams', + name: 'react-server-dom-webpack-server.node-webstreams', + condition: 'react-server', + global: 'ReactServerDOMServer', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], + }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -530,6 +542,17 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'react-dom', 'util', 'crypto'], }, + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: + 'react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams', + name: 'react-server-dom-webpack-client.node-webstreams', + global: 'ReactServerDOMClient', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'react-dom', 'util', 'crypto'], + }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 24db4fe0ea142..d169e1fcded18 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -190,6 +190,19 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, + { + shortName: 'dom-node-webstreams-webpack', + entryPoints: [ + 'react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams', + 'react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams', + ], + paths: [ + 'react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams', + 'react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams', + ], + isFlowTyped: false, + isServerSupported: true, + }, { shortName: 'dom-node-turbopack', entryPoints: [ From ab859e31be5db56106161060033109c9f2d26eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 6 Jun 2025 11:07:40 -0400 Subject: [PATCH 08/33] [Flight] Build Node.js Web Streams builds for Turbopack and Parcel (#33457) Same as #33456 and #33442 but for Turbopack and Parcel. --- ...ClientConfig.dom-node-webstreams-parcel.js | 18 +++++++ ...entConfig.dom-node-webstreams-turbopack.js | 19 +++++++ .../react-server-dom-parcel/client.browser.js | 2 +- .../react-server-dom-parcel/client.edge.js | 2 +- .../react-server-dom-parcel/client.node.js | 2 +- .../npm/client.node.js | 25 ++++++++- .../npm/server.node.js | 12 ++++- .../client/react-flight-dom-client.browser.js | 10 ++++ .../client/react-flight-dom-client.edge.js | 10 ++++ ...react-flight-dom-client.node-webstreams.js | 10 ++++ .../client/react-flight-dom-client.node.js | 10 ++++ ...react-flight-dom-server.node-webstreams.js | 22 ++++++++ .../client.browser.js | 2 +- .../react-server-dom-turbopack/client.edge.js | 2 +- .../react-server-dom-turbopack/client.node.js | 2 +- .../npm/client.node.js | 25 ++++++++- .../npm/server.node.js | 7 ++- .../client/react-flight-dom-client.browser.js | 10 ++++ .../client/react-flight-dom-client.edge.js | 10 ++++ ...react-flight-dom-client.node-webstreams.js | 10 ++++ .../client/react-flight-dom-client.node.js | 10 ++++ ...react-flight-dom-server.node-webstreams.js | 21 ++++++++ .../client.browser.js | 2 +- .../react-server-dom-webpack/client.edge.js | 2 +- .../react-server-dom-webpack/client.node.js | 2 +- .../client.node.unbundled.js | 2 +- scripts/rollup/bundles.js | 54 ++++++++++++++++--- scripts/shared/inlinedHostConfigs.js | 44 ++++++++++++--- 28 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-parcel.js create mode 100644 packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-turbopack.js create mode 100644 packages/react-server-dom-parcel/src/client/react-flight-dom-client.browser.js create mode 100644 packages/react-server-dom-parcel/src/client/react-flight-dom-client.edge.js create mode 100644 packages/react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams.js create mode 100644 packages/react-server-dom-parcel/src/client/react-flight-dom-client.node.js create mode 100644 packages/react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams.js create mode 100644 packages/react-server-dom-turbopack/src/client/react-flight-dom-client.browser.js create mode 100644 packages/react-server-dom-turbopack/src/client/react-flight-dom-client.edge.js create mode 100644 packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams.js create mode 100644 packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node.js create mode 100644 packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams.js diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-parcel.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-parcel.js new file mode 100644 index 0000000000000..626f26903ed73 --- /dev/null +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-parcel.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-parcel'; + +export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; +export * from 'react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel'; +export * from 'react-server-dom-parcel/src/client/ReactFlightClientConfigTargetParcelServer'; +export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; +export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-turbopack.js new file mode 100644 index 0000000000000..fbdb9fc683ac6 --- /dev/null +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-turbopack.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-server-dom-turbopack'; + +export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; +export * from 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack'; +export * from 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer'; +export * from 'react-server-dom-turbopack/src/client/ReactFlightClientConfigTargetTurbopackServer'; +export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; +export const usedWithSSR = true; diff --git a/packages/react-server-dom-parcel/client.browser.js b/packages/react-server-dom-parcel/client.browser.js index 945ceed7f394a..1be0bc04a7ec2 100644 --- a/packages/react-server-dom-parcel/client.browser.js +++ b/packages/react-server-dom-parcel/client.browser.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientBrowser'; +export * from './src/client/react-flight-dom-client.browser'; diff --git a/packages/react-server-dom-parcel/client.edge.js b/packages/react-server-dom-parcel/client.edge.js index 0ba1d6b6b0457..ab6a110c112eb 100644 --- a/packages/react-server-dom-parcel/client.edge.js +++ b/packages/react-server-dom-parcel/client.edge.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientEdge'; +export * from './src/client/react-flight-dom-client.edge'; diff --git a/packages/react-server-dom-parcel/client.node.js b/packages/react-server-dom-parcel/client.node.js index c2e364f42f133..c3ec7662d6e90 100644 --- a/packages/react-server-dom-parcel/client.node.js +++ b/packages/react-server-dom-parcel/client.node.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientNode'; +export * from './src/client/react-flight-dom-client.node'; diff --git a/packages/react-server-dom-parcel/npm/client.node.js b/packages/react-server-dom-parcel/npm/client.node.js index 75583db96f735..aa5995537607d 100644 --- a/packages/react-server-dom-parcel/npm/client.node.js +++ b/packages/react-server-dom-parcel/npm/client.node.js @@ -1,7 +1,28 @@ 'use strict'; +var n, w; if (process.env.NODE_ENV === 'production') { - module.exports = require('./cjs/react-server-dom-parcel-client.node.production.js'); + n = require('./cjs/react-server-dom-parcel-client.node.production.js'); + w = require('./cjs/react-server-dom-parcel-client.node-webstreams.production.js'); } else { - module.exports = require('./cjs/react-server-dom-parcel-client.node.development.js'); + n = require('./cjs/react-server-dom-parcel-client.node.development.js'); + w = require('./cjs/react-server-dom-parcel-client.node-webstreams.development.js'); } + +exports.registerServerReference = function (r, i, e) { + return w.registerServerReference(n.registerServerReference(r, i, e), i, e); +}; +exports.createServerReference = function (i, c, e, d, f) { + return w.registerServerReference( + n.createServerReference(i, c, e, d, f), + i, + e + ); +}; + +exports.createFromNodeStream = n.createFromNodeStream; +exports.createFromFetch = w.createFromFetch; +exports.createFromReadableStream = w.createFromReadableStream; + +exports.createTemporaryReferenceSet = w.createTemporaryReferenceSet; +exports.encodeReply = w.encodeReply; diff --git a/packages/react-server-dom-parcel/npm/server.node.js b/packages/react-server-dom-parcel/npm/server.node.js index 92b2551dc7080..0d79550b4021d 100644 --- a/packages/react-server-dom-parcel/npm/server.node.js +++ b/packages/react-server-dom-parcel/npm/server.node.js @@ -1,10 +1,12 @@ 'use strict'; -var s; +var s, w; if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-parcel-server.node.production.js'); + w = require('./cjs/react-server-dom-parcel-server.node-webstreams.production.js'); } else { s = require('./cjs/react-server-dom-parcel-server.node.development.js'); + w = require('./cjs/react-server-dom-parcel-server.node-webstreams.development.js'); } exports.renderToPipeableStream = s.renderToPipeableStream; @@ -15,5 +17,11 @@ exports.decodeFormState = s.decodeFormState; exports.createClientReference = s.createClientReference; exports.registerServerReference = s.registerServerReference; exports.createTemporaryReferenceSet = s.createTemporaryReferenceSet; -exports.registerServerActions = s.registerServerActions; +exports.registerServerActions = function (m) { + w.registerServerActions(m); + s.registerServerActions(m); +}; exports.loadServerAction = s.loadServerAction; + +exports.renderToReadableStream = w.renderToReadableStream; +exports.decodeReplyFromAsyncIterable = w.decodeReplyFromAsyncIterable; diff --git a/packages/react-server-dom-parcel/src/client/react-flight-dom-client.browser.js b/packages/react-server-dom-parcel/src/client/react-flight-dom-client.browser.js new file mode 100644 index 0000000000000..a3f15d0116cfe --- /dev/null +++ b/packages/react-server-dom-parcel/src/client/react-flight-dom-client.browser.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ReactFlightDOMClientBrowser'; diff --git a/packages/react-server-dom-parcel/src/client/react-flight-dom-client.edge.js b/packages/react-server-dom-parcel/src/client/react-flight-dom-client.edge.js new file mode 100644 index 0000000000000..14a8876953b2c --- /dev/null +++ b/packages/react-server-dom-parcel/src/client/react-flight-dom-client.edge.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams.js b/packages/react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams.js new file mode 100644 index 0000000000000..14a8876953b2c --- /dev/null +++ b/packages/react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-parcel/src/client/react-flight-dom-client.node.js b/packages/react-server-dom-parcel/src/client/react-flight-dom-client.node.js new file mode 100644 index 0000000000000..8eb9daa35b6f5 --- /dev/null +++ b/packages/react-server-dom-parcel/src/client/react-flight-dom-client.node.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ReactFlightDOMClientNode'; diff --git a/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams.js b/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams.js new file mode 100644 index 0000000000000..54f3dbb2ec346 --- /dev/null +++ b/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToReadableStream, + prerender as unstable_prerender, + decodeReply, + decodeReplyFromAsyncIterable, + decodeAction, + decodeFormState, + createClientReference, + registerServerReference, + createTemporaryReferenceSet, + registerServerActions, + loadServerAction, +} from './ReactFlightDOMServerEdge'; diff --git a/packages/react-server-dom-turbopack/client.browser.js b/packages/react-server-dom-turbopack/client.browser.js index 945ceed7f394a..1be0bc04a7ec2 100644 --- a/packages/react-server-dom-turbopack/client.browser.js +++ b/packages/react-server-dom-turbopack/client.browser.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientBrowser'; +export * from './src/client/react-flight-dom-client.browser'; diff --git a/packages/react-server-dom-turbopack/client.edge.js b/packages/react-server-dom-turbopack/client.edge.js index 0ba1d6b6b0457..ab6a110c112eb 100644 --- a/packages/react-server-dom-turbopack/client.edge.js +++ b/packages/react-server-dom-turbopack/client.edge.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientEdge'; +export * from './src/client/react-flight-dom-client.edge'; diff --git a/packages/react-server-dom-turbopack/client.node.js b/packages/react-server-dom-turbopack/client.node.js index c2e364f42f133..c3ec7662d6e90 100644 --- a/packages/react-server-dom-turbopack/client.node.js +++ b/packages/react-server-dom-turbopack/client.node.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientNode'; +export * from './src/client/react-flight-dom-client.node'; diff --git a/packages/react-server-dom-turbopack/npm/client.node.js b/packages/react-server-dom-turbopack/npm/client.node.js index d9c2ddf9985d4..a0f366be00901 100644 --- a/packages/react-server-dom-turbopack/npm/client.node.js +++ b/packages/react-server-dom-turbopack/npm/client.node.js @@ -1,7 +1,28 @@ 'use strict'; +var n, w; if (process.env.NODE_ENV === 'production') { - module.exports = require('./cjs/react-server-dom-turbopack-client.node.production.js'); + n = require('./cjs/react-server-dom-turbopack-client.node.production.js'); + w = require('./cjs/react-server-dom-turbopack-client.node-webstreams.production.js'); } else { - module.exports = require('./cjs/react-server-dom-turbopack-client.node.development.js'); + n = require('./cjs/react-server-dom-turbopack-client.node.development.js'); + w = require('./cjs/react-server-dom-turbopack-client.node-webstreams.development.js'); } + +exports.registerServerReference = function (r, i, e) { + return w.registerServerReference(n.registerServerReference(r, i, e), i, e); +}; +exports.createServerReference = function (i, c, e, d, f) { + return w.registerServerReference( + n.createServerReference(i, c, e, d, f), + i, + e + ); +}; + +exports.createFromNodeStream = n.createFromNodeStream; +exports.createFromFetch = w.createFromFetch; +exports.createFromReadableStream = w.createFromReadableStream; + +exports.createTemporaryReferenceSet = w.createTemporaryReferenceSet; +exports.encodeReply = w.encodeReply; diff --git a/packages/react-server-dom-turbopack/npm/server.node.js b/packages/react-server-dom-turbopack/npm/server.node.js index f9a4cf31f6e8c..2dd087a2882d9 100644 --- a/packages/react-server-dom-turbopack/npm/server.node.js +++ b/packages/react-server-dom-turbopack/npm/server.node.js @@ -1,10 +1,12 @@ 'use strict'; -var s; +var s, w; if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-turbopack-server.node.production.js'); + w = require('./cjs/react-server-dom-turbopack-server.node-webstreams.production.js'); } else { s = require('./cjs/react-server-dom-turbopack-server.node.development.js'); + w = require('./cjs/react-server-dom-turbopack-server.node-webstreams.development.js'); } exports.renderToPipeableStream = s.renderToPipeableStream; @@ -16,3 +18,6 @@ exports.registerServerReference = s.registerServerReference; exports.registerClientReference = s.registerClientReference; exports.createClientModuleProxy = s.createClientModuleProxy; exports.createTemporaryReferenceSet = s.createTemporaryReferenceSet; + +exports.renderToReadableStream = w.renderToReadableStream; +exports.decodeReplyFromAsyncIterable = w.decodeReplyFromAsyncIterable; diff --git a/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.browser.js b/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.browser.js new file mode 100644 index 0000000000000..a3f15d0116cfe --- /dev/null +++ b/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.browser.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ReactFlightDOMClientBrowser'; diff --git a/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.edge.js b/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.edge.js new file mode 100644 index 0000000000000..14a8876953b2c --- /dev/null +++ b/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.edge.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams.js b/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams.js new file mode 100644 index 0000000000000..14a8876953b2c --- /dev/null +++ b/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node.js b/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node.js new file mode 100644 index 0000000000000..8eb9daa35b6f5 --- /dev/null +++ b/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ReactFlightDOMClientNode'; diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams.js new file mode 100644 index 0000000000000..9198f9913ed37 --- /dev/null +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export { + renderToReadableStream, + prerender as unstable_prerender, + decodeReply, + decodeReplyFromAsyncIterable, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createClientModuleProxy, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerEdge'; diff --git a/packages/react-server-dom-webpack/client.browser.js b/packages/react-server-dom-webpack/client.browser.js index 945ceed7f394a..1be0bc04a7ec2 100644 --- a/packages/react-server-dom-webpack/client.browser.js +++ b/packages/react-server-dom-webpack/client.browser.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientBrowser'; +export * from './src/client/react-flight-dom-client.browser'; diff --git a/packages/react-server-dom-webpack/client.edge.js b/packages/react-server-dom-webpack/client.edge.js index 0ba1d6b6b0457..ab6a110c112eb 100644 --- a/packages/react-server-dom-webpack/client.edge.js +++ b/packages/react-server-dom-webpack/client.edge.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientEdge'; +export * from './src/client/react-flight-dom-client.edge'; diff --git a/packages/react-server-dom-webpack/client.node.js b/packages/react-server-dom-webpack/client.node.js index c2e364f42f133..c3ec7662d6e90 100644 --- a/packages/react-server-dom-webpack/client.node.js +++ b/packages/react-server-dom-webpack/client.node.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientNode'; +export * from './src/client/react-flight-dom-client.node'; diff --git a/packages/react-server-dom-webpack/client.node.unbundled.js b/packages/react-server-dom-webpack/client.node.unbundled.js index c2e364f42f133..e5f8c2cb7252a 100644 --- a/packages/react-server-dom-webpack/client.node.unbundled.js +++ b/packages/react-server-dom-webpack/client.node.unbundled.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/client/ReactFlightDOMClientNode'; +export * from './src/client/react-flight-dom-client.node.unbundled'; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 78f633009becd..57decb70a28ce 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -646,6 +646,18 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'async_hooks', 'react-dom'], }, + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: + 'react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams', + name: 'react-server-dom-turbopack-server.node-webstreams', + condition: 'react-server', + global: 'ReactServerDOMServer', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'util', 'async_hooks', 'react-dom'], + }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -662,7 +674,9 @@ const bundles = [ { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, - entry: 'react-server-dom-turbopack/client.browser', + entry: + 'react-server-dom-turbopack/src/client/react-flight-dom-client.browser', + name: 'react-server-dom-turbopack-client.browser', global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, @@ -671,7 +685,19 @@ const bundles = [ { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, - entry: 'react-server-dom-turbopack/client.node', + entry: 'react-server-dom-turbopack/src/client/react-flight-dom-client.node', + name: 'react-server-dom-turbopack-client.node', + global: 'ReactServerDOMClient', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'react-dom', 'util'], + }, + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: + 'react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams', + name: 'react-server-dom-turbopack-client.node-webstreams', global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, @@ -680,7 +706,8 @@ const bundles = [ { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, - entry: 'react-server-dom-turbopack/client.edge', + entry: 'react-server-dom-turbopack/src/client/react-flight-dom-client.edge', + name: 'react-server-dom-turbopack-client.edge', global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, @@ -710,6 +737,18 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'async_hooks', 'react-dom'], }, + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: + 'react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams', + name: 'react-server-dom-parcel-server.node-webstreams', + condition: 'react-server', + global: 'ReactServerDOMServer', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'util', 'async_hooks', 'react-dom'], + }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -726,7 +765,8 @@ const bundles = [ { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, - entry: 'react-server-dom-parcel/client.browser', + entry: 'react-server-dom-parcel/src/client/react-flight-dom-client.browser', + name: 'react-server-dom-parcel-client.browser', global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, @@ -735,7 +775,8 @@ const bundles = [ { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, - entry: 'react-server-dom-parcel/client.node', + entry: 'react-server-dom-parcel/src/client/react-flight-dom-client.node', + name: 'react-server-dom-parcel-client.node', global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, @@ -744,7 +785,8 @@ const bundles = [ { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, - entry: 'react-server-dom-parcel/client.edge', + entry: 'react-server-dom-parcel/src/client/react-flight-dom-client.edge', + name: 'react-server-dom-parcel-client.edge', global: 'ReactServerDOMClient', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index d169e1fcded18..4e024c827a9d4 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -206,7 +206,7 @@ module.exports = [ { shortName: 'dom-node-turbopack', entryPoints: [ - 'react-server-dom-turbopack/client.node', + 'react-server-dom-turbopack/src/client/react-flight-dom-client.node', 'react-server-dom-turbopack/src/server/react-flight-dom-server.node', ], paths: [ @@ -233,6 +233,7 @@ module.exports = [ 'react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js', // react-server-dom-turbopack/client.node 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer.js', + 'react-server-dom-turbopack/src/client/react-flight-dom-client.node', 'react-server-dom-turbopack/src/server/react-flight-dom-server.node', 'react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js', // react-server-dom-turbopack/src/server/react-flight-dom-server.node 'react-server-dom-turbopack/node-register', @@ -247,10 +248,23 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, + { + shortName: 'dom-node-webstreams-turbopack', + entryPoints: [ + 'react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams', + 'react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams', + ], + paths: [ + 'react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams', + 'react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams', + ], + isFlowTyped: false, + isServerSupported: true, + }, { shortName: 'dom-node-parcel', entryPoints: [ - 'react-server-dom-parcel/client.node', + 'react-server-dom-parcel/src/client/react-flight-dom-client.node', 'react-server-dom-parcel/src/server/react-flight-dom-server.node', ], paths: [ @@ -276,6 +290,7 @@ module.exports = [ 'react-server-dom-parcel/static.node', 'react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js', // react-server-dom-parcel/client.node 'react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js', + 'react-server-dom-parcel/src/client/react-flight-dom-client.node', 'react-server-dom-parcel/src/server/react-flight-dom-server.node', 'react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js', // react-server-dom-parcel/src/server/react-flight-dom-server.node 'react-devtools', @@ -288,6 +303,19 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, + { + shortName: 'dom-node-webstreams-parcel', + entryPoints: [ + 'react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams', + 'react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams', + ], + paths: [ + 'react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams', + 'react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams', + ], + isFlowTyped: false, + isServerSupported: true, + }, { shortName: 'dom-bun', entryPoints: ['react-dom/src/server/react-dom-server.bun.js'], @@ -339,7 +367,7 @@ module.exports = [ { shortName: 'dom-browser-turbopack', entryPoints: [ - 'react-server-dom-turbopack/client.browser', + 'react-server-dom-turbopack/src/client/react-flight-dom-client.browser', 'react-server-dom-turbopack/src/server/react-flight-dom-server.browser', ], paths: [ @@ -360,6 +388,7 @@ module.exports = [ 'react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js', // react-server-dom-turbopack/client.browser 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackBrowser.js', + 'react-server-dom-turbopack/src/client/react-flight-dom-client.browser', 'react-server-dom-turbopack/src/server/react-flight-dom-server.browser', 'react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js', // react-server-dom-turbopack/src/server/react-flight-dom-server.browser 'react-devtools', @@ -375,7 +404,7 @@ module.exports = [ { shortName: 'dom-browser-parcel', entryPoints: [ - 'react-server-dom-parcel/client.browser', + 'react-server-dom-parcel/src/client/react-flight-dom-client.browser', 'react-server-dom-parcel/src/server/react-flight-dom-server.browser', ], paths: [ @@ -395,6 +424,7 @@ module.exports = [ 'react-server-dom-parcel/static.browser', 'react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js', // react-server-dom-parcel/client.browser 'react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js', + 'react-server-dom-parcel/src/client/react-flight-dom-client.browser', 'react-server-dom-parcel/src/server/react-flight-dom-server.browser', 'react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js', // react-server-dom-parcel/src/server/react-flight-dom-server.browser 'react-devtools', @@ -453,7 +483,7 @@ module.exports = [ { shortName: 'dom-edge-turbopack', entryPoints: [ - 'react-server-dom-turbopack/client.edge', + 'react-server-dom-turbopack/src/client/react-flight-dom-client.edge', 'react-server-dom-turbopack/src/server/react-flight-dom-server.edge', ], paths: [ @@ -478,6 +508,7 @@ module.exports = [ 'react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-turbopack/client.edge 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer.js', + 'react-server-dom-turbopack/src/client/react-flight-dom-client.edge', 'react-server-dom-turbopack/src/server/react-flight-dom-server.edge', 'react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js', // react-server-dom-turbopack/src/server/react-flight-dom-server.edge 'react-devtools', @@ -493,7 +524,7 @@ module.exports = [ { shortName: 'dom-edge-parcel', entryPoints: [ - 'react-server-dom-parcel/client.edge', + 'react-server-dom-parcel/src/client/react-flight-dom-client.edge', 'react-server-dom-parcel/src/server/react-flight-dom-server.edge', ], paths: [ @@ -517,6 +548,7 @@ module.exports = [ 'react-server-dom-parcel/static.edge', 'react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-parcel/client.edge 'react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js', + 'react-server-dom-parcel/src/client/react-flight-dom-client.edge', 'react-server-dom-parcel/src/server/react-flight-dom-server.edge', 'react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js', // react-server-dom-parcel/src/server/react-flight-dom-server.edge 'react-devtools', From a374e0ec87ec1d45a94b69e26c747529ea5dbab0 Mon Sep 17 00:00:00 2001 From: lauren Date: Fri, 6 Jun 2025 13:32:51 -0400 Subject: [PATCH 09/33] [ci] Fix missing permissions for stale job (#33466) Missed these the last time. --- .github/workflows/shared_stale.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/shared_stale.yml b/.github/workflows/shared_stale.yml index a2c707973c927..c24895edc5da7 100644 --- a/.github/workflows/shared_stale.yml +++ b/.github/workflows/shared_stale.yml @@ -6,7 +6,10 @@ on: - cron: '0 * * * *' workflow_dispatch: -permissions: {} +permissions: + # https://github.com/actions/stale/tree/v9/?tab=readme-ov-file#recommended-permissions + issues: write + pull-requests: write env: TZ: /usr/share/zoneinfo/America/Los_Angeles From 6ccf328499f06c140ffe96a096744c22319394cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 6 Jun 2025 14:01:15 -0400 Subject: [PATCH 10/33] [Fizz] Shorten throttle to hit a specific target metric (#33463) Adding throttling or delaying on images, can obviously impact metrics. However, it's all in the name of better actual user experience overall. (Note that it's not strictly worse even for metric. Often it's actually strictly better due to less work being done overall thanks to batching.) Metrics can impact things like search ranking but I believe this is on a curve. If you're already pretty good, then a slight delay won't suddenly make you rank in a completely different category. Similarly, if you're already pretty bad then a slight delay won't make it suddenly way worse. It's still in the same realm. It's just one weight of many. I don't think this will make a meaningful practical impact and if it does, that's probably a bug in the weights that will get fixed. However, because there's a race to try to "make everything green" in terms of web vitals, if you go from green to yellow only because of some throttling or suspensey images, it can feel bad. Therefore this implements a heuristic where if the only reason we'd miss a specific target is because of throttling or suspensey images, then we shorten the timeout to hit the metric. This is a worse user experience because it can lead to extra flashing but feeling good about "green" matters too. If you then have another reveal that happens to be the largest contentful paint after that, then that's throttled again so that it doesn't become flashy after that. If you've already missed the deadline then you're not going to hit your metric target anyway. It can affect average but not median. This is mainly about LCP. It doesn't affect FCP since that doesn't have a throttle. If your LCP is the same as your FCP then it also doesn't matter. We assume that `performance.now()`'s zero point starts at the "start of the navigation" which makes this simple. Even if we used the `PerformanceNavigationTiming` API it would just tell us the same thing. This only implements for Fizz since these metrics tend to currently only by tracked for initial loads, but with soft navs tracking we could consider implementing the same for Fiber throttles. --- ...tDOMFizzInstructionSetInlineCodeStrings.js | 4 +-- .../ReactDOMFizzInstructionSetShared.js | 34 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 72a5aba4fb332..6cfb4b61eda50 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -6,9 +6,9 @@ export const markShellTime = export const clientRenderBoundary = '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};'; export const completeBoundary = - '$RB=[];$RV=function(c){$RT=performance.now();for(var a=0;aa&&2E3q&&2E3 - setTimeout(resolve, SUSPENSEY_FONT_AND_IMAGE_TIMEOUT), - ), + new Promise(resolve => { + const currentTime = performance.now(); + const msUntilTimeout = + // If the throttle would make us miss the target metric, then shorten the throttle. + // performance.now()'s zero value is assumed to be the start time of the metric. + currentTime < TARGET_VANITY_METRIC && + currentTime > TARGET_VANITY_METRIC - FALLBACK_THROTTLE_MS + ? TARGET_VANITY_METRIC - currentTime + : // Otherwise it's throttled starting from last commit time. + SUSPENSEY_FONT_AND_IMAGE_TIMEOUT; + setTimeout(resolve, msUntilTimeout); + }), ]); }, types: [], // TODO: Add a hard coded type for Suspense reveals. @@ -360,8 +377,6 @@ export function clientRenderBoundary( } } -const FALLBACK_THROTTLE_MS = 300; - export function completeBoundary(suspenseBoundaryID, contentID) { const contentNodeOuter = document.getElementById(contentID); if (!contentNodeOuter) { @@ -395,8 +410,15 @@ export function completeBoundary(suspenseBoundaryID, contentID) { // to flush the batch. This is delayed by the throttle heuristic. const globalMostRecentFallbackTime = typeof window['$RT'] !== 'number' ? 0 : window['$RT']; + const currentTime = performance.now(); const msUntilTimeout = - globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - performance.now(); + // If the throttle would make us miss the target metric, then shorten the throttle. + // performance.now()'s zero value is assumed to be the start time of the metric. + currentTime < TARGET_VANITY_METRIC && + currentTime > TARGET_VANITY_METRIC - FALLBACK_THROTTLE_MS + ? TARGET_VANITY_METRIC - currentTime + : // Otherwise it's throttled starting from last commit time. + globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - currentTime; // We always schedule the flush in a timer even if it's very low or negative to allow // for multiple completeBoundary calls that are already queued to have a chance to // make the batch. From 142aa0744d0e73dc5390bc19d4d41dd8aeda2b19 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 6 Jun 2025 11:59:15 -0700 Subject: [PATCH 11/33] [Fizz] Support deeply nested Suspense inside fallback (#33467) When deeply nested Suspense boundaries inside a fallback of another boundary resolve it is possible to encounter situations where you either attempt to flush an aborted Segment or you have a boundary without any root segment. We intended for both of these conditions to be impossible to arrive at legitimately however it turns out in this situation you can. The fix is two-fold 1. allow flushing aborted segments by simply skipping them. This does remove some protection against future misconfiguraiton of React because it is no longer an invariant that you hsould never attempt to flush an aborted segment but there are legitimate cases where this can come up and simply omitting the segment is fine b/c we know that the user will never observe this. A semantically better solution would be to avoid flushing boudaries inside an unneeded fallback but to do this we would need to track all boundaries inside a fallback or create back pointers which add to memory overhead and possibly make GC harder to do efficiently. By flushing extra we're maintaining status quo and only suffer in performance not with broken semantics. 2. when queuing completed segments allow for queueing aborted segments and if we are eliding the enqueued segment allow for child segments that are errored to be enqueued too. This will mean that we can maintain the invariant that a boundary must have a root segment the first time we flush it, it just might be aborted (see point 1 above). This change has two seemingly similar test cases to exercise this fix. The reason we need both is that when you have empty segments you hit different code paths within Fizz and so each one (without this fix) triggers a different error pathway. This change also includes a fix to our tests where we were not appropriately setting CSPnonce back to null at the start of each test so in some contexts scripts would not run for some tests --- .../src/__tests__/ReactDOMFizzServer-test.js | 107 ++++++++++++++++++ .../src/__tests__/ReactDOMFloat-test.js | 3 +- packages/react-server/src/ReactFizzServer.js | 13 ++- 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 57124ec6e0c0e..2b9c77c08ba94 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -88,6 +88,7 @@ describe('ReactDOMFizzServer', () => { setTimeout(cb); container = document.getElementById('container'); + CSPnonce = null; Scheduler = require('scheduler'); React = require('react'); ReactDOM = require('react-dom'); @@ -10447,4 +10448,110 @@ describe('ReactDOMFizzServer', () => { , ); }); + + it('should not error when discarding deeply nested Suspense boundaries in a parent fallback partially complete before the parent boundary resolves', async () => { + let resolve1; + const promise1 = new Promise(r => (resolve1 = r)); + let resolve2; + const promise2 = new Promise(r => (resolve2 = r)); + const promise3 = new Promise(r => {}); + + function Use({children, promise}) { + React.use(promise); + return children; + } + function App() { + return ( +
+ + +
+ +
+ +
+ +
deep fallback
+
+
+
+
+
+
+
+
+ }> + Success! +
+ + ); + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+
Loading...
+
, + ); + + await act(() => { + resolve1('resolved'); + resolve2('resolved'); + }); + + expect(getVisibleChildren(container)).toEqual(
Success!
); + }); + + it('should not error when discarding deeply nested Suspense boundaries in a parent fallback partially complete before the parent boundary resolves with empty segments', async () => { + let resolve1; + const promise1 = new Promise(r => (resolve1 = r)); + let resolve2; + const promise2 = new Promise(r => (resolve2 = r)); + const promise3 = new Promise(r => {}); + + function Use({children, promise}) { + React.use(promise); + return children; + } + function App() { + return ( +
+ + + + +
deep fallback
+
+
+
+
+ }> + Success! + +
+ ); + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + await act(() => { + resolve1('resolved'); + resolve2('resolved'); + }); + + expect(getVisibleChildren(container)).toEqual(
Success!
); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index b8a4e5b86ae91..a28d492b1cf83 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -25,7 +25,7 @@ let SuspenseList; let textCache; let loadCache; let writable; -const CSPnonce = null; +let CSPnonce = null; let container; let buffer = ''; let hasErrored = false; @@ -69,6 +69,7 @@ describe('ReactDOMFloat', () => { setTimeout(cb); container = document.getElementById('container'); + CSPnonce = null; React = require('react'); ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 19860614ad450..bff81ce607989 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -4918,7 +4918,11 @@ function queueCompletedSegment( const childSegment = segment.children[0]; childSegment.id = segment.id; childSegment.parentFlushed = true; - if (childSegment.status === COMPLETED) { + if ( + childSegment.status === COMPLETED || + childSegment.status === ABORTED || + childSegment.status === ERRORED + ) { queueCompletedSegment(boundary, childSegment); } } else { @@ -4989,7 +4993,7 @@ function finishedTask( // Our parent segment already flushed, so we need to schedule this segment to be emitted. // If it is a segment that was aborted, we'll write other content instead so we don't need // to emit it. - if (segment.status === COMPLETED) { + if (segment.status === COMPLETED || segment.status === ABORTED) { queueCompletedSegment(boundary, segment); } } @@ -5058,7 +5062,7 @@ function finishedTask( // Our parent already flushed, so we need to schedule this segment to be emitted. // If it is a segment that was aborted, we'll write other content instead so we don't need // to emit it. - if (segment.status === COMPLETED) { + if (segment.status === COMPLETED || segment.status === ABORTED) { queueCompletedSegment(boundary, segment); const completedSegments = boundary.completedSegments; if (completedSegments.length === 1) { @@ -5575,6 +5579,9 @@ function flushSubtree( } return r; } + case ABORTED: { + return true; + } default: { throw new Error( 'Aborted, errored or already flushed boundaries should not be flushed again. This is a bug in React.', From 82f3684c63fd60fdacbe4d536214596ffd7a465f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 6 Jun 2025 16:26:36 -0400 Subject: [PATCH 12/33] Revert Node Web Streams (#33472) Reverts #33457, #33456 and #33442. There are too many issues with wrappers, lazy init, stateful modules, duplicate instantiation of async_hooks and duplication of code. Instead, we'll just do a wrapper polyfill that uses Node Streams internally. I kept the client indirection files that I added for consistency with the server though. --- ...ClientConfig.dom-node-webstreams-parcel.js | 18 -- ...entConfig.dom-node-webstreams-turbopack.js | 19 -- ...lientConfig.dom-node-webstreams-webpack.js | 19 -- ...tFlightClientConfig.dom-node-webstreams.js | 18 -- packages/react-dom/npm/server.node.js | 8 +- packages/react-dom/npm/static.node.js | 6 +- packages/react-dom/server.node.js | 14 -- .../ReactDOMFizzServerNodeWebStreams-test.js | 43 ---- .../ReactDOMFizzStaticNodeWebStreams-test.js | 177 ----------------- .../react-dom-server.node-webstreams.js | 11 -- ...react-dom-server.node-webstreams.stable.js | 11 -- packages/react-dom/static.node.js | 14 -- .../npm/client.node.js | 25 +-- .../npm/server.node.js | 12 +- ...react-flight-dom-client.node-webstreams.js | 10 - ...react-flight-dom-server.node-webstreams.js | 22 --- .../npm/client.node.js | 25 +-- .../npm/server.node.js | 7 +- ...react-flight-dom-client.node-webstreams.js | 10 - ...react-flight-dom-server.node-webstreams.js | 21 -- .../npm/client.node.js | 25 +-- .../npm/client.node.unbundled.js | 25 +-- .../npm/server.node.js | 11 +- .../npm/server.node.unbundled.js | 7 +- ...react-flight-dom-client.node-webstreams.js | 10 - ...ht-dom-client.node-webstreams.unbundled.js | 10 - ...react-flight-dom-server.node-webstreams.js | 21 -- ...ht-dom-server.node-webstreams.unbundled.js | 21 -- .../ReactServerStreamConfigNodeWebStreams.js | 186 ------------------ ...tServerStreamConfig.dom-node-webstreams.js | 10 - scripts/jest/setupTests.js | 9 +- scripts/rollup/bundles.js | 91 --------- scripts/shared/inlinedHostConfigs.js | 80 -------- 33 files changed, 19 insertions(+), 977 deletions(-) delete mode 100644 packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-parcel.js delete mode 100644 packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-turbopack.js delete mode 100644 packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-webpack.js delete mode 100644 packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams.js delete mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js delete mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js delete mode 100644 packages/react-dom/src/server/react-dom-server.node-webstreams.js delete mode 100644 packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js delete mode 100644 packages/react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams.js delete mode 100644 packages/react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams.js delete mode 100644 packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams.js delete mode 100644 packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams.js delete mode 100644 packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.js delete mode 100644 packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.unbundled.js delete mode 100644 packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.js delete mode 100644 packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.unbundled.js delete mode 100644 packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js delete mode 100644 packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.js diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-parcel.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-parcel.js deleted file mode 100644 index 626f26903ed73..0000000000000 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-parcel.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export {default as rendererVersion} from 'shared/ReactVersion'; -export const rendererPackageName = 'react-server-dom-parcel'; - -export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactClientConsoleConfigServer'; -export * from 'react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel'; -export * from 'react-server-dom-parcel/src/client/ReactFlightClientConfigTargetParcelServer'; -export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; -export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-turbopack.js deleted file mode 100644 index fbdb9fc683ac6..0000000000000 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-turbopack.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export {default as rendererVersion} from 'shared/ReactVersion'; -export const rendererPackageName = 'react-server-dom-turbopack'; - -export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactClientConsoleConfigServer'; -export * from 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack'; -export * from 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer'; -export * from 'react-server-dom-turbopack/src/client/ReactFlightClientConfigTargetTurbopackServer'; -export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; -export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-webpack.js deleted file mode 100644 index f328a3e2ed7b1..0000000000000 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams-webpack.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export {default as rendererVersion} from 'shared/ReactVersion'; -export const rendererPackageName = 'react-server-dom-webpack'; - -export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactClientConsoleConfigServer'; -export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack'; -export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackServer'; -export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigTargetWebpackServer'; -export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; -export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams.js deleted file mode 100644 index eb9ad28d46fa3..0000000000000 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webstreams.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export {default as rendererVersion} from 'shared/ReactVersion'; -export const rendererPackageName = 'react-server-dom-webpack'; - -export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactClientConsoleConfigServer'; -export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode'; -export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigTargetWebpackServer'; -export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; -export const usedWithSSR = true; diff --git a/packages/react-dom/npm/server.node.js b/packages/react-dom/npm/server.node.js index f5e7b82597c4f..0373a33b3a750 100644 --- a/packages/react-dom/npm/server.node.js +++ b/packages/react-dom/npm/server.node.js @@ -1,14 +1,12 @@ 'use strict'; -var l, s, w; +var l, s; if (process.env.NODE_ENV === 'production') { l = require('./cjs/react-dom-server-legacy.node.production.js'); s = require('./cjs/react-dom-server.node.production.js'); - w = require('./cjs/react-dom-server.node-webstreams.production.js'); } else { l = require('./cjs/react-dom-server-legacy.node.development.js'); s = require('./cjs/react-dom-server.node.development.js'); - w = require('./cjs/react-dom-server.node-webstreams.development.js'); } exports.version = l.version; @@ -18,7 +16,3 @@ exports.renderToPipeableStream = s.renderToPipeableStream; if (s.resumeToPipeableStream) { exports.resumeToPipeableStream = s.resumeToPipeableStream; } -exports.renderToReadableStream = w.renderToReadableStream; -if (w.resume) { - exports.resume = w.resume; -} diff --git a/packages/react-dom/npm/static.node.js b/packages/react-dom/npm/static.node.js index 60936401c9b16..5dc47d472ba4b 100644 --- a/packages/react-dom/npm/static.node.js +++ b/packages/react-dom/npm/static.node.js @@ -1,16 +1,12 @@ 'use strict'; -var s, w; +var s; if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-dom-server.node.production.js'); - w = require('./cjs/react-dom-server.node-webstreams.production.js'); } else { s = require('./cjs/react-dom-server.node.development.js'); - w = require('./cjs/react-dom-server.node-webstreams.development.js'); } exports.version = s.version; exports.prerenderToNodeStream = s.prerenderToNodeStream; exports.resumeAndPrerenderToNodeStream = s.resumeAndPrerenderToNodeStream; -exports.prerender = w.prerender; -exports.resumeAndPrerender = w.resumeAndPrerender; diff --git a/packages/react-dom/server.node.js b/packages/react-dom/server.node.js index 2e25bc044b687..5f9c78f6dbd1d 100644 --- a/packages/react-dom/server.node.js +++ b/packages/react-dom/server.node.js @@ -37,17 +37,3 @@ export function resumeToPipeableStream() { arguments, ); } - -export function renderToReadableStream() { - return require('./src/server/react-dom-server.node-webstreams').renderToReadableStream.apply( - this, - arguments, - ); -} - -export function resume() { - return require('./src/server/react-dom-server.node-webstreams').resume.apply( - this, - arguments, - ); -} diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js deleted file mode 100644 index 403beefeda445..0000000000000 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @emails react-core - * @jest-environment node - */ - -'use strict'; - -let React; -let ReactDOMFizzServer; - -describe('ReactDOMFizzServerNodeWebStreams', () => { - beforeEach(() => { - jest.resetModules(); - jest.useRealTimers(); - React = require('react'); - ReactDOMFizzServer = require('react-dom/server.node'); - }); - - async function readResult(stream) { - const reader = stream.getReader(); - let result = ''; - while (true) { - const {done, value} = await reader.read(); - if (done) { - return result; - } - result += Buffer.from(value).toString('utf8'); - } - } - - it('should call renderToPipeableStream', async () => { - const stream = await ReactDOMFizzServer.renderToReadableStream( -
hello world
, - ); - const result = await readResult(stream); - expect(result).toMatchInlineSnapshot(`"
hello world
"`); - }); -}); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js deleted file mode 100644 index 9b328f6bbf49e..0000000000000 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @emails react-core - * @jest-environment node - */ - -'use strict'; - -import { - getVisibleChildren, - insertNodesAndExecuteScripts, -} from '../test-utils/FizzTestUtils'; - -let JSDOM; -let React; -let ReactDOMFizzServer; -let ReactDOMFizzStatic; -let Suspense; -let container; -let serverAct; - -describe('ReactDOMFizzStaticNodeWebStreams', () => { - beforeEach(() => { - jest.resetModules(); - serverAct = require('internal-test-utils').serverAct; - - JSDOM = require('jsdom').JSDOM; - - React = require('react'); - ReactDOMFizzServer = require('react-dom/server.node'); - ReactDOMFizzStatic = require('react-dom/static.node'); - Suspense = React.Suspense; - - const jsdom = new JSDOM( - // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it. - '', - { - runScripts: 'dangerously', - }, - ); - global.window = jsdom.window; - global.document = jsdom.window.document; - container = document.createElement('div'); - document.body.appendChild(container); - }); - - afterEach(() => { - document.body.removeChild(container); - }); - - async function readContent(stream) { - const reader = stream.getReader(); - let content = ''; - while (true) { - const {done, value} = await reader.read(); - if (done) { - return content; - } - content += Buffer.from(value).toString('utf8'); - } - } - - async function readIntoContainer(stream) { - const reader = stream.getReader(); - let result = ''; - while (true) { - const {done, value} = await reader.read(); - if (done) { - break; - } - result += Buffer.from(value).toString('utf8'); - } - const temp = document.createElement('div'); - temp.innerHTML = result; - await insertNodesAndExecuteScripts(temp, container, null); - jest.runAllTimers(); - } - - it('should call prerender', async () => { - const result = await serverAct(() => - ReactDOMFizzStatic.prerender(
hello world
), - ); - const prelude = await readContent(result.prelude); - expect(prelude).toMatchInlineSnapshot(`"
hello world
"`); - }); - - // @gate enableHalt - it('can resume render of a prerender', async () => { - const errors = []; - - let resolveA; - const promiseA = new Promise(r => (resolveA = r)); - let resolveB; - const promiseB = new Promise(r => (resolveB = r)); - - async function ComponentA() { - await promiseA; - return ( - - - - ); - } - - async function ComponentB() { - await promiseB; - return 'Hello'; - } - - function App() { - return ( -
- - - -
- ); - } - - const controller = new AbortController(); - let pendingResult; - await serverAct(async () => { - pendingResult = ReactDOMFizzStatic.prerender(, { - signal: controller.signal, - onError(x) { - errors.push(x.message); - }, - }); - }); - - controller.abort(); - const prerendered = await pendingResult; - const postponedState = JSON.stringify(prerendered.postponed); - - await readIntoContainer(prerendered.prelude); - expect(getVisibleChildren(container)).toEqual(
Loading A
); - - await resolveA(); - - expect(prerendered.postponed).not.toBe(null); - - const controller2 = new AbortController(); - await serverAct(async () => { - pendingResult = ReactDOMFizzStatic.resumeAndPrerender( - , - JSON.parse(postponedState), - { - signal: controller2.signal, - onError(x) { - errors.push(x.message); - }, - }, - ); - }); - - controller2.abort(); - - const prerendered2 = await pendingResult; - const postponedState2 = JSON.stringify(prerendered2.postponed); - - await readIntoContainer(prerendered2.prelude); - expect(getVisibleChildren(container)).toEqual(
Loading B
); - - await resolveB(); - - const dynamic = await serverAct(() => - ReactDOMFizzServer.resume(, JSON.parse(postponedState2)), - ); - - await readIntoContainer(dynamic); - expect(getVisibleChildren(container)).toEqual(
Hello
); - }); -}); diff --git a/packages/react-dom/src/server/react-dom-server.node-webstreams.js b/packages/react-dom/src/server/react-dom-server.node-webstreams.js deleted file mode 100644 index e70e8fd4cbefe..0000000000000 --- a/packages/react-dom/src/server/react-dom-server.node-webstreams.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export * from './ReactDOMFizzServerEdge.js'; -export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticEdge.js'; diff --git a/packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js b/packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js deleted file mode 100644 index 5f47ecafd371a..0000000000000 --- a/packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export {renderToReadableStream, version} from './ReactDOMFizzServerEdge.js'; -export {prerender} from './ReactDOMFizzStaticEdge.js'; diff --git a/packages/react-dom/static.node.js b/packages/react-dom/static.node.js index b9d037bdaa07f..0a45343f915a3 100644 --- a/packages/react-dom/static.node.js +++ b/packages/react-dom/static.node.js @@ -37,17 +37,3 @@ export function resumeAndPrerenderToNodeStream() { arguments, ); } - -export function prerender() { - return require('./src/server/react-dom-server.node-webstreams').prerender.apply( - this, - arguments, - ); -} - -export function resumeAndPrerender() { - return require('./src/server/react-dom-server.node-webstreams').resumeAndPrerender.apply( - this, - arguments, - ); -} diff --git a/packages/react-server-dom-parcel/npm/client.node.js b/packages/react-server-dom-parcel/npm/client.node.js index aa5995537607d..75583db96f735 100644 --- a/packages/react-server-dom-parcel/npm/client.node.js +++ b/packages/react-server-dom-parcel/npm/client.node.js @@ -1,28 +1,7 @@ 'use strict'; -var n, w; if (process.env.NODE_ENV === 'production') { - n = require('./cjs/react-server-dom-parcel-client.node.production.js'); - w = require('./cjs/react-server-dom-parcel-client.node-webstreams.production.js'); + module.exports = require('./cjs/react-server-dom-parcel-client.node.production.js'); } else { - n = require('./cjs/react-server-dom-parcel-client.node.development.js'); - w = require('./cjs/react-server-dom-parcel-client.node-webstreams.development.js'); + module.exports = require('./cjs/react-server-dom-parcel-client.node.development.js'); } - -exports.registerServerReference = function (r, i, e) { - return w.registerServerReference(n.registerServerReference(r, i, e), i, e); -}; -exports.createServerReference = function (i, c, e, d, f) { - return w.registerServerReference( - n.createServerReference(i, c, e, d, f), - i, - e - ); -}; - -exports.createFromNodeStream = n.createFromNodeStream; -exports.createFromFetch = w.createFromFetch; -exports.createFromReadableStream = w.createFromReadableStream; - -exports.createTemporaryReferenceSet = w.createTemporaryReferenceSet; -exports.encodeReply = w.encodeReply; diff --git a/packages/react-server-dom-parcel/npm/server.node.js b/packages/react-server-dom-parcel/npm/server.node.js index 0d79550b4021d..92b2551dc7080 100644 --- a/packages/react-server-dom-parcel/npm/server.node.js +++ b/packages/react-server-dom-parcel/npm/server.node.js @@ -1,12 +1,10 @@ 'use strict'; -var s, w; +var s; if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-parcel-server.node.production.js'); - w = require('./cjs/react-server-dom-parcel-server.node-webstreams.production.js'); } else { s = require('./cjs/react-server-dom-parcel-server.node.development.js'); - w = require('./cjs/react-server-dom-parcel-server.node-webstreams.development.js'); } exports.renderToPipeableStream = s.renderToPipeableStream; @@ -17,11 +15,5 @@ exports.decodeFormState = s.decodeFormState; exports.createClientReference = s.createClientReference; exports.registerServerReference = s.registerServerReference; exports.createTemporaryReferenceSet = s.createTemporaryReferenceSet; -exports.registerServerActions = function (m) { - w.registerServerActions(m); - s.registerServerActions(m); -}; +exports.registerServerActions = s.registerServerActions; exports.loadServerAction = s.loadServerAction; - -exports.renderToReadableStream = w.renderToReadableStream; -exports.decodeReplyFromAsyncIterable = w.decodeReplyFromAsyncIterable; diff --git a/packages/react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams.js b/packages/react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams.js deleted file mode 100644 index 14a8876953b2c..0000000000000 --- a/packages/react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export * from './ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams.js b/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams.js deleted file mode 100644 index 54f3dbb2ec346..0000000000000 --- a/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export { - renderToReadableStream, - prerender as unstable_prerender, - decodeReply, - decodeReplyFromAsyncIterable, - decodeAction, - decodeFormState, - createClientReference, - registerServerReference, - createTemporaryReferenceSet, - registerServerActions, - loadServerAction, -} from './ReactFlightDOMServerEdge'; diff --git a/packages/react-server-dom-turbopack/npm/client.node.js b/packages/react-server-dom-turbopack/npm/client.node.js index a0f366be00901..d9c2ddf9985d4 100644 --- a/packages/react-server-dom-turbopack/npm/client.node.js +++ b/packages/react-server-dom-turbopack/npm/client.node.js @@ -1,28 +1,7 @@ 'use strict'; -var n, w; if (process.env.NODE_ENV === 'production') { - n = require('./cjs/react-server-dom-turbopack-client.node.production.js'); - w = require('./cjs/react-server-dom-turbopack-client.node-webstreams.production.js'); + module.exports = require('./cjs/react-server-dom-turbopack-client.node.production.js'); } else { - n = require('./cjs/react-server-dom-turbopack-client.node.development.js'); - w = require('./cjs/react-server-dom-turbopack-client.node-webstreams.development.js'); + module.exports = require('./cjs/react-server-dom-turbopack-client.node.development.js'); } - -exports.registerServerReference = function (r, i, e) { - return w.registerServerReference(n.registerServerReference(r, i, e), i, e); -}; -exports.createServerReference = function (i, c, e, d, f) { - return w.registerServerReference( - n.createServerReference(i, c, e, d, f), - i, - e - ); -}; - -exports.createFromNodeStream = n.createFromNodeStream; -exports.createFromFetch = w.createFromFetch; -exports.createFromReadableStream = w.createFromReadableStream; - -exports.createTemporaryReferenceSet = w.createTemporaryReferenceSet; -exports.encodeReply = w.encodeReply; diff --git a/packages/react-server-dom-turbopack/npm/server.node.js b/packages/react-server-dom-turbopack/npm/server.node.js index 2dd087a2882d9..f9a4cf31f6e8c 100644 --- a/packages/react-server-dom-turbopack/npm/server.node.js +++ b/packages/react-server-dom-turbopack/npm/server.node.js @@ -1,12 +1,10 @@ 'use strict'; -var s, w; +var s; if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-turbopack-server.node.production.js'); - w = require('./cjs/react-server-dom-turbopack-server.node-webstreams.production.js'); } else { s = require('./cjs/react-server-dom-turbopack-server.node.development.js'); - w = require('./cjs/react-server-dom-turbopack-server.node-webstreams.development.js'); } exports.renderToPipeableStream = s.renderToPipeableStream; @@ -18,6 +16,3 @@ exports.registerServerReference = s.registerServerReference; exports.registerClientReference = s.registerClientReference; exports.createClientModuleProxy = s.createClientModuleProxy; exports.createTemporaryReferenceSet = s.createTemporaryReferenceSet; - -exports.renderToReadableStream = w.renderToReadableStream; -exports.decodeReplyFromAsyncIterable = w.decodeReplyFromAsyncIterable; diff --git a/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams.js b/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams.js deleted file mode 100644 index 14a8876953b2c..0000000000000 --- a/packages/react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export * from './ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams.js deleted file mode 100644 index 9198f9913ed37..0000000000000 --- a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export { - renderToReadableStream, - prerender as unstable_prerender, - decodeReply, - decodeReplyFromAsyncIterable, - decodeAction, - decodeFormState, - registerServerReference, - registerClientReference, - createClientModuleProxy, - createTemporaryReferenceSet, -} from './ReactFlightDOMServerEdge'; diff --git a/packages/react-server-dom-webpack/npm/client.node.js b/packages/react-server-dom-webpack/npm/client.node.js index 71a093f0cd5af..32b45503d142c 100644 --- a/packages/react-server-dom-webpack/npm/client.node.js +++ b/packages/react-server-dom-webpack/npm/client.node.js @@ -1,28 +1,7 @@ 'use strict'; -var n, w; if (process.env.NODE_ENV === 'production') { - n = require('./cjs/react-server-dom-webpack-client.node.production.js'); - w = require('./cjs/react-server-dom-webpack-client.node-webstreams.production.js'); + module.exports = require('./cjs/react-server-dom-webpack-client.node.production.js'); } else { - n = require('./cjs/react-server-dom-webpack-client.node.development.js'); - w = require('./cjs/react-server-dom-webpack-client.node-webstreams.development.js'); + module.exports = require('./cjs/react-server-dom-webpack-client.node.development.js'); } - -exports.registerServerReference = function (r, i, e) { - return w.registerServerReference(n.registerServerReference(r, i, e), i, e); -}; -exports.createServerReference = function (i, c, e, d, f) { - return w.registerServerReference( - n.createServerReference(i, c, e, d, f), - i, - e - ); -}; - -exports.createFromNodeStream = n.createFromNodeStream; -exports.createFromFetch = w.createFromFetch; -exports.createFromReadableStream = w.createFromReadableStream; - -exports.createTemporaryReferenceSet = w.createTemporaryReferenceSet; -exports.encodeReply = w.encodeReply; diff --git a/packages/react-server-dom-webpack/npm/client.node.unbundled.js b/packages/react-server-dom-webpack/npm/client.node.unbundled.js index 7883ee9125c1d..5ec0f2cb36236 100644 --- a/packages/react-server-dom-webpack/npm/client.node.unbundled.js +++ b/packages/react-server-dom-webpack/npm/client.node.unbundled.js @@ -1,28 +1,7 @@ 'use strict'; -var n, w; if (process.env.NODE_ENV === 'production') { - n = require('./cjs/react-server-dom-webpack-client.node.unbundled.production.js'); - w = require('./cjs/react-server-dom-webpack-client.node-webstreams.unbundled.production.js'); + module.exports = require('./cjs/react-server-dom-webpack-client.node.unbundled.production.js'); } else { - n = require('./cjs/react-server-dom-webpack-client.node.unbundled.development.js'); - w = require('./cjs/react-server-dom-webpack-client.node-webstreams.unbundled.development.js'); + module.exports = require('./cjs/react-server-dom-webpack-client.node.unbundled.development.js'); } - -exports.registerServerReference = function (r, i, e) { - return w.registerServerReference(n.registerServerReference(r, i, e), i, e); -}; -exports.createServerReference = function (i, c, e, d, f) { - return w.registerServerReference( - n.createServerReference(i, c, e, d, f), - i, - e - ); -}; - -exports.createFromNodeStream = n.createFromNodeStream; -exports.createFromFetch = w.createFromFetch; -exports.createFromReadableStream = w.createFromReadableStream; - -exports.createTemporaryReferenceSet = w.createTemporaryReferenceSet; -exports.encodeReply = w.encodeReply; diff --git a/packages/react-server-dom-webpack/npm/server.node.js b/packages/react-server-dom-webpack/npm/server.node.js index 2d215c45d5fe0..6885e43a44fc0 100644 --- a/packages/react-server-dom-webpack/npm/server.node.js +++ b/packages/react-server-dom-webpack/npm/server.node.js @@ -1,12 +1,10 @@ 'use strict'; -var s, w; +var s; if (process.env.NODE_ENV === 'production') { - s = require('./cjs/react-server-dom-webpack-server.node.unbundled.production.js'); - w = require('./cjs/react-server-dom-webpack-server.node-webstreams.unbundled.production.js'); + s = require('./cjs/react-server-dom-webpack-server.node.production.js'); } else { - s = require('./cjs/react-server-dom-webpack-server.node.unbundled.development.js'); - w = require('./cjs/react-server-dom-webpack-server.node-webstreams.unbundled.development.js'); + s = require('./cjs/react-server-dom-webpack-server.node.development.js'); } exports.renderToPipeableStream = s.renderToPipeableStream; @@ -18,6 +16,3 @@ exports.registerServerReference = s.registerServerReference; exports.registerClientReference = s.registerClientReference; exports.createClientModuleProxy = s.createClientModuleProxy; exports.createTemporaryReferenceSet = s.createTemporaryReferenceSet; - -exports.renderToReadableStream = w.renderToReadableStream; -exports.decodeReplyFromAsyncIterable = w.decodeReplyFromAsyncIterable; diff --git a/packages/react-server-dom-webpack/npm/server.node.unbundled.js b/packages/react-server-dom-webpack/npm/server.node.unbundled.js index 2d215c45d5fe0..333b6b0d3122e 100644 --- a/packages/react-server-dom-webpack/npm/server.node.unbundled.js +++ b/packages/react-server-dom-webpack/npm/server.node.unbundled.js @@ -1,12 +1,10 @@ 'use strict'; -var s, w; +var s; if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-webpack-server.node.unbundled.production.js'); - w = require('./cjs/react-server-dom-webpack-server.node-webstreams.unbundled.production.js'); } else { s = require('./cjs/react-server-dom-webpack-server.node.unbundled.development.js'); - w = require('./cjs/react-server-dom-webpack-server.node-webstreams.unbundled.development.js'); } exports.renderToPipeableStream = s.renderToPipeableStream; @@ -18,6 +16,3 @@ exports.registerServerReference = s.registerServerReference; exports.registerClientReference = s.registerClientReference; exports.createClientModuleProxy = s.createClientModuleProxy; exports.createTemporaryReferenceSet = s.createTemporaryReferenceSet; - -exports.renderToReadableStream = w.renderToReadableStream; -exports.decodeReplyFromAsyncIterable = w.decodeReplyFromAsyncIterable; diff --git a/packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.js b/packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.js deleted file mode 100644 index 14a8876953b2c..0000000000000 --- a/packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export * from './ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.unbundled.js b/packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.unbundled.js deleted file mode 100644 index 14a8876953b2c..0000000000000 --- a/packages/react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.unbundled.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export * from './ReactFlightDOMClientEdge'; diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.js deleted file mode 100644 index 9198f9913ed37..0000000000000 --- a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export { - renderToReadableStream, - prerender as unstable_prerender, - decodeReply, - decodeReplyFromAsyncIterable, - decodeAction, - decodeFormState, - registerServerReference, - registerClientReference, - createClientModuleProxy, - createTemporaryReferenceSet, -} from './ReactFlightDOMServerEdge'; diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.unbundled.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.unbundled.js deleted file mode 100644 index 9198f9913ed37..0000000000000 --- a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.unbundled.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export { - renderToReadableStream, - prerender as unstable_prerender, - decodeReply, - decodeReplyFromAsyncIterable, - decodeAction, - decodeFormState, - registerServerReference, - registerClientReference, - createClientModuleProxy, - createTemporaryReferenceSet, -} from './ReactFlightDOMServerEdge'; diff --git a/packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js b/packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js deleted file mode 100644 index 16df0dfc37163..0000000000000 --- a/packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import {TextEncoder} from 'util'; -import {createHash} from 'crypto'; - -export type Destination = ReadableStreamController; - -export type PrecomputedChunk = Uint8Array; -export opaque type Chunk = Uint8Array; -export type BinaryChunk = Uint8Array; - -export function scheduleWork(callback: () => void) { - setImmediate(callback); -} - -export const scheduleMicrotask = queueMicrotask; - -export function flushBuffered(destination: Destination) { - // WHATWG Streams do not yet have a way to flush the underlying - // transform streams. https://github.com/whatwg/streams/issues/960 -} - -const VIEW_SIZE = 2048; -let currentView = null; -let writtenBytes = 0; - -export function beginWriting(destination: Destination) { - currentView = new Uint8Array(VIEW_SIZE); - writtenBytes = 0; -} - -export function writeChunk( - destination: Destination, - chunk: PrecomputedChunk | Chunk | BinaryChunk, -): void { - if (chunk.byteLength === 0) { - return; - } - - if (chunk.byteLength > VIEW_SIZE) { - // this chunk may overflow a single view which implies it was not - // one that is cached by the streaming renderer. We will enqueu - // it directly and expect it is not re-used - if (writtenBytes > 0) { - destination.enqueue( - new Uint8Array( - ((currentView: any): Uint8Array).buffer, - 0, - writtenBytes, - ), - ); - currentView = new Uint8Array(VIEW_SIZE); - writtenBytes = 0; - } - destination.enqueue(chunk); - return; - } - - let bytesToWrite = chunk; - const allowableBytes = ((currentView: any): Uint8Array).length - writtenBytes; - if (allowableBytes < bytesToWrite.byteLength) { - // this chunk would overflow the current view. We enqueue a full view - // and start a new view with the remaining chunk - if (allowableBytes === 0) { - // the current view is already full, send it - destination.enqueue(currentView); - } else { - // fill up the current view and apply the remaining chunk bytes - // to a new view. - ((currentView: any): Uint8Array).set( - bytesToWrite.subarray(0, allowableBytes), - writtenBytes, - ); - // writtenBytes += allowableBytes; // this can be skipped because we are going to immediately reset the view - destination.enqueue(currentView); - bytesToWrite = bytesToWrite.subarray(allowableBytes); - } - currentView = new Uint8Array(VIEW_SIZE); - writtenBytes = 0; - } - ((currentView: any): Uint8Array).set(bytesToWrite, writtenBytes); - writtenBytes += bytesToWrite.byteLength; -} - -export function writeChunkAndReturn( - destination: Destination, - chunk: PrecomputedChunk | Chunk | BinaryChunk, -): boolean { - writeChunk(destination, chunk); - // in web streams there is no backpressure so we can alwas write more - return true; -} - -export function completeWriting(destination: Destination) { - if (currentView && writtenBytes > 0) { - destination.enqueue(new Uint8Array(currentView.buffer, 0, writtenBytes)); - currentView = null; - writtenBytes = 0; - } -} - -export function close(destination: Destination) { - destination.close(); -} - -const textEncoder = new TextEncoder(); - -export function stringToChunk(content: string): Chunk { - return textEncoder.encode(content); -} - -export function stringToPrecomputedChunk(content: string): PrecomputedChunk { - const precomputedChunk = textEncoder.encode(content); - - if (__DEV__) { - if (precomputedChunk.byteLength > VIEW_SIZE) { - console.error( - 'precomputed chunks must be smaller than the view size configured for this host. This is a bug in React.', - ); - } - } - - return precomputedChunk; -} - -export function typedArrayToBinaryChunk( - content: $ArrayBufferView, -): BinaryChunk { - // Convert any non-Uint8Array array to Uint8Array. We could avoid this for Uint8Arrays. - // If we passed through this straight to enqueue we wouldn't have to convert it but since - // we need to copy the buffer in that case, we need to convert it to copy it. - // When we copy it into another array using set() it needs to be a Uint8Array. - const buffer = new Uint8Array( - content.buffer, - content.byteOffset, - content.byteLength, - ); - // We clone large chunks so that we can transfer them when we write them. - // Others get copied into the target buffer. - return content.byteLength > VIEW_SIZE ? buffer.slice() : buffer; -} - -export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { - return chunk.byteLength; -} - -export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { - return chunk.byteLength; -} - -export function closeWithError(destination: Destination, error: mixed): void { - // $FlowFixMe[method-unbinding] - if (typeof destination.error === 'function') { - // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. - destination.error(error); - } else { - // Earlier implementations doesn't support this method. In that environment you're - // supposed to throw from a promise returned but we don't return a promise in our - // approach. We could fork this implementation but this is environment is an edge - // case to begin with. It's even less common to run this in an older environment. - // Even then, this is not where errors are supposed to happen and they get reported - // to a global callback in addition to this anyway. So it's fine just to close this. - destination.close(); - } -} - -export function createFastHash(input: string): string | number { - const hash = createHash('md5'); - hash.update(input); - return hash.digest('hex'); -} - -export function readAsDataURL(blob: Blob): Promise { - return blob.arrayBuffer().then(arrayBuffer => { - const encoded = Buffer.from(arrayBuffer).toString('base64'); - const mimeType = blob.type || 'application/octet-stream'; - return 'data:' + mimeType + ';base64,' + encoded; - }); -} diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.js deleted file mode 100644 index 7862f283eee9e..0000000000000 --- a/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -export * from '../ReactServerStreamConfigNodeWebStreams'; diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index 175e0cd7cab1c..1e8d8e5276e5c 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -296,19 +296,14 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { // We mock createHook so that we can automatically clean it up. let installedHook = null; -let outgoingHook = null; jest.mock('async_hooks', () => { const actual = jest.requireActual('async_hooks'); return { ...actual, createHook(config) { - // We unmount when there's more than two hooks installed. - // We use two because the build of server.node actually installs two hooks. - // One in each build. - if (outgoingHook) { - outgoingHook.disable(); + if (installedHook) { + installedHook.disable(); } - outgoingHook = installedHook; return (installedHook = actual.createHook(config)); }, }; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 57decb70a28ce..63e702f77ff54 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -365,16 +365,6 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], }, - { - bundleTypes: [NODE_DEV, NODE_PROD], - moduleType: RENDERER, - entry: 'react-dom/src/server/react-dom-server.node-webstreams.js', - name: 'react-dom-server.node-webstreams', - global: 'ReactDOMServer', - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: false, - externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], - }, { bundleTypes: __EXPERIMENTAL__ ? [FB_WWW_DEV, FB_WWW_PROD] : [], moduleType: RENDERER, @@ -472,18 +462,6 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], }, - { - bundleTypes: [NODE_DEV, NODE_PROD], - moduleType: RENDERER, - entry: - 'react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams', - name: 'react-server-dom-webpack-server.node-webstreams', - condition: 'react-server', - global: 'ReactServerDOMServer', - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: false, - externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], - }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -496,18 +474,6 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], }, - { - bundleTypes: [NODE_DEV, NODE_PROD], - moduleType: RENDERER, - entry: - 'react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.unbundled', - name: 'react-server-dom-webpack-server.node-webstreams.unbundled', - condition: 'react-server', - global: 'ReactServerDOMServer', - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: false, - externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], - }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -542,17 +508,6 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'react-dom', 'util', 'crypto'], }, - { - bundleTypes: [NODE_DEV, NODE_PROD], - moduleType: RENDERER, - entry: - 'react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams', - name: 'react-server-dom-webpack-client.node-webstreams', - global: 'ReactServerDOMClient', - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: false, - externals: ['react', 'react-dom', 'util', 'crypto'], - }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -564,17 +519,6 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'react-dom', 'util', 'crypto'], }, - { - bundleTypes: [NODE_DEV, NODE_PROD], - moduleType: RENDERER, - entry: - 'react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.unbundled', - name: 'react-server-dom-webpack-client.node-webstreams.unbundled', - global: 'ReactServerDOMClient', - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: false, - externals: ['react', 'react-dom', 'util', 'crypto'], - }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -646,18 +590,6 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'async_hooks', 'react-dom'], }, - { - bundleTypes: [NODE_DEV, NODE_PROD], - moduleType: RENDERER, - entry: - 'react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams', - name: 'react-server-dom-turbopack-server.node-webstreams', - condition: 'react-server', - global: 'ReactServerDOMServer', - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: false, - externals: ['react', 'util', 'async_hooks', 'react-dom'], - }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -692,17 +624,6 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'react-dom', 'util'], }, - { - bundleTypes: [NODE_DEV, NODE_PROD], - moduleType: RENDERER, - entry: - 'react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams', - name: 'react-server-dom-turbopack-client.node-webstreams', - global: 'ReactServerDOMClient', - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: false, - externals: ['react', 'react-dom', 'util'], - }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -737,18 +658,6 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'async_hooks', 'react-dom'], }, - { - bundleTypes: [NODE_DEV, NODE_PROD], - moduleType: RENDERER, - entry: - 'react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams', - name: 'react-server-dom-parcel-server.node-webstreams', - condition: 'react-server', - global: 'ReactServerDOMServer', - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: false, - externals: ['react', 'util', 'async_hooks', 'react-dom'], - }, { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 4e024c827a9d4..78a6b0b99fd4d 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -104,47 +104,6 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, - { - shortName: 'dom-node-webstreams', - entryPoints: [ - 'react-dom/src/server/react-dom-server.node-webstreams.js', - 'react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.unbundled', - 'react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.unbundled', - ], - paths: [ - 'react-dom', - 'react-dom/src/ReactDOMReactServer.js', - 'react-dom-bindings', - 'react-dom/client', - 'react-dom/profiling', - 'react-dom/server', - 'react-dom/server.node', - 'react-dom/static', - 'react-dom/static.node', - 'react-dom/test-utils', - 'react-dom/src/server/react-dom-server.node-webstreams', - 'react-dom/src/server/ReactDOMFizzServerEdge.js', - 'react-dom/src/server/ReactDOMFizzStaticEdge.js', - 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', - 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', - 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', - 'react-server-dom-webpack', - 'react-server-dom-webpack/client.node.unbundled', - 'react-server-dom-webpack/server', - 'react-server-dom-webpack/server.node.unbundled', - 'react-server-dom-webpack/static', - 'react-server-dom-webpack/static.node.unbundled', - 'react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-webpack/client.node - 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js', - 'react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams.unbundled', - 'react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams.unbundled', - 'react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js', // react-server-dom-webpack/src/server/react-flight-dom-server.node - 'shared/ReactDOMSharedInternals', - 'react-server/src/ReactFlightServerConfigDebugNode.js', - ], - isFlowTyped: true, - isServerSupported: true, - }, { shortName: 'dom-node-webpack', entryPoints: [ @@ -190,19 +149,6 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, - { - shortName: 'dom-node-webstreams-webpack', - entryPoints: [ - 'react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams', - 'react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams', - ], - paths: [ - 'react-server-dom-webpack/src/client/react-flight-dom-client.node-webstreams', - 'react-server-dom-webpack/src/server/react-flight-dom-server.node-webstreams', - ], - isFlowTyped: false, - isServerSupported: true, - }, { shortName: 'dom-node-turbopack', entryPoints: [ @@ -248,19 +194,6 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, - { - shortName: 'dom-node-webstreams-turbopack', - entryPoints: [ - 'react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams', - 'react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams', - ], - paths: [ - 'react-server-dom-turbopack/src/client/react-flight-dom-client.node-webstreams', - 'react-server-dom-turbopack/src/server/react-flight-dom-server.node-webstreams', - ], - isFlowTyped: false, - isServerSupported: true, - }, { shortName: 'dom-node-parcel', entryPoints: [ @@ -303,19 +236,6 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, - { - shortName: 'dom-node-webstreams-parcel', - entryPoints: [ - 'react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams', - 'react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams', - ], - paths: [ - 'react-server-dom-parcel/src/client/react-flight-dom-client.node-webstreams', - 'react-server-dom-parcel/src/server/react-flight-dom-server.node-webstreams', - ], - isFlowTyped: false, - isServerSupported: true, - }, { shortName: 'dom-bun', entryPoints: ['react-dom/src/server/react-dom-server.bun.js'], From 280ff6fed2a84b6ad7588c72d3e66b20f0f3c91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 6 Jun 2025 17:14:15 -0400 Subject: [PATCH 13/33] [Flight] Add Web Stream support to the Flight Client in Node (#33473) This effectively lets us consume Web Streams in a Node build. In fact the Node entry point is now just adding Node stream APIs. For the client, this is simple because the configs are not actually stream type specific. The server is a little trickier. --- .../src/client/ReactFlightDOMClientNode.js | 17 +---------------- .../src/client/ReactFlightDOMClientNode.js | 11 +---------- .../src/client/ReactFlightDOMClientNode.js | 11 +---------- scripts/shared/inlinedHostConfigs.js | 6 +++++- 4 files changed, 8 insertions(+), 37 deletions(-) diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js index b12a3a3ff49d8..b4b69443ccab8 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js @@ -19,9 +19,7 @@ import { close, } from 'react-client/src/ReactFlightClient'; -import {createServerReference as createServerReferenceImpl} from 'react-client/src/ReactFlightReplyClient'; - -export {registerServerReference} from 'react-client/src/ReactFlightReplyClient'; +export * from './ReactFlightDOMClientEdge'; function findSourceMapURL(filename: string, environmentName: string) { const devServer = parcelRequire.meta.devServer; @@ -42,19 +40,6 @@ function noServerCall() { ); } -export function createServerReference, T>( - id: string, - exportName: string, -): (...A) => Promise { - return createServerReferenceImpl( - id + '#' + exportName, - noServerCall, - undefined, - findSourceMapURL, - exportName, - ); -} - type EncodeFormActionCallback = ( id: any, args: Promise, diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js index 919be523f8823..fbdf5b49e7c98 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js @@ -36,9 +36,7 @@ import { close, } from 'react-client/src/ReactFlightClient'; -import {createServerReference as createServerReferenceImpl} from 'react-client/src/ReactFlightReplyClient'; - -export {registerServerReference} from 'react-client/src/ReactFlightReplyClient'; +export * from './ReactFlightDOMClientEdge'; function noServerCall() { throw new Error( @@ -48,13 +46,6 @@ function noServerCall() { ); } -export function createServerReference, T>( - id: any, - callServer: any, -): (...A) => Promise { - return createServerReferenceImpl(id, noServerCall); -} - type EncodeFormActionCallback = ( id: any, args: Promise, diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js index 4118ad046d99d..b5d59ceace2df 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js @@ -37,9 +37,7 @@ import { close, } from 'react-client/src/ReactFlightClient'; -import {createServerReference as createServerReferenceImpl} from 'react-client/src/ReactFlightReplyClient'; - -export {registerServerReference} from 'react-client/src/ReactFlightReplyClient'; +export * from './ReactFlightDOMClientEdge'; function noServerCall() { throw new Error( @@ -49,13 +47,6 @@ function noServerCall() { ); } -export function createServerReference, T>( - id: any, - callServer: any, -): (...A) => Promise { - return createServerReferenceImpl(id, noServerCall); -} - type EncodeFormActionCallback = ( id: any, args: Promise, diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 78a6b0b99fd4d..801060c4c5455 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -89,6 +89,7 @@ module.exports = [ 'react-server-dom-webpack/server.node.unbundled', 'react-server-dom-webpack/static', 'react-server-dom-webpack/static.node.unbundled', + 'react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-webpack/client.node 'react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js', // react-server-dom-webpack/client.node 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js', 'react-server-dom-webpack/src/client/react-flight-dom-client.node.unbundled', @@ -131,7 +132,8 @@ module.exports = [ 'react-server-dom-webpack/server.node', 'react-server-dom-webpack/static', 'react-server-dom-webpack/static.node', - 'react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js', // react-server-dom-turbopack/client.node + 'react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-webpack/client.node + 'react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js', // react-server-dom-webpack/client.node 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js', 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackServer.js', 'react-server-dom-webpack/src/client/react-flight-dom-client.node', @@ -176,6 +178,7 @@ module.exports = [ 'react-server-dom-turbopack/server.node', 'react-server-dom-turbopack/static', 'react-server-dom-turbopack/static.node', + 'react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-turbopack/client.node 'react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js', // react-server-dom-turbopack/client.node 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopackServer.js', @@ -221,6 +224,7 @@ module.exports = [ 'react-server-dom-parcel/server.node', 'react-server-dom-parcel/static', 'react-server-dom-parcel/static.node', + 'react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-parcel/client.node 'react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js', // react-server-dom-parcel/client.node 'react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js', 'react-server-dom-parcel/src/client/react-flight-dom-client.node', From b3d5e9078685c000e7e9ee3668a7a4b4f3256b1f Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Sat, 7 Jun 2025 02:11:33 +0200 Subject: [PATCH 14/33] [Fizz] Include unit of threshold in rel=expect deopt error (#33476) --- packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js | 2 +- packages/react-server/src/ReactFizzServer.js | 2 +- scripts/error-codes/codes.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js index 91f0774aa661e..06b3c61ce3ce5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js @@ -138,7 +138,7 @@ describe('ReactDOMFizzServerEdge', () => { if (gate(flags => flags.enableFizzBlockingRender)) { expect(errors.length).toBe(1); expect(errors[0].message).toContain( - 'This rendered a large document (>512) without any Suspense boundaries around most of it.', + 'This rendered a large document (>512 kB) without any Suspense boundaries around most of it.', ); } else { expect(errors.length).toBe(0); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index bff81ce607989..2995b498f4971 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -5946,7 +5946,7 @@ function flushCompletedQueues( const error = new Error( 'This rendered a large document (>' + maxSizeKb + - ') without any Suspense ' + + ' kB) without any Suspense ' + 'boundaries around most of it. That can delay initial paint longer than ' + 'necessary. To improve load performance, add a or ' + 'around the content you expect to be below the header or below the fold. ' + diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 7e709903a841d..a3147f4dde3b3 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -546,5 +546,5 @@ "558": "Client rendering an Activity suspended it again. This is a bug in React.", "559": "Expected to find a host node. This is a bug in React.", "560": "Cannot use a startGestureTransition() with a comment node root.", - "561": "This rendered a large document (>%s) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a or around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML." + "561": "This rendered a large document (>%s kB) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a or around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML." } From 65ec57df3781d2c62456bb136c7f160f7e834492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 6 Jun 2025 20:16:43 -0400 Subject: [PATCH 15/33] [Fizz] Add Web Streams to Fizz Node entry point (#33475) New take on #33441. This uses a wrapper instead of a separate bundle. --- packages/react-dom/npm/server.node.js | 4 + packages/react-dom/npm/static.node.js | 2 + packages/react-dom/server.node.js | 14 ++ .../__tests__/ReactDOMFizzServerNode-test.js | 20 ++ .../__tests__/ReactDOMFizzStaticNode-test.js | 19 ++ .../src/server/ReactDOMFizzServerNode.js | 218 ++++++++++++++++++ .../src/server/ReactDOMFizzStaticEdge.js | 5 +- .../src/server/ReactDOMFizzStaticNode.js | 204 +++++++++++++++- .../src/server/react-dom-server.node.js | 2 + .../server/react-dom-server.node.stable.js | 8 +- packages/react-dom/static.node.js | 14 ++ .../src/ReactServerStreamConfigNode.js | 2 +- 12 files changed, 503 insertions(+), 9 deletions(-) diff --git a/packages/react-dom/npm/server.node.js b/packages/react-dom/npm/server.node.js index 0373a33b3a750..34276711b1025 100644 --- a/packages/react-dom/npm/server.node.js +++ b/packages/react-dom/npm/server.node.js @@ -13,6 +13,10 @@ exports.version = l.version; exports.renderToString = l.renderToString; exports.renderToStaticMarkup = l.renderToStaticMarkup; exports.renderToPipeableStream = s.renderToPipeableStream; +exports.renderToReadableStream = s.renderToReadableStream; if (s.resumeToPipeableStream) { exports.resumeToPipeableStream = s.resumeToPipeableStream; } +if (s.resume) { + exports.resume = s.resume; +} diff --git a/packages/react-dom/npm/static.node.js b/packages/react-dom/npm/static.node.js index 5dc47d472ba4b..24fc9cbc20f86 100644 --- a/packages/react-dom/npm/static.node.js +++ b/packages/react-dom/npm/static.node.js @@ -9,4 +9,6 @@ if (process.env.NODE_ENV === 'production') { exports.version = s.version; exports.prerenderToNodeStream = s.prerenderToNodeStream; +exports.prerender = s.prerender; exports.resumeAndPrerenderToNodeStream = s.resumeAndPrerenderToNodeStream; +exports.resumeAndPrerender = s.resumeAndPrerender; diff --git a/packages/react-dom/server.node.js b/packages/react-dom/server.node.js index 5f9c78f6dbd1d..bed7b9895d05b 100644 --- a/packages/react-dom/server.node.js +++ b/packages/react-dom/server.node.js @@ -37,3 +37,17 @@ export function resumeToPipeableStream() { arguments, ); } + +export function renderToReadableStream() { + return require('./src/server/react-dom-server.node').renderToReadableStream.apply( + this, + arguments, + ); +} + +export function resume() { + return require('./src/server/react-dom-server.node').resume.apply( + this, + arguments, + ); +} diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js index 7db7a669c3efd..98030d43386c9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js @@ -56,6 +56,18 @@ describe('ReactDOMFizzServerNode', () => { throw theInfinitePromise; } + async function readContentWeb(stream) { + const reader = stream.getReader(); + let content = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + return content; + } + content += Buffer.from(value).toString('utf8'); + } + } + it('should call renderToPipeableStream', async () => { const {writable, output} = getTestWritable(); await act(() => { @@ -67,6 +79,14 @@ describe('ReactDOMFizzServerNode', () => { expect(output.result).toMatchInlineSnapshot(`"
hello world
"`); }); + it('should support web streams', async () => { + const stream = await act(() => + ReactDOMFizzServer.renderToReadableStream(
hello world
), + ); + const result = await readContentWeb(stream); + expect(result).toMatchInlineSnapshot(`"
hello world
"`); + }); + it('flush fully if piping in on onShellReady', async () => { const {writable, output} = getTestWritable(); await act(() => { diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js index 99a417657b8b2..e4b21fcf8d5a6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js @@ -46,6 +46,18 @@ describe('ReactDOMFizzStaticNode', () => { }); } + async function readContentWeb(stream) { + const reader = stream.getReader(); + let content = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + return content; + } + content += Buffer.from(value).toString('utf8'); + } + } + // @gate experimental it('should call prerenderToNodeStream', async () => { const result = await ReactDOMFizzStatic.prerenderToNodeStream( @@ -55,6 +67,13 @@ describe('ReactDOMFizzStaticNode', () => { expect(prelude).toMatchInlineSnapshot(`"
hello world
"`); }); + // @gate experimental + it('should suppport web streams', async () => { + const result = await ReactDOMFizzStatic.prerender(
hello world
); + const prelude = await readContentWeb(result.prelude); + expect(prelude).toMatchInlineSnapshot(`"
hello world
"`); + }); + // @gate experimental it('should emit DOCTYPE at the root of the document', async () => { const result = await ReactDOMFizzStatic.prerenderToNodeStream( diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index 317fc9e0867eb..0bf6ba5452f64 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -41,6 +41,8 @@ import { createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; + import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; ensureCorrectIsomorphicReactVersion(); @@ -167,6 +169,141 @@ function renderToPipeableStream( }; } +function createFakeWritableFromReadableStreamController( + controller: ReadableStreamController, +): Writable { + // The current host config expects a Writable so we create + // a fake writable for now to push into the Readable. + return ({ + write(chunk: string | Uint8Array) { + if (typeof chunk === 'string') { + chunk = textEncoder.encode(chunk); + } + controller.enqueue(chunk); + // in web streams there is no backpressure so we can alwas write more + return true; + }, + end() { + controller.close(); + }, + destroy(error) { + // $FlowFixMe[method-unbinding] + if (typeof controller.error === 'function') { + // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. + controller.error(error); + } else { + controller.close(); + } + }, + }: any); +} + +// TODO: Move to sub-classing ReadableStream. +type ReactDOMServerReadableStream = ReadableStream & { + allReady: Promise, +}; + +type WebStreamsOptions = Omit< + Options, + 'onShellReady' | 'onShellError' | 'onAllReady' | 'onHeaders', +> & {signal: AbortSignal, onHeaders?: (headers: Headers) => void}; + +function renderToReadableStream( + children: ReactNodeList, + options?: WebStreamsOptions, +): Promise { + return new Promise((resolve, reject) => { + let onFatalError; + let onAllReady; + const allReady = new Promise((res, rej) => { + onAllReady = res; + onFatalError = rej; + }); + + function onShellReady() { + let writable: Writable; + const stream: ReactDOMServerReadableStream = (new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = + createFakeWritableFromReadableStreamController(controller); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ): any); + // TODO: Move to sub-classing ReadableStream. + stream.allReady = allReady; + resolve(stream); + } + function onShellError(error: mixed) { + // If the shell errors the caller of `renderToReadableStream` won't have access to `allReady`. + // However, `allReady` will be rejected by `onFatalError` as well. + // So we need to catch the duplicate, uncatchable fatal error in `allReady` to prevent a `UnhandledPromiseRejection`. + allReady.catch(() => {}); + reject(error); + } + + const onHeaders = options ? options.onHeaders : undefined; + let onHeadersImpl; + if (onHeaders) { + onHeadersImpl = (headersDescriptor: HeadersDescriptor) => { + onHeaders(new Headers(headersDescriptor)); + }; + } + + const resumableState = createResumableState( + options ? options.identifierPrefix : undefined, + options ? options.unstable_externalRuntimeSrc : undefined, + options ? options.bootstrapScriptContent : undefined, + options ? options.bootstrapScripts : undefined, + options ? options.bootstrapModules : undefined, + ); + const request = createRequest( + children, + resumableState, + createRenderState( + resumableState, + options ? options.nonce : undefined, + options ? options.unstable_externalRuntimeSrc : undefined, + options ? options.importMap : undefined, + onHeadersImpl, + options ? options.maxHeadersLength : undefined, + ), + createRootFormatContext(options ? options.namespaceURI : undefined), + options ? options.progressiveChunkSize : undefined, + options ? options.onError : undefined, + onAllReady, + onShellReady, + onShellError, + onFatalError, + options ? options.onPostpone : undefined, + options ? options.formState : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + function resumeRequestImpl( children: ReactNodeList, postponedState: PostponedState, @@ -225,8 +362,89 @@ function resumeToPipeableStream( }; } +type WebStreamsResumeOptions = Omit< + Options, + 'onShellReady' | 'onShellError' | 'onAllReady', +> & {signal: AbortSignal}; + +function resume( + children: ReactNodeList, + postponedState: PostponedState, + options?: WebStreamsResumeOptions, +): Promise { + return new Promise((resolve, reject) => { + let onFatalError; + let onAllReady; + const allReady = new Promise((res, rej) => { + onAllReady = res; + onFatalError = rej; + }); + + function onShellReady() { + let writable: Writable; + const stream: ReactDOMServerReadableStream = (new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = + createFakeWritableFromReadableStreamController(controller); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ): any); + // TODO: Move to sub-classing ReadableStream. + stream.allReady = allReady; + resolve(stream); + } + function onShellError(error: mixed) { + // If the shell errors the caller of `renderToReadableStream` won't have access to `allReady`. + // However, `allReady` will be rejected by `onFatalError` as well. + // So we need to catch the duplicate, uncatchable fatal error in `allReady` to prevent a `UnhandledPromiseRejection`. + allReady.catch(() => {}); + reject(error); + } + const request = resumeRequest( + children, + postponedState, + resumeRenderState( + postponedState.resumableState, + options ? options.nonce : undefined, + ), + options ? options.onError : undefined, + onAllReady, + onShellReady, + onShellError, + onFatalError, + options ? options.onPostpone : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + export { renderToPipeableStream, + renderToReadableStream, resumeToPipeableStream, + resume, ReactVersion as version, }; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js index 7373ec49f69ea..4c8afd916f203 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -159,9 +159,8 @@ function prerender( type ResumeOptions = { nonce?: NonceOption, signal?: AbortSignal, - onError?: (error: mixed) => ?string, - onPostpone?: (reason: string) => void, - unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, + onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void, }; function resumeAndPrerender( diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index 5dbf128e0f527..94ccd0ebed8a8 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -28,6 +28,7 @@ import { resumeAndPrerenderRequest, startWork, startFlowing, + stopFlowing, abort, getPostponedState, } from 'react-server/src/ReactFizzServer'; @@ -41,6 +42,8 @@ import { import {enablePostpone, enableHalt} from 'shared/ReactFeatureFlags'; +import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; + import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; ensureCorrectIsomorphicReactVersion(); @@ -72,7 +75,36 @@ type StaticResult = { prelude: Readable, }; -function createFakeWritable(readable: any): Writable { +function createFakeWritableFromReadableStreamController( + controller: ReadableStreamController, +): Writable { + // The current host config expects a Writable so we create + // a fake writable for now to push into the Readable. + return ({ + write(chunk: string | Uint8Array) { + if (typeof chunk === 'string') { + chunk = textEncoder.encode(chunk); + } + controller.enqueue(chunk); + // in web streams there is no backpressure so we can alwas write more + return true; + }, + end() { + controller.close(); + }, + destroy(error) { + // $FlowFixMe[method-unbinding] + if (typeof controller.error === 'function') { + // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. + controller.error(error); + } else { + controller.close(); + } + }, + }: any); +} + +function createFakeWritableFromReadable(readable: any): Writable { // The current host config expects a Writable so we create // a fake writable for now to push into the Readable. return ({ @@ -101,7 +133,7 @@ function prerenderToNodeStream( startFlowing(request, writable); }, }); - const writable = createFakeWritable(readable); + const writable = createFakeWritableFromReadable(readable); const result: StaticResult = enablePostpone || enableHalt @@ -157,6 +189,101 @@ function prerenderToNodeStream( }); } +function prerender( + children: ReactNodeList, + options?: Omit & { + onHeaders?: (headers: Headers) => void, + }, +): Promise<{ + postponed: null | PostponedState, + prelude: ReadableStream, +}> { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + let writable: Writable; + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = + createFakeWritableFromReadableStreamController(controller); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + + const result = + enablePostpone || enableHalt + ? { + postponed: getPostponedState(request), + prelude: stream, + } + : ({ + prelude: stream, + }: any); + resolve(result); + } + + const onHeaders = options ? options.onHeaders : undefined; + let onHeadersImpl; + if (onHeaders) { + onHeadersImpl = (headersDescriptor: HeadersDescriptor) => { + onHeaders(new Headers(headersDescriptor)); + }; + } + const resources = createResumableState( + options ? options.identifierPrefix : undefined, + options ? options.unstable_externalRuntimeSrc : undefined, + options ? options.bootstrapScriptContent : undefined, + options ? options.bootstrapScripts : undefined, + options ? options.bootstrapModules : undefined, + ); + const request = createPrerenderRequest( + children, + resources, + createRenderState( + resources, + undefined, // nonce is not compatible with prerendered bootstrap scripts + options ? options.unstable_externalRuntimeSrc : undefined, + options ? options.importMap : undefined, + onHeadersImpl, + options ? options.maxHeadersLength : undefined, + ), + createRootFormatContext(options ? options.namespaceURI : undefined), + options ? options.progressiveChunkSize : undefined, + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + options ? options.onPostpone : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + type ResumeOptions = { nonce?: NonceOption, signal?: AbortSignal, @@ -178,7 +305,7 @@ function resumeAndPrerenderToNodeStream( startFlowing(request, writable); }, }); - const writable = createFakeWritable(readable); + const writable = createFakeWritableFromReadable(readable); const result = { postponed: getPostponedState(request), @@ -216,8 +343,79 @@ function resumeAndPrerenderToNodeStream( }); } +function resumeAndPrerender( + children: ReactNodeList, + postponedState: PostponedState, + options?: ResumeOptions, +): Promise<{ + postponed: null | PostponedState, + prelude: ReadableStream, +}> { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + let writable: Writable; + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = + createFakeWritableFromReadableStreamController(controller); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + + const result = { + postponed: getPostponedState(request), + prelude: stream, + }; + resolve(result); + } + + const request = resumeAndPrerenderRequest( + children, + postponedState, + resumeRenderState( + postponedState.resumableState, + options ? options.nonce : undefined, + ), + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + options ? options.onPostpone : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + export { + prerender, prerenderToNodeStream, + resumeAndPrerender, resumeAndPrerenderToNodeStream, ReactVersion as version, }; diff --git a/packages/react-dom/src/server/react-dom-server.node.js b/packages/react-dom/src/server/react-dom-server.node.js index 17c2d755b4873..e01fb6413ce84 100644 --- a/packages/react-dom/src/server/react-dom-server.node.js +++ b/packages/react-dom/src/server/react-dom-server.node.js @@ -10,5 +10,7 @@ export * from './ReactDOMFizzServerNode.js'; export { prerenderToNodeStream, + prerender, resumeAndPrerenderToNodeStream, + resumeAndPrerender, } from './ReactDOMFizzStaticNode.js'; diff --git a/packages/react-dom/src/server/react-dom-server.node.stable.js b/packages/react-dom/src/server/react-dom-server.node.stable.js index 4003622625110..a650dc161013c 100644 --- a/packages/react-dom/src/server/react-dom-server.node.stable.js +++ b/packages/react-dom/src/server/react-dom-server.node.stable.js @@ -7,5 +7,9 @@ * @flow */ -export {renderToPipeableStream, version} from './ReactDOMFizzServerNode.js'; -export {prerenderToNodeStream} from './ReactDOMFizzStaticNode.js'; +export { + renderToPipeableStream, + renderToReadableStream, + version, +} from './ReactDOMFizzServerNode.js'; +export {prerenderToNodeStream, prerender} from './ReactDOMFizzStaticNode.js'; diff --git a/packages/react-dom/static.node.js b/packages/react-dom/static.node.js index 0a45343f915a3..7e6bccaf4d31c 100644 --- a/packages/react-dom/static.node.js +++ b/packages/react-dom/static.node.js @@ -31,9 +31,23 @@ export function prerenderToNodeStream() { ); } +export function prerender() { + return require('./src/server/react-dom-server.node').prerender.apply( + this, + arguments, + ); +} + export function resumeAndPrerenderToNodeStream() { return require('./src/server/react-dom-server.node').resumeAndPrerenderToNodeStream.apply( this, arguments, ); } + +export function resumeAndPrerender() { + return require('./src/server/react-dom-server.node').resumeAndPrerender.apply( + this, + arguments, + ); +} diff --git a/packages/react-server/src/ReactServerStreamConfigNode.js b/packages/react-server/src/ReactServerStreamConfigNode.js index 67d70e848cd3a..3fb698411721e 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -189,7 +189,7 @@ export function close(destination: Destination) { destination.end(); } -const textEncoder = new TextEncoder(); +export const textEncoder: TextEncoder = new TextEncoder(); export function stringToChunk(content: string): Chunk { return content; From 9666605abfee7e525a22931ce38d40bb29ddc8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 7 Jun 2025 10:40:09 -0400 Subject: [PATCH 16/33] [Flight] Add Web Stream support to the Flight Server in Node (#33474) This needs some tweaks to the implementation and a conversion but simple enough. --------- Co-authored-by: Hendrik Liebau --- .../npm/server.node.js | 4 +- .../npm/static.node.js | 3 + .../react-server-dom-parcel/server.node.js | 4 +- .../src/server/ReactFlightDOMServerNode.js | 200 ++++++++++++++++- .../server/react-flight-dom-server.node.js | 5 +- .../react-server-dom-parcel/static.node.js | 5 +- .../npm/server.node.js | 4 +- .../npm/static.node.js | 3 + .../react-server-dom-turbopack/server.node.js | 4 +- .../src/server/ReactFlightDOMServerNode.js | 204 +++++++++++++++++- .../server/react-flight-dom-server.node.js | 5 +- .../react-server-dom-turbopack/static.node.js | 5 +- .../npm/server.node.js | 4 +- .../npm/static.node.js | 3 + .../react-server-dom-webpack/server.node.js | 4 +- .../src/__tests__/ReactFlightDOMNode-test.js | 47 +++- .../src/server/ReactFlightDOMServerNode.js | 204 +++++++++++++++++- .../server/react-flight-dom-server.node.js | 5 +- .../react-flight-dom-server.node.unbundled.js | 5 +- .../react-server-dom-webpack/static.node.js | 5 +- 20 files changed, 696 insertions(+), 27 deletions(-) diff --git a/packages/react-server-dom-parcel/npm/server.node.js b/packages/react-server-dom-parcel/npm/server.node.js index 92b2551dc7080..6d2e9516d4095 100644 --- a/packages/react-server-dom-parcel/npm/server.node.js +++ b/packages/react-server-dom-parcel/npm/server.node.js @@ -7,9 +7,11 @@ if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-parcel-server.node.development.js'); } +exports.renderToReadableStream = s.renderToReadableStream; exports.renderToPipeableStream = s.renderToPipeableStream; -exports.decodeReplyFromBusboy = s.decodeReplyFromBusboy; exports.decodeReply = s.decodeReply; +exports.decodeReplyFromBusboy = s.decodeReplyFromBusboy; +exports.decodeReplyFromAsyncIterable = s.decodeReplyFromAsyncIterable; exports.decodeAction = s.decodeAction; exports.decodeFormState = s.decodeFormState; exports.createClientReference = s.createClientReference; diff --git a/packages/react-server-dom-parcel/npm/static.node.js b/packages/react-server-dom-parcel/npm/static.node.js index 386ccc1c82aa4..411c2958ef966 100644 --- a/packages/react-server-dom-parcel/npm/static.node.js +++ b/packages/react-server-dom-parcel/npm/static.node.js @@ -7,6 +7,9 @@ if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-parcel-server.node.development.js'); } +if (s.unstable_prerender) { + exports.unstable_prerender = s.unstable_prerender; +} if (s.unstable_prerenderToNodeStream) { exports.unstable_prerenderToNodeStream = s.unstable_prerenderToNodeStream; } diff --git a/packages/react-server-dom-parcel/server.node.js b/packages/react-server-dom-parcel/server.node.js index bc450cb148c20..3550d44ac1829 100644 --- a/packages/react-server-dom-parcel/server.node.js +++ b/packages/react-server-dom-parcel/server.node.js @@ -9,8 +9,10 @@ export { renderToPipeableStream, - decodeReplyFromBusboy, + renderToReadableStream, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, createClientReference, diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index e38a8e89d3679..9ce1d43fa718d 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -21,6 +21,9 @@ import type { } from '../client/ReactFlightClientConfigBundlerParcel'; import {Readable} from 'stream'; + +import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; + import { createRequest, createPrerenderRequest, @@ -35,6 +38,7 @@ import { reportGlobalError, close, resolveField, + resolveFile, resolveFileInfo, resolveFileChunk, resolveFileComplete, @@ -56,9 +60,12 @@ export { registerServerReference, } from '../ReactFlightParcelReferences'; +import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + export type {TemporaryReferenceSet}; function createDrainHandler(destination: Destination, request: Request) { @@ -131,11 +138,91 @@ export function renderToPipeableStream( }; } -function createFakeWritable(readable: any): Writable { +function createFakeWritableFromReadableStreamController( + controller: ReadableStreamController, +): Writable { // The current host config expects a Writable so we create // a fake writable for now to push into the Readable. return ({ - write(chunk) { + write(chunk: string | Uint8Array) { + if (typeof chunk === 'string') { + chunk = textEncoder.encode(chunk); + } + controller.enqueue(chunk); + // in web streams there is no backpressure so we can alwas write more + return true; + }, + end() { + controller.close(); + }, + destroy(error) { + // $FlowFixMe[method-unbinding] + if (typeof controller.error === 'function') { + // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. + controller.error(error); + } else { + controller.close(); + } + }, + }: any); +} + +export function renderToReadableStream( + model: ReactClientValue, + + options?: Options & { + signal?: AbortSignal, + }, +): ReadableStream { + const request = createRequest( + model, + null, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + let writable: Writable; + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = createFakeWritableFromReadableStreamController(controller); + startWork(request); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + return stream; +} + +function createFakeWritableFromNodeReadable(readable: any): Writable { + // The current host config expects a Writable so we create + // a fake writable for now to push into the Readable. + return ({ + write(chunk: string | Uint8Array) { return readable.push(chunk); }, end() { @@ -173,7 +260,7 @@ export function prerenderToNodeStream( startFlowing(request, writable); }, }); - const writable = createFakeWritable(readable); + const writable = createFakeWritableFromNodeReadable(readable); resolve({prelude: readable}); } @@ -207,6 +294,69 @@ export function prerenderToNodeStream( }); } +export function prerender( + model: ReactClientValue, + + options?: Options & { + signal?: AbortSignal, + }, +): Promise<{ + prelude: ReadableStream, +}> { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + let writable: Writable; + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = + createFakeWritableFromReadableStreamController(controller); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + resolve({prelude: stream}); + } + const request = createPrerenderRequest( + model, + null, + onAllReady, + onFatalError, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + const reason = (signal: any).reason; + abort(request, reason); + } else { + const listener = () => { + const reason = (signal: any).reason; + abort(request, reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + let serverManifest = {}; export function registerServerActions(manifest: ServerManifest) { // This function is called by the bundler to register the manifest. @@ -292,6 +442,50 @@ export function decodeReply( return root; } +export function decodeReplyFromAsyncIterable( + iterable: AsyncIterable<[string, string | File]>, + options?: {temporaryReferences?: TemporaryReferenceSet}, +): Thenable { + const iterator: AsyncIterator<[string, string | File]> = + iterable[ASYNC_ITERATOR](); + + const response = createResponse( + serverManifest, + '', + options ? options.temporaryReferences : undefined, + ); + + function progress( + entry: + | {done: false, +value: [string, string | File], ...} + | {done: true, +value: void, ...}, + ) { + if (entry.done) { + close(response); + } else { + const [name, value] = entry.value; + if (typeof value === 'string') { + resolveField(response, name, value); + } else { + resolveFile(response, name, value); + } + iterator.next().then(progress, error); + } + } + function error(reason: Error) { + reportGlobalError(response, reason); + 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.next().then(progress, error); + + return getRoot(response); +} + export function decodeAction(body: FormData): Promise<() => T> | null { return decodeActionImpl(body, serverManifest); } diff --git a/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node.js b/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node.js index 3e3e7c6baeb94..37c0497178422 100644 --- a/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node.js +++ b/packages/react-server-dom-parcel/src/server/react-flight-dom-server.node.js @@ -8,10 +8,13 @@ */ export { + renderToReadableStream, renderToPipeableStream, + prerender as unstable_prerender, prerenderToNodeStream as unstable_prerenderToNodeStream, - decodeReplyFromBusboy, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, createClientReference, diff --git a/packages/react-server-dom-parcel/static.node.js b/packages/react-server-dom-parcel/static.node.js index 345f4123c9f09..1b2c11edc10f1 100644 --- a/packages/react-server-dom-parcel/static.node.js +++ b/packages/react-server-dom-parcel/static.node.js @@ -7,4 +7,7 @@ * @flow */ -export {unstable_prerenderToNodeStream} from './src/server/react-flight-dom-server.node'; +export { + unstable_prerender, + unstable_prerenderToNodeStream, +} from './src/server/react-flight-dom-server.node'; diff --git a/packages/react-server-dom-turbopack/npm/server.node.js b/packages/react-server-dom-turbopack/npm/server.node.js index f9a4cf31f6e8c..9507639540484 100644 --- a/packages/react-server-dom-turbopack/npm/server.node.js +++ b/packages/react-server-dom-turbopack/npm/server.node.js @@ -7,9 +7,11 @@ if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-turbopack-server.node.development.js'); } +exports.renderToReadableStream = s.renderToReadableStream; exports.renderToPipeableStream = s.renderToPipeableStream; -exports.decodeReplyFromBusboy = s.decodeReplyFromBusboy; exports.decodeReply = s.decodeReply; +exports.decodeReplyFromBusboy = s.decodeReplyFromBusboy; +exports.decodeReplyFromAsyncIterable = s.decodeReplyFromAsyncIterable; exports.decodeAction = s.decodeAction; exports.decodeFormState = s.decodeFormState; exports.registerServerReference = s.registerServerReference; diff --git a/packages/react-server-dom-turbopack/npm/static.node.js b/packages/react-server-dom-turbopack/npm/static.node.js index 544a15530d24f..34c9d63a4a26b 100644 --- a/packages/react-server-dom-turbopack/npm/static.node.js +++ b/packages/react-server-dom-turbopack/npm/static.node.js @@ -7,6 +7,9 @@ if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-turbopack-server.node.development.js'); } +if (s.unstable_prerender) { + exports.unstable_prerender = s.unstable_prerender; +} if (s.unstable_prerenderToNodeStream) { exports.unstable_prerenderToNodeStream = s.unstable_prerenderToNodeStream; } diff --git a/packages/react-server-dom-turbopack/server.node.js b/packages/react-server-dom-turbopack/server.node.js index 7e511aa577cec..bd00ba7275c14 100644 --- a/packages/react-server-dom-turbopack/server.node.js +++ b/packages/react-server-dom-turbopack/server.node.js @@ -9,8 +9,10 @@ export { renderToPipeableStream, - decodeReplyFromBusboy, + renderToReadableStream, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference, diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 9f25004ea4b67..10d39e67a8169 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -20,6 +20,8 @@ import type {Thenable} from 'shared/ReactTypes'; import {Readable} from 'stream'; +import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; + import { createRequest, createPrerenderRequest, @@ -34,6 +36,7 @@ import { reportGlobalError, close, resolveField, + resolveFile, resolveFileInfo, resolveFileChunk, resolveFileComplete, @@ -51,6 +54,8 @@ export { createClientModuleProxy, } from '../ReactFlightTurbopackReferences'; +import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -128,11 +133,91 @@ function renderToPipeableStream( }; } -function createFakeWritable(readable: any): Writable { +function createFakeWritableFromReadableStreamController( + controller: ReadableStreamController, +): Writable { // The current host config expects a Writable so we create // a fake writable for now to push into the Readable. return ({ - write(chunk) { + write(chunk: string | Uint8Array) { + if (typeof chunk === 'string') { + chunk = textEncoder.encode(chunk); + } + controller.enqueue(chunk); + // in web streams there is no backpressure so we can always write more + return true; + }, + end() { + controller.close(); + }, + destroy(error) { + // $FlowFixMe[method-unbinding] + if (typeof controller.error === 'function') { + // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. + controller.error(error); + } else { + controller.close(); + } + }, + }: any); +} + +function renderToReadableStream( + model: ReactClientValue, + turbopackMap: ClientManifest, + options?: Options & { + signal?: AbortSignal, + }, +): ReadableStream { + const request = createRequest( + model, + turbopackMap, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + let writable: Writable; + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = createFakeWritableFromReadableStreamController(controller); + startWork(request); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + return stream; +} + +function createFakeWritableFromNodeReadable(readable: any): Writable { + // The current host config expects a Writable so we create + // a fake writable for now to push into the Readable. + return ({ + write(chunk: string | Uint8Array) { return readable.push(chunk); }, end() { @@ -171,7 +256,7 @@ function prerenderToNodeStream( startFlowing(request, writable); }, }); - const writable = createFakeWritable(readable); + const writable = createFakeWritableFromNodeReadable(readable); resolve({prelude: readable}); } @@ -205,6 +290,69 @@ function prerenderToNodeStream( }); } +function prerender( + model: ReactClientValue, + turbopackMap: ClientManifest, + options?: Options & { + signal?: AbortSignal, + }, +): Promise<{ + prelude: ReadableStream, +}> { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + let writable: Writable; + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = + createFakeWritableFromReadableStreamController(controller); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + resolve({prelude: stream}); + } + const request = createPrerenderRequest( + model, + turbopackMap, + onAllReady, + onFatalError, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + const reason = (signal: any).reason; + abort(request, reason); + } else { + const listener = () => { + const reason = (signal: any).reason; + abort(request, reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + function decodeReplyFromBusboy( busboyStream: Busboy, turbopackMap: ServerManifest, @@ -286,11 +434,59 @@ function decodeReply( return root; } +function decodeReplyFromAsyncIterable( + iterable: AsyncIterable<[string, string | File]>, + turbopackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, +): Thenable { + const iterator: AsyncIterator<[string, string | File]> = + iterable[ASYNC_ITERATOR](); + + const response = createResponse( + turbopackMap, + '', + options ? options.temporaryReferences : undefined, + ); + + function progress( + entry: + | {done: false, +value: [string, string | File], ...} + | {done: true, +value: void, ...}, + ) { + if (entry.done) { + close(response); + } else { + const [name, value] = entry.value; + if (typeof value === 'string') { + resolveField(response, name, value); + } else { + resolveFile(response, name, value); + } + iterator.next().then(progress, error); + } + } + function error(reason: Error) { + reportGlobalError(response, reason); + 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.next().then(progress, error); + + return getRoot(response); +} + export { + renderToReadableStream, renderToPipeableStream, + prerender, prerenderToNodeStream, - decodeReplyFromBusboy, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, }; diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.js index fde57467327b6..1e3571a6f2ba4 100644 --- a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.js +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.node.js @@ -8,10 +8,13 @@ */ export { + renderToReadableStream, renderToPipeableStream, + prerender as unstable_prerender, prerenderToNodeStream as unstable_prerenderToNodeStream, - decodeReplyFromBusboy, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference, diff --git a/packages/react-server-dom-turbopack/static.node.js b/packages/react-server-dom-turbopack/static.node.js index 345f4123c9f09..1b2c11edc10f1 100644 --- a/packages/react-server-dom-turbopack/static.node.js +++ b/packages/react-server-dom-turbopack/static.node.js @@ -7,4 +7,7 @@ * @flow */ -export {unstable_prerenderToNodeStream} from './src/server/react-flight-dom-server.node'; +export { + unstable_prerender, + unstable_prerenderToNodeStream, +} from './src/server/react-flight-dom-server.node'; diff --git a/packages/react-server-dom-webpack/npm/server.node.js b/packages/react-server-dom-webpack/npm/server.node.js index 6885e43a44fc0..e507f64363460 100644 --- a/packages/react-server-dom-webpack/npm/server.node.js +++ b/packages/react-server-dom-webpack/npm/server.node.js @@ -7,9 +7,11 @@ if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-webpack-server.node.development.js'); } +exports.renderToReadableStream = s.renderToReadableStream; exports.renderToPipeableStream = s.renderToPipeableStream; -exports.decodeReplyFromBusboy = s.decodeReplyFromBusboy; exports.decodeReply = s.decodeReply; +exports.decodeReplyFromBusboy = s.decodeReplyFromBusboy; +exports.decodeReplyFromAsyncIterable = s.decodeReplyFromAsyncIterable; exports.decodeAction = s.decodeAction; exports.decodeFormState = s.decodeFormState; exports.registerServerReference = s.registerServerReference; diff --git a/packages/react-server-dom-webpack/npm/static.node.js b/packages/react-server-dom-webpack/npm/static.node.js index 6346a449d3b48..b0e4477fab466 100644 --- a/packages/react-server-dom-webpack/npm/static.node.js +++ b/packages/react-server-dom-webpack/npm/static.node.js @@ -7,6 +7,9 @@ if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-webpack-server.node.development.js'); } +if (s.unstable_prerender) { + exports.unstable_prerender = s.unstable_prerender; +} if (s.unstable_prerenderToNodeStream) { exports.unstable_prerenderToNodeStream = s.unstable_prerenderToNodeStream; } diff --git a/packages/react-server-dom-webpack/server.node.js b/packages/react-server-dom-webpack/server.node.js index 7e511aa577cec..bd00ba7275c14 100644 --- a/packages/react-server-dom-webpack/server.node.js +++ b/packages/react-server-dom-webpack/server.node.js @@ -9,8 +9,10 @@ export { renderToPipeableStream, - decodeReplyFromBusboy, + renderToReadableStream, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference, 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 381d6a434ba2e..5840762a73c28 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @emails react-core + * @jest-environment node */ 'use strict'; @@ -92,6 +93,48 @@ describe('ReactFlightDOMNode', () => { }); } + it('should support web streams in node', async () => { + function Text({children}) { + return {children}; + } + // Large strings can get encoded differently so we need to test that. + const largeString = 'world'.repeat(1000); + function HTML() { + return ( +
+ hello + {largeString} +
+ ); + } + + function App() { + const model = { + html: , + }; + return model; + } + + const readable = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(, webpackMap), + ); + const response = ReactServerDOMClient.createFromReadableStream(readable, { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const model = await response; + expect(model).toEqual({ + html: ( +
+ hello + {largeString} +
+ ), + }); + }); + it('should allow an alternative module mapping to be used for SSR', async () => { function ClientComponent() { return Client Component; @@ -498,8 +541,6 @@ describe('ReactFlightDOMNode', () => { expect(errors).toEqual([new Error('Connection closed.')]); // 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...'); + expect(result).toContain('loading...'); }); }); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index f459e04914b6e..cede8a46d69ad 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -20,6 +20,8 @@ import type {Thenable} from 'shared/ReactTypes'; import {Readable} from 'stream'; +import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; + import { createRequest, createPrerenderRequest, @@ -34,6 +36,7 @@ import { reportGlobalError, close, resolveField, + resolveFile, resolveFileInfo, resolveFileChunk, resolveFileComplete, @@ -51,6 +54,8 @@ export { createClientModuleProxy, } from '../ReactFlightWebpackReferences'; +import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -128,11 +133,91 @@ function renderToPipeableStream( }; } -function createFakeWritable(readable: any): Writable { +function createFakeWritableFromReadableStreamController( + controller: ReadableStreamController, +): Writable { // The current host config expects a Writable so we create // a fake writable for now to push into the Readable. return ({ - write(chunk) { + write(chunk: string | Uint8Array) { + if (typeof chunk === 'string') { + chunk = textEncoder.encode(chunk); + } + controller.enqueue(chunk); + // in web streams there is no backpressure so we can always write more + return true; + }, + end() { + controller.close(); + }, + destroy(error) { + // $FlowFixMe[method-unbinding] + if (typeof controller.error === 'function') { + // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. + controller.error(error); + } else { + controller.close(); + } + }, + }: any); +} + +function renderToReadableStream( + model: ReactClientValue, + webpackMap: ClientManifest, + options?: Options & { + signal?: AbortSignal, + }, +): ReadableStream { + const request = createRequest( + model, + webpackMap, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + let writable: Writable; + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = createFakeWritableFromReadableStreamController(controller); + startWork(request); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + return stream; +} + +function createFakeWritableFromNodeReadable(readable: any): Writable { + // The current host config expects a Writable so we create + // a fake writable for now to push into the Readable. + return ({ + write(chunk: string | Uint8Array) { return readable.push(chunk); }, end() { @@ -171,7 +256,7 @@ function prerenderToNodeStream( startFlowing(request, writable); }, }); - const writable = createFakeWritable(readable); + const writable = createFakeWritableFromNodeReadable(readable); resolve({prelude: readable}); } @@ -205,6 +290,69 @@ function prerenderToNodeStream( }); } +function prerender( + model: ReactClientValue, + webpackMap: ClientManifest, + options?: Options & { + signal?: AbortSignal, + }, +): Promise<{ + prelude: ReadableStream, +}> { + return new Promise((resolve, reject) => { + const onFatalError = reject; + function onAllReady() { + let writable: Writable; + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + writable = + createFakeWritableFromReadableStreamController(controller); + }, + pull: (controller): ?Promise => { + startFlowing(request, writable); + }, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, + }, + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + resolve({prelude: stream}); + } + const request = createPrerenderRequest( + model, + webpackMap, + onAllReady, + onFatalError, + options ? options.onError : undefined, + options ? options.identifierPrefix : undefined, + options ? options.onPostpone : undefined, + options ? options.temporaryReferences : undefined, + __DEV__ && options ? options.environmentName : undefined, + __DEV__ && options ? options.filterStackFrame : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + const reason = (signal: any).reason; + abort(request, reason); + } else { + const listener = () => { + const reason = (signal: any).reason; + abort(request, reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, @@ -286,11 +434,59 @@ function decodeReply( return root; } +function decodeReplyFromAsyncIterable( + iterable: AsyncIterable<[string, string | File]>, + webpackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, +): Thenable { + const iterator: AsyncIterator<[string, string | File]> = + iterable[ASYNC_ITERATOR](); + + const response = createResponse( + webpackMap, + '', + options ? options.temporaryReferences : undefined, + ); + + function progress( + entry: + | {done: false, +value: [string, string | File], ...} + | {done: true, +value: void, ...}, + ) { + if (entry.done) { + close(response); + } else { + const [name, value] = entry.value; + if (typeof value === 'string') { + resolveField(response, name, value); + } else { + resolveFile(response, name, value); + } + iterator.next().then(progress, error); + } + } + function error(reason: Error) { + reportGlobalError(response, reason); + 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.next().then(progress, error); + + return getRoot(response); +} + export { + renderToReadableStream, renderToPipeableStream, + prerender, prerenderToNodeStream, - decodeReplyFromBusboy, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, }; diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.js index fde57467327b6..1e3571a6f2ba4 100644 --- a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.js +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.js @@ -8,10 +8,13 @@ */ export { + renderToReadableStream, renderToPipeableStream, + prerender as unstable_prerender, prerenderToNodeStream as unstable_prerenderToNodeStream, - decodeReplyFromBusboy, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference, diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.js index fde57467327b6..1e3571a6f2ba4 100644 --- a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.js +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled.js @@ -8,10 +8,13 @@ */ export { + renderToReadableStream, renderToPipeableStream, + prerender as unstable_prerender, prerenderToNodeStream as unstable_prerenderToNodeStream, - decodeReplyFromBusboy, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference, diff --git a/packages/react-server-dom-webpack/static.node.js b/packages/react-server-dom-webpack/static.node.js index 345f4123c9f09..1b2c11edc10f1 100644 --- a/packages/react-server-dom-webpack/static.node.js +++ b/packages/react-server-dom-webpack/static.node.js @@ -7,4 +7,7 @@ * @flow */ -export {unstable_prerenderToNodeStream} from './src/server/react-flight-dom-server.node'; +export { + unstable_prerender, + unstable_prerenderToNodeStream, +} from './src/server/react-flight-dom-server.node'; From b367b60927dd85239852bfee60715034c7ca97ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 7 Jun 2025 11:28:57 -0400 Subject: [PATCH 17/33] [Flight] Add "use ..." boundary after the change instead of before it (#33478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I noticed that the ThirdPartyComponent in the fixture was showing the wrong stack and the `"use third-party"` is in the wrong location. Screenshot 2025-06-06 at 11 22 11 PM When creating the initial JSX inside the third party server, we should make sure that it has no owner. In a real cross-server environment you get this by default by just executing in different context. But since the fixture example is inside the same AsyncLocalStorage as the parent it already has an owner which gets transferred. So we should make sure that were we create the JSX has no owner to simulate this. When we then parse a null owner on the receiving side, we replace its owner/stack with the owner/stack of the call to `createFrom...` to connect them. This worked fine with only two environments. The bug was that when we did this and then transferred the result to a third environment we took the original parsed stack trace. We should instead parse a new one from the replaced stack in the current environment. The second bug was that the `"use third-party"` badge ends up in the wrong place when we do this kind of thing. Because the stack of the thing entering the new environment is the call to `createFrom...` which is in the old environment even though the component itself executes in the new environment. So to see if there's a change we should be comparing the current environment of the task to the owner's environment instead of the next environment after the task. After: Screenshot 2025-06-07 at 1 13 28 AM --- fixtures/flight/src/App.js | 20 ++-- .../react-client/src/ReactFlightClient.js | 90 +++++++++--------- .../src/__tests__/ReactFlight-test.js | 11 --- .../__tests__/ReactFlightDOMBrowser-test.js | 2 - .../src/__tests__/ReactFlightDOMEdge-test.js | 1 - .../react-server/src/ReactFlightServer.js | 75 ++++++++++++--- .../ReactFlightAsyncDebugInfo-test.js | 91 ++++++++----------- 7 files changed, 152 insertions(+), 138 deletions(-) diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 5f12956fd6fc4..d244ec8d39402 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -33,20 +33,26 @@ function Foo({children}) { return
{children}
; } +async function delay(text, ms) { + return new Promise(resolve => setTimeout(() => resolve(text), ms)); +} + async function Bar({children}) { - await new Promise(resolve => setTimeout(() => resolve('deferred text'), 10)); + await delay('deferred text', 10); return
{children}
; } async function ThirdPartyComponent() { - return new Promise(resolve => - setTimeout(() => resolve('hello from a 3rd party'), 30) - ); + return delay('hello from a 3rd party', 30); } // Using Web streams for tee'ing convenience here. let cachedThirdPartyReadableWeb; +// We create the Component outside of AsyncLocalStorage so that it has no owner. +// That way it gets the owner from the call to createFromNodeStream. +const thirdPartyComponent = ; + function fetchThirdParty(noCache) { if (cachedThirdPartyReadableWeb && !noCache) { const [readableWeb1, readableWeb2] = cachedThirdPartyReadableWeb.tee(); @@ -59,7 +65,7 @@ function fetchThirdParty(noCache) { } const stream = renderToPipeableStream( - , + thirdPartyComponent, {}, {environmentName: 'third-party'} ); @@ -80,8 +86,8 @@ function fetchThirdParty(noCache) { } async function ServerComponent({noCache}) { - await new Promise(resolve => setTimeout(() => resolve('deferred text'), 50)); - return fetchThirdParty(noCache); + await delay('deferred text', 50); + return await fetchThirdParty(noCache); } export default async function App({prerender, noCache}) { diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 78a2d85eea287..a69ede9efdf9d 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -844,7 +844,7 @@ function createElement( // This owner should ideally have already been initialized to avoid getting // user stack frames on the stack. const ownerTask = - owner === null ? null : initializeFakeTask(response, owner, env); + owner === null ? null : initializeFakeTask(response, owner); if (ownerTask === null) { const rootTask = response._debugRootTask; if (rootTask != null) { @@ -2494,7 +2494,6 @@ function getRootTask( function initializeFakeTask( response: Response, debugInfo: ReactComponentInfo | ReactAsyncInfo | ReactIOInfo, - childEnvironmentName: string, ): null | ConsoleTask { if (!supportsCreateTask) { return null; @@ -2504,6 +2503,10 @@ function initializeFakeTask( // If it's null, we can't initialize a task. return null; } + const cachedEntry = debugInfo.debugTask; + if (cachedEntry !== undefined) { + return cachedEntry; + } // Workaround for a bug where Chrome Performance tracking uses the enclosing line/column // instead of the callsite. For ReactAsyncInfo/ReactIOInfo, the only thing we're going @@ -2516,47 +2519,35 @@ function initializeFakeTask( const stack = debugInfo.stack; const env: string = debugInfo.env == null ? response._rootEnvironmentName : debugInfo.env; - if (env !== childEnvironmentName) { + const ownerEnv: string = + debugInfo.owner == null || debugInfo.owner.env == null + ? response._rootEnvironmentName + : debugInfo.owner.env; + const ownerTask = + debugInfo.owner == null + ? null + : initializeFakeTask(response, debugInfo.owner); + const taskName = // This is the boundary between two environments so we'll annotate the task name. - // That is unusual so we don't cache it. - const ownerTask = - debugInfo.owner == null - ? null - : initializeFakeTask(response, debugInfo.owner, env); - return buildFakeTask( - response, - ownerTask, - stack, - '"use ' + childEnvironmentName.toLowerCase() + '"', - env, - useEnclosingLine, - ); - } else { - const cachedEntry = debugInfo.debugTask; - if (cachedEntry !== undefined) { - return cachedEntry; - } - const ownerTask = - debugInfo.owner == null - ? null - : initializeFakeTask(response, debugInfo.owner, env); - // Some unfortunate pattern matching to refine the type. - const taskName = - debugInfo.key !== undefined + // We assume that the stack frame of the entry into the new environment was done + // from the old environment. So we use the owner's environment as the current. + env !== ownerEnv + ? '"use ' + env.toLowerCase() + '"' + : // Some unfortunate pattern matching to refine the type. + debugInfo.key !== undefined ? getServerComponentTaskName(((debugInfo: any): ReactComponentInfo)) : debugInfo.name !== undefined ? getIOInfoTaskName(((debugInfo: any): ReactIOInfo)) : getAsyncInfoTaskName(((debugInfo: any): ReactAsyncInfo)); - // $FlowFixMe[cannot-write]: We consider this part of initialization. - return (debugInfo.debugTask = buildFakeTask( - response, - ownerTask, - stack, - taskName, - env, - useEnclosingLine, - )); - } + // $FlowFixMe[cannot-write]: We consider this part of initialization. + return (debugInfo.debugTask = buildFakeTask( + response, + ownerTask, + stack, + taskName, + ownerEnv, + useEnclosingLine, + )); } function buildFakeTask( @@ -2658,27 +2649,30 @@ function resolveDebugInfo( 'resolveDebugInfo should never be called in production mode. This is a bug in React.', ); } - // We eagerly initialize the fake task because this resolving happens outside any - // render phase so we're not inside a user space stack at this point. If we waited - // to initialize it when we need it, we might be inside user code. - const env = - debugInfo.env === undefined ? response._rootEnvironmentName : debugInfo.env; if (debugInfo.stack !== undefined) { const componentInfoOrAsyncInfo: ReactComponentInfo | ReactAsyncInfo = // $FlowFixMe[incompatible-type] debugInfo; - initializeFakeTask(response, componentInfoOrAsyncInfo, env); + // We eagerly initialize the fake task because this resolving happens outside any + // render phase so we're not inside a user space stack at this point. If we waited + // to initialize it when we need it, we might be inside user code. + initializeFakeTask(response, componentInfoOrAsyncInfo); } - if (debugInfo.owner === null && response._debugRootOwner != null) { + if (debugInfo.owner == null && response._debugRootOwner != null) { const componentInfoOrAsyncInfo: ReactComponentInfo | ReactAsyncInfo = // $FlowFixMe: By narrowing `owner` to `null`, we narrowed `debugInfo` to `ReactComponentInfo` debugInfo; // $FlowFixMe[cannot-write] componentInfoOrAsyncInfo.owner = response._debugRootOwner; + // We clear the parsed stack frames to indicate that it needs to be re-parsed from debugStack. + // $FlowFixMe[cannot-write] + componentInfoOrAsyncInfo.stack = null; // We override the stack if we override the owner since the stack where the root JSX // was created on the server isn't very useful but where the request was made is. // $FlowFixMe[cannot-write] componentInfoOrAsyncInfo.debugStack = response._debugRootStack; + // $FlowFixMe[cannot-write] + componentInfoOrAsyncInfo.debugTask = response._debugRootTask; } else if (debugInfo.stack !== undefined) { const componentInfoOrAsyncInfo: ReactComponentInfo | ReactAsyncInfo = // $FlowFixMe[incompatible-type] @@ -2738,7 +2732,7 @@ const replayConsoleWithCallStack = { bindToConsole(methodName, args, env), ); if (owner != null) { - const task = initializeFakeTask(response, owner, env); + const task = initializeFakeTask(response, owner); initializeFakeStack(response, owner); if (task !== null) { task.run(callStack); @@ -2812,10 +2806,8 @@ function resolveConsoleEntry( } function initializeIOInfo(response: Response, ioInfo: ReactIOInfo): void { - const env = - ioInfo.env === undefined ? response._rootEnvironmentName : ioInfo.env; if (ioInfo.stack !== undefined) { - initializeFakeTask(response, ioInfo, env); + initializeFakeTask(response, ioInfo); initializeFakeStack(response, ioInfo); } // Adjust the time to the current environment's time space. diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index ef9864200588a..90f72416e3a25 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -320,7 +320,6 @@ describe('ReactFlight', () => { name: 'Greeting', env: 'Server', key: null, - owner: null, stack: ' in Object. (at **)', props: { firstName: 'Seb', @@ -364,7 +363,6 @@ describe('ReactFlight', () => { name: 'Greeting', env: 'Server', key: null, - owner: null, stack: ' in Object. (at **)', props: { firstName: 'Seb', @@ -2812,7 +2810,6 @@ describe('ReactFlight', () => { name: 'ServerComponent', env: 'Server', key: null, - owner: null, stack: ' in Object. (at **)', props: { transport: expect.arrayContaining([]), @@ -2834,7 +2831,6 @@ describe('ReactFlight', () => { name: 'ThirdPartyComponent', env: 'third-party', key: null, - owner: null, stack: ' in Object. (at **)', props: {}, }, @@ -2851,7 +2847,6 @@ describe('ReactFlight', () => { name: 'ThirdPartyLazyComponent', env: 'third-party', key: null, - owner: null, stack: ' in myLazy (at **)\n in lazyInitializer (at **)', props: {}, }, @@ -2867,7 +2862,6 @@ describe('ReactFlight', () => { name: 'ThirdPartyFragmentComponent', env: 'third-party', key: '3', - owner: null, stack: ' in Object. (at **)', props: {}, }, @@ -2941,7 +2935,6 @@ describe('ReactFlight', () => { name: 'ServerComponent', env: 'Server', key: null, - owner: null, stack: ' in Object. (at **)', props: { transport: expect.arrayContaining([]), @@ -2961,7 +2954,6 @@ describe('ReactFlight', () => { name: 'Keyed', env: 'Server', key: 'keyed', - owner: null, stack: ' in ServerComponent (at **)', props: { children: {}, @@ -2980,7 +2972,6 @@ describe('ReactFlight', () => { name: 'ThirdPartyAsyncIterableComponent', env: 'third-party', key: null, - owner: null, stack: ' in Object. (at **)', props: {}, }, @@ -3137,7 +3128,6 @@ describe('ReactFlight', () => { name: 'Component', env: 'A', key: null, - owner: null, stack: ' in Object. (at **)', props: {}, }, @@ -3325,7 +3315,6 @@ describe('ReactFlight', () => { name: 'Greeting', env: 'Server', key: null, - owner: null, stack: ' in Object. (at **)', props: { firstName: 'Seb', 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 8670f606ba75a..6afaa207297da 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -708,7 +708,6 @@ describe('ReactFlightDOMBrowser', () => { name: 'Server', env: 'Server', key: null, - owner: null, }), }), ); @@ -724,7 +723,6 @@ describe('ReactFlightDOMBrowser', () => { name: 'Server', env: 'Server', key: null, - owner: null, }), }), ); 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 43e0aaa7e7fc3..0c31177f1db49 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -1190,7 +1190,6 @@ describe('ReactFlightDOMEdge', () => { const greetInfo = expect.objectContaining({ name: 'Greeting', env: 'Server', - owner: null, }); expect(lazyWrapper._debugInfo).toEqual([ {time: 12}, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index b1d3a91dc0b7e..c56103665e7c1 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -3587,12 +3587,27 @@ function outlineComponentInfo( 'debugTask' | 'debugStack', > = { name: componentInfo.name, - env: componentInfo.env, key: componentInfo.key, - owner: componentInfo.owner, }; - // $FlowFixMe[cannot-write] - componentDebugInfo.stack = componentInfo.stack; + if (componentInfo.env != null) { + // $FlowFixMe[cannot-write] + componentDebugInfo.env = componentInfo.env; + } + if (componentInfo.owner != null) { + // $FlowFixMe[cannot-write] + componentDebugInfo.owner = componentInfo.owner; + } + if (componentInfo.stack == null && componentInfo.debugStack != null) { + // If we have a debugStack but no parsed stack we should parse it. + // $FlowFixMe[cannot-write] + componentDebugInfo.stack = filterStackTrace( + request, + parseStackTrace(componentInfo.debugStack, 1), + ); + } else if (componentInfo.stack != null) { + // $FlowFixMe[cannot-write] + componentDebugInfo.stack = componentInfo.stack; + } // Ensure we serialize props after the stack to favor the stack being complete. // $FlowFixMe[cannot-write] componentDebugInfo.props = componentInfo.props; @@ -3679,6 +3694,16 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void { if (owner != null) { outlineComponentInfo(request, owner); } + let debugStack; + if (ioInfo.stack == null && ioInfo.debugStack != null) { + // If we have a debugStack but no parsed stack we should parse it. + debugStack = filterStackTrace( + request, + parseStackTrace(ioInfo.debugStack, 1), + ); + } else { + debugStack = ioInfo.stack; + } emitIOInfoChunk( request, id, @@ -3687,7 +3712,7 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void { ioInfo.end, ioInfo.env, owner, - ioInfo.stack, + debugStack, ); request.writtenObjects.set(ioInfo, serializeByValueID(id)); } @@ -4243,30 +4268,50 @@ function forwardDebugInfo( debugInfo: ReactDebugInfo, ) { for (let i = 0; i < debugInfo.length; i++) { - if (typeof debugInfo[i].time === 'number') { + const info = debugInfo[i]; + if (typeof info.time === 'number') { // When forwarding time we need to ensure to convert it to the time space of the payload. - emitTimingChunk(request, id, debugInfo[i].time); + emitTimingChunk(request, id, info.time); } else { request.pendingChunks++; - if (typeof debugInfo[i].name === 'string') { + if (typeof info.name === 'string') { // We outline this model eagerly so that we can refer to by reference as an owner. // If we had a smarter way to dedupe we might not have to do this if there ends up // being no references to this as an owner. - outlineComponentInfo(request, (debugInfo[i]: any)); + outlineComponentInfo(request, (info: any)); // Emit a reference to the outlined one. - emitDebugChunk(request, id, debugInfo[i]); - } else if (debugInfo[i].awaited) { - const ioInfo = debugInfo[i].awaited; + emitDebugChunk(request, id, info); + } else if (info.awaited) { + const ioInfo = info.awaited; // Outline the IO info in case the same I/O is awaited in more than one place. outlineIOInfo(request, ioInfo); // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing. + let debugStack; + if (info.stack == null && info.debugStack != null) { + // If we have a debugStack but no parsed stack we should parse it. + debugStack = filterStackTrace( + request, + parseStackTrace(info.debugStack, 1), + ); + } else { + debugStack = info.stack; + } const debugAsyncInfo: Omit = { awaited: ioInfo, - env: debugInfo[i].env, - owner: debugInfo[i].owner, - stack: debugInfo[i].stack, }; + if (info.env != null) { + // $FlowFixMe[cannot-write] + debugAsyncInfo.env = info.env; + } + if (info.owner != null) { + // $FlowFixMe[cannot-write] + debugAsyncInfo.owner = info.owner; + } + if (debugStack != null) { + // $FlowFixMe[cannot-write] + debugAsyncInfo.stack = debugStack; + } emitDebugChunk(request, id, debugAsyncInfo); } else { emitDebugChunk(request, id, debugInfo[i]); diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index d1c84f9cc272c..eee2a3749f94d 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -174,7 +174,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ @@ -199,7 +198,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ @@ -245,7 +243,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ @@ -292,7 +289,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ @@ -338,7 +334,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ @@ -421,15 +416,14 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 397, + 392, 109, - 384, + 379, 67, ], ], @@ -446,15 +440,14 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 397, + 392, 109, - 384, + 379, 67, ], ], @@ -463,9 +456,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 387, + 382, 7, - 385, + 380, 5, ], ], @@ -520,15 +513,14 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 496, + 489, 109, - 487, + 480, 94, ], ], @@ -592,15 +584,14 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 568, + 560, 109, - 544, + 536, 50, ], ], @@ -675,15 +666,14 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "Server", "key": null, "name": "Component", - "owner": null, "props": {}, "stack": [ [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 651, + 642, 109, - 634, + 625, 63, ], ], @@ -695,7 +685,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "third-party", "key": null, "name": "ThirdPartyComponent", - "owner": null, "props": {}, "stack": [ [ @@ -709,9 +698,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 647, + 638, 24, - 646, + 637, 5, ], ], @@ -728,7 +717,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "third-party", "key": null, "name": "ThirdPartyComponent", - "owner": null, "props": {}, "stack": [ [ @@ -742,9 +730,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 647, + 638, 24, - 646, + 637, 5, ], ], @@ -761,17 +749,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 636, + 627, 13, - 635, + 626, 5, ], [ "ThirdPartyComponent", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 642, + 633, 24, - 641, + 632, 5, ], ], @@ -782,7 +770,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "third-party", "key": null, "name": "ThirdPartyComponent", - "owner": null, "props": {}, "stack": [ [ @@ -796,9 +783,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 647, + 638, 24, - 646, + 637, 5, ], ], @@ -807,17 +794,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 636, + 627, 13, - 635, + 626, 5, ], [ "ThirdPartyComponent", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 642, + 633, 24, - 641, + 632, 5, ], ], @@ -837,7 +824,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "third-party", "key": null, "name": "ThirdPartyComponent", - "owner": null, "props": {}, "stack": [ [ @@ -851,9 +837,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 647, + 638, 24, - 646, + 637, 5, ], ], @@ -870,17 +856,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 637, + 628, 13, - 635, + 626, 5, ], [ "ThirdPartyComponent", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 642, + 633, 18, - 641, + 632, 5, ], ], @@ -891,7 +877,6 @@ describe('ReactFlightAsyncDebugInfo', () => { "env": "third-party", "key": null, "name": "ThirdPartyComponent", - "owner": null, "props": {}, "stack": [ [ @@ -905,9 +890,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 647, + 638, 24, - 646, + 637, 5, ], ], @@ -916,17 +901,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 637, + 628, 13, - 635, + 626, 5, ], [ "ThirdPartyComponent", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 642, + 633, 18, - 641, + 632, 5, ], ], From 6c8bcdaf1b0c3340150e174a342429d94e729fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sat, 7 Jun 2025 17:26:36 -0400 Subject: [PATCH 18/33] [Flight] Clarify Semantics for Awaiting Cached Data (#33438) Technically the async call graph spans basically all the way back to the start of the app potentially, but we don't want to include everything. Similarly we don't want to include everything from previous components in every child component. So we need some heuristics for filtering out data. We roughly want to be able to inspect is what might contribute to a Suspense loading sequence even if it didn't this time e.g. due to a race condition. One flaw with the previous approach was that awaiting a cached promise in a sibling that happened to finish after another sibling would be excluded. However, in a different race condition that might end up being used so I wanted to include an empty "await" in that scenario to have some association from that component. However, for data that resolved fully before the request even started, it's a little different. This can be things that are part of the start up sequence of the app or externally cached data. We decided that this should be excluded because it doesn't contribute to the loading sequence in the expected scenario. I.e. if it's cached. Things that end up being cache misses would still be included. If you want to test externally cached data misses, then it's up to you or the framework to simulate those. E.g. by dropping the cache. This also helps free up some noise since static / cached data can be excluded in visualizations. I also apply this principle to forwarding debug info. If you reuse a cached RSC payload, then the Server Component render time and its awaits gets clamped to the caller as if it has zero render/await time. The I/O entry is still back dated but if it was fully resolved before we started then it's completely excluded. --- .../src/__tests__/ReactFlight-test.js | 16 +- .../react-server/src/ReactFlightServer.js | 211 ++++--- .../ReactFlightAsyncDebugInfo-test.js | 552 +++++++++++++++--- 3 files changed, 602 insertions(+), 177 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 90f72416e3a25..eb354aba58753 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -2826,7 +2826,7 @@ describe('ReactFlight', () => { expect(getDebugInfo(thirdPartyChildren[0])).toEqual( __DEV__ ? [ - {time: 14}, + {time: 22}, // Clamped to the start { name: 'ThirdPartyComponent', env: 'third-party', @@ -2834,7 +2834,7 @@ describe('ReactFlight', () => { stack: ' in Object. (at **)', props: {}, }, - {time: 15}, + {time: 22}, {time: 23}, // This last one is when the promise resolved into the first party. ] : undefined, @@ -2842,7 +2842,7 @@ describe('ReactFlight', () => { expect(getDebugInfo(thirdPartyChildren[1])).toEqual( __DEV__ ? [ - {time: 16}, + {time: 22}, // Clamped to the start { name: 'ThirdPartyLazyComponent', env: 'third-party', @@ -2850,14 +2850,14 @@ describe('ReactFlight', () => { stack: ' in myLazy (at **)\n in lazyInitializer (at **)', props: {}, }, - {time: 17}, + {time: 22}, ] : undefined, ); expect(getDebugInfo(thirdPartyChildren[2])).toEqual( __DEV__ ? [ - {time: 12}, + {time: 22}, { name: 'ThirdPartyFragmentComponent', env: 'third-party', @@ -2865,7 +2865,7 @@ describe('ReactFlight', () => { stack: ' in Object. (at **)', props: {}, }, - {time: 13}, + {time: 22}, ] : undefined, ); @@ -2967,7 +2967,7 @@ describe('ReactFlight', () => { expect(getDebugInfo(thirdPartyFragment.props.children)).toEqual( __DEV__ ? [ - {time: 12}, + {time: 19}, // Clamp to the start { name: 'ThirdPartyAsyncIterableComponent', env: 'third-party', @@ -2975,7 +2975,7 @@ describe('ReactFlight', () => { stack: ' in Object. (at **)', props: {}, }, - {time: 13}, + {time: 19}, ] : undefined, ); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index c56103665e7c1..5a9d0082ad4fc 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -687,22 +687,29 @@ function serializeThenable( __DEV__ ? task.debugStack : null, __DEV__ ? task.debugTask : null, ); - if (__DEV__) { - // If this came from Flight, forward any debug info into this new row. - const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo; - if (debugInfo) { - forwardDebugInfo(request, newTask.id, debugInfo); - } - } switch (thenable.status) { case 'fulfilled': { + if (__DEV__) { + // If this came from Flight, forward any debug info into this new row. + const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo; + if (debugInfo) { + forwardDebugInfo(request, newTask, debugInfo); + } + } // We have the resolved value, we can go ahead and schedule it for serialization. newTask.model = thenable.value; pingTask(request, newTask); return newTask.id; } case 'rejected': { + if (__DEV__) { + // If this came from Flight, forward any debug info into this new row. + const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo; + if (debugInfo) { + forwardDebugInfo(request, newTask, debugInfo); + } + } const x = thenable.reason; erroredTask(request, newTask, x); return newTask.id; @@ -751,10 +758,24 @@ function serializeThenable( thenable.then( value => { + if (__DEV__) { + // If this came from Flight, forward any debug info into this new row. + const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo; + if (debugInfo) { + forwardDebugInfo(request, newTask, debugInfo); + } + } newTask.model = value; pingTask(request, newTask); }, reason => { + if (__DEV__) { + // If this came from Flight, forward any debug info into this new row. + const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo; + if (debugInfo) { + forwardDebugInfo(request, newTask, debugInfo); + } + } if (newTask.status === PENDING) { // We expect that the only status it might be otherwise is ABORTED. // When we abort we emit chunks in each pending task slot and don't need @@ -911,7 +932,7 @@ function serializeAsyncIterable( if (__DEV__) { const debugInfo: ?ReactDebugInfo = (iterable: any)._debugInfo; if (debugInfo) { - forwardDebugInfo(request, streamTask.id, debugInfo); + forwardDebugInfo(request, streamTask, debugInfo); } } @@ -1278,7 +1299,7 @@ function renderFunctionComponent( let componentDebugInfo: ReactComponentInfo; if (__DEV__) { - if (debugID === null) { + if (!canEmitDebugInfo) { // We don't have a chunk to assign debug info. We need to outline this // component to assign it an ID. return outlineTask(request, task); @@ -1289,7 +1310,7 @@ function renderFunctionComponent( componentDebugInfo = (prevThenableState: any)._componentDebugInfo; } else { // This is a new component in the same task so we can emit more debug info. - const componentDebugID = debugID; + const componentDebugID = task.id; const componentName = (Component: any).displayName || Component.name || ''; const componentEnv = (0, request.environmentName)(); @@ -1543,7 +1564,7 @@ function renderFragment( const debugInfo: ?ReactDebugInfo = (children: any)._debugInfo; if (debugInfo) { // If this came from Flight, forward any debug info into this new row. - if (debugID === null) { + if (!canEmitDebugInfo) { // We don't have a chunk to assign debug info. We need to outline this // component to assign it an ID. return outlineTask(request, task); @@ -1551,7 +1572,7 @@ function renderFragment( // Forward any debug info we have the first time we see it. // We do this after init so that we have received all the debug info // from the server by the time we emit it. - forwardDebugInfo(request, debugID, debugInfo); + forwardDebugInfo(request, task, debugInfo); } // Since we're rendering this array again, create a copy that doesn't // have the debug info so we avoid outlining or emitting debug info again. @@ -1659,8 +1680,10 @@ function renderClientElement( return element; } -// The chunk ID we're currently rendering that we can assign debug data to. -let debugID: null | number = null; +// Determines if we're currently rendering at the top level of a task and therefore +// is safe to emit debug info associated with that task. Otherwise, if we're in +// a nested context, we need to first outline. +let canEmitDebugInfo: boolean = false; // Approximate string length of the currently serializing row. // Used to power outlining heuristics. @@ -1879,6 +1902,7 @@ function visitAsyncNode( // First visit anything that blocked this sequence to start in the first place. if (node.previous !== null) { // We ignore the return value here because if it wasn't awaited in user space, then we don't log it. + // It also means that it can just have been part of a previous component's render. // TODO: This means that some I/O can get lost that was still blocking the sequence. visitAsyncNode(request, task, node.previous, cutOff, visited); } @@ -1890,11 +1914,10 @@ function visitAsyncNode( return null; } case PROMISE_NODE: { - if (node.end < cutOff) { - // This was already resolved when we started this sequence. It must have been - // part of a different component. - // TODO: Think of some other way to exclude irrelevant data since if we awaited - // a cached promise, we should still log this component as being dependent on that data. + if (node.end <= request.timeOrigin) { + // This was already resolved when we started this render. It must have been either something + // that's part of a start up sequence or externally cached data. We exclude that information. + // The technique for debugging the effects of uncached data on the render is to simply uncache it. return null; } const awaited = node.awaited; @@ -1928,7 +1951,7 @@ function visitAsyncNode( // the thing that generated this node and its virtual children. const debugInfo = node.debugInfo; if (debugInfo !== null) { - forwardDebugInfo(request, task.id, debugInfo); + forwardDebugInfo(request, task, debugInfo); } return match; } @@ -1942,6 +1965,7 @@ function visitAsyncNode( if (awaited !== null) { const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited); if (ioNode !== null) { + const startTime: number = node.start; let endTime: number; if (node.tag === UNRESOLVED_AWAIT_NODE) { // If we haven't defined an end time, use the resolve of the inner Promise. @@ -1955,11 +1979,18 @@ function visitAsyncNode( } else { endTime = node.end; } - if (endTime < cutOff) { - // This was already resolved when we started this sequence. It must have been - // part of a different component. - // TODO: Think of some other way to exclude irrelevant data since if we awaited - // a cached promise, we should still log this component as being dependent on that data. + if (endTime <= request.timeOrigin) { + // This was already resolved when we started this render. It must have been either something + // that's part of a start up sequence or externally cached data. We exclude that information. + return null; + } else if (startTime < cutOff) { + // We started awaiting this node before we started rendering this sequence. + // This means that this particular await was never part of the current sequence. + // If we have another await higher up in the chain it might have a more actionable stack + // from the perspective of this component. If we end up here from the "previous" path, + // then this gets I/O ignored, which is what we want because it means it was likely + // just part of a previous component's rendering. + match = ioNode; } else { const stack = filterStackTrace( request, @@ -1978,15 +2009,7 @@ function visitAsyncNode( // We log the environment at the time when the last promise pigned ping which may // be later than what the environment was when we actually started awaiting. const env = (0, request.environmentName)(); - if (node.start <= cutOff) { - // If this was an await that started before this sequence but finished after, - // then we clamp it to the start of this sequence. We don't need to emit a time - // TODO: Typically we'll already have a previous time stamp with the cutOff time - // so we shouldn't need to emit another one. But not always. - emitTimingChunk(request, task.id, cutOff); - } else { - emitTimingChunk(request, task.id, node.start); - } + emitTimingChunk(request, task.id, startTime); // Then emit a reference to us awaiting it in the current task. request.pendingChunks++; emitDebugChunk(request, task.id, { @@ -1995,7 +2018,7 @@ function visitAsyncNode( owner: node.owner, stack: stack, }); - emitTimingChunk(request, task.id, node.end); + emitTimingChunk(request, task.id, endTime); } } } @@ -2013,7 +2036,7 @@ function visitAsyncNode( debugInfo = node.debugInfo; } if (debugInfo !== null) { - forwardDebugInfo(request, task.id, debugInfo); + forwardDebugInfo(request, task, debugInfo); } return match; } @@ -2049,12 +2072,16 @@ function emitAsyncSequence( const env = (0, request.environmentName)(); // If we don't have any thing awaited, the time we started awaiting was internal // when we yielded after rendering. The cutOff time is basically that. - emitTimingChunk(request, task.id, cutOff); + const awaitStartTime = cutOff; + // If the end time finished before we started, it could've been a cached thing so + // we clamp it to the cutOff time. Effectively leading to a zero-time await. + const awaitEndTime = awaitedNode.end < cutOff ? cutOff : awaitedNode.end; + emitTimingChunk(request, task.id, awaitStartTime); emitDebugChunk(request, task.id, { awaited: ((awaitedNode: any): ReactIOInfo), // This is deduped by this reference. env: env, }); - emitTimingChunk(request, task.id, awaitedNode.end); + emitTimingChunk(request, task.id, awaitEndTime); } } @@ -2763,13 +2790,13 @@ function renderModelDestructive( const debugInfo: ?ReactDebugInfo = (value: any)._debugInfo; if (debugInfo) { // If this came from Flight, forward any debug info into this new row. - if (debugID === null) { + if (!canEmitDebugInfo) { // We don't have a chunk to assign debug info. We need to outline this // component to assign it an ID. return outlineTask(request, task); } else { // Forward any debug info we have the first time we see it. - forwardDebugInfo(request, debugID, debugInfo); + forwardDebugInfo(request, task, debugInfo); } } } @@ -2845,7 +2872,7 @@ function renderModelDestructive( const debugInfo: ?ReactDebugInfo = lazy._debugInfo; if (debugInfo) { // If this came from Flight, forward any debug info into this new row. - if (debugID === null) { + if (!canEmitDebugInfo) { // We don't have a chunk to assign debug info. We need to outline this // component to assign it an ID. return outlineTask(request, task); @@ -2853,7 +2880,7 @@ function renderModelDestructive( // Forward any debug info we have the first time we see it. // We do this after init so that we have received all the debug info // from the server by the time we emit it. - forwardDebugInfo(request, debugID, debugInfo); + forwardDebugInfo(request, task, debugInfo); } } } @@ -4264,57 +4291,77 @@ function emitTimeOriginChunk(request: Request, timeOrigin: number): void { function forwardDebugInfo( request: Request, - id: number, + task: Task, debugInfo: ReactDebugInfo, ) { + const id = task.id; + const minimumTime = + enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0; for (let i = 0; i < debugInfo.length; i++) { const info = debugInfo[i]; if (typeof info.time === 'number') { // When forwarding time we need to ensure to convert it to the time space of the payload. - emitTimingChunk(request, id, info.time); + // We clamp the time to the starting render of the current component. It's as if it took + // no time to render and await if we reuse cached content. + emitTimingChunk( + request, + id, + info.time < minimumTime ? minimumTime : info.time, + ); } else { - request.pendingChunks++; if (typeof info.name === 'string') { // We outline this model eagerly so that we can refer to by reference as an owner. // If we had a smarter way to dedupe we might not have to do this if there ends up // being no references to this as an owner. outlineComponentInfo(request, (info: any)); // Emit a reference to the outlined one. + request.pendingChunks++; emitDebugChunk(request, id, info); } else if (info.awaited) { const ioInfo = info.awaited; - // Outline the IO info in case the same I/O is awaited in more than one place. - outlineIOInfo(request, ioInfo); - // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing. - let debugStack; - if (info.stack == null && info.debugStack != null) { - // If we have a debugStack but no parsed stack we should parse it. - debugStack = filterStackTrace( - request, - parseStackTrace(info.debugStack, 1), - ); + if (ioInfo.end <= request.timeOrigin) { + // This was already resolved when we started this render. It must have been some + // externally cached data. We exclude that information but we keep components and + // awaits that happened inside this render but might have been deduped within the + // render. } else { - debugStack = info.stack; - } - const debugAsyncInfo: Omit = - { + // Outline the IO info in case the same I/O is awaited in more than one place. + outlineIOInfo(request, ioInfo); + // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing. + let debugStack; + if (info.stack == null && info.debugStack != null) { + // If we have a debugStack but no parsed stack we should parse it. + debugStack = filterStackTrace( + request, + parseStackTrace(info.debugStack, 1), + ); + } else { + debugStack = info.stack; + } + const debugAsyncInfo: Omit< + ReactAsyncInfo, + 'debugTask' | 'debugStack', + > = { awaited: ioInfo, }; - if (info.env != null) { - // $FlowFixMe[cannot-write] - debugAsyncInfo.env = info.env; - } - if (info.owner != null) { - // $FlowFixMe[cannot-write] - debugAsyncInfo.owner = info.owner; - } - if (debugStack != null) { - // $FlowFixMe[cannot-write] - debugAsyncInfo.stack = debugStack; + if (info.env != null) { + // $FlowFixMe[cannot-write] + debugAsyncInfo.env = info.env; + } + if (info.owner != null) { + // $FlowFixMe[cannot-write] + debugAsyncInfo.owner = info.owner; + } + if (debugStack != null) { + // $FlowFixMe[cannot-write] + debugAsyncInfo.stack = debugStack; + } + request.pendingChunks++; + emitDebugChunk(request, id, debugAsyncInfo); } - emitDebugChunk(request, id, debugAsyncInfo); } else { - emitDebugChunk(request, id, debugInfo[i]); + request.pendingChunks++; + emitDebugChunk(request, id, info); } } } @@ -4457,7 +4504,7 @@ function retryTask(request: Request, task: Task): void { return; } - const prevDebugID = debugID; + const prevCanEmitDebugInfo = canEmitDebugInfo; task.status = RENDERING; // We stash the outer parent size so we can restore it when we exit. @@ -4472,8 +4519,8 @@ function retryTask(request: Request, task: Task): void { modelRoot = task.model; if (__DEV__) { - // Track the ID of the current task so we can assign debug info to this id. - debugID = task.id; + // Track that we can emit debug info for the current task. + canEmitDebugInfo = true; } // We call the destructive form that mutates this task. That way if something @@ -4489,7 +4536,7 @@ function retryTask(request: Request, task: Task): void { if (__DEV__) { // We're now past rendering this task and future renders will spawn new tasks for their // debug info. - debugID = null; + canEmitDebugInfo = false; } // Track the root again for the resolved object. @@ -4574,7 +4621,7 @@ function retryTask(request: Request, task: Task): void { erroredTask(request, task, x); } finally { if (__DEV__) { - debugID = prevDebugID; + canEmitDebugInfo = prevCanEmitDebugInfo; } serializedSize = parentSerializedSize; } @@ -4583,11 +4630,11 @@ function retryTask(request: Request, task: Task): void { function tryStreamTask(request: Request, task: Task): void { // This is used to try to emit something synchronously but if it suspends, // we emit a reference to a new outlined task immediately instead. - const prevDebugID = debugID; + const prevCanEmitDebugInfo = canEmitDebugInfo; if (__DEV__) { - // We don't use the id of the stream task for debugID. Instead we leave it null - // so that we instead outline the row to get a new debugID if needed. - debugID = null; + // We can't emit debug into to a specific row of a stream task. Instead we leave + // it false so that we instead outline the row to get a new canEmitDebugInfo if needed. + canEmitDebugInfo = false; } const parentSerializedSize = serializedSize; try { @@ -4595,7 +4642,7 @@ function tryStreamTask(request: Request, task: Task): void { } finally { serializedSize = parentSerializedSize; if (__DEV__) { - debugID = prevDebugID; + canEmitDebugInfo = prevCanEmitDebugInfo; } } } diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index eee2a3749f94d..989dbdf19cbff 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -5,6 +5,8 @@ const path = require('path'); import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; let React; +let ReactServer; +let cache; let ReactServerDOMServer; let ReactServerDOMClient; let Stream; @@ -61,6 +63,9 @@ function normalizeDebugInfo(debugInfo) { if (debugInfo.awaited) { copy.awaited = normalizeIOInfo(copy.awaited); } + if (debugInfo.props) { + copy.props = {}; + } return copy; } else if (typeof debugInfo.time === 'number') { return {...debugInfo, time: 0}; @@ -83,6 +88,17 @@ function getDebugInfo(obj) { return debugInfo; } +function filterStackFrame(filename, functionName) { + return ( + filename !== '' && + !filename.startsWith('node:') && + !filename.includes('node_modules') && + // Filter out our own internal source code since it'll typically be in node_modules + (!filename.includes('/packages/') || filename.includes('/__tests__/')) && + !filename.includes('/build/') + ); +} + describe('ReactFlightAsyncDebugInfo', () => { beforeEach(() => { jest.resetModules(); @@ -94,7 +110,9 @@ describe('ReactFlightAsyncDebugInfo', () => { jest.mock('react-server-dom-webpack/server', () => require('react-server-dom-webpack/server.node'), ); + ReactServer = require('react'); ReactServerDOMServer = require('react-server-dom-webpack/server'); + cache = ReactServer.cache; jest.resetModules(); jest.useRealTimers(); @@ -135,16 +153,23 @@ describe('ReactFlightAsyncDebugInfo', () => { } it('can track async information when awaited', async () => { - async function getData() { + async function getData(text) { await delay(1); const promise = delay(2); await Promise.all([promise]); - return 'hi'; + return text.toUpperCase(); } async function Component() { - const result = await getData(); - return result; + const result = await getData('hi'); + const moreData = getData('seb'); + return ; + } + + async function InnerComponent({text, promise}) { + // This async function depends on the I/O in parent components but it should not + // include that I/O as part of its own meta data. + return text + ', ' + (await promise); } const stream = ReactServerDOMServer.renderToPipeableStream(); @@ -157,7 +182,7 @@ describe('ReactFlightAsyncDebugInfo', () => { }); stream.pipe(readable); - expect(await result).toBe('hi'); + expect(await result).toBe('HI, SEB'); if ( __DEV__ && gate( @@ -179,9 +204,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 150, + 175, 109, - 137, + 155, 50, ], ], @@ -203,9 +228,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 150, + 175, 109, - 137, + 155, 50, ], ], @@ -214,25 +239,25 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "delay", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 115, + 133, 12, - 114, + 132, 3, ], [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 139, + 157, 13, - 138, + 156, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 146, + 164, 26, - 145, + 163, 5, ], ], @@ -248,9 +273,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 150, + 175, 109, - 137, + 155, 50, ], ], @@ -259,17 +284,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 139, + 157, 13, - 138, + 156, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 146, + 164, 26, - 145, + 163, 5, ], ], @@ -294,9 +319,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 150, + 175, 109, - 137, + 155, 50, ], ], @@ -305,25 +330,25 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "delay", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 115, + 133, 12, - 114, + 132, 3, ], [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 140, + 158, 21, - 138, + 156, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 146, + 164, 20, - 145, + 163, 5, ], ], @@ -339,9 +364,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 150, + 175, 109, - 137, + 155, 50, ], ], @@ -350,17 +375,111 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 141, + 159, 21, - 138, + 156, 5, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 146, + 164, 20, - 145, + 163, + 5, + ], + ], + }, + { + "time": 0, + }, + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "InnerComponent", + "props": {}, + "stack": [ + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 166, + 60, + 163, + 5, + ], + ], + }, + { + "time": 0, + }, + { + "awaited": { + "end": 0, + "env": "Server", + "name": "getData", + "owner": { + "env": "Server", + "key": null, + "name": "Component", + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 175, + 109, + 155, + 50, + ], + ], + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 156, + 27, + 156, + 5, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 165, + 22, + 163, + 5, + ], + ], + "start": 0, + }, + "env": "Server", + "owner": { + "env": "Server", + "key": null, + "name": "InnerComponent", + "props": {}, + "stack": [ + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 166, + 60, + 163, + 5, + ], + ], + }, + "stack": [ + [ + "InnerComponent", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 172, + 35, + 169, 5, ], ], @@ -421,9 +540,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 392, + 511, 109, - 379, + 498, 67, ], ], @@ -445,9 +564,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 392, + 511, 109, - 379, + 498, 67, ], ], @@ -456,9 +575,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 382, + 501, 7, - 380, + 499, 5, ], ], @@ -518,9 +637,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 489, + 608, 109, - 480, + 599, 94, ], ], @@ -589,9 +708,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 560, + 679, 109, - 536, + 655, 50, ], ], @@ -671,9 +790,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 642, + 761, 109, - 625, + 744, 63, ], ], @@ -690,17 +809,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "fetchThirdParty", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 122, + 140, 40, - 120, + 138, 3, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 638, + 757, 24, - 637, + 756, 5, ], ], @@ -722,17 +841,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "fetchThirdParty", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 122, + 140, 40, - 120, + 138, 3, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 638, + 757, 24, - 637, + 756, 5, ], ], @@ -741,25 +860,25 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "delay", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 115, + 133, 12, - 114, + 132, 3, ], [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 627, + 746, 13, - 626, + 745, 5, ], [ "ThirdPartyComponent", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 633, + 752, 24, - 632, + 751, 5, ], ], @@ -775,17 +894,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "fetchThirdParty", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 122, + 140, 40, - 120, + 138, 3, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 638, + 757, 24, - 637, + 756, 5, ], ], @@ -794,17 +913,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 627, + 746, 13, - 626, + 745, 5, ], [ "ThirdPartyComponent", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 633, + 752, 24, - 632, + 751, 5, ], ], @@ -829,17 +948,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "fetchThirdParty", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 122, + 140, 40, - 120, + 138, 3, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 638, + 757, 24, - 637, + 756, 5, ], ], @@ -848,25 +967,25 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "delay", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 115, + 133, 12, - 114, + 132, 3, ], [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 628, + 747, 13, - 626, + 745, 5, ], [ "ThirdPartyComponent", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 633, + 752, 18, - 632, + 751, 5, ], ], @@ -882,17 +1001,17 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "fetchThirdParty", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 122, + 140, 40, - 120, + 138, 3, ], [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 638, + 757, 24, - 637, + 756, 5, ], ], @@ -901,17 +1020,185 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "getData", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 628, + 747, 13, - 626, + 745, 5, ], [ "ThirdPartyComponent", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 633, + 752, 18, - 632, + 751, + 5, + ], + ], + }, + { + "time": 0, + }, + { + "time": 0, + }, + { + "time": 0, + }, + ] + `); + } + }); + + it('can track cached entries awaited in later components', async () => { + let cacheKey; + let cacheValue; + const getData = cache(async function getData(text) { + if (cacheKey === text) { + return cacheValue; + } + await delay(1); + return text.toUpperCase(); + }); + + async function Child() { + const greeting = await getData('hi'); + return greeting + ', Seb'; + } + + async function Component() { + await getData('hi'); + return ; + } + + const stream = ReactServerDOMServer.renderToPipeableStream( + , + {}, + { + filterStackFrame, + }, + ); + + const readable = new Stream.PassThrough(streamOptions); + + const result = ReactServerDOMClient.createFromNodeStream(readable, { + moduleMap: {}, + moduleLoading: {}, + }); + stream.pipe(readable); + + expect(await result).toBe('HI, Seb'); + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { + expect(getDebugInfo(result)).toMatchInlineSnapshot(` + [ + { + "time": 0, + }, + { + "env": "Server", + "key": null, + "name": "Component", + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1074, + 40, + 1052, + 62, + ], + ], + }, + { + "time": 0, + }, + { + "awaited": { + "end": 0, + "env": "Server", + "name": "delay", + "owner": { + "env": "Server", + "key": null, + "name": "Component", + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1074, + 40, + 1052, + 62, + ], + ], + }, + "stack": [ + [ + "delay", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 133, + 12, + 132, + 3, + ], + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1059, + 13, + 1055, + 25, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1069, + 13, + 1068, + 5, + ], + ], + "start": 0, + }, + "env": "Server", + "owner": { + "env": "Server", + "key": null, + "name": "Component", + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1074, + 40, + 1052, + 62, + ], + ], + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1059, + 13, + 1055, + 25, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1069, + 13, + 1068, 5, ], ], @@ -922,6 +1209,97 @@ describe('ReactFlightAsyncDebugInfo', () => { { "time": 0, }, + { + "env": "Server", + "key": null, + "name": "Child", + "props": {}, + "stack": [ + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1070, + 60, + 1068, + 5, + ], + ], + }, + { + "time": 0, + }, + { + "awaited": { + "end": 0, + "env": "Server", + "name": "getData", + "owner": { + "env": "Server", + "key": null, + "name": "Component", + "props": {}, + "stack": [ + [ + "Object.", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1074, + 40, + 1052, + 62, + ], + ], + }, + "stack": [ + [ + "getData", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1055, + 47, + 1055, + 25, + ], + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1069, + 13, + 1068, + 5, + ], + ], + "start": 0, + }, + "env": "Server", + "owner": { + "env": "Server", + "key": null, + "name": "Child", + "props": {}, + "stack": [ + [ + "Component", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1070, + 60, + 1068, + 5, + ], + ], + }, + "stack": [ + [ + "Child", + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", + 1064, + 28, + 1063, + 5, + ], + ], + }, + { + "time": 0, + }, { "time": 0, }, From e4b88ae4c6c30791b6c1c2794d5a8e32ed19c931 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sat, 7 Jun 2025 23:39:25 +0200 Subject: [PATCH 19/33] [Flight] Add Web Streams APIs to unbundled Node entries for Webpack (#33480) --- .../react-server-dom-webpack/npm/server.node.unbundled.js | 4 +++- packages/react-server-dom-webpack/server.node.unbundled.js | 4 +++- packages/react-server-dom-webpack/static.node.unbundled.js | 5 ++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/react-server-dom-webpack/npm/server.node.unbundled.js b/packages/react-server-dom-webpack/npm/server.node.unbundled.js index 333b6b0d3122e..5ecd09924975e 100644 --- a/packages/react-server-dom-webpack/npm/server.node.unbundled.js +++ b/packages/react-server-dom-webpack/npm/server.node.unbundled.js @@ -7,9 +7,11 @@ if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-server-dom-webpack-server.node.unbundled.development.js'); } +exports.renderToReadableStream = s.renderToReadableStream; exports.renderToPipeableStream = s.renderToPipeableStream; -exports.decodeReplyFromBusboy = s.decodeReplyFromBusboy; exports.decodeReply = s.decodeReply; +exports.decodeReplyFromBusboy = s.decodeReplyFromBusboy; +exports.decodeReplyFromAsyncIterable = s.decodeReplyFromAsyncIterable; exports.decodeAction = s.decodeAction; exports.decodeFormState = s.decodeFormState; exports.registerServerReference = s.registerServerReference; diff --git a/packages/react-server-dom-webpack/server.node.unbundled.js b/packages/react-server-dom-webpack/server.node.unbundled.js index 9b8455bf66877..db7af8607b33e 100644 --- a/packages/react-server-dom-webpack/server.node.unbundled.js +++ b/packages/react-server-dom-webpack/server.node.unbundled.js @@ -9,8 +9,10 @@ export { renderToPipeableStream, - decodeReplyFromBusboy, + renderToReadableStream, decodeReply, + decodeReplyFromBusboy, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference, diff --git a/packages/react-server-dom-webpack/static.node.unbundled.js b/packages/react-server-dom-webpack/static.node.unbundled.js index 35296ee12785a..be7dbcb721b9f 100644 --- a/packages/react-server-dom-webpack/static.node.unbundled.js +++ b/packages/react-server-dom-webpack/static.node.unbundled.js @@ -7,4 +7,7 @@ * @flow */ -export {unstable_prerenderToNodeStream} from './src/server/react-flight-dom-server.node.unbundled'; +export { + unstable_prerender, + unstable_prerenderToNodeStream, +} from './src/server/react-flight-dom-server.node.unbundled'; From c0b5a0cad32cbf237d4c0134bef702d6ba3e393c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 8 Jun 2025 06:33:25 +0200 Subject: [PATCH 20/33] [Flight] Use Web Streams APIs for 3rd-party component in Flight fixture (#33481) --- fixtures/flight/src/App.js | 52 +++++++++++++++----------------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index d244ec8d39402..8dacafd92310a 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -1,6 +1,6 @@ import * as React from 'react'; -import {renderToPipeableStream} from 'react-server-dom-webpack/server'; -import {createFromNodeStream} from 'react-server-dom-webpack/client'; +import {renderToReadableStream} from 'react-server-dom-webpack/server'; +import {createFromReadableStream} from 'react-server-dom-webpack/client'; import {PassThrough, Readable} from 'stream'; import Container from './Container.js'; @@ -46,43 +46,33 @@ async function ThirdPartyComponent() { return delay('hello from a 3rd party', 30); } -// Using Web streams for tee'ing convenience here. -let cachedThirdPartyReadableWeb; +let cachedThirdPartyStream; // We create the Component outside of AsyncLocalStorage so that it has no owner. // That way it gets the owner from the call to createFromNodeStream. const thirdPartyComponent = ; function fetchThirdParty(noCache) { - if (cachedThirdPartyReadableWeb && !noCache) { - const [readableWeb1, readableWeb2] = cachedThirdPartyReadableWeb.tee(); - cachedThirdPartyReadableWeb = readableWeb1; - - return createFromNodeStream(Readable.fromWeb(readableWeb2), { + // We're using the Web Streams APIs for tee'ing convenience. + const stream = + cachedThirdPartyStream && !noCache + ? cachedThirdPartyStream + : renderToReadableStream( + thirdPartyComponent, + {}, + {environmentName: 'third-party'} + ); + + const [stream1, stream2] = stream.tee(); + cachedThirdPartyStream = stream1; + + return createFromReadableStream(stream2, { + serverConsumerManifest: { moduleMap: {}, - moduleLoading: {}, - }); - } - - const stream = renderToPipeableStream( - thirdPartyComponent, - {}, - {environmentName: 'third-party'} - ); - - const readable = new PassThrough(); - // React currently only supports piping to one stream, so we convert, tee, and - // convert back again. - // TODO: Switch to web streams without converting when #33442 has landed. - const [readableWeb1, readableWeb2] = Readable.toWeb(readable).tee(); - cachedThirdPartyReadableWeb = readableWeb1; - const result = createFromNodeStream(Readable.fromWeb(readableWeb2), { - moduleMap: {}, - moduleLoading: {}, + serverModuleMap: null, + moduleLoading: null, + }, }); - stream.pipe(readable); - - return result; } async function ServerComponent({noCache}) { From 911dbd9e34048b21e96f24acb837b926687aa939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Mon, 9 Jun 2025 11:55:28 +0200 Subject: [PATCH 21/33] feat(ReactNative): prioritize attribute config `process` function to allow processing function props (#32119) ## Summary In react-native props that are passed as function get converted to a boolean (`true`). This is the default pattern for event handlers in react-native. However, there are reasons for why you might want to opt-out of this behavior, and instead, pass along the actual function as the prop. Right now, there is no way to do this, and props that are functions always get set to `true`. The `ViewConfig` attributes already have the API for a `process` function. I simply moved the check for the process function up, so if a ViewConfig's prop attribute configured a process function this is always called first. This provides an API to opt out of the default behavior. This is the accompanied PR for react-native: - https://github.com/facebook/react-native/pull/48777 ## How did you test this change? I modified the code manually in a template react-native app and confirmed its working. This is a code path you only need in very special cases, thus it's a bit hard to provide a test for this. I recorded a video where you can see that the changes are active and the prop is being passed as native value. For this I created a custom native component with a view config that looked like this: ```js const viewConfig = { uiViewClassName: 'CustomView', bubblingEventTypes: {}, directEventTypes: {}, validAttributes: { nativeProp: { process: (nativeProp) => { // Identity function that simply returns the prop function callback // to opt out of this prop being set to `true` as its a function return nativeProp }, }, }, } ``` https://github.com/user-attachments/assets/493534b2-a508-4142-a760-0b1b24419e19 Additionally I made sure that this doesn't conflict with any existing view configs in react native. In general, this shouldn't be a breaking change, as for existing view configs it didn't made a difference if you simply set `myProp: true` or `myProp: { process: () => {...} }` because as soon as it was detected that the prop is a function the config wouldn't be used (which is what this PR fixes). Probably everyone, including the react-native core components use `myProp: true` for callback props, so this change should be fine. --- .../src/ReactNativeAttributePayloadFabric.js | 45 +++++++++++-------- ...iveAttributePayloadFabric-test.internal.js | 25 +++++++++++ 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js index ecbef65733d4d..e260a5cce758d 100644 --- a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js +++ b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js @@ -254,14 +254,17 @@ function diffProperties( prevProp = prevProps[propKey]; nextProp = nextProps[propKey]; - // functions are converted to booleans as markers that the associated - // events should be sent from native. if (typeof nextProp === 'function') { - nextProp = (true: any); - // If nextProp is not a function, then don't bother changing prevProp - // since nextProp will win and go into the updatePayload regardless. - if (typeof prevProp === 'function') { - prevProp = (true: any); + const attributeConfigHasProcess = typeof attributeConfig === 'object' && typeof attributeConfig.process === 'function'; + if (!attributeConfigHasProcess) { + // functions are converted to booleans as markers that the associated + // events should be sent from native. + nextProp = (true: any); + // If nextProp is not a function, then don't bother changing prevProp + // since nextProp will win and go into the updatePayload regardless. + if (typeof prevProp === 'function') { + prevProp = (true: any); + } } } @@ -444,18 +447,22 @@ function addNestedProperty( } else { continue; } - } else if (typeof prop === 'function') { - // A function prop. It represents an event handler. Pass it to native as 'true'. - newValue = true; - } else if (typeof attributeConfig !== 'object') { - // An atomic prop. Doesn't need to be flattened. - newValue = prop; - } else if (typeof attributeConfig.process === 'function') { - // An atomic prop with custom processing. - newValue = attributeConfig.process(prop); - } else if (typeof attributeConfig.diff === 'function') { - // An atomic prop with custom diffing. We don't need to do diffing when adding props. - newValue = prop; + } else if (typeof attributeConfig === 'object') { + if (typeof attributeConfig.process === 'function') { + // An atomic prop with custom processing. + newValue = attributeConfig.process(prop); + } else if (typeof attributeConfig.diff === 'function') { + // An atomic prop with custom diffing. We don't need to do diffing when adding props. + newValue = prop; + } + } else { + if (typeof prop === 'function') { + // A function prop. It represents an event handler. Pass it to native as 'true'. + newValue = true; + } else { + // An atomic prop. Doesn't need to be flattened. + newValue = prop; + } } if (newValue !== undefined) { diff --git a/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js index 0225ca46012e6..33f34f6d25515 100644 --- a/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js @@ -102,6 +102,14 @@ describe('ReactNativeAttributePayloadFabric.create', () => { expect(processA).toBeCalledWith(2); }); + it('should use the process attribute for functions as well', () => { + const process = x => x; + const nextFunction = () => {}; + expect(create({a: nextFunction}, {a: {process}})).toEqual({ + a: nextFunction, + }); + }); + it('should work with undefined styles', () => { expect(create({style: undefined}, {style: {b: true}})).toEqual(null); expect(create({style: {a: '#ffffff', b: 1}}, {style: {b: true}})).toEqual({ @@ -452,4 +460,21 @@ describe('ReactNativeAttributePayloadFabric.diff', () => { ), ).toEqual(null); }); + + it('should use the process function config when prop is a function', () => { + const process = jest.fn(a => a); + const nextFunction = function () {}; + expect( + diff( + { + a: function () {}, + }, + { + a: nextFunction, + }, + {a: {process}}, + ), + ).toEqual({a: nextFunction}); + expect(process).toBeCalled(); + }); }); From 95bcf87e6b29f4efee26d0a79cbdc84776180cce Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 9 Jun 2025 13:42:10 +0200 Subject: [PATCH 22/33] Format `ReactNativeAttributePayloadFabric.js` with Prettier (#33486) The prettier check for this file is currently failing on `main`, after #32119 was merged. --- .../src/ReactNativeAttributePayloadFabric.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js index e260a5cce758d..88ff3c73099f3 100644 --- a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js +++ b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js @@ -255,7 +255,9 @@ function diffProperties( nextProp = nextProps[propKey]; if (typeof nextProp === 'function') { - const attributeConfigHasProcess = typeof attributeConfig === 'object' && typeof attributeConfig.process === 'function'; + const attributeConfigHasProcess = + typeof attributeConfig === 'object' && + typeof attributeConfig.process === 'function'; if (!attributeConfigHasProcess) { // functions are converted to booleans as markers that the associated // events should be sent from native. From 4df098c4c2c51a033592ebc84abc47cc49a6bfb2 Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Mon, 9 Jun 2025 09:26:45 -0400 Subject: [PATCH 23/33] [compiler] Don't include useEffectEvent values in autodeps (#33450) Summary: useEffectEvent values are not meant to be added to the dep array --- .../src/HIR/Globals.ts | 23 +++++++++ .../src/HIR/HIR.ts | 7 +++ .../src/HIR/ObjectShape.ts | 16 ++++++ .../src/Inference/InferEffectDependencies.ts | 4 +- .../nonreactive-effect-event.expect.md | 49 +++++++++++++++++++ .../nonreactive-effect-event.js | 11 +++++ 6 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/nonreactive-effect-event.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/nonreactive-effect-event.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts index b8504494662d6..cc11d0faceb18 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts @@ -17,6 +17,7 @@ import { BuiltInSetId, BuiltInUseActionStateId, BuiltInUseContextHookId, + BuiltInUseEffectEventId, BuiltInUseEffectHookId, BuiltInUseInsertionEffectHookId, BuiltInUseLayoutEffectHookId, @@ -27,6 +28,7 @@ import { BuiltInUseTransitionId, BuiltInWeakMapId, BuiltInWeakSetId, + BuiltinEffectEventId, ReanimatedSharedValueId, ShapeRegistry, addFunction, @@ -722,6 +724,27 @@ const REACT_APIS: Array<[string, BuiltInType]> = [ BuiltInFireId, ), ], + [ + 'useEffectEvent', + addHook( + DEFAULT_SHAPES, + { + positionalParams: [], + restParam: Effect.Freeze, + returnType: { + kind: 'Function', + return: {kind: 'Poly'}, + shapeId: BuiltinEffectEventId, + isConstructor: false, + }, + calleeEffect: Effect.Read, + hookKind: 'useEffectEvent', + // Frozen because it should not mutate any locally-bound values + returnValueKind: ValueKind.Frozen, + }, + BuiltInUseEffectEventId, + ), + ], ]; TYPED_GLOBALS.push( diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts index 1699a0fc3d292..6c55ff22bc649 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -1785,6 +1785,13 @@ export function isFireFunctionType(id: Identifier): boolean { ); } +export function isEffectEventFunctionType(id: Identifier): boolean { + return ( + id.type.kind === 'Function' && + id.type.shapeId === 'BuiltInEffectEventFunction' + ); +} + export function isStableType(id: Identifier): boolean { return ( isSetStateType(id) || diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts index 03f4120149b0e..a017e1479a22b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -131,6 +131,7 @@ export type HookKind = | 'useCallback' | 'useTransition' | 'useImperativeHandle' + | 'useEffectEvent' | 'Custom'; /* @@ -226,6 +227,8 @@ export const BuiltInUseTransitionId = 'BuiltInUseTransition'; export const BuiltInStartTransitionId = 'BuiltInStartTransition'; export const BuiltInFireId = 'BuiltInFire'; export const BuiltInFireFunctionId = 'BuiltInFireFunction'; +export const BuiltInUseEffectEventId = 'BuiltInUseEffectEvent'; +export const BuiltinEffectEventId = 'BuiltInEffectEventFunction'; // See getReanimatedModuleType() in Globals.ts — this is part of supporting Reanimated's ref-like types export const ReanimatedSharedValueId = 'ReanimatedSharedValueId'; @@ -948,6 +951,19 @@ addObject(BUILTIN_SHAPES, BuiltInRefValueId, [ ['*', {kind: 'Object', shapeId: BuiltInRefValueId}], ]); +addFunction( + BUILTIN_SHAPES, + [], + { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: {kind: 'Poly'}, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Mutable, + }, + BuiltinEffectEventId, +); + /** * MixedReadOnly = * | primitive diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts index 4daa2f9fbaee7..eab3c241bcccf 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts @@ -31,6 +31,7 @@ import { HIR, BasicBlock, BlockId, + isEffectEventFunctionType, } from '../HIR'; import {collectHoistablePropertyLoadsInInnerFn} from '../HIR/CollectHoistablePropertyLoads'; import {collectOptionalChainSidemap} from '../HIR/CollectOptionalChainDependencies'; @@ -209,7 +210,8 @@ export function inferEffectDependencies(fn: HIRFunction): void { ((isUseRefType(maybeDep.identifier) || isSetStateType(maybeDep.identifier)) && !reactiveIds.has(maybeDep.identifier.id)) || - isFireFunctionType(maybeDep.identifier) + isFireFunctionType(maybeDep.identifier) || + isEffectEventFunctionType(maybeDep.identifier) ) { // exclude non-reactive hook results, which will never be in a memo block continue; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/nonreactive-effect-event.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/nonreactive-effect-event.expect.md new file mode 100644 index 0000000000000..56c5f6f6f8807 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/nonreactive-effect-event.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +// @inferEffectDependencies +import {useEffect, useEffectEvent} from 'react'; +import {print} from 'shared-runtime'; + +/** + * We do not include effect events in dep arrays. + */ +function NonReactiveEffectEvent() { + const fn = useEffectEvent(() => print('hello world')); + useEffect(() => fn()); +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies +import { useEffect, useEffectEvent } from "react"; +import { print } from "shared-runtime"; + +/** + * We do not include effect events in dep arrays. + */ +function NonReactiveEffectEvent() { + const $ = _c(2); + const fn = useEffectEvent(_temp); + let t0; + if ($[0] !== fn) { + t0 = () => fn(); + $[0] = fn; + $[1] = t0; + } else { + t0 = $[1]; + } + useEffect(t0, []); +} +function _temp() { + return print("hello world"); +} + +``` + +### Eval output +(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/nonreactive-effect-event.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/nonreactive-effect-event.js new file mode 100644 index 0000000000000..02706c6b23735 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/nonreactive-effect-event.js @@ -0,0 +1,11 @@ +// @inferEffectDependencies +import {useEffect, useEffectEvent} from 'react'; +import {print} from 'shared-runtime'; + +/** + * We do not include effect events in dep arrays. + */ +function NonReactiveEffectEvent() { + const fn = useEffectEvent(() => print('hello world')); + useEffect(() => fn()); +} From 428ab8200128d9421828dbe644c3448d21ea8c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 9 Jun 2025 10:04:40 -0400 Subject: [PATCH 24/33] [Flight] Simulate fetch to third party in fixture (#33484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds some I/O to go get the third party thing to test how it overlaps. With #33482, this is what it looks like. The await gets cut off when the third party component starts rendering. I.e. after the latency to start. Screenshot 2025-06-08 at 5 42 46 PM This doesn't fully simulate everything because it should actually also simulate each chunk of the stream coming back too. We could wrap the ReadableStream to simulate that. In that scenario, it would probably get some awaits on the chunks at the end too. --- fixtures/flight/src/App.js | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 8dacafd92310a..833c655cbffc7 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -43,7 +43,7 @@ async function Bar({children}) { } async function ThirdPartyComponent() { - return delay('hello from a 3rd party', 30); + return await delay('hello from a 3rd party', 30); } let cachedThirdPartyStream; @@ -52,16 +52,35 @@ let cachedThirdPartyStream; // That way it gets the owner from the call to createFromNodeStream. const thirdPartyComponent = ; -function fetchThirdParty(noCache) { +function simulateFetch(cb, latencyMs) { + return new Promise(resolve => { + // Request latency + setTimeout(() => { + const result = cb(); + // Response latency + setTimeout(() => { + resolve(result); + }, latencyMs); + }, latencyMs); + }); +} + +async function fetchThirdParty(noCache) { // We're using the Web Streams APIs for tee'ing convenience. - const stream = - cachedThirdPartyStream && !noCache - ? cachedThirdPartyStream - : renderToReadableStream( + let stream; + if (cachedThirdPartyStream && !noCache) { + stream = cachedThirdPartyStream; + } else { + stream = await simulateFetch( + () => + renderToReadableStream( thirdPartyComponent, {}, {environmentName: 'third-party'} - ); + ), + 25 + ); + } const [stream1, stream2] = stream.tee(); cachedThirdPartyStream = stream1; From b6c0aa88140bba2a61c1de16bda2505c89b26235 Mon Sep 17 00:00:00 2001 From: Wesley LeMahieu Date: Mon, 9 Jun 2025 08:40:27 -0700 Subject: [PATCH 25/33] [compiler]: fix link compiler & 4 broken tests from path containing spaces (#33409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Problem #1: Running the `link-compiler.sh` bash script via `"prebuild"` script fails if a developer has cloned the `react` repo into a folder that contains _any_ spaces. 3 tests fail because of this. fail-1 fail-2 fail-3 For example, my current folder is: `/Users/wes/Development/Open Source Contributions/react` The link compiler error returns: `./scripts/react-compiler/link-compiler.sh: line 15: cd: /Users/wes/Development/Open: No such file or directory` Problem #2: 1 test in `ReactChildren-test.js` fails due the existing stack trace regex which should be lightly revised. `([^(\[\n]+)[^\n]*/g` is more robust for stack traces: it captures the function/class name (with dots) and does not break on spaces in file paths. `([\S]+)[^\n]*/g` is simpler but breaks if there are spaces and doesn't handle dotted names well. Additionally, we trim the whitespace off the name to resolve extra spaces breaking this test as well: ``` - in div (at **) + in div (at **) ``` fail-4 All of the above tests pass if I hyphenate my local folder: `/Users/wes/Development/Open-Source-Contributions/react` I selfishly want to keep spaces in my folder names. 🫣 ## How did you test this change? **npx yarn prebuild** Before: Screenshot at Jun 01 11-42-56 After: Screenshot at Jun 01 11-43-42 **npx yarn test** **npx yarn test ./packages/react/src/\_\_tests\_\_/ReactChildren-test.js** **npx yarn test -r=xplat --env=development --variant=true --ci --shard=3/5** Before: before After: after Screenshot at Jun 02 18-03-39 Screenshot at Jun 03 12-53-47 --- compiler/apps/playground/scripts/link-compiler.sh | 4 ++-- packages/internal-test-utils/consoleMock.js | 3 ++- scripts/react-compiler/link-compiler.sh | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/compiler/apps/playground/scripts/link-compiler.sh b/compiler/apps/playground/scripts/link-compiler.sh index 1ee5f0b81bf09..96188f7b45137 100755 --- a/compiler/apps/playground/scripts/link-compiler.sh +++ b/compiler/apps/playground/scripts/link-compiler.sh @@ -8,8 +8,8 @@ set -eo pipefail HERE=$(pwd) -cd ../../packages/react-compiler-runtime && yarn --silent link && cd $HERE -cd ../../packages/babel-plugin-react-compiler && yarn --silent link && cd $HERE +cd ../../packages/react-compiler-runtime && yarn --silent link && cd "$HERE" +cd ../../packages/babel-plugin-react-compiler && yarn --silent link && cd "$HERE" yarn --silent link babel-plugin-react-compiler yarn --silent link react-compiler-runtime diff --git a/packages/internal-test-utils/consoleMock.js b/packages/internal-test-utils/consoleMock.js index 9bb797bac395b..ecb97b3a03059 100644 --- a/packages/internal-test-utils/consoleMock.js +++ b/packages/internal-test-utils/consoleMock.js @@ -156,7 +156,8 @@ function normalizeCodeLocInfo(str) { // at Component (/path/filename.js:123:45) // React format: // in Component (at filename.js:123) - return str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) { + return str.replace(/\n +(?:at|in) ([^(\[\n]+)[^\n]*/g, function (m, name) { + name = name.trim(); if (name.endsWith('.render')) { // Class components will have the `render` method as part of their stack trace. // We strip that out in our normalization to make it look more like component stacks. diff --git a/scripts/react-compiler/link-compiler.sh b/scripts/react-compiler/link-compiler.sh index 47bb84be94d9f..987209688783a 100755 --- a/scripts/react-compiler/link-compiler.sh +++ b/scripts/react-compiler/link-compiler.sh @@ -12,6 +12,6 @@ fi HERE=$(pwd) -cd compiler/packages/babel-plugin-react-compiler && yarn --silent link && cd $HERE +cd compiler/packages/babel-plugin-react-compiler && yarn --silent link && cd "$HERE" yarn --silent link babel-plugin-react-compiler From 80c03eb7e0f05da5e0de6faebbe8dbb434455454 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Mon, 9 Jun 2025 18:25:19 +0100 Subject: [PATCH 26/33] refactor[devtools]: update css for settings and support css variables in shadow dom scnenario (#33487) ## Summary Minor changes around css and styling of Settings dialog. 1. `:root` selector was updated to `:is(:root, :host)` to make css variables available on Shadow Root 2. CSS tweaks around Settings dialog: removed references to deleted styles, removed unused styles, ironed out styling for cases when input styles are enhanced by user agent stylesheet ## How did you test this change? | Before | After | |--------|--------| | ![Screenshot 2025-06-09 at 15 35 55](https://github.com/user-attachments/assets/1ac5d002-744b-4b10-9501-d4f2a7c827d2) | ![Screenshot 2025-06-09 at 15 26 12](https://github.com/user-attachments/assets/8cc07cda-99a5-4930-973b-b139b193e349) | | ![Screenshot 2025-06-09 at 15 36 02](https://github.com/user-attachments/assets/1af4257c-928d-4ec6-a614-801cc1936f4b) | ![Screenshot 2025-06-09 at 15 26 25](https://github.com/user-attachments/assets/7a3a0f7c-5f3d-4567-a782-dd37368a15ae) | | ![Screenshot 2025-06-09 at 15 36 05](https://github.com/user-attachments/assets/a1e00381-2901-4e22-b1c6-4a3f66ba78c9) | ![Screenshot 2025-06-09 at 15 26 30](https://github.com/user-attachments/assets/bdefce68-cbb5-4b88-b44c-a74f28533f7d) | | ![Screenshot 2025-06-09 at 15 36 12](https://github.com/user-attachments/assets/4eda6234-0ef0-40ca-ad9d-5990a2b1e8b4) | ![Screenshot 2025-06-09 at 15 26 37](https://github.com/user-attachments/assets/5cac305e-fd29-460c-b0b8-30e477b8c26e) | --- .../views/Settings/ComponentsSettings.js | 46 ++++++++++--------- .../views/Settings/DebuggingSettings.js | 36 ++++++++------- .../views/Settings/GeneralSettings.js | 19 ++++---- .../views/Settings/ProfilerSettings.js | 36 ++++++++------- .../views/Settings/SettingsShared.css | 29 ++++++------ .../src/devtools/views/root.css | 2 +- 6 files changed, 88 insertions(+), 80 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js index 4309b0e5fbe4d..106538cad3ab2 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js @@ -340,30 +340,35 @@ export default function ComponentsSettings({ ); return ( -
- +
+
+ +
- +
+ +
-
-
+
+
-
-
-
-