From 43714eb4e970d0200fdc5eac887691df7fae53d5 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 5 Jun 2025 21:08:57 +0200 Subject: [PATCH 001/144] 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 002/144] 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 003/144] [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 004/144] [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 005/144] [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 006/144] [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 007/144] [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 008/144] [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 009/144] [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 010/144] [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 011/144] [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 012/144] 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 013/144] [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 014/144] [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 015/144] [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 016/144] [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 017/144] [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 018/144] [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 019/144] [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 020/144] [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 021/144] 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 022/144] 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 023/144] [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 024/144] [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 025/144] [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 026/144] 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 ( -
- +
+
+ +
- +
+ +
-
-
+
+
-
-
-
-
+
{isInternalFacebookBuild && ( -
+
This is an internal build of React DevTools for Meta
)} -
+
Theme
-
+
Display density
setTraceUpdatesEnabled(currentTarget.checked) } - />{' '} - Highlight updates when components render. + className={styles.SettingRowCheckbox} + /> + Highlight updates when components render
)} diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/ProfilerSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/ProfilerSettings.js index 25bc08d7ad128..604fe4cba14d4 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/ProfilerSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/ProfilerSettings.js @@ -69,35 +69,37 @@ export default function ProfilerSettings(_: {}): React.Node { const minCommitDurationInputRef = useRef(null); return ( -
-
-
); } 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 6cfb4b61eda50..186d9c4c780d8 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,7 +6,7 @@ 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(b){$RT=performance.now();for(var a=0;aa&&2E3a&&2E3q&&2E3 Date: Fri, 13 Jun 2025 10:25:04 -0400 Subject: [PATCH 034/144] [devtools-shell] layout options for testing (#33516) ## Summary This PR adds a 'Layout' selector to the devtools shell main example, as well as a resizable split pane, allowing more realistic testing of how the devtools behaves when used in a vertical or horizontal layout and at different sizes (e.g. when resizing the Chrome Dev Tools pane). ## How did you test this change? https://github.com/user-attachments/assets/81179413-7b46-47a9-bc52-4f7ec414e8be --- packages/react-devtools-shell/index.html | 265 +++++++++++++++++------ 1 file changed, 202 insertions(+), 63 deletions(-) diff --git a/packages/react-devtools-shell/index.html b/packages/react-devtools-shell/index.html index 4cde55278a9a9..ce381a7345325 100644 --- a/packages/react-devtools-shell/index.html +++ b/packages/react-devtools-shell/index.html @@ -1,74 +1,213 @@ - - - React DevTools - - - - -
+ + + React DevTools + + + + + +
+ +
 
+ + multi DevTools + | + e2e tests + | + e2e regression tests + | + perf regression tests + + + +
+ +
+ +
+
+
+ + + + + + + - - - - - - \ No newline at end of file + From 12bc60f50989a7e10b96c6fad429af7739c537df Mon Sep 17 00:00:00 2001 From: James Friend Date: Fri, 13 Jun 2025 10:28:31 -0400 Subject: [PATCH 035/144] [devtools] Added minimum indent size to Component Tree (#33517) ## Summary The devtools Components tab's component tree view currently has a behavior where the indentation of each level of the tree scales based on the available width of the view. If the view is narrow or component names are long, all indentation showing the hierarchy of the tree scales down with the view width until there is no indentation at all. This makes it impossible to see the nesting of the tree, making the tree view much less useful. With long component names and deep hierarchies this issue is particularly egregious. For comparison, the Chrome Dev Tools Elements panel uses a fixed indentation size, so it doesn't suffer from this issue. This PR adds a minimum pixel value for the indentation width, so that even when the window is narrow some indentation will still be visible, maintaining the visual representation of the component tree hierarchy. Alternatively, we could match the behavior of the Chrome Dev Tools and just use a constant indentation width. ## How did you test this change? - tests (yarn test-build-devtools) - tested in browser: - added an alternate left/right split pane layout to react-devtools-shell to test with (https://github.com/facebook/react/pull/33516) - tested resizing the tree view in different layout modes ### before this change: https://github.com/user-attachments/assets/470991f1-dc05-473f-a2cb-4f7333f6bae4 with a long component name: https://github.com/user-attachments/assets/1568fc64-c7d7-4659-bfb1-9bfc9592fb9d ### after this change: https://github.com/user-attachments/assets/f60bd7fc-97f6-4680-9656-f0db3d155411 with a long component name: https://github.com/user-attachments/assets/6ac3f58c-42ea-4c5a-9a52-c3b397f37b45 --- .../src/devtools/views/Components/Tree.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js index 1ba61c52dd1a4..67cf50a07411c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js @@ -41,7 +41,8 @@ import {useExtensionComponentsPanelVisibility} from 'react-devtools-shared/src/f import {useChangeOwnerAction} from './OwnersListContext'; // Never indent more than this number of pixels (even if we have the room). -const DEFAULT_INDENTATION_SIZE = 12; +const MAX_INDENTATION_SIZE = 12; +const MIN_INDENTATION_SIZE = 4; export type ItemData = { isNavigatingWithKeyboard: boolean, @@ -490,11 +491,11 @@ function updateIndentationSizeVar( // Reset the max indentation size if the width of the tree has increased. if (listWidth > prevListWidthRef.current) { - indentationSizeRef.current = DEFAULT_INDENTATION_SIZE; + indentationSizeRef.current = MAX_INDENTATION_SIZE; } prevListWidthRef.current = listWidth; - let maxIndentationSize: number = indentationSizeRef.current; + let indentationSize: number = indentationSizeRef.current; // eslint-disable-next-line no-for-of-loops/no-for-of-loops for (const child of innerDiv.children) { @@ -517,12 +518,13 @@ function updateIndentationSizeVar( const remainingWidth = Math.max(0, listWidth - childWidth); - maxIndentationSize = Math.min(maxIndentationSize, remainingWidth / depth); + indentationSize = Math.min(indentationSize, remainingWidth / depth); } - indentationSizeRef.current = maxIndentationSize; + indentationSize = Math.max(indentationSize, MIN_INDENTATION_SIZE); + indentationSizeRef.current = indentationSize; - list.style.setProperty('--indentation-size', `${maxIndentationSize}px`); + list.style.setProperty('--indentation-size', `${indentationSize}px`); } // $FlowFixMe[missing-local-annot] @@ -545,7 +547,7 @@ function InnerElementType({children, style}) { // The user may have resized the window specifically to make more room for DevTools. // In either case, this should reset our max indentation size logic. // 2. The second is when the user enters or exits an owner tree. - const indentationSizeRef = useRef(DEFAULT_INDENTATION_SIZE); + const indentationSizeRef = useRef(MAX_INDENTATION_SIZE); const prevListWidthRef = useRef(0); const prevOwnerIDRef = useRef(ownerID); const divRef = useRef(null); @@ -554,7 +556,7 @@ function InnerElementType({children, style}) { // so when the user opens the "owners tree" view, we should discard the previous width. if (ownerID !== prevOwnerIDRef.current) { prevOwnerIDRef.current = ownerID; - indentationSizeRef.current = DEFAULT_INDENTATION_SIZE; + indentationSizeRef.current = MAX_INDENTATION_SIZE; } // When we render new content, measure to see if we need to shrink indentation to fit it. From d60f77a533da830613431ddef83d0eda928697ad Mon Sep 17 00:00:00 2001 From: lauren Date: Fri, 13 Jun 2025 14:22:55 -0400 Subject: [PATCH 036/144] [ci] Update prerelease workflows to allow publishing specific packages (#33525) It may be useful at times to publish only specific packages as an experimental tag. For example, if we need to cherry pick some fixes for an old release, we can first do so by creating that as an experimental release just for that package to allow for quick testing by downstream projects. Similar to .github/workflows/runtime_releases_from_npm_manual.yml I added three options (`dry`, `only_packages`, `skip_packages`) to `runtime_prereleases.yml` which both the manual and nightly workflows reuse. I also added a discord notification when the manual workflow is run. --- .github/workflows/runtime_prereleases.yml | 41 +++++++++++++++++- .../workflows/runtime_prereleases_manual.yml | 43 +++++++++++++++++++ .../workflows/runtime_prereleases_nightly.yml | 2 + 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/.github/workflows/runtime_prereleases.yml b/.github/workflows/runtime_prereleases.yml index ee8dd72ce9665..a97add8cbc41f 100644 --- a/.github/workflows/runtime_prereleases.yml +++ b/.github/workflows/runtime_prereleases.yml @@ -17,6 +17,17 @@ on: description: 'Whether to notify the team on Discord when the release fails. Useful if this workflow is called from an automation.' required: false type: boolean + only_packages: + description: Packages to publish (space separated) + type: string + skip_packages: + description: Packages to NOT publish (space separated) + type: string + dry: + required: true + description: Dry run instead of publish? + type: boolean + default: true secrets: DISCORD_WEBHOOK_URL: description: 'Discord webhook URL to notify on failure. Only required if enableFailureNotification is true.' @@ -61,10 +72,36 @@ jobs: if: steps.node_modules.outputs.cache-hit != 'true' - run: yarn --cwd scripts/release install --frozen-lockfile if: steps.node_modules.outputs.cache-hit != 'true' + - run: cp ./scripts/release/ci-npmrc ~/.npmrc - run: | GH_TOKEN=${{ secrets.GH_TOKEN }} scripts/release/prepare-release-from-ci.js --skipTests -r ${{ inputs.release_channel }} --commit=${{ inputs.commit_sha }} - cp ./scripts/release/ci-npmrc ~/.npmrc - scripts/release/publish.js --ci --tags ${{ inputs.dist_tag }} + - name: Check prepared files + run: ls -R build/node_modules + - if: '${{ inputs.only_packages }}' + name: 'Publish ${{ inputs.only_packages }}' + run: | + scripts/release/publish.js \ + --ci \ + --skipTests \ + --tags=${{ inputs.dist_tag }} \ + --onlyPackages=${{ inputs.only_packages }} ${{ (inputs.dry && '') || '\'}} + ${{ inputs.dry && '--dry'}} + - if: '${{ inputs.skip_packages }}' + name: 'Publish all packages EXCEPT ${{ inputs.skip_packages }}' + run: | + scripts/release/publish.js \ + --ci \ + --skipTests \ + --tags=${{ inputs.dist_tag }} \ + --skipPackages=${{ inputs.skip_packages }} ${{ (inputs.dry && '') || '\'}} + ${{ inputs.dry && '--dry'}} + - if: '${{ !(inputs.skip_packages && inputs.only_packages) }}' + name: 'Publish all packages' + run: | + scripts/release/publish.js \ + --ci \ + --tags=${{ inputs.dist_tag }} ${{ (inputs.dry && '') || '\'}} + ${{ inputs.dry && '--dry'}} - name: Notify Discord on failure if: failure() && inputs.enableFailureNotification == true uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4 diff --git a/.github/workflows/runtime_prereleases_manual.yml b/.github/workflows/runtime_prereleases_manual.yml index 71e25ba073a83..552b3e43a17f7 100644 --- a/.github/workflows/runtime_prereleases_manual.yml +++ b/.github/workflows/runtime_prereleases_manual.yml @@ -5,6 +5,25 @@ on: inputs: prerelease_commit_sha: required: true + only_packages: + description: Packages to publish (space separated) + type: string + skip_packages: + description: Packages to NOT publish (space separated) + type: string + dry: + required: true + description: Dry run instead of publish? + type: boolean + default: true + experimental_only: + type: boolean + description: Only publish to the experimental tag + default: false + force_notify: + description: Force a Discord notification? + type: boolean + default: false permissions: {} @@ -12,8 +31,26 @@ env: TZ: /usr/share/zoneinfo/America/Los_Angeles jobs: + notify: + if: ${{ inputs.force_notify || inputs.dry == false || inputs.dry == 'false' }} + runs-on: ubuntu-latest + steps: + - name: Discord Webhook Action + uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + embed-author-name: ${{ github.event.sender.login }} + embed-author-url: ${{ github.event.sender.html_url }} + embed-author-icon-url: ${{ github.event.sender.avatar_url }} + embed-title: "⚠️ Publishing ${{ inputs.experimental_only && 'EXPERIMENTAL' || 'CANARY & EXPERIMENTAL' }} release ${{ (inputs.dry && ' (dry run)') || '' }}" + embed-description: | + ```json + ${{ toJson(inputs) }} + ``` + embed-url: https://github.com/facebook/react/actions/runs/${{ github.run_id }} publish_prerelease_canary: + if: ${{ !inputs.experimental_only }} name: Publish to Canary channel uses: facebook/react/.github/workflows/runtime_prereleases.yml@main permissions: @@ -33,6 +70,9 @@ jobs: # downstream consumers might still expect that tag. We can remove this # after some time has elapsed and the change has been communicated. dist_tag: canary,next + only_packages: ${{ inputs.only_packages }} + skip_packages: ${{ inputs.skip_packages }} + dry: ${{ inputs.dry }} secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -52,6 +92,9 @@ jobs: commit_sha: ${{ inputs.prerelease_commit_sha }} release_channel: experimental dist_tag: experimental + only_packages: ${{ inputs.only_packages }} + skip_packages: ${{ inputs.skip_packages }} + dry: ${{ inputs.dry }} secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/runtime_prereleases_nightly.yml b/.github/workflows/runtime_prereleases_nightly.yml index a38e241d53996..f13a92e46f401 100644 --- a/.github/workflows/runtime_prereleases_nightly.yml +++ b/.github/workflows/runtime_prereleases_nightly.yml @@ -22,6 +22,7 @@ jobs: release_channel: stable dist_tag: canary,next enableFailureNotification: true + dry: false secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -43,6 +44,7 @@ jobs: release_channel: experimental dist_tag: experimental enableFailureNotification: true + dry: false secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} From 6b7e207cabe4c1bc9390d862dd9228e94e9edf4b Mon Sep 17 00:00:00 2001 From: lauren Date: Fri, 13 Jun 2025 15:29:59 -0400 Subject: [PATCH 037/144] [ci] Don't skip experimental prerelease incorrectly (#33527) Previously the experimental workflow relied on the canary one running first to avoid race conditions. However, I didn't account for the fact that the canary one can now be skipped. --- .github/workflows/runtime_prereleases_manual.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/runtime_prereleases_manual.yml b/.github/workflows/runtime_prereleases_manual.yml index 552b3e43a17f7..407d931e90738 100644 --- a/.github/workflows/runtime_prereleases_manual.yml +++ b/.github/workflows/runtime_prereleases_manual.yml @@ -88,6 +88,8 @@ jobs: # different versions of the same package, even if they use different # dist tags. needs: publish_prerelease_canary + # Ensures the job runs even if canary is skipped + if: always() with: commit_sha: ${{ inputs.prerelease_commit_sha }} release_channel: experimental From 5d24c64cc9c019fc644c4c6f0da640131b80ba18 Mon Sep 17 00:00:00 2001 From: Jan Kassens Date: Mon, 16 Jun 2025 12:22:47 -0400 Subject: [PATCH 038/144] Remove feature flag enableDO_NOT_USE_disableStrictPassiveEffect (#33524) --- packages/react-reconciler/src/ReactFiber.js | 8 ---- .../react-reconciler/src/ReactFiberHooks.js | 4 +- .../src/ReactFiberWorkLoop.js | 23 ++-------- .../react-reconciler/src/ReactTypeOfMode.js | 1 - .../src/__tests__/ActivityStrictMode-test.js | 22 --------- .../ReactStrictMode-test.internal.js | 46 ------------------- packages/shared/ReactFeatureFlags.js | 3 -- .../forks/ReactFeatureFlags.native-fb.js | 1 - .../forks/ReactFeatureFlags.native-oss.js | 1 - .../forks/ReactFeatureFlags.test-renderer.js | 1 - ...actFeatureFlags.test-renderer.native-fb.js | 1 - .../ReactFeatureFlags.test-renderer.www.js | 1 - .../forks/ReactFeatureFlags.www-dynamic.js | 1 - .../shared/forks/ReactFeatureFlags.www.js | 1 - 14 files changed, 5 insertions(+), 109 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 65feabd8c0a34..996bc72603f56 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -40,7 +40,6 @@ import { enableScopeAPI, enableLegacyHidden, enableTransitionTracing, - enableDO_NOT_USE_disableStrictPassiveEffect, disableLegacyMode, enableObjectFiber, enableViewTransition, @@ -92,7 +91,6 @@ import { ProfileMode, StrictLegacyMode, StrictEffectsMode, - NoStrictPassiveEffectsMode, SuspenseyImagesMode, } from './ReactTypeOfMode'; import { @@ -599,12 +597,6 @@ export function createFiberFromTypeAndProps( if (disableLegacyMode || (mode & ConcurrentMode) !== NoMode) { // Strict effects should never run on legacy roots mode |= StrictEffectsMode; - if ( - enableDO_NOT_USE_disableStrictPassiveEffect && - pendingProps.DO_NOT_USE_disableStrictPassiveEffect - ) { - mode |= NoStrictPassiveEffectsMode; - } } break; case REACT_PROFILER_TYPE: diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 1740ee0a8600a..63332124e2f0b 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -55,7 +55,6 @@ import { ConcurrentMode, StrictEffectsMode, StrictLegacyMode, - NoStrictPassiveEffectsMode, } from './ReactTypeOfMode'; import { NoLane, @@ -2672,8 +2671,7 @@ function mountEffect( ): void { if ( __DEV__ && - (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode && - (currentlyRenderingFiber.mode & NoStrictPassiveEffectsMode) === NoMode + (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode ) { mountEffectImpl( MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index fd74620975649..813633656eed7 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -123,7 +123,6 @@ import { ConcurrentMode, StrictLegacyMode, StrictEffectsMode, - NoStrictPassiveEffectsMode, } from './ReactTypeOfMode'; import { HostRoot, @@ -4607,21 +4606,13 @@ function recursivelyTraverseAndDoubleInvokeEffectsInDEV( } // Unconditionally disconnects and connects passive and layout effects. -function doubleInvokeEffectsOnFiber( - root: FiberRoot, - fiber: Fiber, - shouldDoubleInvokePassiveEffects: boolean = true, -) { +function doubleInvokeEffectsOnFiber(root: FiberRoot, fiber: Fiber) { setIsStrictModeForDevtools(true); try { disappearLayoutEffects(fiber); - if (shouldDoubleInvokePassiveEffects) { - disconnectPassiveEffect(fiber); - } + disconnectPassiveEffect(fiber); reappearLayoutEffects(root, fiber.alternate, fiber, false); - if (shouldDoubleInvokePassiveEffects) { - reconnectPassiveEffects(root, fiber, NoLanes, null, false, 0); - } + reconnectPassiveEffects(root, fiber, NoLanes, null, false, 0); } finally { setIsStrictModeForDevtools(false); } @@ -4640,13 +4631,7 @@ function doubleInvokeEffectsInDEVIfNecessary( if (fiber.tag !== OffscreenComponent) { if (fiber.flags & PlacementDEV) { if (isInStrictMode) { - runWithFiberInDEV( - fiber, - doubleInvokeEffectsOnFiber, - root, - fiber, - (fiber.mode & NoStrictPassiveEffectsMode) === NoMode, - ); + runWithFiberInDEV(fiber, doubleInvokeEffectsOnFiber, root, fiber); } } else { recursivelyTraverseAndDoubleInvokeEffectsInDEV( diff --git a/packages/react-reconciler/src/ReactTypeOfMode.js b/packages/react-reconciler/src/ReactTypeOfMode.js index 4348fe2668647..22d0a7c6d1a01 100644 --- a/packages/react-reconciler/src/ReactTypeOfMode.js +++ b/packages/react-reconciler/src/ReactTypeOfMode.js @@ -16,7 +16,6 @@ export const ProfileMode = /* */ 0b0000010; //export const DebugTracingMode = /* */ 0b0000100; // Removed export const StrictLegacyMode = /* */ 0b0001000; export const StrictEffectsMode = /* */ 0b0010000; -export const NoStrictPassiveEffectsMode = /* */ 0b1000000; // Keep track of if we're in a SuspenseyImages eligible subtree. // TODO: Remove this when enableSuspenseyImages ship where it's always on. export const SuspenseyImagesMode = /* */ 0b0100000; diff --git a/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js b/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js index 78947437f6473..de8adc7ce3ee4 100644 --- a/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js +++ b/packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js @@ -55,28 +55,6 @@ describe('Activity StrictMode', () => { ]); }); - // @gate __DEV__ && enableActivity && enableDO_NOT_USE_disableStrictPassiveEffect - it('does not trigger strict effects when disableStrictPassiveEffect is presented on StrictMode', async () => { - await act(() => { - ReactNoop.render( - - - - - , - ); - }); - - expect(log).toEqual([ - 'A: render', - 'A: render', - 'A: useLayoutEffect mount', - 'A: useEffect mount', - 'A: useLayoutEffect unmount', - 'A: useLayoutEffect mount', - ]); - }); - // @gate __DEV__ && enableActivity it('should not trigger strict effects when offscreen is hidden', async () => { await act(() => { diff --git a/packages/react/src/__tests__/ReactStrictMode-test.internal.js b/packages/react/src/__tests__/ReactStrictMode-test.internal.js index 2867e06483792..28ac8a997449c 100644 --- a/packages/react/src/__tests__/ReactStrictMode-test.internal.js +++ b/packages/react/src/__tests__/ReactStrictMode-test.internal.js @@ -104,52 +104,6 @@ describe('ReactStrictMode', () => { ]); }); - // @gate enableDO_NOT_USE_disableStrictPassiveEffect - it('should include legacy + strict effects mode, but not strict passive effect with disableStrictPassiveEffect', async () => { - await act(() => { - const container = document.createElement('div'); - const root = ReactDOMClient.createRoot(container); - root.render( - - - , - ); - }); - - expect(log).toEqual([ - 'A: render', - 'A: render', - 'A: useLayoutEffect mount', - 'A: useEffect mount', - 'A: useLayoutEffect unmount', - 'A: useLayoutEffect mount', - ]); - }); - - // @gate enableDO_NOT_USE_disableStrictPassiveEffect - it('should include legacy + strict effects mode, but not strict passive effect with disableStrictPassiveEffect in Suspense', async () => { - await act(() => { - const container = document.createElement('div'); - const root = ReactDOMClient.createRoot(container); - root.render( - - - - - , - ); - }); - - expect(log).toEqual([ - 'A: render', - 'A: render', - 'A: useLayoutEffect mount', - 'A: useEffect mount', - 'A: useLayoutEffect unmount', - 'A: useLayoutEffect mount', - ]); - }); - it('should allow level to be increased with nesting', async () => { await act(() => { const container = document.createElement('div'); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index a1ea476b56e48..a56cc2582e5e0 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -260,7 +260,4 @@ export const enableAsyncDebugInfo = __EXPERIMENTAL__; // Track which Fiber(s) schedule render work. export const enableUpdaterTracking = __PROFILE__; -// Internal only. -export const enableDO_NOT_USE_disableStrictPassiveEffect = false; - export const ownerStackLimit = 1e4; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 3dd11f4af414b..c0eea7ee4ccf8 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -44,7 +44,6 @@ export const enableAsyncDebugInfo = false; export const enableAsyncIterableChildren = false; export const enableCPUSuspense = true; export const enableCreateEventHandleAPI = false; -export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableMoveBefore = true; export const enableFizzExternalRuntime = true; export const enableHalt = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index b1978f05a1404..6893a3215b17d 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -29,7 +29,6 @@ export const enableAsyncDebugInfo = false; export const enableAsyncIterableChildren = false; export const enableCPUSuspense = false; export const enableCreateEventHandleAPI = false; -export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableMoveBefore = true; export const enableFizzExternalRuntime = true; export const enableHalt = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 4a49fb7316cfd..c0a909ba5ce8a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -49,7 +49,6 @@ export const enableLegacyHidden = false; export const enableTransitionTracing = false; -export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableFizzExternalRuntime = true; export const alwaysThrottleRetries = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index c001a68557baa..51c6239dcd89d 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -24,7 +24,6 @@ export const enableAsyncDebugInfo = false; export const enableAsyncIterableChildren = false; export const enableCPUSuspense = true; export const enableCreateEventHandleAPI = false; -export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableMoveBefore = false; export const enableFizzExternalRuntime = true; export const enableHalt = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index f58e154d0218e..209fbd4b8ceb0 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -50,7 +50,6 @@ export const enableLegacyHidden = false; export const enableTransitionTracing = false; -export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableFizzExternalRuntime = false; export const alwaysThrottleRetries = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 263d7ca04842e..eeccf763ca364 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -17,7 +17,6 @@ export const alwaysThrottleRetries = __VARIANT__; export const disableDefaultPropsExceptForClasses = __VARIANT__; export const disableLegacyContextForFunctionComponents = __VARIANT__; export const disableSchedulerTimeoutInWorkLoop = __VARIANT__; -export const enableDO_NOT_USE_disableStrictPassiveEffect = __VARIANT__; export const enableHiddenSubtreeInsertionEffectCleanup = __VARIANT__; export const enableNoCloningMemoCache = __VARIANT__; export const enableObjectFiber = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 807e86da2b344..29bb652fd74ea 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -19,7 +19,6 @@ export const { disableDefaultPropsExceptForClasses, disableLegacyContextForFunctionComponents, disableSchedulerTimeoutInWorkLoop, - enableDO_NOT_USE_disableStrictPassiveEffect, enableHiddenSubtreeInsertionEffectCleanup, enableInfiniteRenderLoopDetection, enableNoCloningMemoCache, From 75e78d243f749d009fa1c5c09c3464301b992718 Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Mon, 16 Jun 2025 21:53:27 -0400 Subject: [PATCH 039/144] [compiler] Add repro for IIFE in ternary causing a bailout (#33546) --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33546). * #33548 * __->__ #33546 --- .../error.todo-iife-inline-ternary.expect.md | 33 +++++++++++++++++++ .../error.todo-iife-inline-ternary.js | 8 +++++ 2 files changed, 41 insertions(+) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.expect.md new file mode 100644 index 0000000000000..e646e43590845 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.expect.md @@ -0,0 +1,33 @@ + +## Input + +```javascript +function Component(props) { + const x = props.foo + ? 1 + : (() => { + throw new Error('Did not receive 1'); + })(); + return items; +} + +``` + + +## Error + +``` + 2 | const x = props.foo + 3 | ? 1 +> 4 | : (() => { + | ^^^^^^^^ +> 5 | throw new Error('Did not receive 1'); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 6 | })(); + | ^^^^^^^^^^^ Todo: Support labeled statements combined with value blocks (conditional, logical, optional chaining, etc) (4:6) + 7 | return items; + 8 | } + 9 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.js new file mode 100644 index 0000000000000..89cc9553edeee --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.js @@ -0,0 +1,8 @@ +function Component(props) { + const x = props.foo + ? 1 + : (() => { + throw new Error('Did not receive 1'); + })(); + return items; +} From 90bee819028bfecb724df298da798607b6a76abf Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Mon, 16 Jun 2025 21:53:50 -0400 Subject: [PATCH 040/144] [compiler] Do not inline IIFEs in value blocks (#33548) As discussed in chat, this is a simple fix to stop introducing labels inside expressions. The useMemo-with-optional test was added in https://github.com/facebook/react/commit/d70b2c2c4e85c2a7061214c15a8ff13167d10422 and crashes for the same reason- an unexpected label as a value block terminal. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33548). * __->__ #33548 * #33546 --- ...neImmediatelyInvokedFunctionExpressions.ts | 173 +++++++++--------- ...error.todo-useMemo-with-optional.expect.md | 32 ---- .../error.todo-useMemo-with-optional.js | 7 - .../compiler/iife-inline-ternary.expect.md | 40 ++++ .../fixtures/compiler/iife-inline-ternary.js | 13 ++ .../compiler/useMemo-with-optional.expect.md | 47 +++++ .../compiler/useMemo-with-optional.js | 13 ++ 7 files changed, 203 insertions(+), 122 deletions(-) delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useMemo-with-optional.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useMemo-with-optional.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/iife-inline-ternary.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/iife-inline-ternary.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-with-optional.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-with-optional.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts index c6c6f2f54fbef..bbf3b0aeca03f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts @@ -17,6 +17,7 @@ import { InstructionKind, LabelTerminal, Place, + isStatementBlockKind, makeInstructionId, promoteTemporary, reversePostorderBlocks, @@ -90,100 +91,106 @@ export function inlineImmediatelyInvokedFunctionExpressions( */ const queue = Array.from(fn.body.blocks.values()); queue: for (const block of queue) { - for (let ii = 0; ii < block.instructions.length; ii++) { - const instr = block.instructions[ii]!; - switch (instr.value.kind) { - case 'FunctionExpression': { - if (instr.lvalue.identifier.name === null) { - functions.set(instr.lvalue.identifier.id, instr.value); - } - break; - } - case 'CallExpression': { - if (instr.value.args.length !== 0) { - // We don't support inlining when there are arguments - continue; - } - const body = functions.get(instr.value.callee.identifier.id); - if (body === undefined) { - // Not invoking a local function expression, can't inline - continue; + /* + * We can't handle labels inside expressions yet, so we don't inline IIFEs if they are in an + * expression block. + */ + if (isStatementBlockKind(block.kind)) { + for (let ii = 0; ii < block.instructions.length; ii++) { + const instr = block.instructions[ii]!; + switch (instr.value.kind) { + case 'FunctionExpression': { + if (instr.lvalue.identifier.name === null) { + functions.set(instr.lvalue.identifier.id, instr.value); + } + break; } + case 'CallExpression': { + if (instr.value.args.length !== 0) { + // We don't support inlining when there are arguments + continue; + } + const body = functions.get(instr.value.callee.identifier.id); + if (body === undefined) { + // Not invoking a local function expression, can't inline + continue; + } - if ( - body.loweredFunc.func.params.length > 0 || - body.loweredFunc.func.async || - body.loweredFunc.func.generator - ) { - // Can't inline functions with params, or async/generator functions - continue; - } + if ( + body.loweredFunc.func.params.length > 0 || + body.loweredFunc.func.async || + body.loweredFunc.func.generator + ) { + // Can't inline functions with params, or async/generator functions + continue; + } - // We know this function is used for an IIFE and can prune it later - inlinedFunctions.add(instr.value.callee.identifier.id); + // We know this function is used for an IIFE and can prune it later + inlinedFunctions.add(instr.value.callee.identifier.id); - // Create a new block which will contain code following the IIFE call - const continuationBlockId = fn.env.nextBlockId; - const continuationBlock: BasicBlock = { - id: continuationBlockId, - instructions: block.instructions.slice(ii + 1), - kind: block.kind, - phis: new Set(), - preds: new Set(), - terminal: block.terminal, - }; - fn.body.blocks.set(continuationBlockId, continuationBlock); + // Create a new block which will contain code following the IIFE call + const continuationBlockId = fn.env.nextBlockId; + const continuationBlock: BasicBlock = { + id: continuationBlockId, + instructions: block.instructions.slice(ii + 1), + kind: block.kind, + phis: new Set(), + preds: new Set(), + terminal: block.terminal, + }; + fn.body.blocks.set(continuationBlockId, continuationBlock); - /* - * Trim the original block to contain instructions up to (but not including) - * the IIFE - */ - block.instructions.length = ii; + /* + * Trim the original block to contain instructions up to (but not including) + * the IIFE + */ + block.instructions.length = ii; - /* - * To account for complex control flow within the lambda, we treat the lambda - * as if it were a single labeled statement, and replace all returns with gotos - * to the label fallthrough. - */ - const newTerminal: LabelTerminal = { - block: body.loweredFunc.func.body.entry, - id: makeInstructionId(0), - kind: 'label', - fallthrough: continuationBlockId, - loc: block.terminal.loc, - }; - block.terminal = newTerminal; + /* + * To account for complex control flow within the lambda, we treat the lambda + * as if it were a single labeled statement, and replace all returns with gotos + * to the label fallthrough. + */ + const newTerminal: LabelTerminal = { + block: body.loweredFunc.func.body.entry, + id: makeInstructionId(0), + kind: 'label', + fallthrough: continuationBlockId, + loc: block.terminal.loc, + }; + block.terminal = newTerminal; - // We store the result in the IIFE temporary - const result = instr.lvalue; + // We store the result in the IIFE temporary + const result = instr.lvalue; - // Declare the IIFE temporary - declareTemporary(fn.env, block, result); + // Declare the IIFE temporary + declareTemporary(fn.env, block, result); - // Promote the temporary with a name as we require this to persist - promoteTemporary(result.identifier); + // Promote the temporary with a name as we require this to persist + promoteTemporary(result.identifier); - /* - * Rewrite blocks from the lambda to replace any `return` with a - * store to the result and `goto` the continuation block - */ - for (const [id, block] of body.loweredFunc.func.body.blocks) { - block.preds.clear(); - rewriteBlock(fn.env, block, continuationBlockId, result); - fn.body.blocks.set(id, block); - } + /* + * Rewrite blocks from the lambda to replace any `return` with a + * store to the result and `goto` the continuation block + */ + for (const [id, block] of body.loweredFunc.func.body.blocks) { + block.preds.clear(); + rewriteBlock(fn.env, block, continuationBlockId, result); + fn.body.blocks.set(id, block); + } - /* - * Ensure we visit the continuation block, since there may have been - * sequential IIFEs that need to be visited. - */ - queue.push(continuationBlock); - continue queue; - } - default: { - for (const place of eachInstructionValueOperand(instr.value)) { - // Any other use of a function expression means it isn't an IIFE - functions.delete(place.identifier.id); + /* + * Ensure we visit the continuation block, since there may have been + * sequential IIFEs that need to be visited. + */ + queue.push(continuationBlock); + continue queue; + } + default: { + for (const place of eachInstructionValueOperand(instr.value)) { + // Any other use of a function expression means it isn't an IIFE + functions.delete(place.identifier.id); + } } } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useMemo-with-optional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useMemo-with-optional.expect.md deleted file mode 100644 index b24f1f18f0d30..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useMemo-with-optional.expect.md +++ /dev/null @@ -1,32 +0,0 @@ - -## Input - -```javascript -function Component(props) { - return ( - useMemo(() => { - return [props.value]; - }) || [] - ); -} - -``` - - -## Error - -``` - 1 | function Component(props) { - 2 | return ( -> 3 | useMemo(() => { - | ^^^^^^^^^^^^^^^ -> 4 | return [props.value]; - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -> 5 | }) || [] - | ^^^^^^^^^^^^^ Todo: Support labeled statements combined with value blocks (conditional, logical, optional chaining, etc) (3:5) - 6 | ); - 7 | } - 8 | -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useMemo-with-optional.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useMemo-with-optional.js deleted file mode 100644 index 533e2c370fe34..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-useMemo-with-optional.js +++ /dev/null @@ -1,7 +0,0 @@ -function Component(props) { - return ( - useMemo(() => { - return [props.value]; - }) || [] - ); -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/iife-inline-ternary.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/iife-inline-ternary.expect.md new file mode 100644 index 0000000000000..bf4dddc6fc3fb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/iife-inline-ternary.expect.md @@ -0,0 +1,40 @@ + +## Input + +```javascript +function Component(props) { + const x = props.foo + ? 1 + : (() => { + throw new Error('Did not receive 1'); + })(); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: true}], +}; + +``` + +## Code + +```javascript +function Component(props) { + props.foo ? 1 : _temp(); + return items; +} +function _temp() { + throw new Error("Did not receive 1"); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ foo: true }], +}; + +``` + +### Eval output +(kind: exception) items is not defined \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/iife-inline-ternary.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/iife-inline-ternary.js new file mode 100644 index 0000000000000..56d20f7f9ff97 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/iife-inline-ternary.js @@ -0,0 +1,13 @@ +function Component(props) { + const x = props.foo + ? 1 + : (() => { + throw new Error('Did not receive 1'); + })(); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: true}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-with-optional.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-with-optional.expect.md new file mode 100644 index 0000000000000..260d695e09d8a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-with-optional.expect.md @@ -0,0 +1,47 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +function Component(props) { + return ( + useMemo(() => { + return [props.value]; + }) || [] + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 1}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +function Component(props) { + const $ = _c(2); + let t0; + if ($[0] !== props.value) { + t0 = (() => [props.value])() || []; + $[0] = props.value; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: 1 }], +}; + +``` + +### Eval output +(kind: ok) [1] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-with-optional.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-with-optional.js new file mode 100644 index 0000000000000..a96c044a3b86b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-with-optional.js @@ -0,0 +1,13 @@ +import {useMemo} from 'react'; +function Component(props) { + return ( + useMemo(() => { + return [props.value]; + }) || [] + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 1}], +}; From e1dc03492eedaec517e14a6e32b8fda571d00767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 17 Jun 2025 17:04:40 -0400 Subject: [PATCH 041/144] Expose cacheSignal() alongside cache() (#33557) This was really meant to be there from the beginning. A `cache()`:ed entry has a life time. On the server this ends when the render finishes. On the client this ends when the cache of that scope gets refreshed. When a cache is no longer needed, it should be possible to abort any outstanding network requests or other resources. That's what `cacheSignal()` gives you. It returns an `AbortSignal` which aborts when the cache lifetime is done based on the same execution scope as a `cache()`ed function - i.e. `AsyncLocalStorage` on the server or the render scope on the client. ```js import {cacheSignal} from 'react'; async function Component() { await fetch(url, { signal: cacheSignal() }); } ``` For `fetch` in particular, a patch should really just do this automatically for you. But it's useful for other resources like database connections. Another reason it's useful to have a `cacheSignal()` is to ignore any errors that might have triggered from the act of being aborted. This is just a general useful JavaScript pattern if you have access to a signal: ```js async function getData(id, signal) { try { await queryDatabase(id, { signal }); } catch (x) { if (!signal.aborted) { logError(x); // only log if it's a real error and not due to cancellation } return null; } } ``` This just gets you a convenient way to get to it without drilling through so a more idiomatic code in React might look something like. ```js import {cacheSignal} from "react"; async function getData(id) { try { await queryDatabase(id); } catch (x) { if (!cacheSignal()?.aborted) { logError(x); } return null; } } ``` If it's called outside of a React render, we normally treat any cached functions as uncached. They're not an error call. They can still load data. It's just not cached. This is not like an aborted signal because then you couldn't issue any requests. It's also not like an infinite abort signal because it's not actually cached forever. Therefore, `cacheSignal()` returns `null` when called outside of a React render scope. Notably the `signal` option passed to `renderToReadableStream` in both SSR (Fizz) and RSC (Flight Server) is not the same instance that comes out of `cacheSignal()`. If you abort the `signal` passed in, then the `cacheSignal()` is also aborted with the same reason. However, the `cacheSignal()` can also get aborted if the render completes successfully or fatally errors during render - allowing any outstanding work that wasn't used to clean up. In the future we might also expand on this to give different [`TaskSignal`](https://developer.mozilla.org/en-US/docs/Web/API/TaskSignal) to different scopes to pass different render or network priorities. On the client version of `"react"` this exposes a noop (both for Fiber/Fizz) due to `disableClientCache` flag but it's exposed so that you can write shared code. --- .../src/ReactNoopFlightServer.js | 13 +++ .../src/ReactFiberAsyncDispatcher.js | 6 ++ .../src/ReactInternalTypes.js | 1 + .../src/__tests__/ReactCache-test.js | 84 +++++++++++++++++++ .../src/ReactFizzAsyncDispatcher.js | 5 ++ .../react-server/src/ReactFlightServer.js | 20 ++++- .../src/flight/ReactFlightAsyncDispatcher.js | 7 ++ .../src/ReactSuspenseTestUtils.js | 3 + packages/react/index.development.js | 1 + .../react/index.experimental.development.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.fb.js | 1 + packages/react/index.js | 1 + packages/react/index.stable.development.js | 1 + packages/react/index.stable.js | 1 + packages/react/src/ReactCacheClient.js | 15 +++- packages/react/src/ReactCacheImpl.js | 12 +++ packages/react/src/ReactCacheServer.js | 2 +- packages/react/src/ReactClient.js | 3 +- .../ReactServer.experimental.development.js | 3 +- .../react/src/ReactServer.experimental.js | 3 +- packages/react/src/ReactServer.fb.js | 3 +- packages/react/src/ReactServer.js | 3 +- scripts/error-codes/codes.json | 4 +- 24 files changed, 183 insertions(+), 11 deletions(-) diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index d3920331b2370..7c3790e9cf052 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -70,6 +70,7 @@ type Options = { environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, identifierPrefix?: string, + signal?: AbortSignal, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, }; @@ -87,6 +88,18 @@ function render(model: ReactClientValue, options?: Options): Destination { __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, ); + const signal = options ? options.signal : undefined; + if (signal) { + if (signal.aborted) { + ReactNoopFlightServer.abort(request, (signal: any).reason); + } else { + const listener = () => { + ReactNoopFlightServer.abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } ReactNoopFlightServer.startWork(request); ReactNoopFlightServer.startFlowing(request, destination); return destination; diff --git a/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js b/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js index 4ff65fb90011a..2dfee307e2c2c 100644 --- a/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js +++ b/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js @@ -25,8 +25,14 @@ function getCacheForType(resourceType: () => T): T { return cacheForType; } +function cacheSignal(): null | AbortSignal { + const cache: Cache = readContext(CacheContext); + return cache.controller.signal; +} + export const DefaultAsyncDispatcher: AsyncDispatcher = ({ getCacheForType, + cacheSignal, }: any); if (__DEV__) { diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 25840749a1adf..ec75513892900 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -459,6 +459,7 @@ export type Dispatcher = { export type AsyncDispatcher = { getCacheForType: (resourceType: () => T) => T, + cacheSignal: () => null | AbortSignal, // DEV-only getOwner: () => null | Fiber | ReactComponentInfo | ComponentStackNode, }; diff --git a/packages/react-reconciler/src/__tests__/ReactCache-test.js b/packages/react-reconciler/src/__tests__/ReactCache-test.js index e93b68cec3d91..4803ab5283c30 100644 --- a/packages/react-reconciler/src/__tests__/ReactCache-test.js +++ b/packages/react-reconciler/src/__tests__/ReactCache-test.js @@ -14,6 +14,7 @@ let React; let ReactNoopFlightServer; let ReactNoopFlightClient; let cache; +let cacheSignal; describe('ReactCache', () => { beforeEach(() => { @@ -25,6 +26,7 @@ describe('ReactCache', () => { ReactNoopFlightClient = require('react-noop-renderer/flight-client'); cache = React.cache; + cacheSignal = React.cacheSignal; jest.resetModules(); __unmockReact(); @@ -220,4 +222,86 @@ describe('ReactCache', () => { expect(cachedFoo.length).toBe(0); expect(cachedFoo.displayName).toBe(undefined); }); + + it('cacheSignal() returns null outside a render', async () => { + expect(cacheSignal()).toBe(null); + }); + + it('cacheSignal() aborts when the render finishes normally', async () => { + let renderedCacheSignal = null; + + let resolve; + const promise = new Promise(r => (resolve = r)); + + async function Test() { + renderedCacheSignal = cacheSignal(); + await promise; + return 'Hi'; + } + + const controller = new AbortController(); + const errors = []; + const result = ReactNoopFlightServer.render(, { + signal: controller.signal, + onError(x) { + errors.push(x); + }, + }); + expect(errors).toEqual([]); + expect(renderedCacheSignal).not.toBe(controller.signal); // In the future we might make these the same + expect(renderedCacheSignal.aborted).toBe(false); + await resolve(); + await 0; + await 0; + + expect(await ReactNoopFlightClient.read(result)).toBe('Hi'); + + expect(errors).toEqual([]); + expect(renderedCacheSignal.aborted).toBe(true); + expect(renderedCacheSignal.reason.message).toContain( + 'This render completed successfully.', + ); + }); + + it('cacheSignal() aborts when the render is aborted', async () => { + let renderedCacheSignal = null; + + const promise = new Promise(() => {}); + + async function Test() { + renderedCacheSignal = cacheSignal(); + await promise; + return 'Hi'; + } + + const controller = new AbortController(); + const errors = []; + const result = ReactNoopFlightServer.render(, { + signal: controller.signal, + onError(x) { + errors.push(x); + return 'hi'; + }, + }); + expect(errors).toEqual([]); + expect(renderedCacheSignal).not.toBe(controller.signal); // In the future we might make these the same + expect(renderedCacheSignal.aborted).toBe(false); + const reason = new Error('Timed out'); + controller.abort(reason); + expect(errors).toEqual([reason]); + expect(renderedCacheSignal.aborted).toBe(true); + expect(renderedCacheSignal.reason).toBe(reason); + + let clientError = null; + try { + await ReactNoopFlightClient.read(result); + } catch (x) { + clientError = x; + } + expect(clientError).not.toBe(null); + if (__DEV__) { + expect(clientError.message).toBe('Timed out'); + } + expect(clientError.digest).toBe('hi'); + }); }); diff --git a/packages/react-server/src/ReactFizzAsyncDispatcher.js b/packages/react-server/src/ReactFizzAsyncDispatcher.js index e4d940d463c23..d20acdd5e9a82 100644 --- a/packages/react-server/src/ReactFizzAsyncDispatcher.js +++ b/packages/react-server/src/ReactFizzAsyncDispatcher.js @@ -16,8 +16,13 @@ function getCacheForType(resourceType: () => T): T { throw new Error('Not implemented.'); } +function cacheSignal(): null | AbortSignal { + throw new Error('Not implemented.'); +} + export const DefaultAsyncDispatcher: AsyncDispatcher = ({ getCacheForType, + cacheSignal, }: any); if (__DEV__) { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index e51197c5b2b19..7b63b9f6f3cc4 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -419,6 +419,7 @@ export type Request = { destination: null | Destination, bundlerConfig: ClientManifest, cache: Map, + cacheController: AbortController, nextChunkId: number, pendingChunks: number, hints: Hints, @@ -529,6 +530,7 @@ function RequestInstance( this.destination = null; this.bundlerConfig = bundlerConfig; this.cache = new Map(); + this.cacheController = new AbortController(); this.nextChunkId = 0; this.pendingChunks = 0; this.hints = hints; @@ -604,7 +606,7 @@ export function createRequest( model: ReactClientValue, bundlerConfig: ClientManifest, onError: void | ((error: mixed) => ?string), - identifierPrefix?: string, + identifierPrefix: void | string, onPostpone: void | ((reason: string) => void), temporaryReferences: void | TemporaryReferenceSet, environmentName: void | string | (() => string), // DEV-only @@ -636,7 +638,7 @@ export function createPrerenderRequest( onAllReady: () => void, onFatalError: () => void, onError: void | ((error: mixed) => ?string), - identifierPrefix?: string, + identifierPrefix: void | string, onPostpone: void | ((reason: string) => void), temporaryReferences: void | TemporaryReferenceSet, environmentName: void | string | (() => string), // DEV-only @@ -3369,6 +3371,13 @@ function fatalError(request: Request, error: mixed): void { request.status = CLOSING; request.fatalError = error; } + const abortReason = new Error( + 'The render was aborted due to a fatal error.', + { + cause: error, + }, + ); + request.cacheController.abort(abortReason); } function emitPostponeChunk( @@ -4840,6 +4849,12 @@ function flushCompletedChunks( if (enableTaint) { cleanupTaintQueue(request); } + if (request.status < ABORTING) { + const abortReason = new Error( + 'This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.', + ); + request.cacheController.abort(abortReason); + } request.status = CLOSED; close(destination); request.destination = null; @@ -4921,6 +4936,7 @@ export function abort(request: Request, reason: mixed): void { // We define any status below OPEN as OPEN equivalent if (request.status <= OPEN) { request.status = ABORTING; + request.cacheController.abort(reason); } const abortableTasks = request.abortableTasks; if (abortableTasks.size > 0) { diff --git a/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js b/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js index 00c1abf33292d..958b92c9cc6c5 100644 --- a/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js +++ b/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js @@ -31,6 +31,13 @@ export const DefaultAsyncDispatcher: AsyncDispatcher = ({ } return entry; }, + cacheSignal(): null | AbortSignal { + const request = resolveRequest(); + if (request) { + return request.cacheController.signal; + } + return null; + }, }: any); if (__DEV__) { diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js index bc2d4bee79d02..0e8e5c5b687d2 100644 --- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js +++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js @@ -22,6 +22,9 @@ export function waitForSuspense(fn: () => T): Promise { } return entry; }, + cacheSignal(): null { + return null; + }, getOwner(): null { return null; }, diff --git a/packages/react/index.development.js b/packages/react/index.development.js index fa79633001a06..0f7703e5111d5 100644 --- a/packages/react/index.development.js +++ b/packages/react/index.development.js @@ -44,6 +44,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, unstable_LegacyHidden, unstable_Activity, diff --git a/packages/react/index.experimental.development.js b/packages/react/index.experimental.development.js index cfa916dd67855..7f0d03a0b2436 100644 --- a/packages/react/index.experimental.development.js +++ b/packages/react/index.experimental.development.js @@ -27,6 +27,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, unstable_Activity, unstable_postpone, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 628746716e347..dfaeca747ed5a 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -27,6 +27,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, unstable_Activity, unstable_postpone, diff --git a/packages/react/index.fb.js b/packages/react/index.fb.js index 828db7a48a542..fb637b799b0ca 100644 --- a/packages/react/index.fb.js +++ b/packages/react/index.fb.js @@ -14,6 +14,7 @@ export { __COMPILER_RUNTIME, act, cache, + cacheSignal, Children, cloneElement, Component, diff --git a/packages/react/index.js b/packages/react/index.js index 92e1cf181ec1b..5228ae8868191 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -44,6 +44,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, unstable_LegacyHidden, unstable_Activity, diff --git a/packages/react/index.stable.development.js b/packages/react/index.stable.development.js index 2397010cf5604..80fc4d7cac767 100644 --- a/packages/react/index.stable.development.js +++ b/packages/react/index.stable.development.js @@ -27,6 +27,7 @@ export { lazy, memo, cache, + cacheSignal, unstable_useCacheRefresh, startTransition, useId, diff --git a/packages/react/index.stable.js b/packages/react/index.stable.js index 6f25c7a37d983..1cb9de1e37233 100644 --- a/packages/react/index.stable.js +++ b/packages/react/index.stable.js @@ -27,6 +27,7 @@ export { lazy, memo, cache, + cacheSignal, unstable_useCacheRefresh, startTransition, useId, diff --git a/packages/react/src/ReactCacheClient.js b/packages/react/src/ReactCacheClient.js index 64fef23c6c49f..ef1e8d6d1da56 100644 --- a/packages/react/src/ReactCacheClient.js +++ b/packages/react/src/ReactCacheClient.js @@ -8,9 +8,12 @@ */ import {disableClientCache} from 'shared/ReactFeatureFlags'; -import {cache as cacheImpl} from './ReactCacheImpl'; +import { + cache as cacheImpl, + cacheSignal as cacheSignalImpl, +} from './ReactCacheImpl'; -export function noopCache, T>(fn: (...A) => T): (...A) => T { +function noopCache, T>(fn: (...A) => T): (...A) => T { // On the client (i.e. not a Server Components environment) `cache` has // no caching behavior. We just return the function as-is. // @@ -32,3 +35,11 @@ export function noopCache, T>(fn: (...A) => T): (...A) => T { export const cache: typeof noopCache = disableClientCache ? noopCache : cacheImpl; + +function noopCacheSignal(): null | AbortSignal { + return null; +} + +export const cacheSignal: () => null | AbortSignal = disableClientCache + ? noopCacheSignal + : cacheSignalImpl; diff --git a/packages/react/src/ReactCacheImpl.js b/packages/react/src/ReactCacheImpl.js index cc3136897e350..2ff7431fc5198 100644 --- a/packages/react/src/ReactCacheImpl.js +++ b/packages/react/src/ReactCacheImpl.js @@ -126,3 +126,15 @@ export function cache, T>(fn: (...A) => T): (...A) => T { } }; } + +export function cacheSignal(): null | AbortSignal { + const dispatcher = ReactSharedInternals.A; + if (!dispatcher) { + // If there is no dispatcher, then we treat this as not having an AbortSignal + // since in the same context, a cached function will be allowed to be called + // but it won't be cached. So it's neither an infinite AbortSignal nor an + // already resolved one. + return null; + } + return dispatcher.cacheSignal(); +} diff --git a/packages/react/src/ReactCacheServer.js b/packages/react/src/ReactCacheServer.js index dd90d8de2a999..ec68dc7c27249 100644 --- a/packages/react/src/ReactCacheServer.js +++ b/packages/react/src/ReactCacheServer.js @@ -7,4 +7,4 @@ * @flow */ -export {cache} from './ReactCacheImpl'; +export {cache, cacheSignal} from './ReactCacheImpl'; diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index 3ead64acf682f..b9b34e218824d 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -33,7 +33,7 @@ import {createContext} from './ReactContext'; import {lazy} from './ReactLazy'; import {forwardRef} from './ReactForwardRef'; import {memo} from './ReactMemo'; -import {cache} from './ReactCacheClient'; +import {cache, cacheSignal} from './ReactCacheClient'; import {postpone} from './ReactPostpone'; import { getCacheForType, @@ -83,6 +83,7 @@ export { lazy, memo, cache, + cacheSignal, postpone as unstable_postpone, useCallback, useContext, diff --git a/packages/react/src/ReactServer.experimental.development.js b/packages/react/src/ReactServer.experimental.development.js index 0176e0e94f7cc..7b50481231e2e 100644 --- a/packages/react/src/ReactServer.experimental.development.js +++ b/packages/react/src/ReactServer.experimental.development.js @@ -35,7 +35,7 @@ import { import {forwardRef} from './ReactForwardRef'; import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; -import {cache} from './ReactCacheServer'; +import {cache, cacheSignal} from './ReactCacheServer'; import {startTransition} from './ReactStartTransition'; import {postpone} from './ReactPostpone'; import {captureOwnerStack} from './ReactOwnerStack'; @@ -70,6 +70,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, getCacheForType as unstable_getCacheForType, postpone as unstable_postpone, diff --git a/packages/react/src/ReactServer.experimental.js b/packages/react/src/ReactServer.experimental.js index d84672d5f4c2b..ad885f0968e7c 100644 --- a/packages/react/src/ReactServer.experimental.js +++ b/packages/react/src/ReactServer.experimental.js @@ -36,7 +36,7 @@ import { import {forwardRef} from './ReactForwardRef'; import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; -import {cache} from './ReactCacheServer'; +import {cache, cacheSignal} from './ReactCacheServer'; import {startTransition} from './ReactStartTransition'; import {postpone} from './ReactPostpone'; import version from 'shared/ReactVersion'; @@ -70,6 +70,7 @@ export { lazy, memo, cache, + cacheSignal, startTransition, getCacheForType as unstable_getCacheForType, postpone as unstable_postpone, diff --git a/packages/react/src/ReactServer.fb.js b/packages/react/src/ReactServer.fb.js index 634cb077af195..998a78679830e 100644 --- a/packages/react/src/ReactServer.fb.js +++ b/packages/react/src/ReactServer.fb.js @@ -27,7 +27,7 @@ import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks'; import {forwardRef} from './ReactForwardRef'; import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; -import {cache} from './ReactCacheServer'; +import {cache, cacheSignal} from './ReactCacheServer'; import version from 'shared/ReactVersion'; const Children = { @@ -58,6 +58,7 @@ export { lazy, memo, cache, + cacheSignal, useId, useCallback, useDebugValue, diff --git a/packages/react/src/ReactServer.js b/packages/react/src/ReactServer.js index a00e192bd3c1d..be5cd22d91b3f 100644 --- a/packages/react/src/ReactServer.js +++ b/packages/react/src/ReactServer.js @@ -26,7 +26,7 @@ import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks'; import {forwardRef} from './ReactForwardRef'; import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; -import {cache} from './ReactCacheServer'; +import {cache, cacheSignal} from './ReactCacheServer'; import version from 'shared/ReactVersion'; import {captureOwnerStack} from './ReactOwnerStack'; @@ -53,6 +53,7 @@ export { lazy, memo, cache, + cacheSignal, useId, useCallback, useDebugValue, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index a3147f4dde3b3..ef0bad4c09833 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -546,5 +546,7 @@ "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 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." + "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.", + "562": "The render was aborted due to a fatal error.", + "563": "This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources." } From ae962653d63ca88b6727d6f585026f2bbfa313a1 Mon Sep 17 00:00:00 2001 From: Joseph Savona <6425824+josephsavona@users.noreply.github.com> Date: Wed, 18 Jun 2025 12:57:54 -0700 Subject: [PATCH 042/144] [compiler] Remove unnecessary fixture (#33572) This is covered by iife-inline-ternary --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33572). * #33571 * #33558 * #33547 * #33543 * #33533 * #33532 * #33530 * #33526 * #33522 * #33518 * #33514 * #33513 * #33512 * #33504 * #33500 * #33497 * #33496 * #33495 * #33494 * __->__ #33572 --- .../error.todo-iife-inline-ternary.expect.md | 33 ------------------- .../error.todo-iife-inline-ternary.js | 8 ----- 2 files changed, 41 deletions(-) delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.expect.md deleted file mode 100644 index e646e43590845..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.expect.md +++ /dev/null @@ -1,33 +0,0 @@ - -## Input - -```javascript -function Component(props) { - const x = props.foo - ? 1 - : (() => { - throw new Error('Did not receive 1'); - })(); - return items; -} - -``` - - -## Error - -``` - 2 | const x = props.foo - 3 | ? 1 -> 4 | : (() => { - | ^^^^^^^^ -> 5 | throw new Error('Did not receive 1'); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -> 6 | })(); - | ^^^^^^^^^^^ Todo: Support labeled statements combined with value blocks (conditional, logical, optional chaining, etc) (4:6) - 7 | return items; - 8 | } - 9 | -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.js deleted file mode 100644 index 89cc9553edeee..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-inline-ternary.js +++ /dev/null @@ -1,8 +0,0 @@ -function Component(props) { - const x = props.foo - ? 1 - : (() => { - throw new Error('Did not receive 1'); - })(); - return items; -} From 66cfe048d3ab02afd3eeba9e8d7710acb3a4ab38 Mon Sep 17 00:00:00 2001 From: Joseph Savona <6425824+josephsavona@users.noreply.github.com> Date: Wed, 18 Jun 2025 12:58:06 -0700 Subject: [PATCH 043/144] [compiler] New mutability/aliasing model (#33494) Squashed, review-friendly version of the stack from https://github.com/facebook/react/pull/33488. This is new version of our mutability and inference model, designed to replace the core algorithm for determining the sets of instructions involved in constructing a given value or set of values. The new model replaces InferReferenceEffects, InferMutableRanges (and all of its subcomponents), and parts of AnalyzeFunctions. The new model does not use per-Place effect values, but in order to make this drop-in the end _result_ of the inference adds these per-Place effects. I'll write up a larger document on the model, first i'm doing some housekeeping to rebase the PR. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33494). * #33571 * #33558 * #33547 * #33543 * #33533 * #33532 * #33530 * #33526 * #33522 * #33518 * #33514 * #33513 * #33512 * #33504 * #33500 * #33497 * #33496 * #33495 * __->__ #33494 * #33572 --- .../src/Entrypoint/Pipeline.ts | 48 +- .../src/HIR/AssertValidMutableRanges.ts | 44 +- .../src/HIR/BuildHIR.ts | 14 +- .../src/HIR/Environment.ts | 5 + .../src/HIR/Globals.ts | 38 +- .../src/HIR/HIR.ts | 13 + .../src/HIR/HIRBuilder.ts | 1 + .../src/HIR/MergeConsecutiveBlocks.ts | 17 +- .../src/HIR/ObjectShape.ts | 141 +- .../src/HIR/PrintHIR.ts | 129 +- .../src/HIR/ScopeDependencyUtils.ts | 2 + .../src/HIR/visitors.ts | 2 + .../src/Inference/AliasingEffects.ts | 233 ++ .../src/Inference/AnalyseFunctions.ts | 94 +- .../src/Inference/DropManualMemoization.ts | 2 + .../src/Inference/InferEffectDependencies.ts | 2 + .../src/Inference/InferFunctionEffects.ts | 4 +- .../src/Inference/InferMutableRanges.ts | 2 +- .../Inference/InferMutationAliasingEffects.ts | 2378 +++++++++++++++++ .../InferMutationAliasingFunctionEffects.ts | 206 ++ .../Inference/InferMutationAliasingRanges.ts | 737 +++++ .../src/Inference/InferReferenceEffects.ts | 24 +- ...neImmediatelyInvokedFunctionExpressions.ts | 2 + .../src/Optimization/InlineJsxTransform.ts | 14 + .../src/Optimization/LowerContextAccess.ts | 7 + .../src/Optimization/OutlineJsx.ts | 5 + .../ReactiveScopes/CodegenReactiveFunction.ts | 4 +- .../src/Transform/TransformFire.ts | 4 + .../src/Utils/utils.ts | 15 + ...ValidateNoFreezingKnownMutableFunctions.ts | 52 +- ...g-aliased-capture-aliased-mutate.expect.md | 2 +- .../bug-aliased-capture-aliased-mutate.js | 2 +- .../bug-aliased-capture-mutate.expect.md | 2 +- .../compiler/bug-aliased-capture-mutate.js | 2 +- ...-func-maybealias-captured-mutate.expect.md | 3 +- ...pturing-func-maybealias-captured-mutate.ts | 1 + .../bug-invalid-phi-as-dependency.expect.md | 3 +- .../bug-invalid-phi-as-dependency.tsx | 1 + ...nstruction-hoisted-sequence-expr.expect.md | 3 +- ...fter-construction-hoisted-sequence-expr.js | 1 + ...zation-due-to-callback-capturing.expect.md | 138 + ...e-memoization-due-to-callback-capturing.js | 48 + ...n-global-in-jsx-spread-attribute.expect.md | 15 +- ...r.assign-global-in-jsx-spread-attribute.js | 1 + ...ive-ref-validation-in-use-effect.expect.md | 58 + ...e-positive-ref-validation-in-use-effect.js | 27 + ...error.invalid-hoisting-setstate.expect.md} | 51 +- ....js => error.invalid-hoisting-setstate.js} | 1 + ...-argument-mutates-local-variable.expect.md | 2 +- ...id-jsx-captures-context-variable.expect.md | 62 + ....invalid-jsx-captures-context-variable.js} | 1 + ...id-pass-mutable-function-as-prop.expect.md | 2 +- ...eturn-mutable-function-from-hook.expect.md | 2 +- ...es-memoizes-with-captures-values.expect.md | 92 + ...e-values-memoizes-with-captures-values.js} | 2 +- ...ange-shared-inner-outer-function.expect.md | 2 +- ...table-range-shared-inner-outer-function.js | 2 +- ...r.object-capture-global-mutation.expect.md | 15 +- .../error.object-capture-global-mutation.js | 1 + ...on-with-shadowed-local-same-name.expect.md | 2 +- .../jsx-captures-context-variable.expect.md | 129 - .../new-mutability/array-filter.expect.md | 93 + .../compiler/new-mutability/array-filter.js | 12 + ...ay-map-captures-receiver-noAlias.expect.md | 71 + .../array-map-captures-receiver-noAlias.js | 15 + .../new-mutability/array-push.expect.md | 57 + .../compiler/new-mutability/array-push.js | 11 + ...mutation-via-function-expression.expect.md | 49 + .../basic-mutation-via-function-expression.js | 11 + .../new-mutability/basic-mutation.expect.md | 42 + .../compiler/new-mutability/basic-mutation.js | 8 + ...backedge-phi-with-later-mutation.expect.md | 102 + ...apture-backedge-phi-with-later-mutation.js | 35 + ...n-local-variable-in-jsx-callback.expect.md | 53 + ...reassign-local-variable-in-jsx-callback.js | 32 + ...back-captures-reassigned-context.expect.md | 43 + ...useCallback-captures-reassigned-context.js | 20 + .../error.mutate-frozen-value.expect.md | 28 + .../error.mutate-frozen-value.js | 7 + .../iife-return-modified-later-phi.expect.md | 58 + .../iife-return-modified-later-phi.js | 16 + ...ing-function-call-indirections-2.expect.md | 67 + ...g-unboxing-function-call-indirections-2.js | 20 + ...oxing-function-call-indirections.expect.md | 67 + ...ing-unboxing-function-call-indirections.js | 20 + ...ugh-boxing-unboxing-indirections.expect.md | 60 + ...te-through-boxing-unboxing-indirections.js | 17 + .../mutate-through-propertyload.expect.md | 39 + .../mutate-through-propertyload.js | 8 + ...jects-assume-invoked-direct-call.expect.md | 75 + ...able-objects-assume-invoked-direct-call.js | 18 + ...-mutation-in-function-expression.expect.md | 64 + ...tential-mutation-in-function-expression.js | 10 + .../new-mutability/reactive-ref.expect.md | 54 + .../compiler/new-mutability/reactive-ref.js | 12 + .../new-mutability/set-add-mutate.expect.md | 54 + .../compiler/new-mutability/set-add-mutate.js | 11 + ...ssa-renaming-ternary-destruction.expect.md | 70 + .../ssa-renaming-ternary-destruction.js | 21 + ...-capturing-value-created-earlier.expect.md | 50 + ...-before-capturing-value-created-earlier.js | 8 + .../object-access-assignment.expect.md | 83 + .../compiler/object-access-assignment.js | 23 + ...o-aliased-capture-aliased-mutate.expect.md | 104 + .../repro-aliased-capture-aliased-mutate.js | 55 + .../repro-aliased-capture-mutate.expect.md | 84 + .../compiler/repro-aliased-capture-mutate.js | 36 + ...-func-maybealias-captured-mutate.expect.md | 111 + ...pturing-func-maybealias-captured-mutate.ts | 42 + ...ive-ref-validation-in-use-effect.expect.md | 88 + ...e-positive-ref-validation-in-use-effect.js | 28 + .../repro-invalid-phi-as-dependency.expect.md | 80 + .../repro-invalid-phi-as-dependency.tsx | 32 + ...nstruction-hoisted-sequence-expr.expect.md | 91 + ...fter-construction-hoisted-sequence-expr.js | 34 + ...zation-due-to-callback-capturing.expect.md | 149 ++ ...e-memoization-due-to-callback-capturing.js | 52 + ...es-memoizes-with-captures-values.expect.md | 77 - .../packages/snap/src/SproutTodoFilter.ts | 1 + 119 files changed, 7247 insertions(+), 343 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/Inference/AliasingEffects.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingFunctionEffects.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-separate-memoization-due-to-callback-capturing.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-separate-memoization-due-to-callback-capturing.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-old-inference-false-positive-ref-validation-in-use-effect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-old-inference-false-positive-ref-validation-in-use-effect.js rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{hoisting-setstate.expect.md => error.invalid-hoisting-setstate.expect.md} (56%) rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{hoisting-setstate.js => error.invalid-hoisting-setstate.js} (96%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-captures-context-variable.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{jsx-captures-context-variable.js => error.invalid-jsx-captures-context-variable.js} (95%) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.expect.md rename compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/{repro-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.js => error.invalid-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.js} (97%) delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/jsx-captures-context-variable.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-filter.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-filter.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-captures-receiver-noAlias.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-captures-receiver-noAlias.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-push.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-push.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation-via-function-expression.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation-via-function-expression.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-backedge-phi-with-later-mutation.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-backedge-phi-with-later-mutation.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-useCallback-captures-reassigned-context.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-useCallback-captures-reassigned-context.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-frozen-value.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-frozen-value.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/iife-return-modified-later-phi.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/iife-return-modified-later-phi.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections-2.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections-2.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-indirections.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-indirections.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-propertyload.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-propertyload.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/nullable-objects-assume-invoked-direct-call.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/nullable-objects-assume-invoked-direct-call.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/potential-mutation-in-function-expression.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/potential-mutation-in-function-expression.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-ref.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-ref.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/set-add-mutate.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/set-add-mutate.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/ssa-renaming-ternary-destruction.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/ssa-renaming-ternary-destruction.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitive-mutation-before-capturing-value-created-earlier.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitive-mutation-before-capturing-value-created-earlier.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-access-assignment.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-access-assignment.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-aliased-mutate.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-aliased-mutate.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-mutate.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-mutate.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-capturing-func-maybealias-captured-mutate.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-capturing-func-maybealias-captured-mutate.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-false-positive-ref-validation-in-use-effect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-false-positive-ref-validation-in-use-effect.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-invalid-phi-as-dependency.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-invalid-phi-as-dependency.tsx create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-separate-memoization-due-to-callback-capturing.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-separate-memoization-due-to-callback-capturing.js delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.expect.md diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts index fe97c8d642f60..c5ca3434b1b54 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -104,6 +104,8 @@ import {validateNoImpureFunctionsInRender} from '../Validation/ValidateNoImpureF import {CompilerError} from '..'; import {validateStaticComponents} from '../Validation/ValidateStaticComponents'; import {validateNoFreezingKnownMutableFunctions} from '../Validation/ValidateNoFreezingKnownMutableFunctions'; +import {inferMutationAliasingEffects} from '../Inference/InferMutationAliasingEffects'; +import {inferMutationAliasingRanges} from '../Inference/InferMutationAliasingRanges'; export type CompilerPipelineValue = | {kind: 'ast'; name: string; value: CodegenFunction} @@ -227,15 +229,27 @@ function runWithEnvironment( analyseFunctions(hir); log({kind: 'hir', name: 'AnalyseFunctions', value: hir}); - const fnEffectErrors = inferReferenceEffects(hir); - if (env.isInferredMemoEnabled) { - if (fnEffectErrors.length > 0) { - CompilerError.throw(fnEffectErrors[0]); + if (!env.config.enableNewMutationAliasingModel) { + const fnEffectErrors = inferReferenceEffects(hir); + if (env.isInferredMemoEnabled) { + if (fnEffectErrors.length > 0) { + CompilerError.throw(fnEffectErrors[0]); + } + } + log({kind: 'hir', name: 'InferReferenceEffects', value: hir}); + } else { + const mutabilityAliasingErrors = inferMutationAliasingEffects(hir); + log({kind: 'hir', name: 'InferMutationAliasingEffects', value: hir}); + if (env.isInferredMemoEnabled) { + if (mutabilityAliasingErrors.isErr()) { + throw mutabilityAliasingErrors.unwrapErr(); + } } } - log({kind: 'hir', name: 'InferReferenceEffects', value: hir}); - validateLocalsNotReassignedAfterRender(hir); + if (!env.config.enableNewMutationAliasingModel) { + validateLocalsNotReassignedAfterRender(hir); + } // Note: Has to come after infer reference effects because "dead" code may still affect inference deadCodeElimination(hir); @@ -249,8 +263,21 @@ function runWithEnvironment( pruneMaybeThrows(hir); log({kind: 'hir', name: 'PruneMaybeThrows', value: hir}); - inferMutableRanges(hir); - log({kind: 'hir', name: 'InferMutableRanges', value: hir}); + if (!env.config.enableNewMutationAliasingModel) { + inferMutableRanges(hir); + log({kind: 'hir', name: 'InferMutableRanges', value: hir}); + } else { + const mutabilityAliasingErrors = inferMutationAliasingRanges(hir, { + isFunctionExpression: false, + }); + log({kind: 'hir', name: 'InferMutationAliasingRanges', value: hir}); + if (env.isInferredMemoEnabled) { + if (mutabilityAliasingErrors.isErr()) { + throw mutabilityAliasingErrors.unwrapErr(); + } + validateLocalsNotReassignedAfterRender(hir); + } + } if (env.isInferredMemoEnabled) { if (env.config.assertValidMutableRanges) { @@ -277,7 +304,10 @@ function runWithEnvironment( validateNoImpureFunctionsInRender(hir).unwrap(); } - if (env.config.validateNoFreezingKnownMutableFunctions) { + if ( + env.config.validateNoFreezingKnownMutableFunctions || + env.config.enableNewMutationAliasingModel + ) { validateNoFreezingKnownMutableFunctions(hir).unwrap(); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertValidMutableRanges.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertValidMutableRanges.ts index d44f6108eaa57..773986a1b5e77 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertValidMutableRanges.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertValidMutableRanges.ts @@ -5,13 +5,14 @@ * LICENSE file in the root directory of this source tree. */ -import invariant from 'invariant'; -import {HIRFunction, Identifier, MutableRange} from './HIR'; +import {HIRFunction, MutableRange, Place} from './HIR'; import { eachInstructionLValue, eachInstructionOperand, eachTerminalOperand, } from './visitors'; +import {CompilerError} from '..'; +import {printPlace} from './PrintHIR'; /* * Checks that all mutable ranges in the function are well-formed, with @@ -20,38 +21,43 @@ import { export function assertValidMutableRanges(fn: HIRFunction): void { for (const [, block] of fn.body.blocks) { for (const phi of block.phis) { - visitIdentifier(phi.place.identifier); - for (const [, operand] of phi.operands) { - visitIdentifier(operand.identifier); + visit(phi.place, `phi for block bb${block.id}`); + for (const [pred, operand] of phi.operands) { + visit(operand, `phi predecessor bb${pred} for block bb${block.id}`); } } for (const instr of block.instructions) { for (const operand of eachInstructionLValue(instr)) { - visitIdentifier(operand.identifier); + visit(operand, `instruction [${instr.id}]`); } for (const operand of eachInstructionOperand(instr)) { - visitIdentifier(operand.identifier); + visit(operand, `instruction [${instr.id}]`); } } for (const operand of eachTerminalOperand(block.terminal)) { - visitIdentifier(operand.identifier); + visit(operand, `terminal [${block.terminal.id}]`); } } } -function visitIdentifier(identifier: Identifier): void { - validateMutableRange(identifier.mutableRange); - if (identifier.scope !== null) { - validateMutableRange(identifier.scope.range); +function visit(place: Place, description: string): void { + validateMutableRange(place, place.identifier.mutableRange, description); + if (place.identifier.scope !== null) { + validateMutableRange(place, place.identifier.scope.range, description); } } -function validateMutableRange(mutableRange: MutableRange): void { - invariant( - (mutableRange.start === 0 && mutableRange.end === 0) || - mutableRange.end > mutableRange.start, - 'Identifier scope mutableRange was invalid: [%s:%s]', - mutableRange.start, - mutableRange.end, +function validateMutableRange( + place: Place, + range: MutableRange, + description: string, +): void { + CompilerError.invariant( + (range.start === 0 && range.end === 0) || range.end > range.start, + { + reason: `Invalid mutable range: [${range.start}:${range.end}]`, + description: `${printPlace(place)} in ${description}`, + loc: place.loc, + }, ); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts index cfb15fb595ccc..dbdbb1dcbaf1f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts @@ -47,7 +47,7 @@ import { makeType, promoteTemporary, } from './HIR'; -import HIRBuilder, {Bindings} from './HIRBuilder'; +import HIRBuilder, {Bindings, createTemporaryPlace} from './HIRBuilder'; import {BuiltInArrayId} from './ObjectShape'; /* @@ -181,6 +181,7 @@ export function lower( loc: GeneratedSource, value: lowerExpressionToTemporary(builder, body), id: makeInstructionId(0), + effects: null, }; builder.terminateWithContinuation(terminal, fallthrough); } else if (body.isBlockStatement()) { @@ -210,6 +211,7 @@ export function lower( loc: GeneratedSource, }), id: makeInstructionId(0), + effects: null, }, null, ); @@ -220,6 +222,7 @@ export function lower( fnType: bindings == null ? env.fnType : 'Other', returnTypeAnnotation: null, // TODO: extract the actual return type node if present returnType: makeType(), + returns: createTemporaryPlace(env, func.node.loc ?? GeneratedSource), body: builder.build(), context, generator: func.node.generator === true, @@ -227,6 +230,7 @@ export function lower( loc: func.node.loc ?? GeneratedSource, env, effects: null, + aliasingEffects: null, directives, }); } @@ -287,6 +291,7 @@ function lowerStatement( loc: stmt.node.loc ?? GeneratedSource, value, id: makeInstructionId(0), + effects: null, }; builder.terminate(terminal, 'block'); return; @@ -1237,6 +1242,7 @@ function lowerStatement( kind: 'Debugger', loc, }, + effects: null, loc, }); return; @@ -1894,6 +1900,7 @@ function lowerExpression( place: leftValue, loc: exprLoc, }, + effects: null, loc: exprLoc, }); builder.terminateWithContinuation( @@ -2829,6 +2836,7 @@ function lowerOptionalCallExpression( args, loc, }, + effects: null, loc, }); } else { @@ -2842,6 +2850,7 @@ function lowerOptionalCallExpression( args, loc, }, + effects: null, loc, }); } @@ -3465,9 +3474,10 @@ export function lowerValueToTemporary( const place: Place = buildTemporaryPlace(builder, value.loc); builder.push({ id: makeInstructionId(0), + lvalue: {...place}, value: value, + effects: null, loc: value.loc, - lvalue: {...place}, }); return place; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index 27b578b3c7834..206bfc0bca1a4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -243,6 +243,11 @@ export const EnvironmentConfigSchema = z.object({ */ enableUseTypeAnnotations: z.boolean().default(false), + /** + * Enable a new model for mutability and aliasing inference + */ + enableNewMutationAliasingModel: z.boolean().default(false), + /** * Enables inference of optional dependency chains. Without this flag * a property chain such as `props?.items?.foo` will infer as a dep on 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 cc11d0faceb18..c4c85be147930 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import {Effect, ValueKind, ValueReason} from './HIR'; +import {Effect, makeIdentifierId, ValueKind, ValueReason} from './HIR'; import { BUILTIN_SHAPES, BuiltInArrayId, @@ -34,6 +34,7 @@ import { addFunction, addHook, addObject, + signatureArgument, } from './ObjectShape'; import {BuiltInType, ObjectType, PolyType} from './Types'; import {TypeConfig} from './TypeSchema'; @@ -644,6 +645,41 @@ const REACT_APIS: Array<[string, BuiltInType]> = [ calleeEffect: Effect.Read, hookKind: 'useEffect', returnValueKind: ValueKind.Frozen, + aliasing: { + receiver: makeIdentifierId(0), + params: [], + rest: makeIdentifierId(1), + returns: makeIdentifierId(2), + temporaries: [signatureArgument(3)], + effects: [ + // Freezes the function and deps + { + kind: 'Freeze', + value: signatureArgument(1), + reason: ValueReason.Effect, + }, + // Internally creates an effect object that captures the function and deps + { + kind: 'Create', + into: signatureArgument(3), + value: ValueKind.Frozen, + reason: ValueReason.KnownReturnSignature, + }, + // The effect stores the function and dependencies + { + kind: 'Capture', + from: signatureArgument(1), + into: signatureArgument(3), + }, + // Returns undefined + { + kind: 'Create', + into: signatureArgument(2), + value: ValueKind.Primitive, + reason: ValueReason.KnownReturnSignature, + }, + ], + }, }, BuiltInUseEffectHookId, ), 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 6c55ff22bc649..252721765ae70 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -13,6 +13,7 @@ import {Environment, ReactFunctionType} from './Environment'; import type {HookKind} from './ObjectShape'; import {Type, makeType} from './Types'; import {z} from 'zod'; +import type {AliasingEffect} from '../Inference/AliasingEffects'; /* * ******************************************************************************************* @@ -100,6 +101,7 @@ export type ReactiveInstruction = { id: InstructionId; lvalue: Place | null; value: ReactiveValue; + effects?: Array | null; // TODO make non-optional loc: SourceLocation; }; @@ -278,12 +280,14 @@ export type HIRFunction = { params: Array; returnTypeAnnotation: t.FlowType | t.TSType | null; returnType: Type; + returns: Place; context: Array; effects: Array | null; body: HIR; generator: boolean; async: boolean; directives: Array; + aliasingEffects?: Array | null; }; export type FunctionEffect = @@ -449,6 +453,7 @@ export type ReturnTerminal = { value: Place; id: InstructionId; fallthrough?: never; + effects: Array | null; }; export type GotoTerminal = { @@ -609,6 +614,7 @@ export type MaybeThrowTerminal = { id: InstructionId; loc: SourceLocation; fallthrough?: never; + effects: Array | null; }; export type ReactiveScopeTerminal = { @@ -645,12 +651,14 @@ export type Instruction = { lvalue: Place; value: InstructionValue; loc: SourceLocation; + effects: Array | null; }; export type TInstruction = { id: InstructionId; lvalue: Place; value: T; + effects: Array | null; loc: SourceLocation; }; @@ -1380,6 +1388,11 @@ export enum ValueReason { */ JsxCaptured = 'jsx-captured', + /** + * Passed to an effect + */ + Effect = 'effect', + /** * Return value of a function with known frozen return value, e.g. `useState`. */ diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts index 9ed37bb2fc85f..19ccd9a6e89a5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts @@ -165,6 +165,7 @@ export default class HIRBuilder { handler: exceptionHandler, id: makeInstructionId(0), loc: instruction.loc, + effects: null, }, continuationBlock, ); diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/MergeConsecutiveBlocks.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/MergeConsecutiveBlocks.ts index ea132b772aa44..3d6ae4e6b211d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/MergeConsecutiveBlocks.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/MergeConsecutiveBlocks.ts @@ -12,6 +12,7 @@ import { GeneratedSource, HIRFunction, Instruction, + Place, } from './HIR'; import {markPredecessors} from './HIRBuilder'; import {terminalFallthrough, terminalHasFallthrough} from './visitors'; @@ -80,20 +81,22 @@ export function mergeConsecutiveBlocks(fn: HIRFunction): void { suggestions: null, }); const operand = Array.from(phi.operands.values())[0]!; + const lvalue: Place = { + kind: 'Identifier', + identifier: phi.place.identifier, + effect: Effect.ConditionallyMutate, + reactive: false, + loc: GeneratedSource, + }; const instr: Instruction = { id: predecessor.terminal.id, - lvalue: { - kind: 'Identifier', - identifier: phi.place.identifier, - effect: Effect.ConditionallyMutate, - reactive: false, - loc: GeneratedSource, - }, + lvalue: {...lvalue}, value: { kind: 'LoadLocal', place: {...operand}, loc: GeneratedSource, }, + effects: [{kind: 'Alias', from: {...operand}, into: {...lvalue}}], loc: GeneratedSource, }; predecessor.instructions.push(instr); 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 a017e1479a22b..e47d561231b0c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -6,10 +6,21 @@ */ import {CompilerError} from '../CompilerError'; -import {Effect, ValueKind, ValueReason} from './HIR'; +import {AliasingSignature} from '../Inference/AliasingEffects'; +import { + Effect, + GeneratedSource, + makeDeclarationId, + makeIdentifierId, + makeInstructionId, + Place, + ValueKind, + ValueReason, +} from './HIR'; import { BuiltInType, FunctionType, + makeType, ObjectType, PolyType, PrimitiveType, @@ -180,6 +191,9 @@ export type FunctionSignature = { impure?: boolean; canonicalName?: string; + + aliasing?: AliasingSignature | null; + todo_aliasing?: AliasingSignature | null; }; /* @@ -305,6 +319,30 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ returnType: PRIMITIVE_TYPE, calleeEffect: Effect.Store, returnValueKind: ValueKind.Primitive, + aliasing: { + receiver: makeIdentifierId(0), + params: [], + rest: makeIdentifierId(1), + returns: makeIdentifierId(2), + temporaries: [], + effects: [ + // Push directly mutates the array itself + {kind: 'Mutate', value: signatureArgument(0)}, + // The arguments are captured into the array + { + kind: 'Capture', + from: signatureArgument(1), + into: signatureArgument(0), + }, + // Returns the new length, a primitive + { + kind: 'Create', + into: signatureArgument(2), + value: ValueKind.Primitive, + reason: ValueReason.KnownReturnSignature, + }, + ], + }, }), ], [ @@ -335,6 +373,62 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ returnValueKind: ValueKind.Mutable, noAlias: true, mutableOnlyIfOperandsAreMutable: true, + aliasing: { + receiver: makeIdentifierId(0), + params: [makeIdentifierId(1)], + rest: null, + returns: makeIdentifierId(2), + temporaries: [ + // Temporary representing captured items of the receiver + signatureArgument(3), + // Temporary representing the result of the callback + signatureArgument(4), + /* + * Undefined `this` arg to the callback. Note the signature does not + * support passing an explicit thisArg second param + */ + signatureArgument(5), + ], + effects: [ + // Map creates a new mutable array + { + kind: 'Create', + into: signatureArgument(2), + value: ValueKind.Mutable, + reason: ValueReason.KnownReturnSignature, + }, + // The first arg to the callback is an item extracted from the receiver array + { + kind: 'CreateFrom', + from: signatureArgument(0), + into: signatureArgument(3), + }, + // The undefined this for the callback + { + kind: 'Create', + into: signatureArgument(5), + value: ValueKind.Primitive, + reason: ValueReason.KnownReturnSignature, + }, + // calls the callback, returning the result into a temporary + { + kind: 'Apply', + receiver: signatureArgument(5), + args: [signatureArgument(3), {kind: 'Hole'}, signatureArgument(0)], + function: signatureArgument(1), + into: signatureArgument(4), + signature: null, + mutatesFunction: false, + loc: GeneratedSource, + }, + // captures the result of the callback into the return array + { + kind: 'Capture', + from: signatureArgument(4), + into: signatureArgument(2), + }, + ], + }, }), ], [ @@ -482,6 +576,32 @@ addObject(BUILTIN_SHAPES, BuiltInSetId, [ calleeEffect: Effect.Store, // returnValueKind is technically dependent on the ValueKind of the set itself returnValueKind: ValueKind.Mutable, + aliasing: { + receiver: makeIdentifierId(0), + params: [], + rest: makeIdentifierId(1), + returns: makeIdentifierId(2), + temporaries: [], + effects: [ + // Set.add returns the receiver Set + { + kind: 'Assign', + from: signatureArgument(0), + into: signatureArgument(2), + }, + // Set.add mutates the set itself + { + kind: 'Mutate', + value: signatureArgument(0), + }, + // Captures the rest params into the set + { + kind: 'Capture', + from: signatureArgument(1), + into: signatureArgument(0), + }, + ], + }, }), ], [ @@ -1185,3 +1305,22 @@ export const DefaultNonmutatingHook = addHook( }, 'DefaultNonmutatingHook', ); + +export function signatureArgument(id: number): Place { + const place: Place = { + kind: 'Identifier', + effect: Effect.Unknown, + loc: GeneratedSource, + reactive: false, + identifier: { + declarationId: makeDeclarationId(id), + id: makeIdentifierId(id), + loc: GeneratedSource, + mutableRange: {start: makeInstructionId(0), end: makeInstructionId(0)}, + name: null, + scope: null, + type: makeType(), + }, + }; + return place; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts index c8182c9e72a7c..f42f4bcf19b36 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts @@ -35,6 +35,7 @@ import type { Type, } from './HIR'; import {GotoVariant, InstructionKind} from './HIR'; +import {AliasingEffect, AliasingSignature} from '../Inference/AliasingEffects'; export type Options = { indent: number; @@ -67,13 +68,15 @@ export function printFunction(fn: HIRFunction): string { }) .join(', ') + ')'; + } else { + definition += '()'; } if (definition.length !== 0) { output.push(definition); } - output.push(printType(fn.returnType)); - output.push(printHIR(fn.body)); + output.push(`: ${printType(fn.returnType)} @ ${printPlace(fn.returns)}`); output.push(...fn.directives); + output.push(printHIR(fn.body)); return output.join('\n'); } @@ -151,7 +154,10 @@ export function printMixedHIR( export function printInstruction(instr: ReactiveInstruction): string { const id = `[${instr.id}]`; - const value = printInstructionValue(instr.value); + let value = printInstructionValue(instr.value); + if (instr.effects != null) { + value += `\n ${instr.effects.map(printAliasingEffect).join('\n ')}`; + } if (instr.lvalue !== null) { return `${id} ${printPlace(instr.lvalue)} = ${value}`; @@ -213,6 +219,9 @@ export function printTerminal(terminal: Terminal): Array | string { value = `[${terminal.id}] Return${ terminal.value != null ? ' ' + printPlace(terminal.value) : '' }`; + if (terminal.effects != null) { + value += `\n ${terminal.effects.map(printAliasingEffect).join('\n ')}`; + } break; } case 'goto': { @@ -281,6 +290,9 @@ export function printTerminal(terminal: Terminal): Array | string { } case 'maybe-throw': { value = `[${terminal.id}] MaybeThrow continuation=bb${terminal.continuation} handler=bb${terminal.handler}`; + if (terminal.effects != null) { + value += `\n ${terminal.effects.map(printAliasingEffect).join('\n ')}`; + } break; } case 'scope': { @@ -555,8 +567,11 @@ export function printInstructionValue(instrValue: ReactiveValue): string { } }) .join(', ') ?? ''; - const type = printType(instrValue.loweredFunc.func.returnType).trim(); - value = `${kind} ${name} @context[${context}] @effects[${effects}]${type !== '' ? ` return${type}` : ''}:\n${fn}`; + const aliasingEffects = + instrValue.loweredFunc.func.aliasingEffects + ?.map(printAliasingEffect) + ?.join(', ') ?? ''; + value = `${kind} ${name} @context[${context}] @effects[${effects}] @aliasingEffects=[${aliasingEffects}]\n${fn}`; break; } case 'TaggedTemplateExpression': { @@ -922,3 +937,107 @@ function getFunctionName( return defaultValue; } } + +export function printAliasingEffect(effect: AliasingEffect): string { + switch (effect.kind) { + case 'Assign': { + return `Assign ${printPlaceForAliasEffect(effect.into)} = ${printPlaceForAliasEffect(effect.from)}`; + } + case 'Alias': { + return `Alias ${printPlaceForAliasEffect(effect.into)} = ${printPlaceForAliasEffect(effect.from)}`; + } + case 'Capture': { + return `Capture ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`; + } + case 'ImmutableCapture': { + return `ImmutableCapture ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`; + } + case 'Create': { + return `Create ${printPlaceForAliasEffect(effect.into)} = ${effect.value}`; + } + case 'CreateFrom': { + return `Create ${printPlaceForAliasEffect(effect.into)} = kindOf(${printPlaceForAliasEffect(effect.from)})`; + } + case 'CreateFunction': { + return `Function ${printPlaceForAliasEffect(effect.into)} = Function captures=[${effect.captures.map(printPlaceForAliasEffect).join(', ')}]`; + } + case 'Apply': { + const receiverCallee = + effect.receiver.identifier.id === effect.function.identifier.id + ? printPlaceForAliasEffect(effect.receiver) + : `${printPlaceForAliasEffect(effect.receiver)}.${printPlaceForAliasEffect(effect.function)}`; + const args = effect.args + .map(arg => { + if (arg.kind === 'Identifier') { + return printPlaceForAliasEffect(arg); + } else if (arg.kind === 'Hole') { + return ' '; + } + return `...${printPlaceForAliasEffect(arg.place)}`; + }) + .join(', '); + let signature = ''; + if (effect.signature != null) { + if (effect.signature.aliasing != null) { + signature = printAliasingSignature(effect.signature.aliasing); + } else { + signature = JSON.stringify(effect.signature, null, 2); + } + } + return `Apply ${printPlaceForAliasEffect(effect.into)} = ${receiverCallee}(${args})${signature != '' ? '\n ' : ''}${signature}`; + } + case 'Freeze': { + return `Freeze ${printPlaceForAliasEffect(effect.value)} ${effect.reason}`; + } + case 'Mutate': + case 'MutateConditionally': + case 'MutateTransitive': + case 'MutateTransitiveConditionally': { + return `${effect.kind} ${printPlaceForAliasEffect(effect.value)}`; + } + case 'MutateFrozen': { + return `MutateFrozen ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`; + } + case 'MutateGlobal': { + return `MutateGlobal ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`; + } + case 'Impure': { + return `Impure ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`; + } + case 'Render': { + return `Render ${printPlaceForAliasEffect(effect.place)}`; + } + default: { + assertExhaustive(effect, `Unexpected kind '${(effect as any).kind}'`); + } + } +} + +function printPlaceForAliasEffect(place: Place): string { + return printIdentifier(place.identifier); +} + +export function printAliasingSignature(signature: AliasingSignature): string { + const tokens: Array = ['function ']; + if (signature.temporaries.length !== 0) { + tokens.push('<'); + tokens.push( + signature.temporaries.map(temp => `$${temp.identifier.id}`).join(', '), + ); + tokens.push('>'); + } + tokens.push('('); + tokens.push('this=$' + String(signature.receiver)); + for (const param of signature.params) { + tokens.push(', $' + String(param)); + } + if (signature.rest != null) { + tokens.push(`, ...$${String(signature.rest)}`); + } + tokens.push('): '); + tokens.push('$' + String(signature.returns) + ':'); + for (const effect of signature.effects) { + tokens.push('\n ' + printAliasingEffect(effect)); + } + return tokens.join(''); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ScopeDependencyUtils.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ScopeDependencyUtils.ts index 5d30aeb6444ee..6e9ff08b86242 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ScopeDependencyUtils.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ScopeDependencyUtils.ts @@ -88,6 +88,7 @@ function writeNonOptionalDependency( }, id: makeInstructionId(1), loc: loc, + effects: null, }); /** @@ -118,6 +119,7 @@ function writeNonOptionalDependency( }, id: makeInstructionId(1), loc: loc, + effects: null, }); curr = next; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts index 49ff3c256e016..52bbefc732856 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/visitors.ts @@ -735,6 +735,7 @@ export function mapTerminalSuccessors( loc: terminal.loc, value: terminal.value, id: makeInstructionId(0), + effects: terminal.effects, }; } case 'throw': { @@ -842,6 +843,7 @@ export function mapTerminalSuccessors( handler, id: makeInstructionId(0), loc: terminal.loc, + effects: terminal.effects, }; } case 'try': { diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/AliasingEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/AliasingEffects.ts new file mode 100644 index 0000000000000..1a23a9cd3c457 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/AliasingEffects.ts @@ -0,0 +1,233 @@ +/** + * 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. + */ + +import {CompilerErrorDetailOptions} from '../CompilerError'; +import { + FunctionExpression, + Hole, + IdentifierId, + ObjectMethod, + Place, + SourceLocation, + SpreadPattern, + ValueKind, + ValueReason, +} from '../HIR'; +import {FunctionSignature} from '../HIR/ObjectShape'; + +/** + * `AliasingEffect` describes a set of "effects" that an instruction/terminal has on one or + * more values in a program. These effects include mutation of values, freezing values, + * tracking data flow between values, and other specialized cases. + */ +export type AliasingEffect = + /** + * Marks the given value and its direct aliases as frozen. + * + * Captured values are *not* considered frozen, because we cannot be sure that a previously + * captured value will still be captured at the point of the freeze. + * + * For example: + * const x = {}; + * const y = [x]; + * y.pop(); // y dosn't contain x anymore! + * freeze(y); + * mutate(x); // safe to mutate! + * + * The exception to this is FunctionExpressions - since it is impossible to change which + * value a function closes over[1] we can transitively freeze functions and their captures. + * + * [1] Except for `let` values that are reassigned and closed over by a function, but we + * handle this explicitly with StoreContext/LoadContext. + */ + | {kind: 'Freeze'; value: Place; reason: ValueReason} + /** + * Mutate the value and any direct aliases (not captures). Errors if the value is not mutable. + */ + | {kind: 'Mutate'; value: Place} + /** + * Mutate the value and any direct aliases (not captures), but only if the value is known mutable. + * This should be rare. + * + * TODO: this is only used for IteratorNext, but even then MutateTransitiveConditionally is more + * correct for iterators of unknown types. + */ + | {kind: 'MutateConditionally'; value: Place} + /** + * Mutate the value, any direct aliases, and any transitive captures. Errors if the value is not mutable. + */ + | {kind: 'MutateTransitive'; value: Place} + /** + * Mutates any of the value, its direct aliases, and its transitive captures that are mutable. + */ + | {kind: 'MutateTransitiveConditionally'; value: Place} + /** + * Records information flow from `from` to `into` in cases where local mutation of the destination + * will *not* mutate the source: + * + * - Capture a -> b and Mutate(b) X=> (does not imply) Mutate(a) + * - Capture a -> b and MutateTransitive(b) => (does imply) Mutate(a) + * + * Example: `array.push(item)`. Information from item is captured into array, but there is not a + * direct aliasing, and local mutations of array will not modify item. + */ + | {kind: 'Capture'; from: Place; into: Place} + /** + * Records information flow from `from` to `into` in cases where local mutation of the destination + * *will* mutate the source: + * + * - Alias a -> b and Mutate(b) => (does imply) Mutate(a) + * - Alias a -> b and MutateTransitive(b) => (does imply) Mutate(a) + * + * Example: `c = identity(a)`. We don't know what `identity()` returns so we can't use Assign. + * But we have to assume that it _could_ be returning its input, such that a local mutation of + * c could be mutating a. + */ + | {kind: 'Alias'; from: Place; into: Place} + /** + * Records direct assignment: `into = from`. + */ + | {kind: 'Assign'; from: Place; into: Place} + /** + * Creates a value of the given type at the given place + */ + | {kind: 'Create'; into: Place; value: ValueKind; reason: ValueReason} + /** + * Creates a new value with the same kind as the starting value. + */ + | {kind: 'CreateFrom'; from: Place; into: Place} + /** + * Immutable data flow, used for escape analysis. Does not influence mutable range analysis: + */ + | {kind: 'ImmutableCapture'; from: Place; into: Place} + /** + * Calls the function at the given place with the given arguments either captured or aliased, + * and captures/aliases the result into the given place. + */ + | { + kind: 'Apply'; + receiver: Place; + function: Place; + mutatesFunction: boolean; + args: Array; + into: Place; + signature: FunctionSignature | null; + loc: SourceLocation; + } + /** + * Constructs a function value with the given captures. The mutability of the function + * will be determined by the mutability of the capture values when evaluated. + */ + | { + kind: 'CreateFunction'; + captures: Array; + function: FunctionExpression | ObjectMethod; + into: Place; + } + /** + * Mutation of a value known to be immutable + */ + | {kind: 'MutateFrozen'; place: Place; error: CompilerErrorDetailOptions} + /** + * Mutation of a global + */ + | { + kind: 'MutateGlobal'; + place: Place; + error: CompilerErrorDetailOptions; + } + /** + * Indicates a side-effect that is not safe during render + */ + | {kind: 'Impure'; place: Place; error: CompilerErrorDetailOptions} + /** + * Indicates that a given place is accessed during render. Used to distingush + * hook arguments that are known to be called immediately vs those used for + * event handlers/effects, and for JSX values known to be called during render + * (tags, children) vs those that may be events/effect (other props). + */ + | { + kind: 'Render'; + place: Place; + }; + +export function hashEffect(effect: AliasingEffect): string { + switch (effect.kind) { + case 'Apply': { + return [ + effect.kind, + effect.receiver.identifier.id, + effect.function.identifier.id, + effect.mutatesFunction, + effect.args + .map(a => { + if (a.kind === 'Hole') { + return ''; + } else if (a.kind === 'Identifier') { + return a.identifier.id; + } else { + return `...${a.place.identifier.id}`; + } + }) + .join(','), + effect.into.identifier.id, + ].join(':'); + } + case 'CreateFrom': + case 'ImmutableCapture': + case 'Assign': + case 'Alias': + case 'Capture': { + return [ + effect.kind, + effect.from.identifier.id, + effect.into.identifier.id, + ].join(':'); + } + case 'Create': { + return [ + effect.kind, + effect.into.identifier.id, + effect.value, + effect.reason, + ].join(':'); + } + case 'Freeze': { + return [effect.kind, effect.value.identifier.id, effect.reason].join(':'); + } + case 'Impure': + case 'Render': + case 'MutateFrozen': + case 'MutateGlobal': { + return [effect.kind, effect.place.identifier.id].join(':'); + } + case 'Mutate': + case 'MutateConditionally': + case 'MutateTransitive': + case 'MutateTransitiveConditionally': { + return [effect.kind, effect.value.identifier.id].join(':'); + } + case 'CreateFunction': { + return [ + effect.kind, + effect.into.identifier.id, + // return places are a unique way to identify functions themselves + effect.function.loweredFunc.func.returns.identifier.id, + effect.captures.map(p => p.identifier.id).join(','), + ].join(':'); + } + } +} + +export type AliasingSignature = { + receiver: IdentifierId; + params: Array; + rest: IdentifierId | null; + returns: IdentifierId; + effects: Array; + temporaries: Array; +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts index a439b4cd01232..fff913210347d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/AnalyseFunctions.ts @@ -10,6 +10,7 @@ import { Effect, HIRFunction, Identifier, + IdentifierId, LoweredFunction, isRefOrRefValue, makeInstructionId, @@ -19,6 +20,10 @@ import {inferReactiveScopeVariables} from '../ReactiveScopes'; import {rewriteInstructionKindsBasedOnReassignment} from '../SSA'; import {inferMutableRanges} from './InferMutableRanges'; import inferReferenceEffects from './InferReferenceEffects'; +import {assertExhaustive} from '../Utils/utils'; +import {inferMutationAliasingEffects} from './InferMutationAliasingEffects'; +import {inferMutationAliasingFunctionEffects} from './InferMutationAliasingFunctionEffects'; +import {inferMutationAliasingRanges} from './InferMutationAliasingRanges'; export default function analyseFunctions(func: HIRFunction): void { for (const [_, block] of func.body.blocks) { @@ -26,8 +31,12 @@ export default function analyseFunctions(func: HIRFunction): void { switch (instr.value.kind) { case 'ObjectMethod': case 'FunctionExpression': { - lower(instr.value.loweredFunc.func); - infer(instr.value.loweredFunc); + if (!func.env.config.enableNewMutationAliasingModel) { + lower(instr.value.loweredFunc.func); + infer(instr.value.loweredFunc); + } else { + lowerWithMutationAliasing(instr.value.loweredFunc.func); + } /** * Reset mutable range for outer inferReferenceEffects @@ -44,6 +53,87 @@ export default function analyseFunctions(func: HIRFunction): void { } } +function lowerWithMutationAliasing(fn: HIRFunction): void { + /** + * Phase 1: similar to lower(), but using the new mutation/aliasing inference + */ + analyseFunctions(fn); + inferMutationAliasingEffects(fn, {isFunctionExpression: true}); + deadCodeElimination(fn); + inferMutationAliasingRanges(fn, {isFunctionExpression: true}); + rewriteInstructionKindsBasedOnReassignment(fn); + inferReactiveScopeVariables(fn); + const effects = inferMutationAliasingFunctionEffects(fn); + fn.env.logger?.debugLogIRs?.({ + kind: 'hir', + name: 'AnalyseFunction (inner)', + value: fn, + }); + if (effects != null) { + fn.aliasingEffects ??= []; + fn.aliasingEffects?.push(...effects); + } + + /** + * Phase 2: populate the Effect of each context variable to use in inferring + * the outer function. For example, InferMutationAliasingEffects uses context variable + * effects to decide if the function may be mutable or not. + */ + const capturedOrMutated = new Set(); + for (const effect of effects ?? []) { + switch (effect.kind) { + case 'Assign': + case 'Alias': + case 'Capture': + case 'CreateFrom': { + capturedOrMutated.add(effect.from.identifier.id); + break; + } + case 'Apply': { + CompilerError.invariant(false, { + reason: `[AnalyzeFunctions] Expected Apply effects to be replaced with more precise effects`, + loc: effect.function.loc, + }); + } + case 'Mutate': + case 'MutateConditionally': + case 'MutateTransitive': + case 'MutateTransitiveConditionally': { + capturedOrMutated.add(effect.value.identifier.id); + break; + } + case 'Impure': + case 'Render': + case 'MutateFrozen': + case 'MutateGlobal': + case 'CreateFunction': + case 'Create': + case 'Freeze': + case 'ImmutableCapture': { + // no-op + break; + } + default: { + assertExhaustive( + effect, + `Unexpected effect kind ${(effect as any).kind}`, + ); + } + } + } + + for (const operand of fn.context) { + if ( + capturedOrMutated.has(operand.identifier.id) || + operand.effect === Effect.Capture + ) { + operand.effect = Effect.Capture; + } else { + operand.effect = Effect.Read; + } + } +} + function lower(func: HIRFunction): void { analyseFunctions(func); inferReferenceEffects(func, {isFunctionExpression: true}); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts index 8d123845c3739..306e636b12b3a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts @@ -197,6 +197,7 @@ function makeManualMemoizationMarkers( deps: depsList, loc: fnExpr.loc, }, + effects: null, loc: fnExpr.loc, }, { @@ -208,6 +209,7 @@ function makeManualMemoizationMarkers( decl: {...memoDecl}, loc: fnExpr.loc, }, + effects: null, loc: fnExpr.loc, }, ]; 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 eab3c241bcccf..4d4531e1cbe0c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts @@ -257,6 +257,7 @@ export function inferEffectDependencies(fn: HIRFunction): void { loc: GeneratedSource, lvalue: {...depsPlace, effect: Effect.Mutate}, value: deps, + effects: null, }, }); value.args.push({...depsPlace, effect: Effect.Freeze}); @@ -271,6 +272,7 @@ export function inferEffectDependencies(fn: HIRFunction): void { loc: GeneratedSource, lvalue: {...depsPlace, effect: Effect.Mutate}, value: deps, + effects: null, }, }); value.args.push({...depsPlace, effect: Effect.Freeze}); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferFunctionEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferFunctionEffects.ts index a58ae440219b9..4a278850956fe 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferFunctionEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferFunctionEffects.ts @@ -324,7 +324,7 @@ function isEffectSafeOutsideRender(effect: FunctionEffect): boolean { return effect.kind === 'GlobalMutation'; } -function getWriteErrorReason(abstractValue: AbstractValue): string { +export function getWriteErrorReason(abstractValue: AbstractValue): string { if (abstractValue.reason.has(ValueReason.Global)) { return 'Writing to a variable defined outside a component or hook is not allowed. Consider using an effect'; } else if (abstractValue.reason.has(ValueReason.JsxCaptured)) { @@ -339,6 +339,8 @@ function getWriteErrorReason(abstractValue: AbstractValue): string { return "Mutating a value returned from 'useState()', which should not be mutated. Use the setter function to update instead"; } else if (abstractValue.reason.has(ValueReason.ReducerState)) { return "Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead"; + } else if (abstractValue.reason.has(ValueReason.Effect)) { + return 'Updating a value used previously in an effect function or as an effect dependency is not allowed. Consider moving the mutation before calling useEffect()'; } else { return 'This mutates a variable that React considers immutable'; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutableRanges.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutableRanges.ts index 624c302fbf7ee..571a19290ea60 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutableRanges.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutableRanges.ts @@ -86,7 +86,7 @@ export function inferMutableRanges(ir: HIRFunction): void { } } -function areEqualMaps(a: Map, b: Map): boolean { +function areEqualMaps(a: Map, b: Map): boolean { if (a.size !== b.size) { return false; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts new file mode 100644 index 0000000000000..19f0d84b9a8ce --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts @@ -0,0 +1,2378 @@ +/** + * 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. + */ + +import { + CompilerError, + Effect, + ErrorSeverity, + SourceLocation, + ValueKind, +} from '..'; +import { + BasicBlock, + BlockId, + DeclarationId, + Environment, + FunctionExpression, + HIRFunction, + Hole, + IdentifierId, + Instruction, + InstructionKind, + InstructionValue, + isArrayType, + isMapType, + isPrimitiveType, + isRefOrRefValue, + isSetType, + makeIdentifierId, + Phi, + Place, + SpreadPattern, + ValueReason, +} from '../HIR'; +import { + eachInstructionValueLValue, + eachInstructionValueOperand, + eachTerminalSuccessor, +} from '../HIR/visitors'; +import {Ok, Result} from '../Utils/Result'; +import { + getArgumentEffect, + getFunctionCallSignature, + isKnownMutableEffect, + mergeValueKinds, +} from './InferReferenceEffects'; +import { + assertExhaustive, + getOrInsertWith, + Set_isSuperset, +} from '../Utils/utils'; +import { + printAliasingEffect, + printAliasingSignature, + printIdentifier, + printInstruction, + printInstructionValue, + printPlace, + printSourceLocation, +} from '../HIR/PrintHIR'; +import {FunctionSignature} from '../HIR/ObjectShape'; +import {getWriteErrorReason} from './InferFunctionEffects'; +import prettyFormat from 'pretty-format'; +import {createTemporaryPlace} from '../HIR/HIRBuilder'; +import {AliasingEffect, AliasingSignature, hashEffect} from './AliasingEffects'; + +const DEBUG = false; + +/** + * Infers the mutation/aliasing effects for instructions and terminals and annotates + * them on the HIR, making the effects of builtin instructions/functions as well as + * user-defined functions explicit. These effects then form the basis for subsequent + * analysis to determine the mutable range of each value in the program — the set of + * instructions over which the value is created and mutated — as well as validation + * against invalid code. + * + * At a high level the approach is: + * - Determine a set of candidate effects based purely on the syntax of the instruction + * and the types involved. These candidate effects are cached the first time each + * instruction is visited. The idea is to reason about the semantics of the instruction + * or function in isolation, separately from how those effects may interact with later + * abstract interpretation. + * - Then we do abstract interpretation over the HIR, iterating until reaching a fixpoint. + * This phase tracks the abstract kind of each value (mutable, primitive, frozen, etc) + * and the set of values pointed to by each identifier. Each candidate effect is "applied" + * to the current abtract state, and effects may be dropped or rewritten accordingly. + * For example, a "MutateConditionally " effect may be dropped if x is not a mutable + * value. A "Mutate " effect may get converted into a "MutateFrozen " effect + * if y is mutable, etc. + */ +export function inferMutationAliasingEffects( + fn: HIRFunction, + {isFunctionExpression}: {isFunctionExpression: boolean} = { + isFunctionExpression: false, + }, +): Result { + const initialState = InferenceState.empty(fn.env, isFunctionExpression); + + // Map of blocks to the last (merged) incoming state that was processed + const statesByBlock: Map = new Map(); + + for (const ref of fn.context) { + // TODO: using InstructionValue as a bit of a hack, but it's pragmatic + const value: InstructionValue = { + kind: 'ObjectExpression', + properties: [], + loc: ref.loc, + }; + initialState.initialize(value, { + kind: ValueKind.Context, + reason: new Set([ValueReason.Other]), + }); + initialState.define(ref, value); + } + + const paramKind: AbstractValue = isFunctionExpression + ? { + kind: ValueKind.Mutable, + reason: new Set([ValueReason.Other]), + } + : { + kind: ValueKind.Frozen, + reason: new Set([ValueReason.ReactiveFunctionArgument]), + }; + + if (fn.fnType === 'Component') { + CompilerError.invariant(fn.params.length <= 2, { + reason: + 'Expected React component to have not more than two parameters: one for props and for ref', + description: null, + loc: fn.loc, + suggestions: null, + }); + const [props, ref] = fn.params; + if (props != null) { + inferParam(props, initialState, paramKind); + } + if (ref != null) { + const place = ref.kind === 'Identifier' ? ref : ref.place; + const value: InstructionValue = { + kind: 'ObjectExpression', + properties: [], + loc: place.loc, + }; + initialState.initialize(value, { + kind: ValueKind.Mutable, + reason: new Set([ValueReason.Other]), + }); + initialState.define(place, value); + } + } else { + for (const param of fn.params) { + inferParam(param, initialState, paramKind); + } + } + + /* + * Multiple predecessors may be visited prior to reaching a given successor, + * so track the list of incoming state for each successor block. + * These are merged when reaching that block again. + */ + const queuedStates: Map = new Map(); + function queue(blockId: BlockId, state: InferenceState): void { + let queuedState = queuedStates.get(blockId); + if (queuedState != null) { + // merge the queued states for this block + state = queuedState.merge(state) ?? queuedState; + queuedStates.set(blockId, state); + } else { + /* + * this is the first queued state for this block, see whether + * there are changed relative to the last time it was processed. + */ + const prevState = statesByBlock.get(blockId); + const nextState = prevState != null ? prevState.merge(state) : state; + if (nextState != null) { + queuedStates.set(blockId, nextState); + } + } + } + queue(fn.body.entry, initialState); + + const hoistedContextDeclarations = findHoistedContextDeclarations(fn); + + const context = new Context( + isFunctionExpression, + fn, + hoistedContextDeclarations, + ); + + let count = 0; + while (queuedStates.size !== 0) { + count++; + if (count > 1000) { + console.log( + 'oops infinite loop', + fn.id, + typeof fn.loc !== 'symbol' ? fn.loc?.filename : null, + ); + throw new Error('infinite loop'); + } + for (const [blockId, block] of fn.body.blocks) { + const incomingState = queuedStates.get(blockId); + queuedStates.delete(blockId); + if (incomingState == null) { + continue; + } + + statesByBlock.set(blockId, incomingState); + const state = incomingState.clone(); + inferBlock(context, state, block); + + for (const nextBlockId of eachTerminalSuccessor(block.terminal)) { + queue(nextBlockId, state); + } + } + } + return Ok(undefined); +} + +function findHoistedContextDeclarations(fn: HIRFunction): Set { + const hoisted = new Set(); + for (const block of fn.body.blocks.values()) { + for (const instr of block.instructions) { + if (instr.value.kind === 'DeclareContext') { + const kind = instr.value.lvalue.kind; + if ( + kind == InstructionKind.HoistedConst || + kind == InstructionKind.HoistedFunction || + kind == InstructionKind.HoistedLet + ) { + hoisted.add(instr.value.lvalue.place.identifier.declarationId); + } + } + } + } + return hoisted; +} + +class Context { + internedEffects: Map = new Map(); + instructionSignatureCache: Map = new Map(); + effectInstructionValueCache: Map = + new Map(); + catchHandlers: Map = new Map(); + isFuctionExpression: boolean; + fn: HIRFunction; + hoistedContextDeclarations: Set; + + constructor( + isFunctionExpression: boolean, + fn: HIRFunction, + hoistedContextDeclarations: Set, + ) { + this.isFuctionExpression = isFunctionExpression; + this.fn = fn; + this.hoistedContextDeclarations = hoistedContextDeclarations; + } + + internEffect(effect: AliasingEffect): AliasingEffect { + const hash = hashEffect(effect); + let interned = this.internedEffects.get(hash); + if (interned == null) { + this.internedEffects.set(hash, effect); + interned = effect; + } + return interned; + } +} + +function inferParam( + param: Place | SpreadPattern, + initialState: InferenceState, + paramKind: AbstractValue, +): void { + const place = param.kind === 'Identifier' ? param : param.place; + const value: InstructionValue = { + kind: 'Primitive', + loc: place.loc, + value: undefined, + }; + initialState.initialize(value, paramKind); + initialState.define(place, value); +} + +function inferBlock( + context: Context, + state: InferenceState, + block: BasicBlock, +): void { + for (const phi of block.phis) { + state.inferPhi(phi); + } + + for (const instr of block.instructions) { + let instructionSignature = context.instructionSignatureCache.get(instr); + if (instructionSignature == null) { + instructionSignature = computeSignatureForInstruction( + context, + state.env, + instr, + ); + context.instructionSignatureCache.set(instr, instructionSignature); + } + const effects = applySignature(context, state, instructionSignature, instr); + instr.effects = effects; + } + const terminal = block.terminal; + if (terminal.kind === 'try' && terminal.handlerBinding != null) { + context.catchHandlers.set(terminal.handler, terminal.handlerBinding); + } else if (terminal.kind === 'maybe-throw') { + const handlerParam = context.catchHandlers.get(terminal.handler); + if (handlerParam != null) { + const effects: Array = []; + for (const instr of block.instructions) { + if ( + instr.value.kind === 'CallExpression' || + instr.value.kind === 'MethodCall' + ) { + /** + * Many instructions can error, but only calls can throw their result as the error + * itself. For example, `c = a.b` can throw if `a` is nullish, but the thrown value + * is an error object synthesized by the JS runtime. Whereas `throwsInput(x)` can + * throw (effectively) the result of the call. + * + * TODO: call applyEffect() instead. This meant that the catch param wasn't inferred + * as a mutable value, though. See `try-catch-try-value-modified-in-catch-escaping.js` + * fixture as an example + */ + state.appendAlias(handlerParam, instr.lvalue); + const kind = state.kind(instr.lvalue).kind; + if (kind === ValueKind.Mutable || kind == ValueKind.Context) { + effects.push({ + kind: 'Alias', + from: instr.lvalue, + into: handlerParam, + }); + } + } + } + terminal.effects = effects.length !== 0 ? effects : null; + } + } else if (terminal.kind === 'return') { + if (!context.isFuctionExpression) { + terminal.effects = [ + { + kind: 'Freeze', + value: terminal.value, + reason: ValueReason.JsxCaptured, + }, + ]; + } + } +} + +/** + * Applies the signature to the given state to determine the precise set of effects + * that will occur in practice. This takes into account the inferred state of each + * variable. For example, the signature may have a `ConditionallyMutate x` effect. + * Here, we check the abstract type of `x` and either record a `Mutate x` if x is mutable + * or no effect if x is a primitive, global, or frozen. + * + * This phase may also emit errors, for example MutateLocal on a frozen value is invalid. + */ +function applySignature( + context: Context, + state: InferenceState, + signature: InstructionSignature, + instruction: Instruction, +): Array | null { + const effects: Array = []; + /** + * For function instructions, eagerly validate that they aren't mutating + * a known-frozen value. + * + * TODO: make sure we're also validating against global mutations somewhere, but + * account for this being allowed in effects/event handlers. + */ + if ( + instruction.value.kind === 'FunctionExpression' || + instruction.value.kind === 'ObjectMethod' + ) { + const aliasingEffects = + instruction.value.loweredFunc.func.aliasingEffects ?? []; + const context = new Set( + instruction.value.loweredFunc.func.context.map(p => p.identifier.id), + ); + for (const effect of aliasingEffects) { + if (effect.kind === 'Mutate' || effect.kind === 'MutateTransitive') { + if (!context.has(effect.value.identifier.id)) { + continue; + } + const value = state.kind(effect.value); + switch (value.kind) { + case ValueKind.Frozen: { + const reason = getWriteErrorReason({ + kind: value.kind, + reason: value.reason, + context: new Set(), + }); + effects.push({ + kind: 'MutateFrozen', + place: effect.value, + error: { + severity: ErrorSeverity.InvalidReact, + reason, + description: + effect.value.identifier.name !== null && + effect.value.identifier.name.kind === 'named' + ? `Found mutation of \`${effect.value.identifier.name.value}\`` + : null, + loc: effect.value.loc, + suggestions: null, + }, + }); + } + } + } + } + } + + /* + * Track which values we've already aliased once, so that we can switch to + * appendAlias() for subsequent aliases into the same value + */ + const aliased = new Set(); + + if (DEBUG) { + console.log(printInstruction(instruction)); + } + + for (const effect of signature.effects) { + applyEffect(context, state, effect, aliased, effects); + } + if (DEBUG) { + console.log( + prettyFormat(state.debugAbstractValue(state.kind(instruction.lvalue))), + ); + console.log( + effects.map(effect => ` ${printAliasingEffect(effect)}`).join('\n'), + ); + } + if ( + !(state.isDefined(instruction.lvalue) && state.kind(instruction.lvalue)) + ) { + CompilerError.invariant(false, { + reason: `Expected instruction lvalue to be initialized`, + loc: instruction.loc, + }); + } + return effects.length !== 0 ? effects : null; +} + +function applyEffect( + context: Context, + state: InferenceState, + _effect: AliasingEffect, + aliased: Set, + effects: Array, +): void { + const effect = context.internEffect(_effect); + if (DEBUG) { + console.log(printAliasingEffect(effect)); + } + switch (effect.kind) { + case 'Freeze': { + const didFreeze = state.freeze(effect.value, effect.reason); + if (didFreeze) { + effects.push(effect); + } + break; + } + case 'Create': { + let value = context.effectInstructionValueCache.get(effect); + if (value == null) { + value = { + kind: 'ObjectExpression', + properties: [], + loc: effect.into.loc, + }; + context.effectInstructionValueCache.set(effect, value); + } + state.initialize(value, { + kind: effect.value, + reason: new Set([effect.reason]), + }); + state.define(effect.into, value); + break; + } + case 'ImmutableCapture': { + const kind = state.kind(effect.from).kind; + switch (kind) { + case ValueKind.Global: + case ValueKind.Primitive: { + // no-op: we don't need to track data flow for copy types + break; + } + default: { + effects.push(effect); + } + } + break; + } + case 'CreateFrom': { + const fromValue = state.kind(effect.from); + let value = context.effectInstructionValueCache.get(effect); + if (value == null) { + value = { + kind: 'ObjectExpression', + properties: [], + loc: effect.into.loc, + }; + context.effectInstructionValueCache.set(effect, value); + } + state.initialize(value, { + kind: fromValue.kind, + reason: new Set(fromValue.reason), + }); + state.define(effect.into, value); + switch (fromValue.kind) { + case ValueKind.Primitive: + case ValueKind.Global: { + // no need to track this data flow + break; + } + case ValueKind.Frozen: { + effects.push({ + kind: 'ImmutableCapture', + from: effect.from, + into: effect.into, + }); + break; + } + default: { + effects.push({ + // OK: recording information flow + kind: 'CreateFrom', // prev Alias + from: effect.from, + into: effect.into, + }); + } + } + break; + } + case 'CreateFunction': { + effects.push(effect); + /** + * We consider the function mutable if it has any mutable context variables or + * any side-effects that need to be tracked if the function is called. + */ + const hasCaptures = effect.captures.some(capture => { + switch (state.kind(capture).kind) { + case ValueKind.Context: + case ValueKind.Mutable: { + return true; + } + default: { + return false; + } + } + }); + const hasTrackedSideEffects = + effect.function.loweredFunc.func.aliasingEffects?.some( + effect => + // TODO; include "render" here? + effect.kind === 'MutateFrozen' || + effect.kind === 'MutateGlobal' || + effect.kind === 'Impure', + ); + // For legacy compatibility + const capturesRef = effect.function.loweredFunc.func.context.some( + operand => isRefOrRefValue(operand.identifier), + ); + const isMutable = hasCaptures || hasTrackedSideEffects || capturesRef; + for (const operand of effect.function.loweredFunc.func.context) { + if (operand.effect !== Effect.Capture) { + continue; + } + const kind = state.kind(operand).kind; + if ( + kind === ValueKind.Primitive || + kind == ValueKind.Frozen || + kind == ValueKind.Global + ) { + operand.effect = Effect.Read; + } + } + state.initialize(effect.function, { + kind: isMutable ? ValueKind.Mutable : ValueKind.Frozen, + reason: new Set([]), + }); + state.define(effect.into, effect.function); + for (const capture of effect.captures) { + applyEffect( + context, + state, + { + kind: 'Capture', + from: capture, + into: effect.into, + }, + aliased, + effects, + ); + } + break; + } + case 'Alias': + case 'Capture': { + /* + * Capture describes potential information flow: storing a pointer to one value + * within another. If the destination is not mutable, or the source value has + * copy-on-write semantics, then we can prune the effect + */ + const intoKind = state.kind(effect.into).kind; + let isMutableDesination: boolean; + switch (intoKind) { + case ValueKind.Context: + case ValueKind.Mutable: + case ValueKind.MaybeFrozen: { + isMutableDesination = true; + break; + } + default: { + isMutableDesination = false; + break; + } + } + const fromKind = state.kind(effect.from).kind; + let isMutableReferenceType: boolean; + switch (fromKind) { + case ValueKind.Global: + case ValueKind.Primitive: { + isMutableReferenceType = false; + break; + } + case ValueKind.Frozen: { + isMutableReferenceType = false; + effects.push({ + kind: 'ImmutableCapture', + from: effect.from, + into: effect.into, + }); + break; + } + default: { + isMutableReferenceType = true; + break; + } + } + if (isMutableDesination && isMutableReferenceType) { + effects.push(effect); + } + break; + } + case 'Assign': { + /* + * Alias represents potential pointer aliasing. If the type is a global, + * a primitive (copy-on-write semantics) then we can prune the effect + */ + const fromValue = state.kind(effect.from); + const fromKind = fromValue.kind; + switch (fromKind) { + case ValueKind.Frozen: { + effects.push({ + kind: 'ImmutableCapture', + from: effect.from, + into: effect.into, + }); + let value = context.effectInstructionValueCache.get(effect); + if (value == null) { + value = { + kind: 'Primitive', + value: undefined, + loc: effect.from.loc, + }; + context.effectInstructionValueCache.set(effect, value); + } + state.initialize(value, { + kind: fromKind, + reason: new Set(fromValue.reason), + }); + state.define(effect.into, value); + break; + } + case ValueKind.Global: + case ValueKind.Primitive: { + let value = context.effectInstructionValueCache.get(effect); + if (value == null) { + value = { + kind: 'Primitive', + value: undefined, + loc: effect.from.loc, + }; + context.effectInstructionValueCache.set(effect, value); + } + state.initialize(value, { + kind: fromKind, + reason: new Set(fromValue.reason), + }); + state.define(effect.into, value); + break; + } + default: { + if (aliased.has(effect.into.identifier.id)) { + state.appendAlias(effect.into, effect.from); + } else { + aliased.add(effect.into.identifier.id); + state.alias(effect.into, effect.from); + } + effects.push(effect); + break; + } + } + break; + } + case 'Apply': { + const functionValues = state.values(effect.function); + if ( + functionValues.length === 1 && + functionValues[0].kind === 'FunctionExpression' + ) { + /* + * We're calling a locally declared function, we already know it's effects! + * We just have to substitute in the args for the params + */ + const signature = buildSignatureFromFunctionExpression( + state.env, + functionValues[0], + ); + if (DEBUG) { + console.log( + `constructed alias signature:\n${printAliasingSignature(signature)}`, + ); + } + const signatureEffects = computeEffectsForSignature( + state.env, + signature, + effect.into, + effect.receiver, + effect.args, + functionValues[0].loweredFunc.func.context, + effect.loc, + ); + if (signatureEffects != null) { + if (DEBUG) { + console.log('apply function expression effects'); + } + applyEffect( + context, + state, + {kind: 'MutateTransitiveConditionally', value: effect.function}, + aliased, + effects, + ); + for (const signatureEffect of signatureEffects) { + applyEffect(context, state, signatureEffect, aliased, effects); + } + break; + } + } + const signatureEffects = + effect.signature?.aliasing != null + ? computeEffectsForSignature( + state.env, + effect.signature.aliasing, + effect.into, + effect.receiver, + effect.args, + [], + effect.loc, + ) + : null; + if (signatureEffects != null) { + if (DEBUG) { + console.log('apply aliasing signature effects'); + } + for (const signatureEffect of signatureEffects) { + applyEffect(context, state, signatureEffect, aliased, effects); + } + } else if (effect.signature != null) { + if (DEBUG) { + console.log('apply legacy signature effects'); + } + const legacyEffects = computeEffectsForLegacySignature( + state, + effect.signature, + effect.into, + effect.receiver, + effect.args, + effect.loc, + ); + for (const legacyEffect of legacyEffects) { + applyEffect(context, state, legacyEffect, aliased, effects); + } + } else { + if (DEBUG) { + console.log('default effects'); + } + applyEffect( + context, + state, + { + kind: 'Create', + into: effect.into, + value: ValueKind.Mutable, + reason: ValueReason.Other, + }, + aliased, + effects, + ); + /* + * If no signature then by default: + * - All operands are conditionally mutated, except some instruction + * variants are assumed to not mutate the callee (such as `new`) + * - All operands are captured into (but not directly aliased as) + * every other argument. + */ + for (const arg of [effect.receiver, effect.function, ...effect.args]) { + if (arg.kind === 'Hole') { + continue; + } + const operand = arg.kind === 'Identifier' ? arg : arg.place; + if (operand !== effect.function || effect.mutatesFunction) { + applyEffect( + context, + state, + { + kind: 'MutateTransitiveConditionally', + value: operand, + }, + aliased, + effects, + ); + } + const mutateIterator = + arg.kind === 'Spread' ? conditionallyMutateIterator(operand) : null; + if (mutateIterator) { + applyEffect(context, state, mutateIterator, aliased, effects); + } + applyEffect( + context, + state, + // OK: recording information flow + {kind: 'Alias', from: operand, into: effect.into}, + aliased, + effects, + ); + for (const otherArg of [ + effect.receiver, + effect.function, + ...effect.args, + ]) { + if (otherArg.kind === 'Hole') { + continue; + } + const other = + otherArg.kind === 'Identifier' ? otherArg : otherArg.place; + if (other === arg) { + continue; + } + applyEffect( + context, + state, + { + /* + * OK: a function might store one operand into another, + * but it can't force one to alias another + */ + kind: 'Capture', + from: operand, + into: other, + }, + aliased, + effects, + ); + } + } + } + break; + } + case 'Mutate': + case 'MutateConditionally': + case 'MutateTransitive': + case 'MutateTransitiveConditionally': { + const mutationKind = state.mutate(effect.kind, effect.value); + if (mutationKind === 'mutate') { + effects.push(effect); + } else if (mutationKind === 'mutate-ref') { + // no-op + } else if ( + mutationKind !== 'none' && + (effect.kind === 'Mutate' || effect.kind === 'MutateTransitive') + ) { + const value = state.kind(effect.value); + if (DEBUG) { + console.log(`invalid mutation: ${printAliasingEffect(effect)}`); + console.log(prettyFormat(state.debugAbstractValue(value))); + } + + const reason = getWriteErrorReason({ + kind: value.kind, + reason: value.reason, + context: new Set(), + }); + effects.push({ + kind: + value.kind === ValueKind.Frozen ? 'MutateFrozen' : 'MutateGlobal', + place: effect.value, + error: { + severity: ErrorSeverity.InvalidReact, + reason, + description: + effect.value.identifier.name !== null && + effect.value.identifier.name.kind === 'named' + ? `Found mutation of \`${effect.value.identifier.name.value}\`` + : null, + loc: effect.value.loc, + suggestions: null, + }, + }); + } + break; + } + case 'Impure': + case 'Render': + case 'MutateFrozen': + case 'MutateGlobal': { + effects.push(effect); + break; + } + default: { + assertExhaustive( + effect, + `Unexpected effect kind '${(effect as any).kind as any}'`, + ); + } + } +} + +class InferenceState { + env: Environment; + #isFunctionExpression: boolean; + + // The kind of each value, based on its allocation site + #values: Map; + /* + * The set of values pointed to by each identifier. This is a set + * to accomodate phi points (where a variable may have different + * values from different control flow paths). + */ + #variables: Map>; + + constructor( + env: Environment, + isFunctionExpression: boolean, + values: Map, + variables: Map>, + ) { + this.env = env; + this.#isFunctionExpression = isFunctionExpression; + this.#values = values; + this.#variables = variables; + } + + static empty( + env: Environment, + isFunctionExpression: boolean, + ): InferenceState { + return new InferenceState(env, isFunctionExpression, new Map(), new Map()); + } + + get isFunctionExpression(): boolean { + return this.#isFunctionExpression; + } + + // (Re)initializes a @param value with its default @param kind. + initialize(value: InstructionValue, kind: AbstractValue): void { + CompilerError.invariant(value.kind !== 'LoadLocal', { + reason: + '[InferMutationAliasingEffects] Expected all top-level identifiers to be defined as variables, not values', + description: null, + loc: value.loc, + suggestions: null, + }); + this.#values.set(value, kind); + } + + values(place: Place): Array { + const values = this.#variables.get(place.identifier.id); + CompilerError.invariant(values != null, { + reason: `[InferMutationAliasingEffects] Expected value kind to be initialized`, + description: `${printPlace(place)}`, + loc: place.loc, + suggestions: null, + }); + return Array.from(values); + } + + // Lookup the kind of the given @param value. + kind(place: Place): AbstractValue { + const values = this.#variables.get(place.identifier.id); + CompilerError.invariant(values != null, { + reason: `[InferMutationAliasingEffects] Expected value kind to be initialized`, + description: `${printPlace(place)}`, + loc: place.loc, + suggestions: null, + }); + let mergedKind: AbstractValue | null = null; + for (const value of values) { + const kind = this.#values.get(value)!; + mergedKind = + mergedKind !== null ? mergeAbstractValues(mergedKind, kind) : kind; + } + CompilerError.invariant(mergedKind !== null, { + reason: `[InferMutationAliasingEffects] Expected at least one value`, + description: `No value found at \`${printPlace(place)}\``, + loc: place.loc, + suggestions: null, + }); + return mergedKind; + } + + // Updates the value at @param place to point to the same value as @param value. + alias(place: Place, value: Place): void { + const values = this.#variables.get(value.identifier.id); + CompilerError.invariant(values != null, { + reason: `[InferMutationAliasingEffects] Expected value for identifier to be initialized`, + description: `${printIdentifier(value.identifier)}`, + loc: value.loc, + suggestions: null, + }); + this.#variables.set(place.identifier.id, new Set(values)); + } + + appendAlias(place: Place, value: Place): void { + const values = this.#variables.get(value.identifier.id); + CompilerError.invariant(values != null, { + reason: `[InferMutationAliasingEffects] Expected value for identifier to be initialized`, + description: `${printIdentifier(value.identifier)}`, + loc: value.loc, + suggestions: null, + }); + const prevValues = this.values(place); + this.#variables.set( + place.identifier.id, + new Set([...prevValues, ...values]), + ); + } + + // Defines (initializing or updating) a variable with a specific kind of value. + define(place: Place, value: InstructionValue): void { + CompilerError.invariant(this.#values.has(value), { + reason: `[InferMutationAliasingEffects] Expected value to be initialized at '${printSourceLocation( + value.loc, + )}'`, + description: printInstructionValue(value), + loc: value.loc, + suggestions: null, + }); + this.#variables.set(place.identifier.id, new Set([value])); + } + + isDefined(place: Place): boolean { + return this.#variables.has(place.identifier.id); + } + + /** + * Marks @param place as transitively frozen. Returns true if the value was not + * already frozen, false if the value is already frozen (or already known immutable). + */ + freeze(place: Place, reason: ValueReason): boolean { + const value = this.kind(place); + switch (value.kind) { + case ValueKind.Context: + case ValueKind.Mutable: + case ValueKind.MaybeFrozen: { + const values = this.values(place); + for (const instrValue of values) { + this.freezeValue(instrValue, reason); + } + return true; + } + case ValueKind.Frozen: + case ValueKind.Global: + case ValueKind.Primitive: { + return false; + } + default: { + assertExhaustive( + value.kind, + `Unexpected value kind '${(value as any).kind}'`, + ); + } + } + } + + freezeValue(value: InstructionValue, reason: ValueReason): void { + this.#values.set(value, { + kind: ValueKind.Frozen, + reason: new Set([reason]), + }); + if (DEBUG) { + console.log(`freeze value: ${printInstructionValue(value)} ${reason}`); + } + if ( + value.kind === 'FunctionExpression' && + (this.env.config.enablePreserveExistingMemoizationGuarantees || + this.env.config.enableTransitivelyFreezeFunctionExpressions) + ) { + for (const place of value.loweredFunc.func.context) { + this.freeze(place, reason); + } + } + } + + mutate( + variant: + | 'Mutate' + | 'MutateConditionally' + | 'MutateTransitive' + | 'MutateTransitiveConditionally', + place: Place, + ): 'none' | 'mutate' | 'mutate-frozen' | 'mutate-global' | 'mutate-ref' { + if (isRefOrRefValue(place.identifier)) { + return 'mutate-ref'; + } + const kind = this.kind(place).kind; + switch (variant) { + case 'MutateConditionally': + case 'MutateTransitiveConditionally': { + switch (kind) { + case ValueKind.Mutable: + case ValueKind.Context: { + return 'mutate'; + } + default: { + return 'none'; + } + } + } + case 'Mutate': + case 'MutateTransitive': { + switch (kind) { + case ValueKind.Mutable: + case ValueKind.Context: { + return 'mutate'; + } + case ValueKind.Primitive: { + // technically an error, but it's not React specific + return 'none'; + } + case ValueKind.Frozen: { + return 'mutate-frozen'; + } + case ValueKind.Global: { + return 'mutate-global'; + } + case ValueKind.MaybeFrozen: { + return 'none'; + } + default: { + assertExhaustive(kind, `Unexpected kind ${kind}`); + } + } + } + default: { + assertExhaustive(variant, `Unexpected mutation variant ${variant}`); + } + } + } + + /* + * Combine the contents of @param this and @param other, returning a new + * instance with the combined changes _if_ there are any changes, or + * returning null if no changes would occur. Changes include: + * - new entries in @param other that did not exist in @param this + * - entries whose values differ in @param this and @param other, + * and where joining the values produces a different value than + * what was in @param this. + * + * Note that values are joined using a lattice operation to ensure + * termination. + */ + merge(other: InferenceState): InferenceState | null { + let nextValues: Map | null = null; + let nextVariables: Map> | null = null; + + for (const [id, thisValue] of this.#values) { + const otherValue = other.#values.get(id); + if (otherValue !== undefined) { + const mergedValue = mergeAbstractValues(thisValue, otherValue); + if (mergedValue !== thisValue) { + nextValues = nextValues ?? new Map(this.#values); + nextValues.set(id, mergedValue); + } + } + } + for (const [id, otherValue] of other.#values) { + if (this.#values.has(id)) { + // merged above + continue; + } + nextValues = nextValues ?? new Map(this.#values); + nextValues.set(id, otherValue); + } + + for (const [id, thisValues] of this.#variables) { + const otherValues = other.#variables.get(id); + if (otherValues !== undefined) { + let mergedValues: Set | null = null; + for (const otherValue of otherValues) { + if (!thisValues.has(otherValue)) { + mergedValues = mergedValues ?? new Set(thisValues); + mergedValues.add(otherValue); + } + } + if (mergedValues !== null) { + nextVariables = nextVariables ?? new Map(this.#variables); + nextVariables.set(id, mergedValues); + } + } + } + for (const [id, otherValues] of other.#variables) { + if (this.#variables.has(id)) { + continue; + } + nextVariables = nextVariables ?? new Map(this.#variables); + nextVariables.set(id, new Set(otherValues)); + } + + if (nextVariables === null && nextValues === null) { + return null; + } else { + return new InferenceState( + this.env, + this.#isFunctionExpression, + nextValues ?? new Map(this.#values), + nextVariables ?? new Map(this.#variables), + ); + } + } + + /* + * Returns a copy of this state. + * TODO: consider using persistent data structures to make + * clone cheaper. + */ + clone(): InferenceState { + return new InferenceState( + this.env, + this.#isFunctionExpression, + new Map(this.#values), + new Map(this.#variables), + ); + } + + /* + * For debugging purposes, dumps the state to a plain + * object so that it can printed as JSON. + */ + debug(): any { + const result: any = {values: {}, variables: {}}; + const objects: Map = new Map(); + function identify(value: InstructionValue): number { + let id = objects.get(value); + if (id == null) { + id = objects.size; + objects.set(value, id); + } + return id; + } + for (const [value, kind] of this.#values) { + const id = identify(value); + result.values[id] = { + abstract: this.debugAbstractValue(kind), + value: printInstructionValue(value), + }; + } + for (const [variable, values] of this.#variables) { + result.variables[`$${variable}`] = [...values].map(identify); + } + return result; + } + + debugAbstractValue(value: AbstractValue): any { + return { + kind: value.kind, + reason: [...value.reason], + }; + } + + inferPhi(phi: Phi): void { + const values: Set = new Set(); + for (const [_, operand] of phi.operands) { + const operandValues = this.#variables.get(operand.identifier.id); + // This is a backedge that will be handled later by State.merge + if (operandValues === undefined) continue; + for (const v of operandValues) { + values.add(v); + } + } + + if (values.size > 0) { + this.#variables.set(phi.place.identifier.id, values); + } + } +} + +/** + * Returns a value that represents the combined states of the two input values. + * If the two values are semantically equivalent, it returns the first argument. + */ +function mergeAbstractValues( + a: AbstractValue, + b: AbstractValue, +): AbstractValue { + const kind = mergeValueKinds(a.kind, b.kind); + if ( + kind === a.kind && + kind === b.kind && + Set_isSuperset(a.reason, b.reason) + ) { + return a; + } + const reason = new Set(a.reason); + for (const r of b.reason) { + reason.add(r); + } + return {kind, reason}; +} + +type InstructionSignature = { + effects: ReadonlyArray; +}; + +function conditionallyMutateIterator(place: Place): AliasingEffect | null { + if ( + !( + isArrayType(place.identifier) || + isSetType(place.identifier) || + isMapType(place.identifier) + ) + ) { + return { + kind: 'MutateTransitiveConditionally', + value: place, + }; + } + return null; +} + +/** + * Computes an effect signature for the instruction _without_ looking at the inference state, + * and only using the semantics of the instructions and the inferred types. The idea is to make + * it easy to check that the semantics of each instruction are preserved by describing only the + * effects and not making decisions based on the inference state. + * + * Then in applySignature(), above, we refine this signature based on the inference state. + * + * NOTE: this function is designed to be cached so it's only computed once upon first visiting + * an instruction. + */ +function computeSignatureForInstruction( + context: Context, + env: Environment, + instr: Instruction, +): InstructionSignature { + const {lvalue, value} = instr; + const effects: Array = []; + switch (value.kind) { + case 'ArrayExpression': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Mutable, + reason: ValueReason.Other, + }); + // All elements are captured into part of the output value + for (const element of value.elements) { + if (element.kind === 'Identifier') { + effects.push({ + kind: 'Capture', + from: element, + into: lvalue, + }); + } else if (element.kind === 'Spread') { + const mutateIterator = conditionallyMutateIterator(element.place); + if (mutateIterator != null) { + effects.push(mutateIterator); + } + effects.push({ + kind: 'Capture', + from: element.place, + into: lvalue, + }); + } else { + continue; + } + } + break; + } + case 'ObjectExpression': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Mutable, + reason: ValueReason.Other, + }); + for (const property of value.properties) { + if (property.kind === 'ObjectProperty') { + effects.push({ + kind: 'Capture', + from: property.place, + into: lvalue, + }); + } else { + effects.push({ + kind: 'Capture', + from: property.place, + into: lvalue, + }); + } + } + break; + } + case 'Await': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Mutable, + reason: ValueReason.Other, + }); + // Potentially mutates the receiver (awaiting it changes its state and can run side effects) + effects.push({kind: 'MutateTransitiveConditionally', value: value.value}); + /** + * Data from the promise may be returned into the result, but await does not directly return + * the promise itself + */ + effects.push({ + kind: 'Capture', + from: value.value, + into: lvalue, + }); + break; + } + case 'NewExpression': + case 'CallExpression': + case 'MethodCall': { + let callee; + let receiver; + let mutatesCallee; + if (value.kind === 'NewExpression') { + callee = value.callee; + receiver = value.callee; + mutatesCallee = false; + } else if (value.kind === 'CallExpression') { + callee = value.callee; + receiver = value.callee; + mutatesCallee = true; + } else if (value.kind === 'MethodCall') { + callee = value.property; + receiver = value.receiver; + mutatesCallee = false; + } else { + assertExhaustive( + value, + `Unexpected value kind '${(value as any).kind}'`, + ); + } + const signature = getFunctionCallSignature(env, callee.identifier.type); + effects.push({ + kind: 'Apply', + receiver, + function: callee, + mutatesFunction: mutatesCallee, + args: value.args, + into: lvalue, + signature, + loc: value.loc, + }); + break; + } + case 'PropertyDelete': + case 'ComputedDelete': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + // Mutates the object by removing the property, no aliasing + effects.push({kind: 'Mutate', value: value.object}); + break; + } + case 'PropertyLoad': + case 'ComputedLoad': { + if (isPrimitiveType(lvalue.identifier)) { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + } else { + effects.push({ + kind: 'CreateFrom', + from: value.object, + into: lvalue, + }); + } + break; + } + case 'PropertyStore': + case 'ComputedStore': { + effects.push({kind: 'Mutate', value: value.object}); + effects.push({ + kind: 'Capture', + from: value.value, + into: value.object, + }); + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + break; + } + case 'ObjectMethod': + case 'FunctionExpression': { + /** + * We've already analyzed the function expression in AnalyzeFunctions. There, we assign + * a Capture effect to any context variable that appears (locally) to be aliased and/or + * mutated. The precise effects are annotated on the function expression's aliasingEffects + * property, but we don't want to execute those effects yet. We can only use those when + * we know exactly how the function is invoked — via an Apply effect from a custom signature. + * + * But in the general case, functions can be passed around and possibly called in ways where + * we don't know how to interpret their precise effects. For example: + * + * ``` + * const a = {}; + * + * // We don't want to consider a as mutating here, this just declares the function + * const f = () => { maybeMutate(a) }; + * + * // We don't want to consider a as mutating here either, it can't possibly call f yet + * const x = [f]; + * + * // Here we have to assume that f can be called (transitively), and have to consider a + * // as mutating + * callAllFunctionInArray(x); + * ``` + * + * So for any context variables that were inferred as captured or mutated, we record a + * Capture effect. If the resulting function is transitively mutated, this will mean + * that those operands are also considered mutated. If the function is never called, + * they won't be! + * + * This relies on the rule that: + * Capture a -> b and MutateTransitive(b) => Mutate(a) + * + * Substituting: + * Capture contextvar -> function and MutateTransitive(function) => Mutate(contextvar) + * + * Note that if the type of the context variables are frozen, global, or primitive, the + * Capture will either get pruned or downgraded to an ImmutableCapture. + */ + effects.push({ + kind: 'CreateFunction', + into: lvalue, + function: value, + captures: value.loweredFunc.func.context.filter( + operand => operand.effect === Effect.Capture, + ), + }); + break; + } + case 'GetIterator': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Mutable, + reason: ValueReason.Other, + }); + if ( + isArrayType(value.collection.identifier) || + isMapType(value.collection.identifier) || + isSetType(value.collection.identifier) + ) { + /* + * Builtin collections are known to return a fresh iterator on each call, + * so the iterator does not alias the collection + */ + effects.push({ + kind: 'Capture', + from: value.collection, + into: lvalue, + }); + } else { + /* + * Otherwise, the object may return itself as the iterator, so we have to + * assume that the result directly aliases the collection. Further, the + * method to get the iterator could potentially mutate the collection + */ + effects.push({kind: 'Alias', from: value.collection, into: lvalue}); + effects.push({ + kind: 'MutateTransitiveConditionally', + value: value.collection, + }); + } + break; + } + case 'IteratorNext': { + /* + * Technically advancing an iterator will always mutate it (for any reasonable implementation) + * But because we create an alias from the collection to the iterator if we don't know the type, + * then it's possible the iterator is aliased to a frozen value and we wouldn't want to error. + * so we mark this as conditional mutation to allow iterating frozen values. + */ + effects.push({kind: 'MutateConditionally', value: value.iterator}); + // Extracts part of the original collection into the result + effects.push({ + kind: 'CreateFrom', + from: value.collection, + into: lvalue, + }); + break; + } + case 'NextPropertyOf': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + break; + } + case 'JsxExpression': + case 'JsxFragment': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Frozen, + reason: ValueReason.JsxCaptured, + }); + for (const operand of eachInstructionValueOperand(value)) { + effects.push({ + kind: 'Freeze', + value: operand, + reason: ValueReason.JsxCaptured, + }); + effects.push({ + kind: 'Capture', + from: operand, + into: lvalue, + }); + } + if (value.kind === 'JsxExpression') { + if (value.tag.kind === 'Identifier') { + // Tags are render function, by definition they're called during render + effects.push({ + kind: 'Render', + place: value.tag, + }); + } + if (value.children != null) { + // Children are typically called during render, not used as an event/effect callback + for (const child of value.children) { + effects.push({ + kind: 'Render', + place: child, + }); + } + } + } + break; + } + case 'DeclareLocal': { + // TODO check this + effects.push({ + kind: 'Create', + into: value.lvalue.place, + // TODO: what kind here??? + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + effects.push({ + kind: 'Create', + into: lvalue, + // TODO: what kind here??? + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + break; + } + case 'Destructure': { + for (const patternLValue of eachInstructionValueLValue(value)) { + if (isPrimitiveType(patternLValue.identifier)) { + effects.push({ + kind: 'Create', + into: patternLValue, + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + } else { + effects.push({ + kind: 'CreateFrom', + from: value.value, + into: patternLValue, + }); + } + } + effects.push({kind: 'Assign', from: value.value, into: lvalue}); + break; + } + case 'LoadContext': { + /* + * Context variables are like mutable boxes. Loading from one + * is equivalent to a PropertyLoad from the box, so we model it + * with the same effect we use there (CreateFrom) + */ + effects.push({kind: 'CreateFrom', from: value.place, into: lvalue}); + break; + } + case 'DeclareContext': { + // Context variables are conceptually like mutable boxes + const kind = value.lvalue.kind; + if ( + !context.hoistedContextDeclarations.has( + value.lvalue.place.identifier.declarationId, + ) || + kind === InstructionKind.HoistedConst || + kind === InstructionKind.HoistedFunction || + kind === InstructionKind.HoistedLet + ) { + /** + * If this context variable is not hoisted, or this is the declaration doing the hoisting, + * then we create the box. + */ + effects.push({ + kind: 'Create', + into: value.lvalue.place, + value: ValueKind.Mutable, + reason: ValueReason.Other, + }); + } else { + /** + * Otherwise this may be a "declare", but there was a previous DeclareContext that + * hoisted this variable, and we're mutating it here. + */ + effects.push({kind: 'Mutate', value: value.lvalue.place}); + } + effects.push({ + kind: 'Create', + into: lvalue, + // The result can't be referenced so this value doesn't matter + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + break; + } + case 'StoreContext': { + /* + * Context variables are like mutable boxes, so semantically + * we're either creating (let/const) or mutating (reassign) a box, + * and then capturing the value into it. + */ + if ( + value.lvalue.kind === InstructionKind.Reassign || + context.hoistedContextDeclarations.has( + value.lvalue.place.identifier.declarationId, + ) + ) { + effects.push({kind: 'Mutate', value: value.lvalue.place}); + } else { + effects.push({ + kind: 'Create', + into: value.lvalue.place, + value: ValueKind.Mutable, + reason: ValueReason.Other, + }); + } + effects.push({ + kind: 'Capture', + from: value.value, + into: value.lvalue.place, + }); + effects.push({kind: 'Assign', from: value.value, into: lvalue}); + break; + } + case 'LoadLocal': { + effects.push({kind: 'Assign', from: value.place, into: lvalue}); + break; + } + case 'StoreLocal': { + effects.push({ + kind: 'Assign', + from: value.value, + into: value.lvalue.place, + }); + effects.push({kind: 'Assign', from: value.value, into: lvalue}); + break; + } + case 'PostfixUpdate': + case 'PrefixUpdate': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + effects.push({ + kind: 'Create', + into: value.lvalue, + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + break; + } + case 'StoreGlobal': { + effects.push({ + kind: 'MutateGlobal', + place: value.value, + error: { + reason: + 'Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)', + loc: instr.loc, + suggestions: null, + severity: ErrorSeverity.InvalidReact, + }, + }); + effects.push({kind: 'Assign', from: value.value, into: lvalue}); + break; + } + case 'TypeCastExpression': { + effects.push({kind: 'Assign', from: value.value, into: lvalue}); + break; + } + case 'LoadGlobal': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Global, + reason: ValueReason.Global, + }); + break; + } + case 'StartMemoize': + case 'FinishMemoize': { + if (env.config.enablePreserveExistingMemoizationGuarantees) { + for (const operand of eachInstructionValueOperand(value)) { + effects.push({ + kind: 'Freeze', + value: operand, + reason: ValueReason.Other, + }); + } + } + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + break; + } + case 'TaggedTemplateExpression': + case 'BinaryExpression': + case 'Debugger': + case 'JSXText': + case 'MetaProperty': + case 'Primitive': + case 'RegExpLiteral': + case 'TemplateLiteral': + case 'UnaryExpression': + case 'UnsupportedNode': { + effects.push({ + kind: 'Create', + into: lvalue, + value: ValueKind.Primitive, + reason: ValueReason.Other, + }); + break; + } + } + return { + effects, + }; +} + +/** + * Creates a set of aliasing effects given a legacy FunctionSignature. This makes all of the + * old implicit behaviors from the signatures and InferReferenceEffects explicit, see comments + * in the body for details. + * + * The goal of this method is to make it easier to migrate incrementally to the new system, + * so we don't have to immediately write new signatures for all the methods to get expected + * compilation output. + */ +function computeEffectsForLegacySignature( + state: InferenceState, + signature: FunctionSignature, + lvalue: Place, + receiver: Place, + args: Array, + loc: SourceLocation, +): Array { + const returnValueReason = signature.returnValueReason ?? ValueReason.Other; + const effects: Array = []; + effects.push({ + kind: 'Create', + into: lvalue, + value: signature.returnValueKind, + reason: returnValueReason, + }); + if (signature.impure && state.env.config.validateNoImpureFunctionsInRender) { + effects.push({ + kind: 'Impure', + place: receiver, + error: { + reason: + 'Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent)', + description: + signature.canonicalName != null + ? `\`${signature.canonicalName}\` is an impure function whose results may change on every call` + : null, + severity: ErrorSeverity.InvalidReact, + loc, + suggestions: null, + }, + }); + } + const stores: Array = []; + const captures: Array = []; + function visit(place: Place, effect: Effect): void { + switch (effect) { + case Effect.Store: { + effects.push({ + kind: 'Mutate', + value: place, + }); + stores.push(place); + break; + } + case Effect.Capture: { + captures.push(place); + break; + } + case Effect.ConditionallyMutate: { + effects.push({ + kind: 'MutateTransitiveConditionally', + value: place, + }); + break; + } + case Effect.ConditionallyMutateIterator: { + if ( + isArrayType(place.identifier) || + isSetType(place.identifier) || + isMapType(place.identifier) + ) { + effects.push({ + kind: 'Capture', + from: place, + into: lvalue, + }); + } else { + effects.push({ + kind: 'Capture', + from: place, + into: lvalue, + }); + captures.push(place); + effects.push({ + kind: 'MutateTransitiveConditionally', + value: place, + }); + } + break; + } + case Effect.Freeze: { + effects.push({ + kind: 'Freeze', + value: place, + reason: returnValueReason, + }); + break; + } + case Effect.Mutate: { + effects.push({kind: 'MutateTransitive', value: place}); + break; + } + case Effect.Read: { + effects.push({ + kind: 'ImmutableCapture', + from: place, + into: lvalue, + }); + break; + } + } + } + + if ( + signature.mutableOnlyIfOperandsAreMutable && + areArgumentsImmutableAndNonMutating(state, args) + ) { + effects.push({ + kind: 'Alias', + from: receiver, + into: lvalue, + }); + for (const arg of args) { + if (arg.kind === 'Hole') { + continue; + } + const place = arg.kind === 'Identifier' ? arg : arg.place; + effects.push({ + kind: 'ImmutableCapture', + from: place, + into: lvalue, + }); + } + return effects; + } + + if (signature.calleeEffect !== Effect.Capture) { + /* + * InferReferenceEffects and FunctionSignature have an implicit assumption that the receiver + * is captured into the return value. Consider for example the signature for Array.proto.pop: + * the calleeEffect is Store, since it's a known mutation but non-transitive. But the return + * of the pop() captures from the receiver! This isn't specified explicitly. So we add this + * here, and rely on applySignature() to downgrade this to ImmutableCapture (or prune) if + * the type doesn't actually need to be captured based on the input and return type. + */ + effects.push({ + kind: 'Alias', + from: receiver, + into: lvalue, + }); + } + visit(receiver, signature.calleeEffect); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.kind === 'Hole') { + continue; + } + const place = arg.kind === 'Identifier' ? arg : arg.place; + const signatureEffect = + arg.kind === 'Identifier' && i < signature.positionalParams.length + ? signature.positionalParams[i]! + : (signature.restParam ?? Effect.ConditionallyMutate); + const effect = getArgumentEffect(signatureEffect, arg); + + visit(place, effect); + } + if (captures.length !== 0) { + if (stores.length === 0) { + // If no stores, then capture into the return value + for (const capture of captures) { + effects.push({kind: 'Alias', from: capture, into: lvalue}); + } + } else { + // Else capture into the stores + for (const capture of captures) { + for (const store of stores) { + effects.push({kind: 'Capture', from: capture, into: store}); + } + } + } + } + return effects; +} + +/** + * Returns true if all of the arguments are both non-mutable (immutable or frozen) + * _and_ are not functions which might mutate their arguments. Note that function + * expressions count as frozen so long as they do not mutate free variables: this + * function checks that such functions also don't mutate their inputs. + */ +function areArgumentsImmutableAndNonMutating( + state: InferenceState, + args: Array, +): boolean { + for (const arg of args) { + if (arg.kind === 'Hole') { + continue; + } + if (arg.kind === 'Identifier' && arg.identifier.type.kind === 'Function') { + const fnShape = state.env.getFunctionSignature(arg.identifier.type); + if (fnShape != null) { + return ( + !fnShape.positionalParams.some(isKnownMutableEffect) && + (fnShape.restParam == null || + !isKnownMutableEffect(fnShape.restParam)) + ); + } + } + const place = arg.kind === 'Identifier' ? arg : arg.place; + + const kind = state.kind(place).kind; + switch (kind) { + case ValueKind.Primitive: + case ValueKind.Frozen: { + /* + * Only immutable values, or frozen lambdas are allowed. + * A lambda may appear frozen even if it may mutate its inputs, + * so we have a second check even for frozen value types + */ + break; + } + default: { + /** + * Globals, module locals, and other locally defined functions may + * mutate their arguments. + */ + return false; + } + } + const values = state.values(place); + for (const value of values) { + if ( + value.kind === 'FunctionExpression' && + value.loweredFunc.func.params.some(param => { + const place = param.kind === 'Identifier' ? param : param.place; + const range = place.identifier.mutableRange; + return range.end > range.start + 1; + }) + ) { + // This is a function which may mutate its inputs + return false; + } + } + } + return true; +} + +function computeEffectsForSignature( + env: Environment, + signature: AliasingSignature, + lvalue: Place, + receiver: Place, + args: Array, + // Used for signatures constructed dynamically which reference context variables + context: Array = [], + loc: SourceLocation, +): Array | null { + if ( + // Not enough args + signature.params.length > args.length || + // Too many args and there is no rest param to hold them + (args.length > signature.params.length && signature.rest == null) + ) { + if (DEBUG) { + if (signature.params.length > args.length) { + console.log( + `not enough args: ${args.length} args for ${signature.params.length} params`, + ); + } else { + console.log( + `too many args: ${args.length} args for ${signature.params.length} params, with no rest param`, + ); + } + } + return null; + } + // Build substitutions + const substitutions: Map> = new Map(); + substitutions.set(signature.receiver, [receiver]); + substitutions.set(signature.returns, [lvalue]); + const params = signature.params; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.kind === 'Hole') { + continue; + } else if (params == null || i >= params.length || arg.kind === 'Spread') { + if (signature.rest == null) { + if (DEBUG) { + console.log(`no rest value to hold param`); + } + return null; + } + const place = arg.kind === 'Identifier' ? arg : arg.place; + getOrInsertWith(substitutions, signature.rest, () => []).push(place); + } else { + const param = params[i]; + substitutions.set(param, [arg]); + } + } + + /* + * Signatures constructed dynamically from function expressions will reference values + * other than their receiver/args/etc. We populate the substitution table with these + * values so that we can still exit for unpopulated substitutions + */ + for (const operand of context) { + substitutions.set(operand.identifier.id, [operand]); + } + + const effects: Array = []; + for (const signatureTemporary of signature.temporaries) { + const temp = createTemporaryPlace(env, receiver.loc); + substitutions.set(signatureTemporary.identifier.id, [temp]); + } + + // Apply substitutions + for (const effect of signature.effects) { + switch (effect.kind) { + case 'Assign': + case 'ImmutableCapture': + case 'Alias': + case 'CreateFrom': + case 'Capture': { + const from = substitutions.get(effect.from.identifier.id) ?? []; + const to = substitutions.get(effect.into.identifier.id) ?? []; + for (const fromId of from) { + for (const toId of to) { + effects.push({ + kind: effect.kind, + from: fromId, + into: toId, + }); + } + } + break; + } + case 'Impure': + case 'MutateFrozen': + case 'MutateGlobal': { + const values = substitutions.get(effect.place.identifier.id) ?? []; + for (const value of values) { + effects.push({kind: effect.kind, place: value, error: effect.error}); + } + break; + } + case 'Render': { + const values = substitutions.get(effect.place.identifier.id) ?? []; + for (const value of values) { + effects.push({kind: effect.kind, place: value}); + } + break; + } + case 'Mutate': + case 'MutateTransitive': + case 'MutateTransitiveConditionally': + case 'MutateConditionally': { + const values = substitutions.get(effect.value.identifier.id) ?? []; + for (const id of values) { + effects.push({kind: effect.kind, value: id}); + } + break; + } + case 'Freeze': { + const values = substitutions.get(effect.value.identifier.id) ?? []; + for (const value of values) { + effects.push({kind: 'Freeze', value, reason: effect.reason}); + } + break; + } + case 'Create': { + const into = substitutions.get(effect.into.identifier.id) ?? []; + for (const value of into) { + effects.push({ + kind: 'Create', + into: value, + value: effect.value, + reason: effect.reason, + }); + } + break; + } + case 'Apply': { + const applyReceiver = substitutions.get(effect.receiver.identifier.id); + if (applyReceiver == null || applyReceiver.length !== 1) { + if (DEBUG) { + console.log(`too many substitutions for receiver`); + } + return null; + } + const applyFunction = substitutions.get(effect.function.identifier.id); + if (applyFunction == null || applyFunction.length !== 1) { + if (DEBUG) { + console.log(`too many substitutions for function`); + } + return null; + } + const applyInto = substitutions.get(effect.into.identifier.id); + if (applyInto == null || applyInto.length !== 1) { + if (DEBUG) { + console.log(`too many substitutions for into`); + } + return null; + } + const applyArgs: Array = []; + for (const arg of effect.args) { + if (arg.kind === 'Hole') { + applyArgs.push(arg); + } else if (arg.kind === 'Identifier') { + const applyArg = substitutions.get(arg.identifier.id); + if (applyArg == null || applyArg.length !== 1) { + if (DEBUG) { + console.log(`too many substitutions for arg`); + } + return null; + } + applyArgs.push(applyArg[0]); + } else { + const applyArg = substitutions.get(arg.place.identifier.id); + if (applyArg == null || applyArg.length !== 1) { + if (DEBUG) { + console.log(`too many substitutions for arg`); + } + return null; + } + applyArgs.push({kind: 'Spread', place: applyArg[0]}); + } + } + effects.push({ + kind: 'Apply', + mutatesFunction: effect.mutatesFunction, + receiver: applyReceiver[0], + args: applyArgs, + function: applyFunction[0], + into: applyInto[0], + signature: effect.signature, + loc, + }); + break; + } + case 'CreateFunction': { + CompilerError.throwTodo({ + reason: `Support CreateFrom effects in signatures`, + loc: receiver.loc, + }); + } + default: { + assertExhaustive( + effect, + `Unexpected effect kind '${(effect as any).kind}'`, + ); + } + } + } + return effects; +} + +function buildSignatureFromFunctionExpression( + env: Environment, + fn: FunctionExpression, +): AliasingSignature { + let rest: IdentifierId | null = null; + const params: Array = []; + for (const param of fn.loweredFunc.func.params) { + if (param.kind === 'Identifier') { + params.push(param.identifier.id); + } else { + rest = param.place.identifier.id; + } + } + return { + receiver: makeIdentifierId(0), + params, + rest: rest ?? createTemporaryPlace(env, fn.loc).identifier.id, + returns: fn.loweredFunc.func.returns.identifier.id, + effects: fn.loweredFunc.func.aliasingEffects ?? [], + temporaries: [], + }; +} + +export type AbstractValue = { + kind: ValueKind; + reason: ReadonlySet; +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingFunctionEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingFunctionEffects.ts new file mode 100644 index 0000000000000..678c958ad91bb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingFunctionEffects.ts @@ -0,0 +1,206 @@ +/** + * 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. + */ + +import {HIRFunction, IdentifierId, Place, ValueKind, ValueReason} from '../HIR'; +import {getOrInsertDefault} from '../Utils/utils'; +import {AliasingEffect} from './AliasingEffects'; + +/** + * This function tracks data flow within an inner function expression in order to + * compute a set of data-flow aliasing effects describing data flow between the function's + * params, context variables, and return value. + * + * For example, consider the following function expression: + * + * ``` + * (x) => { return [x, y] } + * ``` + * + * This function captures both param `x` and context variable `y` into the return value. + * Unlike our previous inference which counted this as a mutation of x and y, we want to + * build a signature for the function that describes the data flow. We would infer + * `Capture x -> return, Capture y -> return` effects for this function. + * + * This function *also* propagates more ambient-style effects (MutateFrozen, MutateGlobal, Impure, Render) + * from instructions within the function up to the function itself. + */ +export function inferMutationAliasingFunctionEffects( + fn: HIRFunction, +): Array | null { + const effects: Array = []; + + /** + * Map used to identify tracked variables: params, context vars, return value + * This is used to detect mutation/capturing/aliasing of params/context vars + */ + const tracked = new Map(); + tracked.set(fn.returns.identifier.id, fn.returns); + for (const operand of [...fn.context, ...fn.params]) { + const place = operand.kind === 'Identifier' ? operand : operand.place; + tracked.set(place.identifier.id, place); + } + + /** + * Track capturing/aliasing of context vars and params into each other and into the return. + * We don't need to track locals and intermediate values, since we're only concerned with effects + * as they relate to arguments visible outside the function. + * + * For each aliased identifier we track capture/alias/createfrom and then merge this with how + * the value is used. Eg capturing an alias => capture. See joinEffects() helper. + */ + type AliasedIdentifier = { + kind: AliasingKind; + place: Place; + }; + const dataFlow = new Map>(); + + /* + * Check for aliasing of tracked values. Also joins the effects of how the value is + * used (@param kind) with the aliasing type of each value + */ + function lookup( + place: Place, + kind: AliasedIdentifier['kind'], + ): Array | null { + if (tracked.has(place.identifier.id)) { + return [{kind, place}]; + } + return ( + dataFlow.get(place.identifier.id)?.map(aliased => ({ + kind: joinEffects(aliased.kind, kind), + place: aliased.place, + })) ?? null + ); + } + + // todo: fixpoint + for (const block of fn.body.blocks.values()) { + for (const phi of block.phis) { + const operands: Array = []; + for (const operand of phi.operands.values()) { + const inputs = lookup(operand, 'Alias'); + if (inputs != null) { + operands.push(...inputs); + } + } + if (operands.length !== 0) { + dataFlow.set(phi.place.identifier.id, operands); + } + } + for (const instr of block.instructions) { + if (instr.effects == null) continue; + for (const effect of instr.effects) { + if ( + effect.kind === 'Assign' || + effect.kind === 'Capture' || + effect.kind === 'Alias' || + effect.kind === 'CreateFrom' + ) { + const from = lookup(effect.from, effect.kind); + if (from == null) { + continue; + } + const into = lookup(effect.into, 'Alias'); + if (into == null) { + getOrInsertDefault(dataFlow, effect.into.identifier.id, []).push( + ...from, + ); + } else { + for (const aliased of into) { + getOrInsertDefault( + dataFlow, + aliased.place.identifier.id, + [], + ).push(...from); + } + } + } else if ( + effect.kind === 'Create' || + effect.kind === 'CreateFunction' + ) { + getOrInsertDefault(dataFlow, effect.into.identifier.id, [ + {kind: 'Alias', place: effect.into}, + ]); + } else if ( + effect.kind === 'MutateFrozen' || + effect.kind === 'MutateGlobal' || + effect.kind === 'Impure' || + effect.kind === 'Render' + ) { + effects.push(effect); + } + } + } + if (block.terminal.kind === 'return') { + const from = lookup(block.terminal.value, 'Alias'); + if (from != null) { + getOrInsertDefault(dataFlow, fn.returns.identifier.id, []).push( + ...from, + ); + } + } + } + + // Create aliasing effects based on observed data flow + let hasReturn = false; + for (const [into, from] of dataFlow) { + const input = tracked.get(into); + if (input == null) { + continue; + } + for (const aliased of from) { + if ( + aliased.place.identifier.id === input.identifier.id || + !tracked.has(aliased.place.identifier.id) + ) { + continue; + } + const effect = {kind: aliased.kind, from: aliased.place, into: input}; + effects.push(effect); + if ( + into === fn.returns.identifier.id && + (aliased.kind === 'Assign' || aliased.kind === 'CreateFrom') + ) { + hasReturn = true; + } + } + } + // TODO: more precise return effect inference + if (!hasReturn) { + effects.unshift({ + kind: 'Create', + into: fn.returns, + value: + fn.returnType.kind === 'Primitive' + ? ValueKind.Primitive + : ValueKind.Mutable, + reason: ValueReason.KnownReturnSignature, + }); + } + + return effects; +} + +export enum MutationKind { + None = 0, + Conditional = 1, + Definite = 2, +} + +type AliasingKind = 'Alias' | 'Capture' | 'CreateFrom' | 'Assign'; +function joinEffects( + effect1: AliasingKind, + effect2: AliasingKind, +): AliasingKind { + if (effect1 === 'Capture' || effect2 === 'Capture') { + return 'Capture'; + } else if (effect1 === 'Assign' || effect2 === 'Assign') { + return 'Assign'; + } else { + return 'Alias'; + } +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts new file mode 100644 index 0000000000000..64f8cf24313d1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts @@ -0,0 +1,737 @@ +/** + * 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. + */ + +import prettyFormat from 'pretty-format'; +import {CompilerError, SourceLocation} from '..'; +import { + BlockId, + Effect, + HIRFunction, + Identifier, + IdentifierId, + InstructionId, + makeInstructionId, + Place, +} from '../HIR/HIR'; +import { + eachInstructionLValue, + eachInstructionValueOperand, + eachTerminalOperand, +} from '../HIR/visitors'; +import {assertExhaustive, getOrInsertWith} from '../Utils/utils'; +import {printFunction} from '../HIR'; +import {printIdentifier, printPlace} from '../HIR/PrintHIR'; +import {MutationKind} from './InferMutationAliasingFunctionEffects'; +import {Result} from '../Utils/Result'; + +const DEBUG = false; +const VERBOSE = false; + +/** + * Infers mutable ranges for all values in the program, using previously inferred + * mutation/aliasing effects. This pass builds a data flow graph using the effects, + * tracking an abstract notion of "when" each effect occurs relative to the others. + * It then walks each mutation effect against the graph, updating the range of each + * node that would be reachable at the "time" that the effect occurred. + * + * This pass also validates against invalid effects: any function that is reachable + * by being called, or via a Render effect, is validated against mutating globals + * or calling impure code. + * + * Note that this function also populates the outer function's aliasing effects with + * any mutations that apply to its params or context variables. For example, a + * function expression such as the following: + * + * ``` + * (x) => { x.y = true } + * ``` + * + * Would populate a `Mutate x` aliasing effect on the outer function. + */ +export function inferMutationAliasingRanges( + fn: HIRFunction, + {isFunctionExpression}: {isFunctionExpression: boolean}, +): Result { + if (VERBOSE) { + console.log(); + console.log(printFunction(fn)); + } + /** + * Part 1: Infer mutable ranges for values. We build an abstract model of + * values, the alias/capture edges between them, and the set of mutations. + * Edges and mutations are ordered, with mutations processed against the + * abstract model only after it is fully constructed by visiting all blocks + * _and_ connecting phis. Phis are considered ordered at the time of the + * phi node. + * + * This should (may?) mean that mutations are able to see the full state + * of the graph and mark all the appropriate identifiers as mutated at + * the correct point, accounting for both backward and forward edges. + * Ie a mutation of x accounts for both values that flowed into x, + * and values that x flowed into. + */ + const state = new AliasingState(); + type PendingPhiOperand = {from: Place; into: Place; index: number}; + const pendingPhis = new Map>(); + const mutations: Array<{ + index: number; + id: InstructionId; + transitive: boolean; + kind: MutationKind; + place: Place; + }> = []; + const renders: Array<{index: number; place: Place}> = []; + + let index = 0; + + const errors = new CompilerError(); + + for (const param of [...fn.params, ...fn.context, fn.returns]) { + const place = param.kind === 'Identifier' ? param : param.place; + state.create(place, {kind: 'Object'}); + } + const seenBlocks = new Set(); + for (const block of fn.body.blocks.values()) { + for (const phi of block.phis) { + state.create(phi.place, {kind: 'Phi'}); + for (const [pred, operand] of phi.operands) { + if (!seenBlocks.has(pred)) { + // NOTE: annotation required to actually typecheck and not silently infer `any` + const blockPhis = getOrInsertWith>( + pendingPhis, + pred, + () => [], + ); + blockPhis.push({from: operand, into: phi.place, index: index++}); + } else { + state.assign(index++, operand, phi.place); + } + } + } + seenBlocks.add(block.id); + + for (const instr of block.instructions) { + if ( + instr.value.kind === 'FunctionExpression' || + instr.value.kind === 'ObjectMethod' + ) { + state.create(instr.lvalue, { + kind: 'Function', + function: instr.value.loweredFunc.func, + }); + } else { + for (const lvalue of eachInstructionLValue(instr)) { + state.create(lvalue, {kind: 'Object'}); + } + } + + if (instr.effects == null) continue; + for (const effect of instr.effects) { + if (effect.kind === 'Create') { + state.create(effect.into, {kind: 'Object'}); + } else if (effect.kind === 'CreateFunction') { + state.create(effect.into, { + kind: 'Function', + function: effect.function.loweredFunc.func, + }); + } else if (effect.kind === 'CreateFrom') { + state.createFrom(index++, effect.from, effect.into); + } else if (effect.kind === 'Assign') { + if (!state.nodes.has(effect.into.identifier)) { + state.create(effect.into, {kind: 'Object'}); + } + state.assign(index++, effect.from, effect.into); + } else if (effect.kind === 'Alias') { + state.assign(index++, effect.from, effect.into); + } else if (effect.kind === 'Capture') { + state.capture(index++, effect.from, effect.into); + } else if ( + effect.kind === 'MutateTransitive' || + effect.kind === 'MutateTransitiveConditionally' + ) { + mutations.push({ + index: index++, + id: instr.id, + transitive: true, + kind: + effect.kind === 'MutateTransitive' + ? MutationKind.Definite + : MutationKind.Conditional, + place: effect.value, + }); + } else if ( + effect.kind === 'Mutate' || + effect.kind === 'MutateConditionally' + ) { + mutations.push({ + index: index++, + id: instr.id, + transitive: false, + kind: + effect.kind === 'Mutate' + ? MutationKind.Definite + : MutationKind.Conditional, + place: effect.value, + }); + } else if ( + effect.kind === 'MutateFrozen' || + effect.kind === 'MutateGlobal' || + effect.kind === 'Impure' + ) { + errors.push(effect.error); + } else if (effect.kind === 'Render') { + renders.push({index: index++, place: effect.place}); + } + } + } + const blockPhis = pendingPhis.get(block.id); + if (blockPhis != null) { + for (const {from, into, index} of blockPhis) { + state.assign(index, from, into); + } + } + if (block.terminal.kind === 'return') { + state.assign(index++, block.terminal.value, fn.returns); + } + + if ( + (block.terminal.kind === 'maybe-throw' || + block.terminal.kind === 'return') && + block.terminal.effects != null + ) { + for (const effect of block.terminal.effects) { + if (effect.kind === 'Alias') { + state.assign(index++, effect.from, effect.into); + } else { + CompilerError.invariant(effect.kind === 'Freeze', { + reason: `Unexpected '${effect.kind}' effect for MaybeThrow terminal`, + loc: block.terminal.loc, + }); + } + } + } + } + + if (VERBOSE) { + console.log(state.debug()); + console.log(pretty(mutations)); + } + for (const mutation of mutations) { + state.mutate( + mutation.index, + mutation.place.identifier, + makeInstructionId(mutation.id + 1), + mutation.transitive, + mutation.kind, + mutation.place.loc, + errors, + ); + } + for (const render of renders) { + state.render(render.index, render.place.identifier, errors); + } + if (DEBUG) { + console.log(pretty([...state.nodes.keys()])); + } + fn.aliasingEffects ??= []; + for (const param of [...fn.context, ...fn.params]) { + const place = param.kind === 'Identifier' ? param : param.place; + const node = state.nodes.get(place.identifier); + if (node == null) { + continue; + } + let mutated = false; + if (node.local != null) { + if (node.local.kind === MutationKind.Conditional) { + mutated = true; + fn.aliasingEffects.push({ + kind: 'MutateConditionally', + value: {...place, loc: node.local.loc}, + }); + } else if (node.local.kind === MutationKind.Definite) { + mutated = true; + fn.aliasingEffects.push({ + kind: 'Mutate', + value: {...place, loc: node.local.loc}, + }); + } + } + if (node.transitive != null) { + if (node.transitive.kind === MutationKind.Conditional) { + mutated = true; + fn.aliasingEffects.push({ + kind: 'MutateTransitiveConditionally', + value: {...place, loc: node.transitive.loc}, + }); + } else if (node.transitive.kind === MutationKind.Definite) { + mutated = true; + fn.aliasingEffects.push({ + kind: 'MutateTransitive', + value: {...place, loc: node.transitive.loc}, + }); + } + } + if (mutated) { + place.effect = Effect.Capture; + } + } + + /** + * Part 2 + * Add legacy operand-specific effects based on instruction effects and mutable ranges. + * Also fixes up operand mutable ranges, making sure that start is non-zero if the value + * is mutated (depended on by later passes like InferReactiveScopeVariables which uses this + * to filter spurious mutations of globals, which we now guard against more precisely) + */ + for (const block of fn.body.blocks.values()) { + for (const phi of block.phis) { + // TODO: we don't actually set these effects today! + phi.place.effect = Effect.Store; + const isPhiMutatedAfterCreation: boolean = + phi.place.identifier.mutableRange.end > + (block.instructions.at(0)?.id ?? block.terminal.id); + for (const operand of phi.operands.values()) { + operand.effect = isPhiMutatedAfterCreation + ? Effect.Capture + : Effect.Read; + } + if ( + isPhiMutatedAfterCreation && + phi.place.identifier.mutableRange.start === 0 + ) { + /* + * TODO: ideally we'd construct a precise start range, but what really + * matters is that the phi's range appears mutable (end > start + 1) + * so we just set the start to the previous instruction before this block + */ + const firstInstructionIdOfBlock = + block.instructions.at(0)?.id ?? block.terminal.id; + phi.place.identifier.mutableRange.start = makeInstructionId( + firstInstructionIdOfBlock - 1, + ); + } + } + for (const instr of block.instructions) { + for (const lvalue of eachInstructionLValue(instr)) { + lvalue.effect = Effect.ConditionallyMutate; + if (lvalue.identifier.mutableRange.start === 0) { + lvalue.identifier.mutableRange.start = instr.id; + } + if (lvalue.identifier.mutableRange.end === 0) { + lvalue.identifier.mutableRange.end = makeInstructionId( + Math.max(instr.id + 1, lvalue.identifier.mutableRange.end), + ); + } + } + for (const operand of eachInstructionValueOperand(instr.value)) { + operand.effect = Effect.Read; + } + if (instr.effects == null) { + continue; + } + const operandEffects = new Map(); + for (const effect of instr.effects) { + switch (effect.kind) { + case 'Assign': + case 'Alias': + case 'Capture': + case 'CreateFrom': { + const isMutatedOrReassigned = + effect.into.identifier.mutableRange.end > instr.id; + if (isMutatedOrReassigned) { + operandEffects.set(effect.from.identifier.id, Effect.Capture); + operandEffects.set(effect.into.identifier.id, Effect.Store); + } else { + operandEffects.set(effect.from.identifier.id, Effect.Read); + operandEffects.set(effect.into.identifier.id, Effect.Store); + } + break; + } + case 'CreateFunction': + case 'Create': { + break; + } + case 'Mutate': { + operandEffects.set(effect.value.identifier.id, Effect.Store); + break; + } + case 'Apply': { + CompilerError.invariant(false, { + reason: `[AnalyzeFunctions] Expected Apply effects to be replaced with more precise effects`, + loc: effect.function.loc, + }); + } + case 'MutateTransitive': + case 'MutateConditionally': + case 'MutateTransitiveConditionally': { + operandEffects.set( + effect.value.identifier.id, + Effect.ConditionallyMutate, + ); + break; + } + case 'Freeze': { + operandEffects.set(effect.value.identifier.id, Effect.Freeze); + break; + } + case 'ImmutableCapture': { + // no-op, Read is the default + break; + } + case 'Impure': + case 'Render': + case 'MutateFrozen': + case 'MutateGlobal': { + // no-op + break; + } + default: { + assertExhaustive( + effect, + `Unexpected effect kind ${(effect as any).kind}`, + ); + } + } + } + for (const lvalue of eachInstructionLValue(instr)) { + const effect = + operandEffects.get(lvalue.identifier.id) ?? + Effect.ConditionallyMutate; + lvalue.effect = effect; + } + for (const operand of eachInstructionValueOperand(instr.value)) { + if ( + operand.identifier.mutableRange.end > instr.id && + operand.identifier.mutableRange.start === 0 + ) { + operand.identifier.mutableRange.start = instr.id; + } + const effect = operandEffects.get(operand.identifier.id) ?? Effect.Read; + operand.effect = effect; + } + + /** + * This case is targeted at hoisted functions like: + * + * ``` + * x(); + * function x() { ... } + * ``` + * + * Which turns into: + * + * t0 = DeclareContext HoistedFunction x + * t1 = LoadContext x + * t2 = CallExpression t1 ( ) + * t3 = FunctionExpression ... + * t4 = StoreContext Function x = t3 + * + * If the function had captured mutable values, it would already have its + * range extended to include the StoreContext. But if the function doesn't + * capture any mutable values its range won't have been extended yet. We + * want to ensure that the value is memoized along with the context variable, + * not independently of it (bc of the way we do codegen for hoisted functions). + * So here we check for StoreContext rvalues and if they haven't already had + * their range extended to at least this instruction, we extend it. + */ + if ( + instr.value.kind === 'StoreContext' && + instr.value.value.identifier.mutableRange.end <= instr.id + ) { + instr.value.value.identifier.mutableRange.end = makeInstructionId( + instr.id + 1, + ); + } + } + if (block.terminal.kind === 'return') { + block.terminal.value.effect = isFunctionExpression + ? Effect.Read + : Effect.Freeze; + } else { + for (const operand of eachTerminalOperand(block.terminal)) { + operand.effect = Effect.Read; + } + } + } + + if (VERBOSE) { + console.log(printFunction(fn)); + } + return errors.asResult(); +} + +function appendFunctionErrors(errors: CompilerError, fn: HIRFunction): void { + for (const effect of fn.aliasingEffects ?? []) { + switch (effect.kind) { + case 'Impure': + case 'MutateFrozen': + case 'MutateGlobal': { + errors.push(effect.error); + break; + } + } + } +} + +type Node = { + id: Identifier; + createdFrom: Map; + captures: Map; + aliases: Map; + edges: Array<{index: number; node: Identifier; kind: 'capture' | 'alias'}>; + transitive: {kind: MutationKind; loc: SourceLocation} | null; + local: {kind: MutationKind; loc: SourceLocation} | null; + value: + | {kind: 'Object'} + | {kind: 'Phi'} + | {kind: 'Function'; function: HIRFunction}; +}; +class AliasingState { + nodes: Map = new Map(); + + create(place: Place, value: Node['value']): void { + this.nodes.set(place.identifier, { + id: place.identifier, + createdFrom: new Map(), + captures: new Map(), + aliases: new Map(), + edges: [], + transitive: null, + local: null, + value, + }); + } + + createFrom(index: number, from: Place, into: Place): void { + this.create(into, {kind: 'Object'}); + const fromNode = this.nodes.get(from.identifier); + const toNode = this.nodes.get(into.identifier); + if (fromNode == null || toNode == null) { + if (VERBOSE) { + console.log( + `skip: createFrom ${printPlace(from)}${!!fromNode} -> ${printPlace(into)}${!!toNode}`, + ); + } + return; + } + fromNode.edges.push({index, node: into.identifier, kind: 'alias'}); + if (!toNode.createdFrom.has(from.identifier)) { + toNode.createdFrom.set(from.identifier, index); + } + } + + capture(index: number, from: Place, into: Place): void { + const fromNode = this.nodes.get(from.identifier); + const toNode = this.nodes.get(into.identifier); + if (fromNode == null || toNode == null) { + if (VERBOSE) { + console.log( + `skip: capture ${printPlace(from)}${!!fromNode} -> ${printPlace(into)}${!!toNode}`, + ); + } + return; + } + fromNode.edges.push({index, node: into.identifier, kind: 'capture'}); + if (!toNode.captures.has(from.identifier)) { + toNode.captures.set(from.identifier, index); + } + } + + assign(index: number, from: Place, into: Place): void { + const fromNode = this.nodes.get(from.identifier); + const toNode = this.nodes.get(into.identifier); + if (fromNode == null || toNode == null) { + if (VERBOSE) { + console.log( + `skip: assign ${printPlace(from)}${!!fromNode} -> ${printPlace(into)}${!!toNode}`, + ); + } + return; + } + fromNode.edges.push({index, node: into.identifier, kind: 'alias'}); + if (!toNode.aliases.has(from.identifier)) { + toNode.aliases.set(from.identifier, index); + } + } + + render(index: number, start: Identifier, errors: CompilerError): void { + const seen = new Set(); + const queue: Array = [start]; + while (queue.length !== 0) { + const current = queue.pop()!; + if (seen.has(current)) { + continue; + } + seen.add(current); + const node = this.nodes.get(current); + if (node == null || node.transitive != null || node.local != null) { + continue; + } + if (node.value.kind === 'Function') { + appendFunctionErrors(errors, node.value.function); + } + for (const [alias, when] of node.createdFrom) { + if (when >= index) { + continue; + } + queue.push(alias); + } + for (const [alias, when] of node.aliases) { + if (when >= index) { + continue; + } + queue.push(alias); + } + for (const [capture, when] of node.captures) { + if (when >= index) { + continue; + } + queue.push(capture); + } + } + } + + mutate( + index: number, + start: Identifier, + end: InstructionId, + transitive: boolean, + kind: MutationKind, + loc: SourceLocation, + errors: CompilerError, + ): void { + if (DEBUG) { + console.log( + `mutate ix=${index} start=$${start.id} end=[${end}]${transitive ? ' transitive' : ''} kind=${kind}`, + ); + } + const seen = new Set(); + const queue: Array<{ + place: Identifier; + transitive: boolean; + direction: 'backwards' | 'forwards'; + }> = [{place: start, transitive, direction: 'backwards'}]; + while (queue.length !== 0) { + const {place: current, transitive, direction} = queue.pop()!; + if (seen.has(current)) { + continue; + } + seen.add(current); + const node = this.nodes.get(current); + if (node == null) { + if (DEBUG) { + console.log( + `no node! ${printIdentifier(start)} for identifier ${printIdentifier(current)}`, + ); + } + continue; + } + if (DEBUG) { + console.log( + ` mutate $${node.id.id} transitive=${transitive} direction=${direction}`, + ); + } + node.id.mutableRange.end = makeInstructionId( + Math.max(node.id.mutableRange.end, end), + ); + if ( + node.value.kind === 'Function' && + node.transitive == null && + node.local == null + ) { + appendFunctionErrors(errors, node.value.function); + } + if (transitive) { + if (node.transitive == null || node.transitive.kind < kind) { + node.transitive = {kind, loc}; + } + } else { + if (node.local == null || node.local.kind < kind) { + node.local = {kind, loc}; + } + } + /** + * all mutations affect "forward" edges by the rules: + * - Capture a -> b, mutate(a) => mutate(b) + * - Alias a -> b, mutate(a) => mutate(b) + */ + for (const edge of node.edges) { + if (edge.index >= index) { + break; + } + queue.push({place: edge.node, transitive, direction: 'forwards'}); + } + for (const [alias, when] of node.createdFrom) { + if (when >= index) { + continue; + } + queue.push({place: alias, transitive: true, direction: 'backwards'}); + } + if (direction === 'backwards' || node.value.kind !== 'Phi') { + /** + * all mutations affect backward alias edges by the rules: + * - Alias a -> b, mutate(b) => mutate(a) + * - Alias a -> b, mutateTransitive(b) => mutate(a) + * + * However, if we reached a phi because one of its inputs was mutated + * (and we're advancing "forwards" through that node's edges), then + * we know we've already processed the mutation at its source. The + * phi's other inputs can't be affected. + */ + for (const [alias, when] of node.aliases) { + if (when >= index) { + continue; + } + queue.push({place: alias, transitive, direction: 'backwards'}); + } + } + /** + * but only transitive mutations affect captures + */ + if (transitive) { + for (const [capture, when] of node.captures) { + if (when >= index) { + continue; + } + queue.push({place: capture, transitive, direction: 'backwards'}); + } + } + } + if (DEBUG) { + const nodes = new Map(); + for (const id of seen) { + const node = this.nodes.get(id); + nodes.set(id.id, node); + } + console.log(pretty(nodes)); + } + } + + debug(): string { + return pretty(this.nodes); + } +} + +export function pretty(v: any): string { + return prettyFormat(v, { + plugins: [ + { + test: v => + v !== null && typeof v === 'object' && v.kind === 'Identifier', + serialize: v => printPlace(v), + }, + { + test: v => + v !== null && + typeof v === 'object' && + typeof v.declarationId === 'number', + serialize: v => + `${printIdentifier(v)}:${v.mutableRange.start}:${v.mutableRange.end}`, + }, + ], + }); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts index d1546038edcbe..1b0856791a180 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts @@ -48,7 +48,7 @@ import { eachTerminalOperand, eachTerminalSuccessor, } from '../HIR/visitors'; -import {assertExhaustive} from '../Utils/utils'; +import {assertExhaustive, Set_isSuperset} from '../Utils/utils'; import { inferTerminalFunctionEffects, inferInstructionFunctionEffects, @@ -779,7 +779,7 @@ function inferParam( * │ Mutable │───┘ * └──────────────────────────┘ */ -function mergeValues(a: ValueKind, b: ValueKind): ValueKind { +export function mergeValueKinds(a: ValueKind, b: ValueKind): ValueKind { if (a === b) { return a; } else if (a === ValueKind.MaybeFrozen || b === ValueKind.MaybeFrozen) { @@ -821,28 +821,16 @@ function mergeValues(a: ValueKind, b: ValueKind): ValueKind { } } -/** - * @returns `true` if `a` is a superset of `b`. - */ -function isSuperset(a: ReadonlySet, b: ReadonlySet): boolean { - for (const v of b) { - if (!a.has(v)) { - return false; - } - } - return true; -} - function mergeAbstractValues( a: AbstractValue, b: AbstractValue, ): AbstractValue { - const kind = mergeValues(a.kind, b.kind); + const kind = mergeValueKinds(a.kind, b.kind); if ( kind === a.kind && kind === b.kind && - isSuperset(a.reason, b.reason) && - isSuperset(a.context, b.context) + Set_isSuperset(a.reason, b.reason) && + Set_isSuperset(a.context, b.context) ) { return a; } @@ -1989,7 +1977,7 @@ function areArgumentsImmutableAndNonMutating( return true; } -function getArgumentEffect( +export function getArgumentEffect( signatureEffect: Effect | null, arg: Place | SpreadPattern, ): Effect { diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts index bbf3b0aeca03f..a1d381899e20c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts @@ -242,6 +242,7 @@ function rewriteBlock( type: null, loc: terminal.loc, }, + effects: null, }); block.terminal = { kind: 'goto', @@ -270,5 +271,6 @@ function declareTemporary( type: null, loc: result.loc, }, + effects: null, }); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Optimization/InlineJsxTransform.ts b/compiler/packages/babel-plugin-react-compiler/src/Optimization/InlineJsxTransform.ts index 29c59c7b3644a..91e2ce0692576 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/InlineJsxTransform.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/InlineJsxTransform.ts @@ -151,6 +151,7 @@ export function inlineJsxTransform( type: null, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; currentBlockInstructions.push(varInstruction); @@ -167,6 +168,7 @@ export function inlineJsxTransform( }, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; currentBlockInstructions.push(devGlobalInstruction); @@ -220,6 +222,7 @@ export function inlineJsxTransform( type: null, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; thenBlockInstructions.push(reassignElseInstruction); @@ -292,6 +295,7 @@ export function inlineJsxTransform( ], loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; elseBlockInstructions.push(reactElementInstruction); @@ -309,6 +313,7 @@ export function inlineJsxTransform( type: null, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; elseBlockInstructions.push(reassignConditionalInstruction); @@ -436,6 +441,7 @@ function createSymbolProperty( binding: {kind: 'Global', name: 'Symbol'}, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; nextInstructions.push(symbolInstruction); @@ -450,6 +456,7 @@ function createSymbolProperty( property: makePropertyLiteral('for'), loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; nextInstructions.push(symbolForInstruction); @@ -463,6 +470,7 @@ function createSymbolProperty( value: symbolName, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; nextInstructions.push(symbolValueInstruction); @@ -478,6 +486,7 @@ function createSymbolProperty( args: [symbolValueInstruction.lvalue], loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; const $$typeofProperty: ObjectProperty = { @@ -508,6 +517,7 @@ function createTagProperty( value: componentTag.name, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; tagProperty = { @@ -634,6 +644,7 @@ function createPropsProperties( elements: [...children], loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; nextInstructions.push(childrenPropInstruction); @@ -657,6 +668,7 @@ function createPropsProperties( value: null, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; refProperty = { @@ -678,6 +690,7 @@ function createPropsProperties( value: null, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; keyProperty = { @@ -711,6 +724,7 @@ function createPropsProperties( properties: props, loc: instr.value.loc, }, + effects: null, loc: instr.loc, }; propsProperty = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts b/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts index 834f60195af29..32486577fb350 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts @@ -146,6 +146,7 @@ function emitLoadLoweredContextCallee( id: makeInstructionId(0), loc: GeneratedSource, lvalue: createTemporaryPlace(env, GeneratedSource), + effects: null, value: loadGlobal, }; } @@ -192,6 +193,7 @@ function emitPropertyLoad( lvalue: object, value: loadObj, id: makeInstructionId(0), + effects: null, loc: GeneratedSource, }; @@ -206,6 +208,7 @@ function emitPropertyLoad( lvalue: element, value: loadProp, id: makeInstructionId(0), + effects: null, loc: GeneratedSource, }; return { @@ -237,6 +240,7 @@ function emitSelectorFn(env: Environment, keys: Array): Instruction { kind: 'return', loc: GeneratedSource, value: arrayInstr.lvalue, + effects: null, }, preds: new Set(), phis: new Set(), @@ -250,6 +254,7 @@ function emitSelectorFn(env: Environment, keys: Array): Instruction { params: [obj], returnTypeAnnotation: null, returnType: makeType(), + returns: createTemporaryPlace(env, GeneratedSource), context: [], effects: null, body: { @@ -278,6 +283,7 @@ function emitSelectorFn(env: Environment, keys: Array): Instruction { loc: GeneratedSource, }, lvalue: createTemporaryPlace(env, GeneratedSource), + effects: null, loc: GeneratedSource, }; return fnInstr; @@ -294,6 +300,7 @@ function emitArrayInstr(elements: Array, env: Environment): Instruction { id: makeInstructionId(0), value: array, lvalue: arrayLvalue, + effects: null, loc: GeneratedSource, }; return arrayInstr; diff --git a/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts b/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts index d35c4d77362db..667629a3e0763 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts @@ -297,6 +297,7 @@ function emitOutlinedJsx( }, loc: GeneratedSource, }, + effects: null, }; promoteTemporaryJsxTag(loadJsx.lvalue.identifier); const jsxExpr: Instruction = { @@ -312,6 +313,7 @@ function emitOutlinedJsx( openingLoc: GeneratedSource, closingLoc: GeneratedSource, }, + effects: null, }; return [loadJsx, jsxExpr]; @@ -353,6 +355,7 @@ function emitOutlinedFn( kind: 'return', loc: GeneratedSource, value: instructions.at(-1)!.lvalue, + effects: null, }, preds: new Set(), phis: new Set(), @@ -366,6 +369,7 @@ function emitOutlinedFn( params: [propsObj], returnTypeAnnotation: null, returnType: makeType(), + returns: createTemporaryPlace(env, GeneratedSource), context: [], effects: null, body: { @@ -517,6 +521,7 @@ function emitDestructureProps( loc: GeneratedSource, value: propsObj, }, + effects: null, }; return destructurePropsInstr; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts index 17c62c02a6ee8..9e91d481db60c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -44,7 +44,7 @@ import { getHookKind, makeIdentifierName, } from '../HIR/HIR'; -import {printIdentifier, printPlace} from '../HIR/PrintHIR'; +import {printIdentifier, printInstruction, printPlace} from '../HIR/PrintHIR'; import {eachPatternOperand} from '../HIR/visitors'; import {Err, Ok, Result} from '../Utils/Result'; import {GuardKind} from '../Utils/RuntimeDiagnosticConstants'; @@ -1310,7 +1310,7 @@ function codegenInstructionNullable( }); CompilerError.invariant(value?.type === 'FunctionExpression', { reason: 'Expected a function as a function declaration value', - description: null, + description: `Got ${value == null ? String(value) : value.type} at ${printInstruction(instr)}`, loc: instr.value.loc, suggestions: null, }); diff --git a/compiler/packages/babel-plugin-react-compiler/src/Transform/TransformFire.ts b/compiler/packages/babel-plugin-react-compiler/src/Transform/TransformFire.ts index b033af6750c37..f88c85f2f0f39 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Transform/TransformFire.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Transform/TransformFire.ts @@ -436,6 +436,7 @@ function makeLoadUseFireInstruction( value: instrValue, lvalue: {...useFirePlace}, loc: GeneratedSource, + effects: null, }; } @@ -460,6 +461,7 @@ function makeLoadFireCalleeInstruction( }, lvalue: {...loadedFireCallee}, loc: GeneratedSource, + effects: null, }; } @@ -483,6 +485,7 @@ function makeCallUseFireInstruction( value: useFireCall, lvalue: {...useFireCallResultPlace}, loc: GeneratedSource, + effects: null, }; } @@ -511,6 +514,7 @@ function makeStoreUseFireInstruction( }, lvalue: fireFunctionBindingLValuePlace, loc: GeneratedSource, + effects: null, }; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts b/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts index aa91c48b1b0db..e5fbacfc772df 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts @@ -121,6 +121,21 @@ export function Set_intersect(sets: Array>): Set { return result; } +/** + * @returns `true` if `a` is a superset of `b`. + */ +export function Set_isSuperset( + a: ReadonlySet, + b: ReadonlySet, +): boolean { + for (const v of b) { + if (!a.has(v)) { + return false; + } + } + return true; +} + export function Iterable_some( iter: Iterable, pred: (item: T) => boolean, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoFreezingKnownMutableFunctions.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoFreezingKnownMutableFunctions.ts index 81612a7441728..573db2f6b7d00 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoFreezingKnownMutableFunctions.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoFreezingKnownMutableFunctions.ts @@ -58,8 +58,7 @@ export function validateNoFreezingKnownMutableFunctions( const effect = contextMutationEffects.get(operand.identifier.id); if (effect != null) { errors.push({ - reason: `This argument is a function which modifies local variables when called, which can bypass memoization and cause the UI not to update`, - description: `Functions that are returned from hooks, passed as arguments to hooks, or passed as props to components may not mutate local variables`, + reason: `This argument is a function which may reassign or mutate local variables after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead`, loc: operand.loc, severity: ErrorSeverity.InvalidReact, }); @@ -112,6 +111,55 @@ export function validateNoFreezingKnownMutableFunctions( ); if (knownMutation && knownMutation.kind === 'ContextMutation') { contextMutationEffects.set(lvalue.identifier.id, knownMutation); + } else if ( + fn.env.config.enableNewMutationAliasingModel && + value.loweredFunc.func.aliasingEffects != null + ) { + const context = new Set( + value.loweredFunc.func.context.map(p => p.identifier.id), + ); + effects: for (const effect of value.loweredFunc.func + .aliasingEffects) { + switch (effect.kind) { + case 'Mutate': + case 'MutateTransitive': { + const knownMutation = contextMutationEffects.get( + effect.value.identifier.id, + ); + if (knownMutation != null) { + contextMutationEffects.set( + lvalue.identifier.id, + knownMutation, + ); + } else if ( + context.has(effect.value.identifier.id) && + !isRefOrRefLikeMutableType(effect.value.identifier.type) + ) { + contextMutationEffects.set(lvalue.identifier.id, { + kind: 'ContextMutation', + effect: Effect.Mutate, + loc: effect.value.loc, + places: new Set([effect.value]), + }); + break effects; + } + break; + } + case 'MutateConditionally': + case 'MutateTransitiveConditionally': { + const knownMutation = contextMutationEffects.get( + effect.value.identifier.id, + ); + if (knownMutation != null) { + contextMutationEffects.set( + lvalue.identifier.id, + knownMutation, + ); + } + break; + } + } + } } break; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-aliased-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-aliased-mutate.expect.md index d0ad9e2f9dbe5..7d14f2a5dc8e0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-aliased-mutate.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-aliased-mutate.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @flow @enableTransitivelyFreezeFunctionExpressions:false +// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false import {arrayPush, setPropertyByKey, Stringify} from 'shared-runtime'; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-aliased-mutate.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-aliased-mutate.js index c46ecd6250b42..911c06e644661 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-aliased-mutate.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-aliased-mutate.js @@ -1,4 +1,4 @@ -// @flow @enableTransitivelyFreezeFunctionExpressions:false +// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false import {arrayPush, setPropertyByKey, Stringify} from 'shared-runtime'; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-mutate.expect.md index c35efe6a16bb5..698562dad18d2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-mutate.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-mutate.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @flow @enableTransitivelyFreezeFunctionExpressions:false +// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false import {setPropertyByKey, Stringify} from 'shared-runtime'; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-mutate.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-mutate.js index a7e57672665f6..1311a9dcfa69d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-mutate.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-aliased-capture-mutate.js @@ -1,4 +1,4 @@ -// @flow @enableTransitivelyFreezeFunctionExpressions:false +// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false import {setPropertyByKey, Stringify} from 'shared-runtime'; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-capturing-func-maybealias-captured-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-capturing-func-maybealias-captured-mutate.expect.md index b8c7f8d4225f7..ea33e361e3ba2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-capturing-func-maybealias-captured-mutate.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-capturing-func-maybealias-captured-mutate.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel:false import {makeArray, mutate} from 'shared-runtime'; /** @@ -56,7 +57,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel:false import { makeArray, mutate } from "shared-runtime"; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-capturing-func-maybealias-captured-mutate.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-capturing-func-maybealias-captured-mutate.ts index ca7076fda4019..62d891febf41a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-capturing-func-maybealias-captured-mutate.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-capturing-func-maybealias-captured-mutate.ts @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel:false import {makeArray, mutate} from 'shared-runtime'; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-invalid-phi-as-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-invalid-phi-as-dependency.expect.md index 09d2d8800b789..9c874fa68ebc4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-invalid-phi-as-dependency.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-invalid-phi-as-dependency.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel:false import {CONST_TRUE, Stringify, mutate, useIdentity} from 'shared-runtime'; /** @@ -38,7 +39,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel:false import { CONST_TRUE, Stringify, mutate, useIdentity } from "shared-runtime"; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-invalid-phi-as-dependency.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-invalid-phi-as-dependency.tsx index a1a78bfa7e6b1..1a7c996a9e292 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-invalid-phi-as-dependency.tsx +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-invalid-phi-as-dependency.tsx @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel:false import {CONST_TRUE, Stringify, mutate, useIdentity} from 'shared-runtime'; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.expect.md index 4ffe0fcb6a541..93098b916d720 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel:false import {identity, mutate} from 'shared-runtime'; /** @@ -39,7 +40,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel:false import { identity, mutate } from "shared-runtime"; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.js index 94befbdd17b77..620f5eeb17af8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel:false import {identity, mutate} from 'shared-runtime'; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-separate-memoization-due-to-callback-capturing.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-separate-memoization-due-to-callback-capturing.expect.md new file mode 100644 index 0000000000000..7767989574c73 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-separate-memoization-due-to-callback-capturing.expect.md @@ -0,0 +1,138 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel:false +import {ValidateMemoization} from 'shared-runtime'; + +const Codes = { + en: {name: 'English'}, + ja: {name: 'Japanese'}, + ko: {name: 'Korean'}, + zh: {name: 'Chinese'}, +}; + +function Component(a) { + let keys; + if (a) { + keys = Object.keys(Codes); + } else { + return null; + } + const options = keys.map(code => { + const country = Codes[code]; + return { + name: country.name, + code, + }; + }); + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: false}], + sequentialRenders: [ + {a: false}, + {a: true}, + {a: true}, + {a: false}, + {a: true}, + {a: false}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel:false +import { ValidateMemoization } from "shared-runtime"; + +const Codes = { + en: { name: "English" }, + ja: { name: "Japanese" }, + ko: { name: "Korean" }, + zh: { name: "Chinese" }, +}; + +function Component(a) { + const $ = _c(4); + let keys; + if (a) { + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = Object.keys(Codes); + $[0] = t0; + } else { + t0 = $[0]; + } + keys = t0; + } else { + return null; + } + let t0; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t0 = keys.map(_temp); + $[1] = t0; + } else { + t0 = $[1]; + } + const options = t0; + let t1; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t1 = ( + + ); + $[2] = t1; + } else { + t1 = $[2]; + } + let t2; + if ($[3] === Symbol.for("react.memo_cache_sentinel")) { + t2 = ( + <> + {t1} + + + ); + $[3] = t2; + } else { + t2 = $[3]; + } + return t2; +} +function _temp(code) { + const country = Codes[code]; + return { name: country.name, code }; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: false }], + sequentialRenders: [ + { a: false }, + { a: true }, + { a: true }, + { a: false }, + { a: true }, + { a: false }, + ], +}; + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-separate-memoization-due-to-callback-capturing.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-separate-memoization-due-to-callback-capturing.js new file mode 100644 index 0000000000000..c28ee705d1667 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-separate-memoization-due-to-callback-capturing.js @@ -0,0 +1,48 @@ +// @enableNewMutationAliasingModel:false +import {ValidateMemoization} from 'shared-runtime'; + +const Codes = { + en: {name: 'English'}, + ja: {name: 'Japanese'}, + ko: {name: 'Korean'}, + zh: {name: 'Chinese'}, +}; + +function Component(a) { + let keys; + if (a) { + keys = Object.keys(Codes); + } else { + return null; + } + const options = keys.map(code => { + const country = Codes[code]; + return { + name: country.name, + code, + }; + }); + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: false}], + sequentialRenders: [ + {a: false}, + {a: true}, + {a: true}, + {a: false}, + {a: true}, + {a: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.expect.md index 3861b16e90dcf..3f0b5530ee2d2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel:false function Component() { const foo = () => { someGlobal = true; @@ -15,13 +16,13 @@ function Component() { ## Error ``` - 1 | function Component() { - 2 | const foo = () => { -> 3 | someGlobal = true; - | ^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (3:3) - 4 | }; - 5 | return
; - 6 | } + 2 | function Component() { + 3 | const foo = () => { +> 4 | someGlobal = true; + | ^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (4:4) + 5 | }; + 6 | return
; + 7 | } ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.js index 1eea9267b5098..e749f10f78fbd 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.assign-global-in-jsx-spread-attribute.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel:false function Component() { const foo = () => { someGlobal = true; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-old-inference-false-positive-ref-validation-in-use-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-old-inference-false-positive-ref-validation-in-use-effect.expect.md new file mode 100644 index 0000000000000..e1cebb00df56f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-old-inference-false-positive-ref-validation-in-use-effect.expect.md @@ -0,0 +1,58 @@ + +## Input + +```javascript +// @validateNoFreezingKnownMutableFunctions @enableNewMutationAliasingModel:false + +import {useCallback, useEffect, useRef} from 'react'; +import {useHook} from 'shared-runtime'; + +function Component() { + const params = useHook(); + const update = useCallback( + partialParams => { + const nextParams = { + ...params, + ...partialParams, + }; + nextParams.param = 'value'; + console.log(nextParams); + }, + [params] + ); + const ref = useRef(null); + useEffect(() => { + if (ref.current === null) { + update(); + } + }, [update]); + + return 'ok'; +} + +``` + + +## Error + +``` + 18 | ); + 19 | const ref = useRef(null); +> 20 | useEffect(() => { + | ^^^^^^^ +> 21 | if (ref.current === null) { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 22 | update(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 23 | } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 24 | }, [update]); + | ^^^^ InvalidReact: This argument is a function which may reassign or mutate local variables after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead (20:24) + +InvalidReact: The function modifies a local variable here (14:14) + 25 | + 26 | return 'ok'; + 27 | } +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-old-inference-false-positive-ref-validation-in-use-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-old-inference-false-positive-ref-validation-in-use-effect.js new file mode 100644 index 0000000000000..b5d70dbd81611 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-old-inference-false-positive-ref-validation-in-use-effect.js @@ -0,0 +1,27 @@ +// @validateNoFreezingKnownMutableFunctions @enableNewMutationAliasingModel:false + +import {useCallback, useEffect, useRef} from 'react'; +import {useHook} from 'shared-runtime'; + +function Component() { + const params = useHook(); + const update = useCallback( + partialParams => { + const nextParams = { + ...params, + ...partialParams, + }; + nextParams.param = 'value'; + console.log(nextParams); + }, + [params] + ); + const ref = useRef(null); + useEffect(() => { + if (ref.current === null) { + update(); + } + }, [update]); + + return 'ok'; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/hoisting-setstate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hoisting-setstate.expect.md similarity index 56% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/hoisting-setstate.expect.md rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hoisting-setstate.expect.md index 483d9b1a8e2da..fcd5dcc698e2b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/hoisting-setstate.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hoisting-setstate.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel import {useEffect, useState} from 'react'; import {Stringify} from 'shared-runtime'; @@ -33,45 +34,17 @@ export const FIXTURE_ENTRYPOINT = { ``` -## Code -```javascript -import { c as _c } from "react/compiler-runtime"; -import { useEffect, useState } from "react"; -import { Stringify } from "shared-runtime"; - -function Foo() { - const $ = _c(3); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = []; - $[0] = t0; - } else { - t0 = $[0]; - } - useEffect(() => setState(2), t0); - - const [state, t1] = useState(0); - const setState = t1; - let t2; - if ($[1] !== state) { - t2 = ; - $[1] = state; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Foo, - params: [{}], - sequentialRenders: [{}, {}], -}; +## Error ``` - -### Eval output -(kind: ok)
{"state":2}
-
{"state":2}
\ No newline at end of file + 19 | useEffect(() => setState(2), []); + 20 | +> 21 | const [state, setState] = useState(0); + | ^^^^^^^^ InvalidReact: Updating a value used previously in an effect function or as an effect dependency is not allowed. Consider moving the mutation before calling useEffect(). Found mutation of `setState` (21:21) + 22 | return ; + 23 | } + 24 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/hoisting-setstate.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hoisting-setstate.js similarity index 96% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/hoisting-setstate.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hoisting-setstate.js index 7b26c8d086491..f3b4167772e9d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/hoisting-setstate.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hoisting-setstate.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel import {useEffect, useState} from 'react'; import {Stringify} from 'shared-runtime'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.expect.md index 86a9e14d80e8e..340c9570bb6cc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-hook-function-argument-mutates-local-variable.expect.md @@ -24,7 +24,7 @@ function useFoo() { > 6 | cache.set('key', 'value'); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ > 7 | }); - | ^^^^ InvalidReact: This argument is a function which modifies local variables when called, which can bypass memoization and cause the UI not to update. Functions that are returned from hooks, passed as arguments to hooks, or passed as props to components may not mutate local variables (5:7) + | ^^^^ InvalidReact: This argument is a function which may reassign or mutate local variables after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead (5:7) InvalidReact: The function modifies a local variable here (6:6) 8 | } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-captures-context-variable.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-captures-context-variable.expect.md new file mode 100644 index 0000000000000..461b2b9e4511f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-captures-context-variable.expect.md @@ -0,0 +1,62 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {Stringify, useIdentity} from 'shared-runtime'; + +function Component({prop1, prop2}) { + 'use memo'; + + const data = useIdentity( + new Map([ + [0, 'value0'], + [1, 'value1'], + ]) + ); + let i = 0; + const items = []; + items.push( + data.get(i) + prop1} + shouldInvokeFns={true} + /> + ); + i = i + 1; + items.push( + data.get(i) + prop2} + shouldInvokeFns={true} + /> + ); + return <>{items}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prop1: 'prop1', prop2: 'prop2'}], + sequentialRenders: [ + {prop1: 'prop1', prop2: 'prop2'}, + {prop1: 'prop1', prop2: 'prop2'}, + {prop1: 'changed', prop2: 'prop2'}, + ], +}; + +``` + + +## Error + +``` + 20 | /> + 21 | ); +> 22 | i = i + 1; + | ^ InvalidReact: Updating a value used previously in JSX is not allowed. Consider moving the mutation before the JSX. Found mutation of `i` (22:22) + 23 | items.push( + 24 | 7 | return ; - | ^^ InvalidReact: This argument is a function which modifies local variables when called, which can bypass memoization and cause the UI not to update. Functions that are returned from hooks, passed as arguments to hooks, or passed as props to components may not mutate local variables (7:7) + | ^^ InvalidReact: This argument is a function which may reassign or mutate local variables after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead (7:7) InvalidReact: The function modifies a local variable here (5:5) 8 | } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.expect.md index 63a09bedaa0dd..d60433a315803 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-return-mutable-function-from-hook.expect.md @@ -26,7 +26,7 @@ function useFoo() { > 8 | cache.set('key', 'value'); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ > 9 | }; - | ^^^^ InvalidReact: This argument is a function which modifies local variables when called, which can bypass memoization and cause the UI not to update. Functions that are returned from hooks, passed as arguments to hooks, or passed as props to components may not mutate local variables (7:9) + | ^^^^ InvalidReact: This argument is a function which may reassign or mutate local variables after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead (7:9) InvalidReact: The function modifies a local variable here (8:8) 10 | } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.expect.md new file mode 100644 index 0000000000000..734ba6f172841 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.expect.md @@ -0,0 +1,92 @@ + +## Input + +```javascript +// @flow @enableNewMutationAliasingModel +/** + * This hook returns a function that when called with an input object, + * will return the result of mapping that input with the supplied map + * function. Results are cached, so if the same input is passed again, + * the same output object will be returned. + * + * Note that this technically violates the rules of React and is unsafe: + * hooks must return immutable objects and be pure, and a function which + * captures and mutates a value when called is inherently not pure. + * + * However, in this case it is technically safe _if_ the mapping function + * is pure *and* the resulting objects are never modified. This is because + * the function only caches: the result of `returnedFunction(someInput)` + * strictly depends on `returnedFunction` and `someInput`, and cannot + * otherwise change over time. + */ +hook useMemoMap( + map: TInput => TOutput +): TInput => TOutput { + return useMemo(() => { + // The original issue is that `cache` was not memoized together with the returned + // function. This was because neither appears to ever be mutated — the function + // is known to mutate `cache` but the function isn't called. + // + // The fix is to detect cases like this — functions that are mutable but not called - + // and ensure that their mutable captures are aliased together into the same scope. + const cache = new WeakMap(); + return input => { + let output = cache.get(input); + if (output == null) { + output = map(input); + cache.set(input, output); + } + return output; + }; + }, [map]); +} + +``` + + +## Error + +``` + 19 | map: TInput => TOutput + 20 | ): TInput => TOutput { +> 21 | return useMemo(() => { + | ^^^^^^^^^^^^^^^ +> 22 | // The original issue is that `cache` was not memoized together with the returned + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 23 | // function. This was because neither appears to ever be mutated — the function + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 24 | // is known to mutate `cache` but the function isn't called. + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 25 | // + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 26 | // The fix is to detect cases like this — functions that are mutable but not called - + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 27 | // and ensure that their mutable captures are aliased together into the same scope. + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 28 | const cache = new WeakMap(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 29 | return input => { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 30 | let output = cache.get(input); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 31 | if (output == null) { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 32 | output = map(input); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 33 | cache.set(input, output); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 34 | } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 35 | return output; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 36 | }; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 37 | }, [map]); + | ^^^^^^^^^^^^ InvalidReact: This argument is a function which may reassign or mutate local variables after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead (21:37) + +InvalidReact: The function modifies a local variable here (33:33) + 38 | } + 39 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.js similarity index 97% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.js index bce92823e3385..accabed80fc4f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.js @@ -1,4 +1,4 @@ -// @flow +// @flow @enableNewMutationAliasingModel /** * This hook returns a function that when called with an input object, * will return the result of mapping that input with the supplied map diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutable-range-shared-inner-outer-function.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutable-range-shared-inner-outer-function.expect.md index cdcd6b3ffad99..a6f2a2719fb1f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutable-range-shared-inner-outer-function.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutable-range-shared-inner-outer-function.expect.md @@ -18,7 +18,7 @@ function Component(props) { a.property = true; b.push(false); }; - return
; + return
; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutable-range-shared-inner-outer-function.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutable-range-shared-inner-outer-function.js index b975527138f3b..ac7299181ed5f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutable-range-shared-inner-outer-function.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutable-range-shared-inner-outer-function.js @@ -14,7 +14,7 @@ function Component(props) { a.property = true; b.push(false); }; - return
; + return
; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.object-capture-global-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.object-capture-global-mutation.expect.md index 1ab2a46afe893..65292c65e9930 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.object-capture-global-mutation.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.object-capture-global-mutation.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel:false function Foo() { const x = () => { window.href = 'foo'; @@ -21,13 +22,13 @@ export const FIXTURE_ENTRYPOINT = { ## Error ``` - 1 | function Foo() { - 2 | const x = () => { -> 3 | window.href = 'foo'; - | ^^^^^^ InvalidReact: Writing to a variable defined outside a component or hook is not allowed. Consider using an effect (3:3) - 4 | }; - 5 | const y = {x}; - 6 | return ; + 2 | function Foo() { + 3 | const x = () => { +> 4 | window.href = 'foo'; + | ^^^^^^ InvalidReact: Writing to a variable defined outside a component or hook is not allowed. Consider using an effect (4:4) + 5 | }; + 6 | const y = {x}; + 7 | return ; ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.object-capture-global-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.object-capture-global-mutation.js index b3c936a2a284d..d95a0a6265cc8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.object-capture-global-mutation.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.object-capture-global-mutation.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel:false function Foo() { const x = () => { window.href = 'foo'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md index f66b970f00dd4..2a935256d7a0d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md @@ -22,7 +22,7 @@ function Component(props) { 7 | return hasErrors; 8 | } > 9 | return hasErrors(); - | ^^^^^^^^^ Invariant: [hoisting] Expected value for identifier to be initialized. hasErrors_0$14 (9:9) + | ^^^^^^^^^ Invariant: [hoisting] Expected value for identifier to be initialized. hasErrors_0$15 (9:9) 10 | } 11 | ``` diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/jsx-captures-context-variable.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/jsx-captures-context-variable.expect.md deleted file mode 100644 index c1a9ad205c87e..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/jsx-captures-context-variable.expect.md +++ /dev/null @@ -1,129 +0,0 @@ - -## Input - -```javascript -import {Stringify, useIdentity} from 'shared-runtime'; - -function Component({prop1, prop2}) { - 'use memo'; - - const data = useIdentity( - new Map([ - [0, 'value0'], - [1, 'value1'], - ]) - ); - let i = 0; - const items = []; - items.push( - data.get(i) + prop1} - shouldInvokeFns={true} - /> - ); - i = i + 1; - items.push( - data.get(i) + prop2} - shouldInvokeFns={true} - /> - ); - return <>{items}; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{prop1: 'prop1', prop2: 'prop2'}], - sequentialRenders: [ - {prop1: 'prop1', prop2: 'prop2'}, - {prop1: 'prop1', prop2: 'prop2'}, - {prop1: 'changed', prop2: 'prop2'}, - ], -}; - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; -import { Stringify, useIdentity } from "shared-runtime"; - -function Component(t0) { - "use memo"; - const $ = _c(12); - const { prop1, prop2 } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = new Map([ - [0, "value0"], - [1, "value1"], - ]); - $[0] = t1; - } else { - t1 = $[0]; - } - const data = useIdentity(t1); - let t2; - if ($[1] !== data || $[2] !== prop1 || $[3] !== prop2) { - let i = 0; - const items = []; - items.push( - data.get(i) + prop1} - shouldInvokeFns={true} - />, - ); - i = i + 1; - - const t3 = i; - let t4; - if ($[5] !== data || $[6] !== i || $[7] !== prop2) { - t4 = () => data.get(i) + prop2; - $[5] = data; - $[6] = i; - $[7] = prop2; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== t3 || $[10] !== t4) { - t5 = ; - $[9] = t3; - $[10] = t4; - $[11] = t5; - } else { - t5 = $[11]; - } - items.push(t5); - t2 = <>{items}; - $[1] = data; - $[2] = prop1; - $[3] = prop2; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ prop1: "prop1", prop2: "prop2" }], - sequentialRenders: [ - { prop1: "prop1", prop2: "prop2" }, - { prop1: "prop1", prop2: "prop2" }, - { prop1: "changed", prop2: "prop2" }, - ], -}; - -``` - -### Eval output -(kind: ok)
{"onClick":{"kind":"Function","result":"value1prop1"},"shouldInvokeFns":true}
{"onClick":{"kind":"Function","result":"value1prop2"},"shouldInvokeFns":true}
-
{"onClick":{"kind":"Function","result":"value1prop1"},"shouldInvokeFns":true}
{"onClick":{"kind":"Function","result":"value1prop2"},"shouldInvokeFns":true}
-
{"onClick":{"kind":"Function","result":"value1changed"},"shouldInvokeFns":true}
{"onClick":{"kind":"Function","result":"value1prop2"},"shouldInvokeFns":true}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-filter.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-filter.expect.md new file mode 100644 index 0000000000000..b3531c225d9ea --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-filter.expect.md @@ -0,0 +1,93 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component({value}) { + const arr = [{value: 'foo'}, {value: 'bar'}, {value}]; + useIdentity(null); + const derived = arr.filter(Boolean); + return ( + + {derived.at(0)} + {derived.at(-1)} + + ); +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function Component(t0) { + const $ = _c(13); + const { value } = t0; + let t1; + let t2; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t1 = { value: "foo" }; + t2 = { value: "bar" }; + $[0] = t1; + $[1] = t2; + } else { + t1 = $[0]; + t2 = $[1]; + } + let t3; + if ($[2] !== value) { + t3 = [t1, t2, { value }]; + $[2] = value; + $[3] = t3; + } else { + t3 = $[3]; + } + const arr = t3; + useIdentity(null); + let t4; + if ($[4] !== arr) { + t4 = arr.filter(Boolean); + $[4] = arr; + $[5] = t4; + } else { + t4 = $[5]; + } + const derived = t4; + let t5; + if ($[6] !== derived) { + t5 = derived.at(0); + $[6] = derived; + $[7] = t5; + } else { + t5 = $[7]; + } + let t6; + if ($[8] !== derived) { + t6 = derived.at(-1); + $[8] = derived; + $[9] = t6; + } else { + t6 = $[9]; + } + let t7; + if ($[10] !== t5 || $[11] !== t6) { + t7 = ( + + {t5} + {t6} + + ); + $[10] = t5; + $[11] = t6; + $[12] = t7; + } else { + t7 = $[12]; + } + return t7; +} + +``` + +### 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/new-mutability/array-filter.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-filter.js new file mode 100644 index 0000000000000..3229088e1da90 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-filter.js @@ -0,0 +1,12 @@ +// @enableNewMutationAliasingModel +function Component({value}) { + const arr = [{value: 'foo'}, {value: 'bar'}, {value}]; + useIdentity(null); + const derived = arr.filter(Boolean); + return ( + + {derived.at(0)} + {derived.at(-1)} + + ); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-captures-receiver-noAlias.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-captures-receiver-noAlias.expect.md new file mode 100644 index 0000000000000..e687c995d077f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-captures-receiver-noAlias.expect.md @@ -0,0 +1,71 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component(props) { + // This item is part of the receiver, should be memoized + const item = {a: props.a}; + const items = [item]; + const mapped = items.map(item => item); + // mapped[0].a = null; + return mapped; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: {id: 42}}], + isComponent: false, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function Component(props) { + const $ = _c(6); + let t0; + if ($[0] !== props.a) { + t0 = { a: props.a }; + $[0] = props.a; + $[1] = t0; + } else { + t0 = $[1]; + } + const item = t0; + let t1; + if ($[2] !== item) { + t1 = [item]; + $[2] = item; + $[3] = t1; + } else { + t1 = $[3]; + } + const items = t1; + let t2; + if ($[4] !== items) { + t2 = items.map(_temp); + $[4] = items; + $[5] = t2; + } else { + t2 = $[5]; + } + const mapped = t2; + return mapped; +} +function _temp(item_0) { + return item_0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: { id: 42 } }], + isComponent: false, +}; + +``` + +### Eval output +(kind: ok) [{"a":{"id":42}}] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-captures-receiver-noAlias.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-captures-receiver-noAlias.js new file mode 100644 index 0000000000000..42e32b3e38b3f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-captures-receiver-noAlias.js @@ -0,0 +1,15 @@ +// @enableNewMutationAliasingModel +function Component(props) { + // This item is part of the receiver, should be memoized + const item = {a: props.a}; + const items = [item]; + const mapped = items.map(item => item); + // mapped[0].a = null; + return mapped; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: {id: 42}}], + isComponent: false, +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-push.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-push.expect.md new file mode 100644 index 0000000000000..b2564a7a90d1b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-push.expect.md @@ -0,0 +1,57 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component({a, b, c}) { + const x = []; + x.push(a); + const merged = {b}; // could be mutated by mutate(x) below + x.push(merged); + mutate(x); + const independent = {c}; // can't be later mutated + x.push(independent); + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function Component(t0) { + const $ = _c(6); + const { a, b, c } = t0; + let t1; + if ($[0] !== a || $[1] !== b || $[2] !== c) { + const x = []; + x.push(a); + const merged = { b }; + x.push(merged); + mutate(x); + let t2; + if ($[4] !== c) { + t2 = { c }; + $[4] = c; + $[5] = t2; + } else { + t2 = $[5]; + } + const independent = t2; + x.push(independent); + t1 = ; + $[0] = a; + $[1] = b; + $[2] = c; + $[3] = t1; + } else { + t1 = $[3]; + } + return t1; +} + +``` + +### 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/new-mutability/array-push.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-push.js new file mode 100644 index 0000000000000..eb7f31bff631e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-push.js @@ -0,0 +1,11 @@ +// @enableNewMutationAliasingModel +function Component({a, b, c}) { + const x = []; + x.push(a); + const merged = {b}; // could be mutated by mutate(x) below + x.push(merged); + mutate(x); + const independent = {c}; // can't be later mutated + x.push(independent); + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation-via-function-expression.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation-via-function-expression.expect.md new file mode 100644 index 0000000000000..8b767931a8949 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation-via-function-expression.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = {a}; + const y = [b]; + const f = () => { + y.x = x; + mutate(y); + }; + f(); + return
{x}
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function Component(t0) { + const $ = _c(3); + const { a, b } = t0; + let t1; + if ($[0] !== a || $[1] !== b) { + const x = { a }; + const y = [b]; + const f = () => { + y.x = x; + mutate(y); + }; + + f(); + t1 =
{x}
; + $[0] = a; + $[1] = b; + $[2] = t1; + } else { + t1 = $[2]; + } + return t1; +} + +``` + +### 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/new-mutability/basic-mutation-via-function-expression.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation-via-function-expression.js new file mode 100644 index 0000000000000..8d4bb23742b2b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation-via-function-expression.js @@ -0,0 +1,11 @@ +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = {a}; + const y = [b]; + const f = () => { + y.x = x; + mutate(y); + }; + f(); + return
{x}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation.expect.md new file mode 100644 index 0000000000000..0753f007b7b32 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation.expect.md @@ -0,0 +1,42 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = {a}; + const y = [b]; + y.x = x; + mutate(y); + return
{x}
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function Component(t0) { + const $ = _c(3); + const { a, b } = t0; + let t1; + if ($[0] !== a || $[1] !== b) { + const x = { a }; + const y = [b]; + y.x = x; + mutate(y); + t1 =
{x}
; + $[0] = a; + $[1] = b; + $[2] = t1; + } else { + t1 = $[2]; + } + return t1; +} + +``` + +### 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/new-mutability/basic-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation.js new file mode 100644 index 0000000000000..480221fef4dab --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/basic-mutation.js @@ -0,0 +1,8 @@ +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = {a}; + const y = [b]; + y.x = x; + mutate(y); + return
{x}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-backedge-phi-with-later-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-backedge-phi-with-later-mutation.expect.md new file mode 100644 index 0000000000000..df9b5e58f8b0a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-backedge-phi-with-later-mutation.expect.md @@ -0,0 +1,102 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {arrayPush, Stringify} from 'shared-runtime'; + +function Component({prop1, prop2}) { + 'use memo'; + + let x = [{value: prop1}]; + let z; + while (x.length < 2) { + // there's a phi here for x (value before the loop and the reassignment later) + + // this mutation occurs before the reassigned value + arrayPush(x, {value: prop2}); + + if (x[0].value === prop1) { + x = [{value: prop2}]; + const y = x; + z = y[0]; + } + } + z.other = true; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prop1: 0, prop2: 'a'}], + sequentialRenders: [ + {prop1: 0, prop2: 'a'}, + {prop1: 1, prop2: 'a'}, + {prop1: 1, prop2: 'b'}, + {prop1: 0, prop2: 'b'}, + {prop1: 0, prop2: 'a'}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +import { arrayPush, Stringify } from "shared-runtime"; + +function Component(t0) { + "use memo"; + const $ = _c(5); + const { prop1, prop2 } = t0; + let z; + if ($[0] !== prop1 || $[1] !== prop2) { + let x = [{ value: prop1 }]; + while (x.length < 2) { + arrayPush(x, { value: prop2 }); + if (x[0].value === prop1) { + x = [{ value: prop2 }]; + const y = x; + z = y[0]; + } + } + + z.other = true; + $[0] = prop1; + $[1] = prop2; + $[2] = z; + } else { + z = $[2]; + } + let t1; + if ($[3] !== z) { + t1 = ; + $[3] = z; + $[4] = t1; + } else { + t1 = $[4]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ prop1: 0, prop2: "a" }], + sequentialRenders: [ + { prop1: 0, prop2: "a" }, + { prop1: 1, prop2: "a" }, + { prop1: 1, prop2: "b" }, + { prop1: 0, prop2: "b" }, + { prop1: 0, prop2: "a" }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"z":{"value":"a","other":true}}
+
{"z":{"value":"a","other":true}}
+
{"z":{"value":"b","other":true}}
+
{"z":{"value":"b","other":true}}
+
{"z":{"value":"a","other":true}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-backedge-phi-with-later-mutation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-backedge-phi-with-later-mutation.js new file mode 100644 index 0000000000000..042cae823f7f3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-backedge-phi-with-later-mutation.js @@ -0,0 +1,35 @@ +// @enableNewMutationAliasingModel +import {arrayPush, Stringify} from 'shared-runtime'; + +function Component({prop1, prop2}) { + 'use memo'; + + let x = [{value: prop1}]; + let z; + while (x.length < 2) { + // there's a phi here for x (value before the loop and the reassignment later) + + // this mutation occurs before the reassigned value + arrayPush(x, {value: prop2}); + + if (x[0].value === prop1) { + x = [{value: prop2}]; + const y = x; + z = y[0]; + } + } + z.other = true; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prop1: 0, prop2: 'a'}], + sequentialRenders: [ + {prop1: 0, prop2: 'a'}, + {prop1: 1, prop2: 'a'}, + {prop1: 1, prop2: 'b'}, + {prop1: 0, prop2: 'b'}, + {prop1: 0, prop2: 'a'}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.expect.md new file mode 100644 index 0000000000000..fe684586cbd33 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.expect.md @@ -0,0 +1,53 @@ + +## Input + +```javascript +function Component() { + let local; + + const reassignLocal = newValue => { + local = newValue; + }; + + const onClick = newValue => { + reassignLocal('hello'); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log('`local` was updated!'); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error('`local` not updated!'); + } + }; + + return ; +} + +``` + + +## Error + +``` + 3 | + 4 | const reassignLocal = newValue => { +> 5 | local = newValue; + | ^^^^^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `local` cannot be reassigned after render (5:5) + 6 | }; + 7 | + 8 | const onClick = newValue => { +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.js new file mode 100644 index 0000000000000..121495ac1e05c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.js @@ -0,0 +1,32 @@ +function Component() { + let local; + + const reassignLocal = newValue => { + local = newValue; + }; + + const onClick = newValue => { + reassignLocal('hello'); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log('`local` was updated!'); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error('`local` not updated!'); + } + }; + + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-useCallback-captures-reassigned-context.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-useCallback-captures-reassigned-context.expect.md new file mode 100644 index 0000000000000..498f3d8a0766f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-useCallback-captures-reassigned-context.expect.md @@ -0,0 +1,43 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees @enableNewMutationAliasingModel +import {useCallback} from 'react'; +import {makeArray} from 'shared-runtime'; + +// This case is already unsound in source, so we can safely bailout +function Foo(props) { + let x = []; + x.push(props); + + // makeArray() is captured, but depsList contains [props] + const cb = useCallback(() => [x], [x]); + + x = makeArray(); + + return cb; +} +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + + +## Error + +``` + 9 | + 10 | // makeArray() is captured, but depsList contains [props] +> 11 | const cb = useCallback(() => [x], [x]); + | ^ CannotPreserveMemoization: React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This dependency may be mutated later, which could cause the value to change unexpectedly (11:11) + +CannotPreserveMemoization: React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output. (11:11) + 12 | + 13 | x = makeArray(); + 14 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-useCallback-captures-reassigned-context.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-useCallback-captures-reassigned-context.js new file mode 100644 index 0000000000000..b9b914d30ec90 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-useCallback-captures-reassigned-context.js @@ -0,0 +1,20 @@ +// @validatePreserveExistingMemoizationGuarantees @enableNewMutationAliasingModel +import {useCallback} from 'react'; +import {makeArray} from 'shared-runtime'; + +// This case is already unsound in source, so we can safely bailout +function Foo(props) { + let x = []; + x.push(props); + + // makeArray() is captured, but depsList contains [props] + const cb = useCallback(() => [x], [x]); + + x = makeArray(); + + return cb; +} +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-frozen-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-frozen-value.expect.md new file mode 100644 index 0000000000000..de6370f367322 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-frozen-value.expect.md @@ -0,0 +1,28 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = {a}; + useFreeze(x); + x.y = true; + return
error
; +} + +``` + + +## Error + +``` + 3 | const x = {a}; + 4 | useFreeze(x); +> 5 | x.y = true; + | ^ InvalidReact: This mutates a variable that React considers immutable (5:5) + 6 | return
error
; + 7 | } + 8 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-frozen-value.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-frozen-value.js new file mode 100644 index 0000000000000..4964f2304912a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-frozen-value.js @@ -0,0 +1,7 @@ +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = {a}; + useFreeze(x); + x.y = true; + return
error
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/iife-return-modified-later-phi.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/iife-return-modified-later-phi.expect.md new file mode 100644 index 0000000000000..22f967883b09a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/iife-return-modified-later-phi.expect.md @@ -0,0 +1,58 @@ + +## Input + +```javascript +function Component(props) { + const items = (() => { + if (props.cond) { + return []; + } else { + return null; + } + })(); + items?.push(props.a); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: {}}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component(props) { + const $ = _c(3); + let items; + if ($[0] !== props.a || $[1] !== props.cond) { + let t0; + if (props.cond) { + t0 = []; + } else { + t0 = null; + } + items = t0; + + items?.push(props.a); + $[0] = props.a; + $[1] = props.cond; + $[2] = items; + } else { + items = $[2]; + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: {} }], +}; + +``` + +### Eval output +(kind: ok) null \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/iife-return-modified-later-phi.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/iife-return-modified-later-phi.js new file mode 100644 index 0000000000000..f4f953d294e6f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/iife-return-modified-later-phi.js @@ -0,0 +1,16 @@ +function Component(props) { + const items = (() => { + if (props.cond) { + return []; + } else { + return null; + } + })(); + items?.push(props.a); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: {}}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections-2.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections-2.expect.md new file mode 100644 index 0000000000000..013da083261e5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections-2.expect.md @@ -0,0 +1,67 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {Stringify} from 'shared-runtime'; + +function Component({a, b}) { + const x = {a, b}; + const f = () => { + const y = [x]; + return y[0]; + }; + const x0 = f(); + const z = [x0]; + const x1 = z[0]; + x1.key = 'value'; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 1}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +import { Stringify } from "shared-runtime"; + +function Component(t0) { + const $ = _c(3); + const { a, b } = t0; + let t1; + if ($[0] !== a || $[1] !== b) { + const x = { a, b }; + const f = () => { + const y = [x]; + return y[0]; + }; + + const x0 = f(); + const z = [x0]; + const x1 = z[0]; + x1.key = "value"; + t1 = ; + $[0] = a; + $[1] = b; + $[2] = t1; + } else { + t1 = $[2]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 1 }], +}; + +``` + +### Eval output +(kind: ok)
{"x":{"a":0,"b":1,"key":"value"}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections-2.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections-2.js new file mode 100644 index 0000000000000..6a981e840891c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections-2.js @@ -0,0 +1,20 @@ +// @enableNewMutationAliasingModel +import {Stringify} from 'shared-runtime'; + +function Component({a, b}) { + const x = {a, b}; + const f = () => { + const y = [x]; + return y[0]; + }; + const x0 = f(); + const z = [x0]; + const x1 = z[0]; + x1.key = 'value'; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 1}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections.expect.md new file mode 100644 index 0000000000000..f8ceba27158bd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections.expect.md @@ -0,0 +1,67 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {Stringify} from 'shared-runtime'; + +function Component({a, b}) { + const x = {a, b}; + const y = [x]; + const f = () => { + const x0 = y[0]; + return [x0]; + }; + const z = f(); + const x1 = z[0]; + x1.key = 'value'; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 1}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +import { Stringify } from "shared-runtime"; + +function Component(t0) { + const $ = _c(3); + const { a, b } = t0; + let t1; + if ($[0] !== a || $[1] !== b) { + const x = { a, b }; + const y = [x]; + const f = () => { + const x0 = y[0]; + return [x0]; + }; + + const z = f(); + const x1 = z[0]; + x1.key = "value"; + t1 = ; + $[0] = a; + $[1] = b; + $[2] = t1; + } else { + t1 = $[2]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 1 }], +}; + +``` + +### Eval output +(kind: ok)
{"x":{"a":0,"b":1,"key":"value"}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections.js new file mode 100644 index 0000000000000..aecd27a093094 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-function-call-indirections.js @@ -0,0 +1,20 @@ +// @enableNewMutationAliasingModel +import {Stringify} from 'shared-runtime'; + +function Component({a, b}) { + const x = {a, b}; + const y = [x]; + const f = () => { + const x0 = y[0]; + return [x0]; + }; + const z = f(); + const x1 = z[0]; + x1.key = 'value'; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 1}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-indirections.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-indirections.expect.md new file mode 100644 index 0000000000000..5f14dd1fe0770 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-indirections.expect.md @@ -0,0 +1,60 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {Stringify} from 'shared-runtime'; + +function Component({a, b}) { + const x = {a, b}; + const y = [x]; + const x0 = y[0]; + const z = [x0]; + const x1 = z[0]; + x1.key = 'value'; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 1}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +import { Stringify } from "shared-runtime"; + +function Component(t0) { + const $ = _c(3); + const { a, b } = t0; + let t1; + if ($[0] !== a || $[1] !== b) { + const x = { a, b }; + const y = [x]; + const x0 = y[0]; + const z = [x0]; + const x1 = z[0]; + x1.key = "value"; + t1 = ; + $[0] = a; + $[1] = b; + $[2] = t1; + } else { + t1 = $[2]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 1 }], +}; + +``` + +### Eval output +(kind: ok)
{"x":{"a":0,"b":1,"key":"value"}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-indirections.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-indirections.js new file mode 100644 index 0000000000000..ba8808eedfe8c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-boxing-unboxing-indirections.js @@ -0,0 +1,17 @@ +// @enableNewMutationAliasingModel +import {Stringify} from 'shared-runtime'; + +function Component({a, b}) { + const x = {a, b}; + const y = [x]; + const x0 = y[0]; + const z = [x0]; + const x1 = z[0]; + x1.key = 'value'; + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 1}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-propertyload.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-propertyload.expect.md new file mode 100644 index 0000000000000..34345951ed7fa --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-propertyload.expect.md @@ -0,0 +1,39 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = {}; + const y = {x}; + const z = y.x; + z.true = false; + return
{z}
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function Component(t0) { + const $ = _c(1); + let t1; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const x = {}; + const y = { x }; + const z = y.x; + z.true = false; + t1 =
{z}
; + $[0] = t1; + } else { + t1 = $[0]; + } + return t1; +} + +``` + +### 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/new-mutability/mutate-through-propertyload.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-propertyload.js new file mode 100644 index 0000000000000..bff1ea4c35046 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-propertyload.js @@ -0,0 +1,8 @@ +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = {}; + const y = {x}; + const z = y.x; + z.true = false; + return
{z}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/nullable-objects-assume-invoked-direct-call.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/nullable-objects-assume-invoked-direct-call.expect.md new file mode 100644 index 0000000000000..5033da8eac440 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/nullable-objects-assume-invoked-direct-call.expect.md @@ -0,0 +1,75 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {useState} from 'react'; +import {useIdentity} from 'shared-runtime'; + +function useMakeCallback({obj}: {obj: {value: number}}) { + const [state, setState] = useState(0); + const cb = () => { + if (obj.value !== state) setState(obj.value); + }; + useIdentity(); + cb(); + return [cb]; +} +export const FIXTURE_ENTRYPOINT = { + fn: useMakeCallback, + params: [{obj: {value: 1}}], + sequentialRenders: [{obj: {value: 1}}, {obj: {value: 2}}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +import { useState } from "react"; +import { useIdentity } from "shared-runtime"; + +function useMakeCallback(t0) { + const $ = _c(5); + const { obj } = t0; + const [state, setState] = useState(0); + let t1; + if ($[0] !== obj.value || $[1] !== state) { + t1 = () => { + if (obj.value !== state) { + setState(obj.value); + } + }; + $[0] = obj.value; + $[1] = state; + $[2] = t1; + } else { + t1 = $[2]; + } + const cb = t1; + + useIdentity(); + cb(); + let t2; + if ($[3] !== cb) { + t2 = [cb]; + $[3] = cb; + $[4] = t2; + } else { + t2 = $[4]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useMakeCallback, + params: [{ obj: { value: 1 } }], + sequentialRenders: [{ obj: { value: 1 } }, { obj: { value: 2 } }], +}; + +``` + +### Eval output +(kind: ok) ["[[ function params=0 ]]"] +["[[ function params=0 ]]"] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/nullable-objects-assume-invoked-direct-call.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/nullable-objects-assume-invoked-direct-call.js new file mode 100644 index 0000000000000..1f2d69d93193f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/nullable-objects-assume-invoked-direct-call.js @@ -0,0 +1,18 @@ +// @enableNewMutationAliasingModel +import {useState} from 'react'; +import {useIdentity} from 'shared-runtime'; + +function useMakeCallback({obj}: {obj: {value: number}}) { + const [state, setState] = useState(0); + const cb = () => { + if (obj.value !== state) setState(obj.value); + }; + useIdentity(); + cb(); + return [cb]; +} +export const FIXTURE_ENTRYPOINT = { + fn: useMakeCallback, + params: [{obj: {value: 1}}], + sequentialRenders: [{obj: {value: 1}}, {obj: {value: 2}}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/potential-mutation-in-function-expression.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/potential-mutation-in-function-expression.expect.md new file mode 100644 index 0000000000000..a5cfc790ebc06 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/potential-mutation-in-function-expression.expect.md @@ -0,0 +1,64 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component({a, b, c}) { + const x = [a, b]; + const f = () => { + maybeMutate(x); + // different dependency to force this not to merge with x's scope + console.log(c); + }; + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function Component(t0) { + const $ = _c(9); + const { a, b, c } = t0; + let t1; + if ($[0] !== a || $[1] !== b) { + t1 = [a, b]; + $[0] = a; + $[1] = b; + $[2] = t1; + } else { + t1 = $[2]; + } + const x = t1; + let t2; + if ($[3] !== c || $[4] !== x) { + t2 = () => { + maybeMutate(x); + + console.log(c); + }; + $[3] = c; + $[4] = x; + $[5] = t2; + } else { + t2 = $[5]; + } + const f = t2; + let t3; + if ($[6] !== f || $[7] !== x) { + t3 = ; + $[6] = f; + $[7] = x; + $[8] = t3; + } else { + t3 = $[8]; + } + return t3; +} + +``` + +### 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/new-mutability/potential-mutation-in-function-expression.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/potential-mutation-in-function-expression.js new file mode 100644 index 0000000000000..096f4f17ea545 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/potential-mutation-in-function-expression.js @@ -0,0 +1,10 @@ +// @enableNewMutationAliasingModel +function Component({a, b, c}) { + const x = [a, b]; + const f = () => { + maybeMutate(x); + // different dependency to force this not to merge with x's scope + console.log(c); + }; + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-ref.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-ref.expect.md new file mode 100644 index 0000000000000..26757db1a3c28 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-ref.expect.md @@ -0,0 +1,54 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function ReactiveRefInEffect(props) { + const ref1 = useRef('initial value'); + const ref2 = useRef('initial value'); + let ref; + if (props.foo) { + ref = ref1; + } else { + ref = ref2; + } + useEffect(() => print(ref)); +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function ReactiveRefInEffect(props) { + const $ = _c(4); + const ref1 = useRef("initial value"); + const ref2 = useRef("initial value"); + let ref; + if ($[0] !== props.foo) { + if (props.foo) { + ref = ref1; + } else { + ref = ref2; + } + $[0] = props.foo; + $[1] = ref; + } else { + ref = $[1]; + } + let t0; + if ($[2] !== ref) { + t0 = () => print(ref); + $[2] = ref; + $[3] = t0; + } else { + t0 = $[3]; + } + useEffect(t0); +} + +``` + +### 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/new-mutability/reactive-ref.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-ref.js new file mode 100644 index 0000000000000..3ae653c962034 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-ref.js @@ -0,0 +1,12 @@ +// @enableNewMutationAliasingModel +function ReactiveRefInEffect(props) { + const ref1 = useRef('initial value'); + const ref2 = useRef('initial value'); + let ref; + if (props.foo) { + ref = ref1; + } else { + ref = ref2; + } + useEffect(() => print(ref)); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/set-add-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/set-add-mutate.expect.md new file mode 100644 index 0000000000000..955c4e0705797 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/set-add-mutate.expect.md @@ -0,0 +1,54 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function useHook({el1, el2}) { + const s = new Set(); + const arr = makeArray(el1); + s.add(arr); + // Mutate after store + arr.push(el2); + + s.add(makeArray(el2)); + return s.size; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function useHook(t0) { + const $ = _c(5); + const { el1, el2 } = t0; + let s; + if ($[0] !== el1 || $[1] !== el2) { + s = new Set(); + const arr = makeArray(el1); + s.add(arr); + + arr.push(el2); + let t1; + if ($[3] !== el2) { + t1 = makeArray(el2); + $[3] = el2; + $[4] = t1; + } else { + t1 = $[4]; + } + s.add(t1); + $[0] = el1; + $[1] = el2; + $[2] = s; + } else { + s = $[2]; + } + return s.size; +} + +``` + +### 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/new-mutability/set-add-mutate.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/set-add-mutate.js new file mode 100644 index 0000000000000..3afbd93f84b17 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/set-add-mutate.js @@ -0,0 +1,11 @@ +// @enableNewMutationAliasingModel +function useHook({el1, el2}) { + const s = new Set(); + const arr = makeArray(el1); + s.add(arr); + // Mutate after store + arr.push(el2); + + s.add(makeArray(el2)); + return s.size; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/ssa-renaming-ternary-destruction.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/ssa-renaming-ternary-destruction.expect.md new file mode 100644 index 0000000000000..4c04ae197292f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/ssa-renaming-ternary-destruction.expect.md @@ -0,0 +1,70 @@ + +## Input + +```javascript +// @enablePropagateDepsInHIR @enableNewMutationAliasingModel +function useFoo(props) { + let x = []; + x.push(props.bar); + // todo: the below should memoize separately from the above + // my guess is that the phi causes the different `x` identifiers + // to get added to an alias group. this is where we need to track + // the actual state of the alias groups at the time of the mutation + props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR @enableNewMutationAliasingModel +function useFoo(props) { + const $ = _c(5); + let x; + if ($[0] !== props.bar) { + x = []; + x.push(props.bar); + $[0] = props.bar; + $[1] = x; + } else { + x = $[1]; + } + if ($[2] !== props.cond || $[3] !== props.foo) { + props.cond ? (([x] = [[]]), x.push(props.foo)) : null; + $[2] = props.cond; + $[3] = props.foo; + $[4] = x; + } else { + x = $[4]; + } + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: false, foo: 2, bar: 55 }], + sequentialRenders: [ + { cond: false, foo: 2, bar: 55 }, + { cond: false, foo: 3, bar: 55 }, + { cond: true, foo: 3, bar: 55 }, + ], +}; + +``` + +### Eval output +(kind: ok) [55] +[55] +[3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/ssa-renaming-ternary-destruction.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/ssa-renaming-ternary-destruction.js new file mode 100644 index 0000000000000..923d0b59bb810 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/ssa-renaming-ternary-destruction.js @@ -0,0 +1,21 @@ +// @enablePropagateDepsInHIR @enableNewMutationAliasingModel +function useFoo(props) { + let x = []; + x.push(props.bar); + // todo: the below should memoize separately from the above + // my guess is that the phi causes the different `x` identifiers + // to get added to an alias group. this is where we need to track + // the actual state of the alias groups at the time of the mutation + props.cond ? (({x} = {x: {}}), ([x] = [[]]), x.push(props.foo)) : null; + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{cond: false, foo: 2, bar: 55}], + sequentialRenders: [ + {cond: false, foo: 2, bar: 55}, + {cond: false, foo: 3, bar: 55}, + {cond: true, foo: 3, bar: 55}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitive-mutation-before-capturing-value-created-earlier.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitive-mutation-before-capturing-value-created-earlier.expect.md new file mode 100644 index 0000000000000..09c4e3eaf3332 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitive-mutation-before-capturing-value-created-earlier.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = [a]; + const y = {b}; + mutate(y); + y.x = x; + return
{y}
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +function Component(t0) { + const $ = _c(5); + const { a, b } = t0; + let t1; + if ($[0] !== a) { + t1 = [a]; + $[0] = a; + $[1] = t1; + } else { + t1 = $[1]; + } + const x = t1; + let t2; + if ($[2] !== b || $[3] !== x) { + const y = { b }; + mutate(y); + y.x = x; + t2 =
{y}
; + $[2] = b; + $[3] = x; + $[4] = t2; + } else { + t2 = $[4]; + } + return t2; +} + +``` + +### 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/new-mutability/transitive-mutation-before-capturing-value-created-earlier.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitive-mutation-before-capturing-value-created-earlier.js new file mode 100644 index 0000000000000..e6e2e17bc0b96 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitive-mutation-before-capturing-value-created-earlier.js @@ -0,0 +1,8 @@ +// @enableNewMutationAliasingModel +function Component({a, b}) { + const x = [a]; + const y = {b}; + mutate(y); + y.x = x; + return
{y}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-access-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-access-assignment.expect.md new file mode 100644 index 0000000000000..8b4dbc8f863d6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-access-assignment.expect.md @@ -0,0 +1,83 @@ + +## Input + +```javascript +function Component({a, b, c}) { + // This is an object version of array-access-assignment.js + // Meant to confirm that object expressions and PropertyStore/PropertyLoad with strings + // works equivalently to array expressions and property accesses with numeric indices + const x = {zero: a}; + const y = {zero: null, one: b}; + const z = {zero: {}, one: {}, two: {zero: c}}; + x.zero = y.one; + z.zero.zero = x.zero; + return {zero: x, one: z}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 1, b: 20, c: 300}], + sequentialRenders: [ + {a: 2, b: 20, c: 300}, + {a: 3, b: 20, c: 300}, + {a: 3, b: 21, c: 300}, + {a: 3, b: 22, c: 300}, + {a: 3, b: 22, c: 301}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component(t0) { + const $ = _c(6); + const { a, b, c } = t0; + let t1; + if ($[0] !== a || $[1] !== b || $[2] !== c) { + const x = { zero: a }; + let t2; + if ($[4] !== b) { + t2 = { zero: null, one: b }; + $[4] = b; + $[5] = t2; + } else { + t2 = $[5]; + } + const y = t2; + const z = { zero: {}, one: {}, two: { zero: c } }; + x.zero = y.one; + z.zero.zero = x.zero; + t1 = { zero: x, one: z }; + $[0] = a; + $[1] = b; + $[2] = c; + $[3] = t1; + } else { + t1 = $[3]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 1, b: 20, c: 300 }], + sequentialRenders: [ + { a: 2, b: 20, c: 300 }, + { a: 3, b: 20, c: 300 }, + { a: 3, b: 21, c: 300 }, + { a: 3, b: 22, c: 300 }, + { a: 3, b: 22, c: 301 }, + ], +}; + +``` + +### Eval output +(kind: ok) {"zero":{"zero":20},"one":{"zero":{"zero":20},"one":{},"two":{"zero":300}}} +{"zero":{"zero":20},"one":{"zero":{"zero":20},"one":{},"two":{"zero":300}}} +{"zero":{"zero":21},"one":{"zero":{"zero":21},"one":{},"two":{"zero":300}}} +{"zero":{"zero":22},"one":{"zero":{"zero":22},"one":{},"two":{"zero":300}}} +{"zero":{"zero":22},"one":{"zero":{"zero":22},"one":{},"two":{"zero":301}}} \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-access-assignment.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-access-assignment.js new file mode 100644 index 0000000000000..ef047238e7f84 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-access-assignment.js @@ -0,0 +1,23 @@ +function Component({a, b, c}) { + // This is an object version of array-access-assignment.js + // Meant to confirm that object expressions and PropertyStore/PropertyLoad with strings + // works equivalently to array expressions and property accesses with numeric indices + const x = {zero: a}; + const y = {zero: null, one: b}; + const z = {zero: {}, one: {}, two: {zero: c}}; + x.zero = y.one; + z.zero.zero = x.zero; + return {zero: x, one: z}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 1, b: 20, c: 300}], + sequentialRenders: [ + {a: 2, b: 20, c: 300}, + {a: 3, b: 20, c: 300}, + {a: 3, b: 21, c: 300}, + {a: 3, b: 22, c: 300}, + {a: 3, b: 22, c: 301}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-aliased-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-aliased-mutate.expect.md new file mode 100644 index 0000000000000..5a866044bde26 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-aliased-mutate.expect.md @@ -0,0 +1,104 @@ + +## Input + +```javascript +// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel +import {arrayPush, setPropertyByKey, Stringify} from 'shared-runtime'; + +/** + * Repro of a bug fixed in the new aliasing model. + * + * 1. `InferMutableRanges` derives the mutable range of identifiers and their + * aliases from `LoadLocal`, `PropertyLoad`, etc + * - After this pass, y's mutable range only extends to `arrayPush(x, y)` + * - We avoid assigning mutable ranges to loads after y's mutable range, as + * these are working with an immutable value. As a result, `LoadLocal y` and + * `PropertyLoad y` do not get mutable ranges + * 2. `InferReactiveScopeVariables` extends mutable ranges and creates scopes, + * as according to the 'co-mutation' of different values + * - Here, we infer that + * - `arrayPush(y, x)` might alias `x` and `y` to each other + * - `setPropertyKey(x, ...)` may mutate both `x` and `y` + * - This pass correctly extends the mutable range of `y` + * - Since we didn't run `InferMutableRange` logic again, the LoadLocal / + * PropertyLoads still don't have a mutable range + * + * Note that the this bug is an edge case. Compiler output is only invalid for: + * - function expressions with + * `enableTransitivelyFreezeFunctionExpressions:false` + * - functions that throw and get retried without clearing the memocache + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) + *
{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}
+ *
{"cb":{"kind":"Function","result":11},"shouldInvokeFns":true}
+ * Forget: + * (kind: ok) + *
{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}
+ *
{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}
+ */ +function useFoo({a, b}: {a: number, b: number}) { + const x = []; + const y = {value: a}; + + arrayPush(x, y); // x and y co-mutate + const y_alias = y; + const cb = () => y_alias.value; + setPropertyByKey(x[0], 'value', b); // might overwrite y.value + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{a: 2, b: 10}], + sequentialRenders: [ + {a: 2, b: 10}, + {a: 2, b: 11}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { arrayPush, setPropertyByKey, Stringify } from "shared-runtime"; + +function useFoo(t0) { + const $ = _c(3); + const { a, b } = t0; + let t1; + if ($[0] !== a || $[1] !== b) { + const x = []; + const y = { value: a }; + + arrayPush(x, y); + const y_alias = y; + const cb = () => y_alias.value; + setPropertyByKey(x[0], "value", b); + t1 = ; + $[0] = a; + $[1] = b; + $[2] = t1; + } else { + t1 = $[2]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ a: 2, b: 10 }], + sequentialRenders: [ + { a: 2, b: 10 }, + { a: 2, b: 11 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}
+
{"cb":{"kind":"Function","result":11},"shouldInvokeFns":true}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-aliased-mutate.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-aliased-mutate.js new file mode 100644 index 0000000000000..df9e294261e3e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-aliased-mutate.js @@ -0,0 +1,55 @@ +// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel +import {arrayPush, setPropertyByKey, Stringify} from 'shared-runtime'; + +/** + * Repro of a bug fixed in the new aliasing model. + * + * 1. `InferMutableRanges` derives the mutable range of identifiers and their + * aliases from `LoadLocal`, `PropertyLoad`, etc + * - After this pass, y's mutable range only extends to `arrayPush(x, y)` + * - We avoid assigning mutable ranges to loads after y's mutable range, as + * these are working with an immutable value. As a result, `LoadLocal y` and + * `PropertyLoad y` do not get mutable ranges + * 2. `InferReactiveScopeVariables` extends mutable ranges and creates scopes, + * as according to the 'co-mutation' of different values + * - Here, we infer that + * - `arrayPush(y, x)` might alias `x` and `y` to each other + * - `setPropertyKey(x, ...)` may mutate both `x` and `y` + * - This pass correctly extends the mutable range of `y` + * - Since we didn't run `InferMutableRange` logic again, the LoadLocal / + * PropertyLoads still don't have a mutable range + * + * Note that the this bug is an edge case. Compiler output is only invalid for: + * - function expressions with + * `enableTransitivelyFreezeFunctionExpressions:false` + * - functions that throw and get retried without clearing the memocache + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) + *
{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}
+ *
{"cb":{"kind":"Function","result":11},"shouldInvokeFns":true}
+ * Forget: + * (kind: ok) + *
{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}
+ *
{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}
+ */ +function useFoo({a, b}: {a: number, b: number}) { + const x = []; + const y = {value: a}; + + arrayPush(x, y); // x and y co-mutate + const y_alias = y; + const cb = () => y_alias.value; + setPropertyByKey(x[0], 'value', b); // might overwrite y.value + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{a: 2, b: 10}], + sequentialRenders: [ + {a: 2, b: 10}, + {a: 2, b: 11}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-mutate.expect.md new file mode 100644 index 0000000000000..1427ec8eb503d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-mutate.expect.md @@ -0,0 +1,84 @@ + +## Input + +```javascript +// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel +import {setPropertyByKey, Stringify} from 'shared-runtime'; + +/** + * Variation of bug in `bug-aliased-capture-aliased-mutate`. + * Fixed in the new inference model. + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) + *
{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}
+ *
{"cb":{"kind":"Function","result":3},"shouldInvokeFns":true}
+ * Forget: + * (kind: ok) + *
{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}
+ *
{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}
+ */ + +function useFoo({a}: {a: number, b: number}) { + const arr = []; + const obj = {value: a}; + + setPropertyByKey(obj, 'arr', arr); + const obj_alias = obj; + const cb = () => obj_alias.arr.length; + for (let i = 0; i < a; i++) { + arr.push(i); + } + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{a: 2}], + sequentialRenders: [{a: 2}, {a: 3}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { setPropertyByKey, Stringify } from "shared-runtime"; + +function useFoo(t0) { + const $ = _c(2); + const { a } = t0; + let t1; + if ($[0] !== a) { + const arr = []; + const obj = { value: a }; + + setPropertyByKey(obj, "arr", arr); + const obj_alias = obj; + const cb = () => obj_alias.arr.length; + for (let i = 0; i < a; i++) { + arr.push(i); + } + + t1 = ; + $[0] = a; + $[1] = t1; + } else { + t1 = $[1]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ a: 2 }], + sequentialRenders: [{ a: 2 }, { a: 3 }], +}; + +``` + +### Eval output +(kind: ok)
{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}
+
{"cb":{"kind":"Function","result":3},"shouldInvokeFns":true}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-mutate.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-mutate.js new file mode 100644 index 0000000000000..2ed6941fa7f39 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-aliased-capture-mutate.js @@ -0,0 +1,36 @@ +// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel +import {setPropertyByKey, Stringify} from 'shared-runtime'; + +/** + * Variation of bug in `bug-aliased-capture-aliased-mutate`. + * Fixed in the new inference model. + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) + *
{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}
+ *
{"cb":{"kind":"Function","result":3},"shouldInvokeFns":true}
+ * Forget: + * (kind: ok) + *
{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}
+ *
{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}
+ */ + +function useFoo({a}: {a: number, b: number}) { + const arr = []; + const obj = {value: a}; + + setPropertyByKey(obj, 'arr', arr); + const obj_alias = obj; + const cb = () => obj_alias.arr.length; + for (let i = 0; i < a; i++) { + arr.push(i); + } + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{a: 2}], + sequentialRenders: [{a: 2}, {a: 3}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-capturing-func-maybealias-captured-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-capturing-func-maybealias-captured-mutate.expect.md new file mode 100644 index 0000000000000..f6b7ef3b43d5a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-capturing-func-maybealias-captured-mutate.expect.md @@ -0,0 +1,111 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {makeArray, mutate} from 'shared-runtime'; + +/** + * Bug repro, fixed in the new mutability/aliasing inference. + * + * Previous issue: + * + * Fork of `capturing-func-alias-captured-mutate`, but instead of directly + * aliasing `y` via `[y]`, we make an opaque call. + * + * Note that the bug here is that we don't infer that `a = makeArray(y)` + * potentially captures a context variable into a local variable. As a result, + * we don't understand that `a[0].x = b` captures `x` into `y` -- instead, we're + * currently inferring that this lambda captures `y` (for a potential later + * mutation) and simply reads `x`. + * + * Concretely `InferReferenceEffects.hasContextRefOperand` is incorrectly not + * used when we analyze CallExpressions. + */ +function Component({foo, bar}: {foo: number; bar: number}) { + let x = {foo}; + let y: {bar: number; x?: {foo: number}} = {bar}; + const f0 = function () { + let a = makeArray(y); // a = [y] + let b = x; + // this writes y.x = x + a[0].x = b; + }; + f0(); + mutate(y.x); + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 3, bar: 4}], + sequentialRenders: [ + {foo: 3, bar: 4}, + {foo: 3, bar: 5}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +import { makeArray, mutate } from "shared-runtime"; + +/** + * Bug repro, fixed in the new mutability/aliasing inference. + * + * Previous issue: + * + * Fork of `capturing-func-alias-captured-mutate`, but instead of directly + * aliasing `y` via `[y]`, we make an opaque call. + * + * Note that the bug here is that we don't infer that `a = makeArray(y)` + * potentially captures a context variable into a local variable. As a result, + * we don't understand that `a[0].x = b` captures `x` into `y` -- instead, we're + * currently inferring that this lambda captures `y` (for a potential later + * mutation) and simply reads `x`. + * + * Concretely `InferReferenceEffects.hasContextRefOperand` is incorrectly not + * used when we analyze CallExpressions. + */ +function Component(t0) { + const $ = _c(3); + const { foo, bar } = t0; + let y; + if ($[0] !== bar || $[1] !== foo) { + const x = { foo }; + y = { bar }; + const f0 = function () { + const a = makeArray(y); + const b = x; + + a[0].x = b; + }; + + f0(); + mutate(y.x); + $[0] = bar; + $[1] = foo; + $[2] = y; + } else { + y = $[2]; + } + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ foo: 3, bar: 4 }], + sequentialRenders: [ + { foo: 3, bar: 4 }, + { foo: 3, bar: 5 }, + ], +}; + +``` + +### Eval output +(kind: ok) {"bar":4,"x":{"foo":3,"wat0":"joe"}} +{"bar":5,"x":{"foo":3,"wat0":"joe"}} \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-capturing-func-maybealias-captured-mutate.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-capturing-func-maybealias-captured-mutate.ts new file mode 100644 index 0000000000000..8b7bdeb79bee2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-capturing-func-maybealias-captured-mutate.ts @@ -0,0 +1,42 @@ +// @enableNewMutationAliasingModel +import {makeArray, mutate} from 'shared-runtime'; + +/** + * Bug repro, fixed in the new mutability/aliasing inference. + * + * Previous issue: + * + * Fork of `capturing-func-alias-captured-mutate`, but instead of directly + * aliasing `y` via `[y]`, we make an opaque call. + * + * Note that the bug here is that we don't infer that `a = makeArray(y)` + * potentially captures a context variable into a local variable. As a result, + * we don't understand that `a[0].x = b` captures `x` into `y` -- instead, we're + * currently inferring that this lambda captures `y` (for a potential later + * mutation) and simply reads `x`. + * + * Concretely `InferReferenceEffects.hasContextRefOperand` is incorrectly not + * used when we analyze CallExpressions. + */ +function Component({foo, bar}: {foo: number; bar: number}) { + let x = {foo}; + let y: {bar: number; x?: {foo: number}} = {bar}; + const f0 = function () { + let a = makeArray(y); // a = [y] + let b = x; + // this writes y.x = x + a[0].x = b; + }; + f0(); + mutate(y.x); + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 3, bar: 4}], + sequentialRenders: [ + {foo: 3, bar: 4}, + {foo: 3, bar: 5}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-false-positive-ref-validation-in-use-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-false-positive-ref-validation-in-use-effect.expect.md new file mode 100644 index 0000000000000..3896e6a2f2e6c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-false-positive-ref-validation-in-use-effect.expect.md @@ -0,0 +1,88 @@ + +## Input + +```javascript +// @validateNoFreezingKnownMutableFunctions @enableNewMutationAliasingModel +import {useCallback, useEffect, useRef} from 'react'; +import {useHook} from 'shared-runtime'; + +// This was a false positive "can't freeze mutable function" in the old +// inference model, fixed in the new inference model. +function Component() { + const params = useHook(); + const update = useCallback( + partialParams => { + const nextParams = { + ...params, + ...partialParams, + }; + nextParams.param = 'value'; + console.log(nextParams); + }, + [params] + ); + const ref = useRef(null); + useEffect(() => { + if (ref.current === null) { + update(); + } + }, [update]); + + return 'ok'; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validateNoFreezingKnownMutableFunctions @enableNewMutationAliasingModel +import { useCallback, useEffect, useRef } from "react"; +import { useHook } from "shared-runtime"; + +// This was a false positive "can't freeze mutable function" in the old +// inference model, fixed in the new inference model. +function Component() { + const $ = _c(5); + const params = useHook(); + let t0; + if ($[0] !== params) { + t0 = (partialParams) => { + const nextParams = { ...params, ...partialParams }; + + nextParams.param = "value"; + console.log(nextParams); + }; + $[0] = params; + $[1] = t0; + } else { + t0 = $[1]; + } + const update = t0; + + const ref = useRef(null); + let t1; + let t2; + if ($[2] !== update) { + t1 = () => { + if (ref.current === null) { + update(); + } + }; + + t2 = [update]; + $[2] = update; + $[3] = t1; + $[4] = t2; + } else { + t1 = $[3]; + t2 = $[4]; + } + useEffect(t1, t2); + return "ok"; +} + +``` + +### 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/repro-false-positive-ref-validation-in-use-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-false-positive-ref-validation-in-use-effect.js new file mode 100644 index 0000000000000..3ecfcca9c7410 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-false-positive-ref-validation-in-use-effect.js @@ -0,0 +1,28 @@ +// @validateNoFreezingKnownMutableFunctions @enableNewMutationAliasingModel +import {useCallback, useEffect, useRef} from 'react'; +import {useHook} from 'shared-runtime'; + +// This was a false positive "can't freeze mutable function" in the old +// inference model, fixed in the new inference model. +function Component() { + const params = useHook(); + const update = useCallback( + partialParams => { + const nextParams = { + ...params, + ...partialParams, + }; + nextParams.param = 'value'; + console.log(nextParams); + }, + [params] + ); + const ref = useRef(null); + useEffect(() => { + if (ref.current === null) { + update(); + } + }, [update]); + + return 'ok'; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-invalid-phi-as-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-invalid-phi-as-dependency.expect.md new file mode 100644 index 0000000000000..65ff18b65ec0d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-invalid-phi-as-dependency.expect.md @@ -0,0 +1,80 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {CONST_TRUE, Stringify, mutate, useIdentity} from 'shared-runtime'; + +/** + * Fixture showing an edge case for ReactiveScope variable propagation. + * Fixed in the new inference model + * + * Found differences in evaluator results + * Non-forget (expected): + *
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+ *
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+ * Forget: + *
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+ * [[ (exception in render) Error: invariant broken ]] + * + */ +function Component() { + const obj = CONST_TRUE ? {inner: {value: 'hello'}} : null; + const boxedInner = [obj?.inner]; + useIdentity(null); + mutate(obj); + if (boxedInner[0] !== obj?.inner) { + throw new Error('invariant broken'); + } + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{arg: 0}], + sequentialRenders: [{arg: 0}, {arg: 1}], +}; + +``` + +## Code + +```javascript +// @enableNewMutationAliasingModel +import { CONST_TRUE, Stringify, mutate, useIdentity } from "shared-runtime"; + +/** + * Fixture showing an edge case for ReactiveScope variable propagation. + * Fixed in the new inference model + * + * Found differences in evaluator results + * Non-forget (expected): + *
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+ *
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+ * Forget: + *
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+ * [[ (exception in render) Error: invariant broken ]] + * + */ +function Component() { + const obj = CONST_TRUE ? { inner: { value: "hello" } } : null; + const boxedInner = [obj?.inner]; + useIdentity(null); + mutate(obj); + if (boxedInner[0] !== obj?.inner) { + throw new Error("invariant broken"); + } + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ arg: 0 }], + sequentialRenders: [{ arg: 0 }, { arg: 1 }], +}; + +``` + +### Eval output +(kind: ok)
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-invalid-phi-as-dependency.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-invalid-phi-as-dependency.tsx new file mode 100644 index 0000000000000..23c1a070104b2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-invalid-phi-as-dependency.tsx @@ -0,0 +1,32 @@ +// @enableNewMutationAliasingModel +import {CONST_TRUE, Stringify, mutate, useIdentity} from 'shared-runtime'; + +/** + * Fixture showing an edge case for ReactiveScope variable propagation. + * Fixed in the new inference model + * + * Found differences in evaluator results + * Non-forget (expected): + *
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+ *
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+ * Forget: + *
{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}
+ * [[ (exception in render) Error: invariant broken ]] + * + */ +function Component() { + const obj = CONST_TRUE ? {inner: {value: 'hello'}} : null; + const boxedInner = [obj?.inner]; + useIdentity(null); + mutate(obj); + if (boxedInner[0] !== obj?.inner) { + throw new Error('invariant broken'); + } + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{arg: 0}], + sequentialRenders: [{arg: 0}, {arg: 1}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.expect.md new file mode 100644 index 0000000000000..6a9225eb772ba --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.expect.md @@ -0,0 +1,91 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {identity, mutate} from 'shared-runtime'; + +/** + * Fixed in the new inference model. + * + * Bug: copy of error.todo-object-expression-computed-key-modified-during-after-construction-sequence-expr + * with the mutation hoisted to a named variable instead of being directly + * inlined into the Object key. + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] + * [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] + * Forget: + * (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] + * [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe","wat2":"joe"}] + */ +function Component(props) { + const key = {}; + const tmp = (mutate(key), key); + const context = { + // Here, `tmp` is frozen (as it's inferred to be a primitive/string) + [tmp]: identity([props.value]), + }; + mutate(key); + return [context, key]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], + sequentialRenders: [{value: 42}, {value: 42}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +import { identity, mutate } from "shared-runtime"; + +/** + * Fixed in the new inference model. + * + * Bug: copy of error.todo-object-expression-computed-key-modified-during-after-construction-sequence-expr + * with the mutation hoisted to a named variable instead of being directly + * inlined into the Object key. + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] + * [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] + * Forget: + * (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] + * [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe","wat2":"joe"}] + */ +function Component(props) { + const $ = _c(2); + let t0; + if ($[0] !== props.value) { + const key = {}; + const tmp = (mutate(key), key); + const context = { [tmp]: identity([props.value]) }; + + mutate(key); + t0 = [context, key]; + $[0] = props.value; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: 42 }], + sequentialRenders: [{ value: 42 }, { value: 42 }], +}; + +``` + +### Eval output +(kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] +[{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.js new file mode 100644 index 0000000000000..71abb3bc497fd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.js @@ -0,0 +1,34 @@ +// @enableNewMutationAliasingModel +import {identity, mutate} from 'shared-runtime'; + +/** + * Fixed in the new inference model. + * + * Bug: copy of error.todo-object-expression-computed-key-modified-during-after-construction-sequence-expr + * with the mutation hoisted to a named variable instead of being directly + * inlined into the Object key. + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] + * [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] + * Forget: + * (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}] + * [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe","wat2":"joe"}] + */ +function Component(props) { + const key = {}; + const tmp = (mutate(key), key); + const context = { + // Here, `tmp` is frozen (as it's inferred to be a primitive/string) + [tmp]: identity([props.value]), + }; + mutate(key); + return [context, key]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], + sequentialRenders: [{value: 42}, {value: 42}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-separate-memoization-due-to-callback-capturing.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-separate-memoization-due-to-callback-capturing.expect.md new file mode 100644 index 0000000000000..434cbaa908d78 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-separate-memoization-due-to-callback-capturing.expect.md @@ -0,0 +1,149 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +import {ValidateMemoization} from 'shared-runtime'; + +const Codes = { + en: {name: 'English'}, + ja: {name: 'Japanese'}, + ko: {name: 'Korean'}, + zh: {name: 'Chinese'}, +}; + +function Component(a) { + let keys; + if (a) { + keys = Object.keys(Codes); + } else { + return null; + } + const options = keys.map(code => { + // In the old inference model, `keys` was assumed to be mutated bc + // this callback captures its input into its output, and the return + // is treated as a mutation since it's a function expression. The new + // model understands that `code` is captured but not mutated. + const country = Codes[code]; + return { + name: country.name, + code, + }; + }); + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: false}], + sequentialRenders: [ + {a: false}, + {a: true}, + {a: true}, + {a: false}, + {a: true}, + {a: false}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel +import { ValidateMemoization } from "shared-runtime"; + +const Codes = { + en: { name: "English" }, + ja: { name: "Japanese" }, + ko: { name: "Korean" }, + zh: { name: "Chinese" }, +}; + +function Component(a) { + const $ = _c(4); + let keys; + if (a) { + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = Object.keys(Codes); + $[0] = t0; + } else { + t0 = $[0]; + } + keys = t0; + } else { + return null; + } + let t0; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t0 = keys.map(_temp); + $[1] = t0; + } else { + t0 = $[1]; + } + const options = t0; + let t1; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t1 = ( + + ); + $[2] = t1; + } else { + t1 = $[2]; + } + let t2; + if ($[3] === Symbol.for("react.memo_cache_sentinel")) { + t2 = ( + <> + {t1} + + + ); + $[3] = t2; + } else { + t2 = $[3]; + } + return t2; +} +function _temp(code) { + const country = Codes[code]; + return { name: country.name, code }; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: false }], + sequentialRenders: [ + { a: false }, + { a: true }, + { a: true }, + { a: false }, + { a: true }, + { a: false }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[],"output":["en","ja","ko","zh"]}
{"inputs":[],"output":[{"name":"English","code":"en"},{"name":"Japanese","code":"ja"},{"name":"Korean","code":"ko"},{"name":"Chinese","code":"zh"}]}
+
{"inputs":[],"output":["en","ja","ko","zh"]}
{"inputs":[],"output":[{"name":"English","code":"en"},{"name":"Japanese","code":"ja"},{"name":"Korean","code":"ko"},{"name":"Chinese","code":"zh"}]}
+
{"inputs":[],"output":["en","ja","ko","zh"]}
{"inputs":[],"output":[{"name":"English","code":"en"},{"name":"Japanese","code":"ja"},{"name":"Korean","code":"ko"},{"name":"Chinese","code":"zh"}]}
+
{"inputs":[],"output":["en","ja","ko","zh"]}
{"inputs":[],"output":[{"name":"English","code":"en"},{"name":"Japanese","code":"ja"},{"name":"Korean","code":"ko"},{"name":"Chinese","code":"zh"}]}
+
{"inputs":[],"output":["en","ja","ko","zh"]}
{"inputs":[],"output":[{"name":"English","code":"en"},{"name":"Japanese","code":"ja"},{"name":"Korean","code":"ko"},{"name":"Chinese","code":"zh"}]}
+
{"inputs":[],"output":["en","ja","ko","zh"]}
{"inputs":[],"output":[{"name":"English","code":"en"},{"name":"Japanese","code":"ja"},{"name":"Korean","code":"ko"},{"name":"Chinese","code":"zh"}]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-separate-memoization-due-to-callback-capturing.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-separate-memoization-due-to-callback-capturing.js new file mode 100644 index 0000000000000..11aaeb9450804 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-separate-memoization-due-to-callback-capturing.js @@ -0,0 +1,52 @@ +// @enableNewMutationAliasingModel +import {ValidateMemoization} from 'shared-runtime'; + +const Codes = { + en: {name: 'English'}, + ja: {name: 'Japanese'}, + ko: {name: 'Korean'}, + zh: {name: 'Chinese'}, +}; + +function Component(a) { + let keys; + if (a) { + keys = Object.keys(Codes); + } else { + return null; + } + const options = keys.map(code => { + // In the old inference model, `keys` was assumed to be mutated bc + // this callback captures its input into its output, and the return + // is treated as a mutation since it's a function expression. The new + // model understands that `code` is captured but not mutated. + const country = Codes[code]; + return { + name: country.name, + code, + }; + }); + return ( + <> + + + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: false}], + sequentialRenders: [ + {a: false}, + {a: true}, + {a: true}, + {a: false}, + {a: true}, + {a: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.expect.md deleted file mode 100644 index e771bf12bde5c..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-uncalled-function-capturing-mutable-values-memoizes-with-captures-values.expect.md +++ /dev/null @@ -1,77 +0,0 @@ - -## Input - -```javascript -// @flow -/** - * This hook returns a function that when called with an input object, - * will return the result of mapping that input with the supplied map - * function. Results are cached, so if the same input is passed again, - * the same output object will be returned. - * - * Note that this technically violates the rules of React and is unsafe: - * hooks must return immutable objects and be pure, and a function which - * captures and mutates a value when called is inherently not pure. - * - * However, in this case it is technically safe _if_ the mapping function - * is pure *and* the resulting objects are never modified. This is because - * the function only caches: the result of `returnedFunction(someInput)` - * strictly depends on `returnedFunction` and `someInput`, and cannot - * otherwise change over time. - */ -hook useMemoMap( - map: TInput => TOutput -): TInput => TOutput { - return useMemo(() => { - // The original issue is that `cache` was not memoized together with the returned - // function. This was because neither appears to ever be mutated — the function - // is known to mutate `cache` but the function isn't called. - // - // The fix is to detect cases like this — functions that are mutable but not called - - // and ensure that their mutable captures are aliased together into the same scope. - const cache = new WeakMap(); - return input => { - let output = cache.get(input); - if (output == null) { - output = map(input); - cache.set(input, output); - } - return output; - }; - }, [map]); -} - -``` - -## Code - -```javascript -import { c as _c } from "react/compiler-runtime"; - -function useMemoMap(map) { - const $ = _c(2); - let t0; - let t1; - if ($[0] !== map) { - const cache = new WeakMap(); - t1 = (input) => { - let output = cache.get(input); - if (output == null) { - output = map(input); - cache.set(input, output); - } - return output; - }; - $[0] = map; - $[1] = t1; - } else { - t1 = $[1]; - } - t0 = t1; - return t0; -} - -``` - -### Eval output -(kind: exception) Fixture not implemented \ No newline at end of file diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index d7c202956178c..02cb3775cb549 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -486,6 +486,7 @@ const skipFilter = new Set([ 'todo.lower-context-access-array-destructuring', 'lower-context-selector-simple', 'lower-context-acess-multiple', + 'bug-separate-memoization-due-to-callback-capturing', ]); export default skipFilter; From df080d228bdf5260067235c64daaa57ec3cfac23 Mon Sep 17 00:00:00 2001 From: Joseph Savona <6425824+josephsavona@users.noreply.github.com> Date: Wed, 18 Jun 2025 12:58:16 -0700 Subject: [PATCH 044/144] [compiler] Copy fixtures affected by new inference (#33495) --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33495). * #33571 * #33558 * #33547 * #33543 * #33533 * #33532 * #33530 * #33526 * #33522 * #33518 * #33514 * #33513 * #33512 * #33504 * #33500 * #33497 * #33496 * __->__ #33495 * #33494 * #33572 --- ...iased-nested-scope-truncated-dep.expect.md | 221 ++++++++++++++++++ .../aliased-nested-scope-truncated-dep.tsx | 93 ++++++++ ...map-named-callback-cross-context.expect.md | 133 +++++++++++ .../array-map-named-callback-cross-context.js | 35 +++ ...ction-alias-computed-load-2-iife.expect.md | 52 +++++ ...ing-function-alias-computed-load-2-iife.js | 15 ++ ...ction-alias-computed-load-3-iife.expect.md | 61 +++++ ...ing-function-alias-computed-load-3-iife.js | 19 ++ ...ction-alias-computed-load-4-iife.expect.md | 52 +++++ ...ing-function-alias-computed-load-4-iife.js | 15 ++ ...unction-alias-computed-load-iife.expect.md | 50 ++++ ...uring-function-alias-computed-load-iife.js | 14 ++ ...valid-impure-functions-in-render.expect.md | 33 +++ ...rror.invalid-impure-functions-in-render.js | 8 + .../error.mutate-hook-argument.expect.md | 24 ++ .../error.mutate-hook-argument.js | 4 + ...or.not-useEffect-external-mutate.expect.md | 29 +++ .../error.not-useEffect-external-mutate.js | 8 + ....reassignment-to-global-indirect.expect.md | 29 +++ .../error.reassignment-to-global-indirect.js | 8 + .../error.reassignment-to-global.expect.md | 26 +++ .../error.reassignment-to-global.js | 5 + ...on-with-shadowed-local-same-name.expect.md | 30 +++ ...-function-with-shadowed-local-same-name.js | 10 + ...e-after-useeffect-optional-chain.expect.md | 58 +++++ .../mutate-after-useeffect-optional-chain.js | 17 ++ ...utate-after-useeffect-ref-access.expect.md | 57 +++++ .../mutate-after-useeffect-ref-access.js | 16 ++ .../mutate-after-useeffect.expect.md | 56 +++++ .../new-mutability/mutate-after-useeffect.js | 16 ++ ...omputed-key-object-mutated-later.expect.md | 69 ++++++ ...ssion-computed-key-object-mutated-later.js | 15 ++ ...bject-expression-computed-member.expect.md | 53 +++++ .../object-expression-computed-member.js | 15 ++ .../reactive-setState.expect.md | 60 +++++ .../new-mutability/reactive-setState.js | 18 ++ .../new-mutability/retry-no-emit.expect.md | 64 +++++ .../compiler/new-mutability/retry-no-emit.js | 19 ++ .../shared-hook-calls.expect.md | 80 +++++++ .../new-mutability/shared-hook-calls.js | 18 ++ ...k-reordering-deplist-controlflow.expect.md | 94 ++++++++ ...allback-reordering-deplist-controlflow.tsx | 27 +++ ...k-reordering-depslist-assignment.expect.md | 77 ++++++ ...allback-reordering-depslist-assignment.tsx | 22 ++ ...o-reordering-depslist-assignment.expect.md | 69 ++++++ .../useMemo-reordering-depslist-assignment.ts | 18 ++ 46 files changed, 1912 insertions(+) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.tsx create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.tsx create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.tsx create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.expect.md new file mode 100644 index 0000000000000..933fafff5f1ba --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.expect.md @@ -0,0 +1,221 @@ + +## Input + +```javascript +import { + Stringify, + mutate, + identity, + shallowCopy, + setPropertyByKey, +} from 'shared-runtime'; + +/** + * This fixture is similar to `bug-aliased-capture-aliased-mutate` and + * `nonmutating-capture-in-unsplittable-memo-block`, but with a focus on + * dependency extraction. + * + * NOTE: this fixture is currently valid, but will break with optimizations: + * - Scope and mutable-range based reordering may move the array creation + * *after* the `mutate(aliasedObj)` call. This is invalid if mutate + * reassigns inner properties. + * - RecycleInto or other deeper-equality optimizations may produce invalid + * output -- it may compare the array's contents / dependencies too early. + * - Runtime validation for immutable values will break if `mutate` does + * interior mutation of the value captured into the array. + * + * Before scope block creation, HIR looks like this: + * // + * // $1 is unscoped as obj's mutable range will be + * // extended in a later pass + * // + * $1 = LoadLocal obj@0[0:12] + * $2 = PropertyLoad $1.id + * // + * // $3 gets assigned a scope as Array is an allocating + * // instruction, but this does *not* get extended or + * // merged into the later mutation site. + * // (explained in `bug-aliased-capture-aliased-mutate`) + * // + * $3@1 = Array[$2] + * ... + * $10@0 = LoadLocal shallowCopy@0[0, 12] + * $11 = LoadGlobal mutate + * $12 = $11($10@0[0, 12]) + * + * When filling in scope dependencies, we find that it's incorrect to depend on + * PropertyLoads from obj as it hasn't completed its mutable range. Following + * the immutable / mutable-new typing system, we check the identity of obj to + * detect whether it was newly created (and thus mutable) in this render pass. + * + * HIR with scopes looks like this. + * bb0: + * $1 = LoadLocal obj@0[0:12] + * $2 = PropertyLoad $1.id + * scopeTerminal deps=[obj@0] block=bb1 fallt=bb2 + * bb1: + * $3@1 = Array[$2] + * goto bb2 + * bb2: + * ... + * + * This is surprising as deps now is entirely decoupled from temporaries used + * by the block itself. scope @1's instructions now reference a value (1) + * produced outside its scope range and (2) not represented in its dependencies + * + * The right thing to do is to ensure that all Loads from a value get assigned + * the value's reactive scope. This also requires track mutating and aliasing + * separately from scope range. In this example, that would correctly merge + * the scopes of $3 with obj. + * Runtime validation and optimizations such as ReactiveGraph-based reordering + * require this as well. + * + * A tempting fix is to instead extend $3's ReactiveScope range up to include + * $2 (the PropertyLoad). This fixes dependency deduping but not reordering + * and mutability. + */ +function Component({prop}) { + let obj = shallowCopy(prop); + const aliasedObj = identity(obj); + + // [obj.id] currently is assigned its own reactive scope + const id = [obj.id]; + + // Writing to the alias may reassign to previously captured references. + // The compiler currently produces valid output, but this breaks with + // reordering, recycleInto, and other potential optimizations. + mutate(aliasedObj); + setPropertyByKey(aliasedObj, 'id', prop.id + 1); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prop: {id: 1}}], + sequentialRenders: [{prop: {id: 1}}, {prop: {id: 1}}, {prop: {id: 2}}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { + Stringify, + mutate, + identity, + shallowCopy, + setPropertyByKey, +} from "shared-runtime"; + +/** + * This fixture is similar to `bug-aliased-capture-aliased-mutate` and + * `nonmutating-capture-in-unsplittable-memo-block`, but with a focus on + * dependency extraction. + * + * NOTE: this fixture is currently valid, but will break with optimizations: + * - Scope and mutable-range based reordering may move the array creation + * *after* the `mutate(aliasedObj)` call. This is invalid if mutate + * reassigns inner properties. + * - RecycleInto or other deeper-equality optimizations may produce invalid + * output -- it may compare the array's contents / dependencies too early. + * - Runtime validation for immutable values will break if `mutate` does + * interior mutation of the value captured into the array. + * + * Before scope block creation, HIR looks like this: + * // + * // $1 is unscoped as obj's mutable range will be + * // extended in a later pass + * // + * $1 = LoadLocal obj@0[0:12] + * $2 = PropertyLoad $1.id + * // + * // $3 gets assigned a scope as Array is an allocating + * // instruction, but this does *not* get extended or + * // merged into the later mutation site. + * // (explained in `bug-aliased-capture-aliased-mutate`) + * // + * $3@1 = Array[$2] + * ... + * $10@0 = LoadLocal shallowCopy@0[0, 12] + * $11 = LoadGlobal mutate + * $12 = $11($10@0[0, 12]) + * + * When filling in scope dependencies, we find that it's incorrect to depend on + * PropertyLoads from obj as it hasn't completed its mutable range. Following + * the immutable / mutable-new typing system, we check the identity of obj to + * detect whether it was newly created (and thus mutable) in this render pass. + * + * HIR with scopes looks like this. + * bb0: + * $1 = LoadLocal obj@0[0:12] + * $2 = PropertyLoad $1.id + * scopeTerminal deps=[obj@0] block=bb1 fallt=bb2 + * bb1: + * $3@1 = Array[$2] + * goto bb2 + * bb2: + * ... + * + * This is surprising as deps now is entirely decoupled from temporaries used + * by the block itself. scope @1's instructions now reference a value (1) + * produced outside its scope range and (2) not represented in its dependencies + * + * The right thing to do is to ensure that all Loads from a value get assigned + * the value's reactive scope. This also requires track mutating and aliasing + * separately from scope range. In this example, that would correctly merge + * the scopes of $3 with obj. + * Runtime validation and optimizations such as ReactiveGraph-based reordering + * require this as well. + * + * A tempting fix is to instead extend $3's ReactiveScope range up to include + * $2 (the PropertyLoad). This fixes dependency deduping but not reordering + * and mutability. + */ +function Component(t0) { + const $ = _c(4); + const { prop } = t0; + let t1; + if ($[0] !== prop) { + const obj = shallowCopy(prop); + const aliasedObj = identity(obj); + let t2; + if ($[2] !== obj) { + t2 = [obj.id]; + $[2] = obj; + $[3] = t2; + } else { + t2 = $[3]; + } + const id = t2; + + mutate(aliasedObj); + setPropertyByKey(aliasedObj, "id", prop.id + 1); + + t1 = ; + $[0] = prop; + $[1] = t1; + } else { + t1 = $[1]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ prop: { id: 1 } }], + sequentialRenders: [ + { prop: { id: 1 } }, + { prop: { id: 1 } }, + { prop: { id: 2 } }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"id":[1]}
+
{"id":[1]}
+
{"id":[2]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.tsx new file mode 100644 index 0000000000000..4d9d7e78fb309 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.tsx @@ -0,0 +1,93 @@ +import { + Stringify, + mutate, + identity, + shallowCopy, + setPropertyByKey, +} from 'shared-runtime'; + +/** + * This fixture is similar to `bug-aliased-capture-aliased-mutate` and + * `nonmutating-capture-in-unsplittable-memo-block`, but with a focus on + * dependency extraction. + * + * NOTE: this fixture is currently valid, but will break with optimizations: + * - Scope and mutable-range based reordering may move the array creation + * *after* the `mutate(aliasedObj)` call. This is invalid if mutate + * reassigns inner properties. + * - RecycleInto or other deeper-equality optimizations may produce invalid + * output -- it may compare the array's contents / dependencies too early. + * - Runtime validation for immutable values will break if `mutate` does + * interior mutation of the value captured into the array. + * + * Before scope block creation, HIR looks like this: + * // + * // $1 is unscoped as obj's mutable range will be + * // extended in a later pass + * // + * $1 = LoadLocal obj@0[0:12] + * $2 = PropertyLoad $1.id + * // + * // $3 gets assigned a scope as Array is an allocating + * // instruction, but this does *not* get extended or + * // merged into the later mutation site. + * // (explained in `bug-aliased-capture-aliased-mutate`) + * // + * $3@1 = Array[$2] + * ... + * $10@0 = LoadLocal shallowCopy@0[0, 12] + * $11 = LoadGlobal mutate + * $12 = $11($10@0[0, 12]) + * + * When filling in scope dependencies, we find that it's incorrect to depend on + * PropertyLoads from obj as it hasn't completed its mutable range. Following + * the immutable / mutable-new typing system, we check the identity of obj to + * detect whether it was newly created (and thus mutable) in this render pass. + * + * HIR with scopes looks like this. + * bb0: + * $1 = LoadLocal obj@0[0:12] + * $2 = PropertyLoad $1.id + * scopeTerminal deps=[obj@0] block=bb1 fallt=bb2 + * bb1: + * $3@1 = Array[$2] + * goto bb2 + * bb2: + * ... + * + * This is surprising as deps now is entirely decoupled from temporaries used + * by the block itself. scope @1's instructions now reference a value (1) + * produced outside its scope range and (2) not represented in its dependencies + * + * The right thing to do is to ensure that all Loads from a value get assigned + * the value's reactive scope. This also requires track mutating and aliasing + * separately from scope range. In this example, that would correctly merge + * the scopes of $3 with obj. + * Runtime validation and optimizations such as ReactiveGraph-based reordering + * require this as well. + * + * A tempting fix is to instead extend $3's ReactiveScope range up to include + * $2 (the PropertyLoad). This fixes dependency deduping but not reordering + * and mutability. + */ +function Component({prop}) { + let obj = shallowCopy(prop); + const aliasedObj = identity(obj); + + // [obj.id] currently is assigned its own reactive scope + const id = [obj.id]; + + // Writing to the alias may reassign to previously captured references. + // The compiler currently produces valid output, but this breaks with + // reordering, recycleInto, and other potential optimizations. + mutate(aliasedObj); + setPropertyByKey(aliasedObj, 'id', prop.id + 1); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{prop: {id: 1}}], + sequentialRenders: [{prop: {id: 1}}, {prop: {id: 1}}, {prop: {id: 2}}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.expect.md new file mode 100644 index 0000000000000..c1a6dfb3eae11 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.expect.md @@ -0,0 +1,133 @@ + +## Input + +```javascript +import {Stringify} from 'shared-runtime'; + +/** + * Forked from array-map-simple.js + * + * Named lambdas (e.g. cb1) may be defined in the top scope of a function and + * used in a different lambda (getArrMap1). + * + * Here, we should try to determine if cb1 is actually called. In this case: + * - getArrMap1 is assumed to be called as it's passed to JSX + * - cb1 is not assumed to be called since it's only used as a call operand + */ +function useFoo({arr1, arr2}) { + const cb1 = e => arr1[0].value + e.value; + const getArrMap1 = () => arr1.map(cb1); + const cb2 = e => arr2[0].value + e.value; + const getArrMap2 = () => arr1.map(cb2); + return ( + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{arr1: [], arr2: []}], + sequentialRenders: [ + {arr1: [], arr2: []}, + {arr1: [], arr2: null}, + {arr1: [{value: 1}, {value: 2}], arr2: [{value: -1}]}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { Stringify } from "shared-runtime"; + +/** + * Forked from array-map-simple.js + * + * Named lambdas (e.g. cb1) may be defined in the top scope of a function and + * used in a different lambda (getArrMap1). + * + * Here, we should try to determine if cb1 is actually called. In this case: + * - getArrMap1 is assumed to be called as it's passed to JSX + * - cb1 is not assumed to be called since it's only used as a call operand + */ +function useFoo(t0) { + const $ = _c(13); + const { arr1, arr2 } = t0; + let t1; + if ($[0] !== arr1[0]) { + t1 = (e) => arr1[0].value + e.value; + $[0] = arr1[0]; + $[1] = t1; + } else { + t1 = $[1]; + } + const cb1 = t1; + let t2; + if ($[2] !== arr1 || $[3] !== cb1) { + t2 = () => arr1.map(cb1); + $[2] = arr1; + $[3] = cb1; + $[4] = t2; + } else { + t2 = $[4]; + } + const getArrMap1 = t2; + let t3; + if ($[5] !== arr2) { + t3 = (e_0) => arr2[0].value + e_0.value; + $[5] = arr2; + $[6] = t3; + } else { + t3 = $[6]; + } + const cb2 = t3; + let t4; + if ($[7] !== arr1 || $[8] !== cb2) { + t4 = () => arr1.map(cb2); + $[7] = arr1; + $[8] = cb2; + $[9] = t4; + } else { + t4 = $[9]; + } + const getArrMap2 = t4; + let t5; + if ($[10] !== getArrMap1 || $[11] !== getArrMap2) { + t5 = ( + + ); + $[10] = getArrMap1; + $[11] = getArrMap2; + $[12] = t5; + } else { + t5 = $[12]; + } + return t5; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ arr1: [], arr2: [] }], + sequentialRenders: [ + { arr1: [], arr2: [] }, + { arr1: [], arr2: null }, + { arr1: [{ value: 1 }, { value: 2 }], arr2: [{ value: -1 }] }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"getArrMap1":{"kind":"Function","result":[]},"getArrMap2":{"kind":"Function","result":[]},"shouldInvokeFns":true}
+
{"getArrMap1":{"kind":"Function","result":[]},"getArrMap2":{"kind":"Function","result":[]},"shouldInvokeFns":true}
+
{"getArrMap1":{"kind":"Function","result":[2,3]},"getArrMap2":{"kind":"Function","result":[0,1]},"shouldInvokeFns":true}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.js new file mode 100644 index 0000000000000..e9056562262e8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.js @@ -0,0 +1,35 @@ +import {Stringify} from 'shared-runtime'; + +/** + * Forked from array-map-simple.js + * + * Named lambdas (e.g. cb1) may be defined in the top scope of a function and + * used in a different lambda (getArrMap1). + * + * Here, we should try to determine if cb1 is actually called. In this case: + * - getArrMap1 is assumed to be called as it's passed to JSX + * - cb1 is not assumed to be called since it's only used as a call operand + */ +function useFoo({arr1, arr2}) { + const cb1 = e => arr1[0].value + e.value; + const getArrMap1 = () => arr1.map(cb1); + const cb2 = e => arr2[0].value + e.value; + const getArrMap2 = () => arr1.map(cb2); + return ( + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{arr1: [], arr2: []}], + sequentialRenders: [ + {arr1: [], arr2: []}, + {arr1: [], arr2: null}, + {arr1: [{value: 1}, {value: 2}], arr2: [{value: -1}]}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.expect.md new file mode 100644 index 0000000000000..2afc5fd25dbac --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.expect.md @@ -0,0 +1,52 @@ + +## Input + +```javascript +function bar(a) { + let x = [a]; + let y = {}; + (function () { + y = x[0][1]; + })(); + + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: [['val1', 'val2']], + isComponent: false, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function bar(a) { + const $ = _c(2); + let y; + if ($[0] !== a) { + const x = [a]; + y = {}; + + y = x[0][1]; + $[0] = a; + $[1] = y; + } else { + y = $[1]; + } + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: [["val1", "val2"]], + isComponent: false, +}; + +``` + +### Eval output +(kind: ok) "val2" \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.js new file mode 100644 index 0000000000000..4c224e28415b1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.js @@ -0,0 +1,15 @@ +function bar(a) { + let x = [a]; + let y = {}; + (function () { + y = x[0][1]; + })(); + + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: [['val1', 'val2']], + isComponent: false, +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.expect.md new file mode 100644 index 0000000000000..f0267c3309f5b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +function bar(a, b) { + let x = [a, b]; + let y = {}; + let t = {}; + (function () { + y = x[0][1]; + t = x[1][0]; + })(); + + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: [ + [1, 2], + [2, 3], + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function bar(a, b) { + const $ = _c(3); + let y; + if ($[0] !== a || $[1] !== b) { + const x = [a, b]; + y = {}; + let t = {}; + + y = x[0][1]; + t = x[1][0]; + $[0] = a; + $[1] = b; + $[2] = y; + } else { + y = $[2]; + } + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: [ + [1, 2], + [2, 3], + ], +}; + +``` + +### Eval output +(kind: ok) 2 \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.js new file mode 100644 index 0000000000000..1afc28a9922ec --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.js @@ -0,0 +1,19 @@ +function bar(a, b) { + let x = [a, b]; + let y = {}; + let t = {}; + (function () { + y = x[0][1]; + t = x[1][0]; + })(); + + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: [ + [1, 2], + [2, 3], + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.expect.md new file mode 100644 index 0000000000000..22728aaf4323d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.expect.md @@ -0,0 +1,52 @@ + +## Input + +```javascript +function bar(a) { + let x = [a]; + let y = {}; + (function () { + y = x[0].a[1]; + })(); + + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: [{a: ['val1', 'val2']}], + isComponent: false, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function bar(a) { + const $ = _c(2); + let y; + if ($[0] !== a) { + const x = [a]; + y = {}; + + y = x[0].a[1]; + $[0] = a; + $[1] = y; + } else { + y = $[1]; + } + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: [{ a: ["val1", "val2"] }], + isComponent: false, +}; + +``` + +### Eval output +(kind: ok) "val2" \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.js new file mode 100644 index 0000000000000..ca479a7458926 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.js @@ -0,0 +1,15 @@ +function bar(a) { + let x = [a]; + let y = {}; + (function () { + y = x[0].a[1]; + })(); + + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: [{a: ['val1', 'val2']}], + isComponent: false, +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.expect.md new file mode 100644 index 0000000000000..60f829cdc4d66 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +function bar(a) { + let x = [a]; + let y = {}; + (function () { + y = x[0]; + })(); + + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: ['TodoAdd'], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function bar(a) { + const $ = _c(2); + let y; + if ($[0] !== a) { + const x = [a]; + y = {}; + + y = x[0]; + $[0] = a; + $[1] = y; + } else { + y = $[1]; + } + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: ["TodoAdd"], +}; + +``` + +### Eval output +(kind: ok) "TodoAdd" \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.js new file mode 100644 index 0000000000000..9a0c7c19aa700 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.js @@ -0,0 +1,14 @@ +function bar(a) { + let x = [a]; + let y = {}; + (function () { + y = x[0]; + })(); + + return y; +} + +export const FIXTURE_ENTRYPOINT = { + fn: bar, + params: ['TodoAdd'], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md new file mode 100644 index 0000000000000..a67d467df8cfd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md @@ -0,0 +1,33 @@ + +## Input + +```javascript +// @validateNoImpureFunctionsInRender + +function Component() { + const date = Date.now(); + const now = performance.now(); + const rand = Math.random(); + return ; +} + +``` + + +## Error + +``` + 2 | + 3 | function Component() { +> 4 | const date = Date.now(); + | ^^^^^^^^ InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `Date.now` is an impure function whose results may change on every call (4:4) + +InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `performance.now` is an impure function whose results may change on every call (5:5) + +InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `Math.random` is an impure function whose results may change on every call (6:6) + 5 | const now = performance.now(); + 6 | const rand = Math.random(); + 7 | return ; +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.js new file mode 100644 index 0000000000000..6faf98caff702 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.js @@ -0,0 +1,8 @@ +// @validateNoImpureFunctionsInRender + +function Component() { + const date = Date.now(); + const now = performance.now(); + const rand = Math.random(); + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.expect.md new file mode 100644 index 0000000000000..665fc7053b788 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.expect.md @@ -0,0 +1,24 @@ + +## Input + +```javascript +function useHook(a, b) { + b.test = 1; + a.test = 2; +} + +``` + + +## Error + +``` + 1 | function useHook(a, b) { +> 2 | b.test = 1; + | ^ InvalidReact: Mutating component props or hook arguments is not allowed. Consider using a local variable instead (2:2) + 3 | a.test = 2; + 4 | } + 5 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.js new file mode 100644 index 0000000000000..321e9049cddbd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.js @@ -0,0 +1,4 @@ +function useHook(a, b) { + b.test = 1; + a.test = 2; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.expect.md new file mode 100644 index 0000000000000..7d829fe9b013f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.expect.md @@ -0,0 +1,29 @@ + +## Input + +```javascript +let x = {a: 42}; + +function Component(props) { + foo(() => { + x.a = 10; + x.a = 20; + }); +} + +``` + + +## Error + +``` + 3 | function Component(props) { + 4 | foo(() => { +> 5 | x.a = 10; + | ^ InvalidReact: Writing to a variable defined outside a component or hook is not allowed. Consider using an effect (5:5) + 6 | x.a = 20; + 7 | }); + 8 | } +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.js new file mode 100644 index 0000000000000..3b44c4c247760 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.js @@ -0,0 +1,8 @@ +let x = {a: 42}; + +function Component(props) { + foo(() => { + x.a = 10; + x.a = 20; + }); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.expect.md new file mode 100644 index 0000000000000..e4073947f7e15 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.expect.md @@ -0,0 +1,29 @@ + +## Input + +```javascript +function Component() { + const foo = () => { + // Cannot assign to globals + someUnknownGlobal = true; + moduleLocal = true; + }; + foo(); +} + +``` + + +## Error + +``` + 2 | const foo = () => { + 3 | // Cannot assign to globals +> 4 | someUnknownGlobal = true; + | ^^^^^^^^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (4:4) + 5 | moduleLocal = true; + 6 | }; + 7 | foo(); +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.js new file mode 100644 index 0000000000000..708fe643d57df --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.js @@ -0,0 +1,8 @@ +function Component() { + const foo = () => { + // Cannot assign to globals + someUnknownGlobal = true; + moduleLocal = true; + }; + foo(); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.expect.md new file mode 100644 index 0000000000000..4619cd27cb24d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.expect.md @@ -0,0 +1,26 @@ + +## Input + +```javascript +function Component() { + // Cannot assign to globals + someUnknownGlobal = true; + moduleLocal = true; +} + +``` + + +## Error + +``` + 1 | function Component() { + 2 | // Cannot assign to globals +> 3 | someUnknownGlobal = true; + | ^^^^^^^^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (3:3) + 4 | moduleLocal = true; + 5 | } + 6 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.js new file mode 100644 index 0000000000000..d0509a3d52a16 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.js @@ -0,0 +1,5 @@ +function Component() { + // Cannot assign to globals + someUnknownGlobal = true; + moduleLocal = true; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md new file mode 100644 index 0000000000000..2a935256d7a0d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md @@ -0,0 +1,30 @@ + +## Input + +```javascript +function Component(props) { + function hasErrors() { + let hasErrors = false; + if (props.items == null) { + hasErrors = true; + } + return hasErrors; + } + return hasErrors(); +} + +``` + + +## Error + +``` + 7 | return hasErrors; + 8 | } +> 9 | return hasErrors(); + | ^^^^^^^^^ Invariant: [hoisting] Expected value for identifier to be initialized. hasErrors_0$15 (9:9) + 10 | } + 11 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js new file mode 100644 index 0000000000000..b7a450ccba028 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js @@ -0,0 +1,10 @@ +function Component(props) { + function hasErrors() { + let hasErrors = false; + if (props.items == null) { + hasErrors = true; + } + return hasErrors; + } + return hasErrors(); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.expect.md new file mode 100644 index 0000000000000..e4560848dd5f2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.expect.md @@ -0,0 +1,58 @@ + +## Input + +```javascript +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +import {useEffect} from 'react'; +import {print} from 'shared-runtime'; + +function Component({foo}) { + const arr = []; + // Taking either arr[0].value or arr as a dependency is reasonable + // as long as developers know what to expect. + useEffect(() => print(arr[0]?.value)); + arr.push({value: foo}); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 1}], +}; + +``` + +## Code + +```javascript +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +import { useEffect } from "react"; +import { print } from "shared-runtime"; + +function Component(t0) { + const { foo } = t0; + const arr = []; + + useEffect(() => print(arr[0]?.value), [arr[0]?.value]); + arr.push({ value: foo }); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ foo: 1 }], +}; + +``` + +## Logs + +``` +{"kind":"CompileError","fnLoc":{"start":{"line":5,"column":0,"index":139},"end":{"line":12,"column":1,"index":384},"filename":"mutate-after-useeffect-optional-chain.ts"},"detail":{"reason":"This mutates a variable that React considers immutable","description":null,"loc":{"start":{"line":10,"column":2,"index":345},"end":{"line":10,"column":5,"index":348},"filename":"mutate-after-useeffect-optional-chain.ts","identifierName":"arr"},"suggestions":null,"severity":"InvalidReact"}} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":9,"column":2,"index":304},"end":{"line":9,"column":39,"index":341},"filename":"mutate-after-useeffect-optional-chain.ts"},"decorations":[{"start":{"line":9,"column":24,"index":326},"end":{"line":9,"column":27,"index":329},"filename":"mutate-after-useeffect-optional-chain.ts","identifierName":"arr"}]} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":5,"column":0,"index":139},"end":{"line":12,"column":1,"index":384},"filename":"mutate-after-useeffect-optional-chain.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} +``` + +### Eval output +(kind: ok) [{"value":1}] +logs: [1] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.js new file mode 100644 index 0000000000000..c435b72d1a9ef --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.js @@ -0,0 +1,17 @@ +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +import {useEffect} from 'react'; +import {print} from 'shared-runtime'; + +function Component({foo}) { + const arr = []; + // Taking either arr[0].value or arr as a dependency is reasonable + // as long as developers know what to expect. + useEffect(() => print(arr[0]?.value)); + arr.push({value: foo}); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 1}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md new file mode 100644 index 0000000000000..5e6f19dd83e65 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md @@ -0,0 +1,57 @@ + +## Input + +```javascript +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly + +import {useEffect, useRef} from 'react'; +import {print} from 'shared-runtime'; + +function Component({arrRef}) { + // Avoid taking arr.current as a dependency + useEffect(() => print(arrRef.current)); + arrRef.current.val = 2; + return arrRef; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{arrRef: {current: {val: 'initial ref value'}}}], +}; + +``` + +## Code + +```javascript +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly + +import { useEffect, useRef } from "react"; +import { print } from "shared-runtime"; + +function Component(t0) { + const { arrRef } = t0; + + useEffect(() => print(arrRef.current), [arrRef]); + arrRef.current.val = 2; + return arrRef; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ arrRef: { current: { val: "initial ref value" } } }], +}; + +``` + +## Logs + +``` +{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":148},"end":{"line":11,"column":1,"index":311},"filename":"mutate-after-useeffect-ref-access.ts"},"detail":{"reason":"Mutating component props or hook arguments is not allowed. Consider using a local variable instead","description":null,"loc":{"start":{"line":9,"column":2,"index":269},"end":{"line":9,"column":16,"index":283},"filename":"mutate-after-useeffect-ref-access.ts"},"suggestions":null,"severity":"InvalidReact"}} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":8,"column":2,"index":227},"end":{"line":8,"column":40,"index":265},"filename":"mutate-after-useeffect-ref-access.ts"},"decorations":[{"start":{"line":8,"column":24,"index":249},"end":{"line":8,"column":30,"index":255},"filename":"mutate-after-useeffect-ref-access.ts","identifierName":"arrRef"}]} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":6,"column":0,"index":148},"end":{"line":11,"column":1,"index":311},"filename":"mutate-after-useeffect-ref-access.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} +``` + +### Eval output +(kind: ok) {"current":{"val":2}} +logs: [{ val: 2 }] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.js new file mode 100644 index 0000000000000..bd3f6d1de54bd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.js @@ -0,0 +1,16 @@ +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly + +import {useEffect, useRef} from 'react'; +import {print} from 'shared-runtime'; + +function Component({arrRef}) { + // Avoid taking arr.current as a dependency + useEffect(() => print(arrRef.current)); + arrRef.current.val = 2; + return arrRef; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{arrRef: {current: {val: 'initial ref value'}}}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.expect.md new file mode 100644 index 0000000000000..3b61fbf834246 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.expect.md @@ -0,0 +1,56 @@ + +## Input + +```javascript +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +import {useEffect} from 'react'; + +function Component({foo}) { + const arr = []; + useEffect(() => { + arr.push(foo); + }); + arr.push(2); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 1}], +}; + +``` + +## Code + +```javascript +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +import { useEffect } from "react"; + +function Component(t0) { + const { foo } = t0; + const arr = []; + useEffect(() => { + arr.push(foo); + }, [arr, foo]); + arr.push(2); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ foo: 1 }], +}; + +``` + +## Logs + +``` +{"kind":"CompileError","fnLoc":{"start":{"line":4,"column":0,"index":101},"end":{"line":11,"column":1,"index":222},"filename":"mutate-after-useeffect.ts"},"detail":{"reason":"This mutates a variable that React considers immutable","description":null,"loc":{"start":{"line":9,"column":2,"index":194},"end":{"line":9,"column":5,"index":197},"filename":"mutate-after-useeffect.ts","identifierName":"arr"},"suggestions":null,"severity":"InvalidReact"}} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":6,"column":2,"index":149},"end":{"line":8,"column":4,"index":190},"filename":"mutate-after-useeffect.ts"},"decorations":[{"start":{"line":7,"column":4,"index":171},"end":{"line":7,"column":7,"index":174},"filename":"mutate-after-useeffect.ts","identifierName":"arr"},{"start":{"line":7,"column":4,"index":171},"end":{"line":7,"column":7,"index":174},"filename":"mutate-after-useeffect.ts","identifierName":"arr"},{"start":{"line":7,"column":13,"index":180},"end":{"line":7,"column":16,"index":183},"filename":"mutate-after-useeffect.ts","identifierName":"foo"}]} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":101},"end":{"line":11,"column":1,"index":222},"filename":"mutate-after-useeffect.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} +``` + +### Eval output +(kind: ok) [2] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.js new file mode 100644 index 0000000000000..fbcbf004a308c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.js @@ -0,0 +1,16 @@ +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +import {useEffect} from 'react'; + +function Component({foo}) { + const arr = []; + useEffect(() => { + arr.push(foo); + }); + arr.push(2); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 1}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.expect.md new file mode 100644 index 0000000000000..bf0f9da6b1da1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.expect.md @@ -0,0 +1,69 @@ + +## Input + +```javascript +import {identity, mutate} from 'shared-runtime'; + +function Component(props) { + const key = {}; + const context = { + [key]: identity([props.value]), + }; + mutate(key); + return context; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { identity, mutate } from "shared-runtime"; + +function Component(props) { + const $ = _c(5); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = {}; + $[0] = t0; + } else { + t0 = $[0]; + } + const key = t0; + let t1; + if ($[1] !== props.value) { + t1 = identity([props.value]); + $[1] = props.value; + $[2] = t1; + } else { + t1 = $[2]; + } + let t2; + if ($[3] !== t1) { + t2 = { [key]: t1 }; + $[3] = t1; + $[4] = t2; + } else { + t2 = $[4]; + } + const context = t2; + + mutate(key); + return context; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: 42 }], +}; + +``` + +### Eval output +(kind: ok) {"[object Object]":[42]} \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.js new file mode 100644 index 0000000000000..1edaaaef27e4b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.js @@ -0,0 +1,15 @@ +import {identity, mutate} from 'shared-runtime'; + +function Component(props) { + const key = {}; + const context = { + [key]: identity([props.value]), + }; + mutate(key); + return context; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.expect.md new file mode 100644 index 0000000000000..810b03e529e77 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.expect.md @@ -0,0 +1,53 @@ + +## Input + +```javascript +import {identity, mutate, mutateAndReturn} from 'shared-runtime'; + +function Component(props) { + const key = {a: 'key'}; + const context = { + [key.a]: identity([props.value]), + }; + mutate(key); + return context; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { identity, mutate, mutateAndReturn } from "shared-runtime"; + +function Component(props) { + const $ = _c(2); + let context; + if ($[0] !== props.value) { + const key = { a: "key" }; + context = { [key.a]: identity([props.value]) }; + + mutate(key); + $[0] = props.value; + $[1] = context; + } else { + context = $[1]; + } + return context; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ value: 42 }], +}; + +``` + +### Eval output +(kind: ok) {"key":[42]} \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.js new file mode 100644 index 0000000000000..95a1d434624e9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.js @@ -0,0 +1,15 @@ +import {identity, mutate, mutateAndReturn} from 'shared-runtime'; + +function Component(props) { + const key = {a: 'key'}; + const context = { + [key.a]: identity([props.value]), + }; + mutate(key); + return context; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{value: 42}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.expect.md new file mode 100644 index 0000000000000..3af2b9b8b1c89 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.expect.md @@ -0,0 +1,60 @@ + +## Input + +```javascript +// @inferEffectDependencies +import {useEffect, useState} from 'react'; +import {print} from 'shared-runtime'; + +/* + * setState types are not enough to determine to omit from deps. Must also take reactivity into account. + */ +function ReactiveRefInEffect(props) { + const [_state1, setState1] = useRef('initial value'); + const [_state2, setState2] = useRef('initial value'); + let setState; + if (props.foo) { + setState = setState1; + } else { + setState = setState2; + } + useEffect(() => print(setState)); +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies +import { useEffect, useState } from "react"; +import { print } from "shared-runtime"; + +/* + * setState types are not enough to determine to omit from deps. Must also take reactivity into account. + */ +function ReactiveRefInEffect(props) { + const $ = _c(2); + const [, setState1] = useRef("initial value"); + const [, setState2] = useRef("initial value"); + let setState; + if (props.foo) { + setState = setState1; + } else { + setState = setState2; + } + let t0; + if ($[0] !== setState) { + t0 = () => print(setState); + $[0] = setState; + $[1] = t0; + } else { + t0 = $[1]; + } + useEffect(t0, [setState]); +} + +``` + +### 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/new-mutability/reactive-setState.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.js new file mode 100644 index 0000000000000..46a83d8ad42dd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.js @@ -0,0 +1,18 @@ +// @inferEffectDependencies +import {useEffect, useState} from 'react'; +import {print} from 'shared-runtime'; + +/* + * setState types are not enough to determine to omit from deps. Must also take reactivity into account. + */ +function ReactiveRefInEffect(props) { + const [_state1, setState1] = useRef('initial value'); + const [_state2, setState2] = useRef('initial value'); + let setState; + if (props.foo) { + setState = setState1; + } else { + setState = setState2; + } + useEffect(() => print(setState)); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.expect.md new file mode 100644 index 0000000000000..bd70c0138d7e2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.expect.md @@ -0,0 +1,64 @@ + +## Input + +```javascript +// @inferEffectDependencies @noEmit @panicThreshold:"none" @loggerTestOnly +import {print} from 'shared-runtime'; +import useEffectWrapper from 'useEffectWrapper'; + +function Foo({propVal}) { + const arr = [propVal]; + useEffectWrapper(() => print(arr)); + + const arr2 = []; + useEffectWrapper(() => arr2.push(propVal)); + arr2.push(2); + return {arr, arr2}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{propVal: 1}], + sequentialRenders: [{propVal: 1}, {propVal: 2}], +}; + +``` + +## Code + +```javascript +// @inferEffectDependencies @noEmit @panicThreshold:"none" @loggerTestOnly +import { print } from "shared-runtime"; +import useEffectWrapper from "useEffectWrapper"; + +function Foo({ propVal }) { + const arr = [propVal]; + useEffectWrapper(() => print(arr)); + + const arr2 = []; + useEffectWrapper(() => arr2.push(propVal)); + arr2.push(2); + return { arr, arr2 }; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ propVal: 1 }], + sequentialRenders: [{ propVal: 1 }, { propVal: 2 }], +}; + +``` + +## Logs + +``` +{"kind":"CompileError","fnLoc":{"start":{"line":5,"column":0,"index":163},"end":{"line":13,"column":1,"index":357},"filename":"retry-no-emit.ts"},"detail":{"reason":"This mutates a variable that React considers immutable","description":null,"loc":{"start":{"line":11,"column":2,"index":320},"end":{"line":11,"column":6,"index":324},"filename":"retry-no-emit.ts","identifierName":"arr2"},"suggestions":null,"severity":"InvalidReact"}} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":7,"column":2,"index":216},"end":{"line":7,"column":36,"index":250},"filename":"retry-no-emit.ts"},"decorations":[{"start":{"line":7,"column":31,"index":245},"end":{"line":7,"column":34,"index":248},"filename":"retry-no-emit.ts","identifierName":"arr"}]} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":10,"column":2,"index":274},"end":{"line":10,"column":44,"index":316},"filename":"retry-no-emit.ts"},"decorations":[{"start":{"line":10,"column":25,"index":297},"end":{"line":10,"column":29,"index":301},"filename":"retry-no-emit.ts","identifierName":"arr2"},{"start":{"line":10,"column":25,"index":297},"end":{"line":10,"column":29,"index":301},"filename":"retry-no-emit.ts","identifierName":"arr2"},{"start":{"line":10,"column":35,"index":307},"end":{"line":10,"column":42,"index":314},"filename":"retry-no-emit.ts","identifierName":"propVal"}]} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":5,"column":0,"index":163},"end":{"line":13,"column":1,"index":357},"filename":"retry-no-emit.ts"},"fnName":"Foo","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} +``` + +### Eval output +(kind: ok) {"arr":[1],"arr2":[2]} +{"arr":[2],"arr2":[2]} +logs: [[ 1 ],[ 2 ]] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.js new file mode 100644 index 0000000000000..d1dda06a044a2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.js @@ -0,0 +1,19 @@ +// @inferEffectDependencies @noEmit @panicThreshold:"none" @loggerTestOnly +import {print} from 'shared-runtime'; +import useEffectWrapper from 'useEffectWrapper'; + +function Foo({propVal}) { + const arr = [propVal]; + useEffectWrapper(() => print(arr)); + + const arr2 = []; + useEffectWrapper(() => arr2.push(propVal)); + arr2.push(2); + return {arr, arr2}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{propVal: 1}], + sequentialRenders: [{propVal: 1}, {propVal: 2}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.expect.md new file mode 100644 index 0000000000000..92dbf9843ad65 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.expect.md @@ -0,0 +1,80 @@ + +## Input + +```javascript +// @enableFire +import {fire} from 'react'; + +function Component({bar, baz}) { + const foo = () => { + console.log(bar); + }; + useEffect(() => { + fire(foo(bar)); + fire(baz(bar)); + }); + + useEffect(() => { + fire(foo(bar)); + }); + + return null; +} + +``` + +## Code + +```javascript +import { c as _c, useFire } from "react/compiler-runtime"; // @enableFire +import { fire } from "react"; + +function Component(t0) { + const $ = _c(9); + const { bar, baz } = t0; + let t1; + if ($[0] !== bar) { + t1 = () => { + console.log(bar); + }; + $[0] = bar; + $[1] = t1; + } else { + t1 = $[1]; + } + const foo = t1; + const t2 = useFire(foo); + const t3 = useFire(baz); + let t4; + if ($[2] !== bar || $[3] !== t2 || $[4] !== t3) { + t4 = () => { + t2(bar); + t3(bar); + }; + $[2] = bar; + $[3] = t2; + $[4] = t3; + $[5] = t4; + } else { + t4 = $[5]; + } + useEffect(t4); + let t5; + if ($[6] !== bar || $[7] !== t2) { + t5 = () => { + t2(bar); + }; + $[6] = bar; + $[7] = t2; + $[8] = t5; + } else { + t5 = $[8]; + } + useEffect(t5); + return null; +} + +``` + +### 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/new-mutability/shared-hook-calls.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.js new file mode 100644 index 0000000000000..5cb51e9bd3c78 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.js @@ -0,0 +1,18 @@ +// @enableFire +import {fire} from 'react'; + +function Component({bar, baz}) { + const foo = () => { + console.log(bar); + }; + useEffect(() => { + fire(foo(bar)); + fire(baz(bar)); + }); + + useEffect(() => { + fire(foo(bar)); + }); + + return null; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.expect.md new file mode 100644 index 0000000000000..080cc0a74a609 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.expect.md @@ -0,0 +1,94 @@ + +## Input + +```javascript +import {useCallback} from 'react'; +import {Stringify} from 'shared-runtime'; + +function Foo({arr1, arr2, foo}) { + const x = [arr1]; + + let y = []; + + const getVal1 = useCallback(() => { + return {x: 2}; + }, []); + + const getVal2 = useCallback(() => { + return [y]; + }, [foo ? (y = x.concat(arr2)) : y]); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{arr1: [1, 2], arr2: [3, 4], foo: true}], + sequentialRenders: [ + {arr1: [1, 2], arr2: [3, 4], foo: true}, + {arr1: [1, 2], arr2: [3, 4], foo: false}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useCallback } from "react"; +import { Stringify } from "shared-runtime"; + +function Foo(t0) { + const $ = _c(8); + const { arr1, arr2, foo } = t0; + let getVal1; + let t1; + if ($[0] !== arr1 || $[1] !== arr2 || $[2] !== foo) { + const x = [arr1]; + + let y = []; + + getVal1 = _temp; + + t1 = () => [y]; + foo ? (y = x.concat(arr2)) : y; + $[0] = arr1; + $[1] = arr2; + $[2] = foo; + $[3] = getVal1; + $[4] = t1; + } else { + getVal1 = $[3]; + t1 = $[4]; + } + const getVal2 = t1; + let t2; + if ($[5] !== getVal1 || $[6] !== getVal2) { + t2 = ; + $[5] = getVal1; + $[6] = getVal2; + $[7] = t2; + } else { + t2 = $[7]; + } + return t2; +} +function _temp() { + return { x: 2 }; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ arr1: [1, 2], arr2: [3, 4], foo: true }], + sequentialRenders: [ + { arr1: [1, 2], arr2: [3, 4], foo: true }, + { arr1: [1, 2], arr2: [3, 4], foo: false }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"val1":{"kind":"Function","result":{"x":2}},"val2":{"kind":"Function","result":[[[1,2],3,4]]},"shouldInvokeFns":true}
+
{"val1":{"kind":"Function","result":{"x":2}},"val2":{"kind":"Function","result":[[]]},"shouldInvokeFns":true}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.tsx new file mode 100644 index 0000000000000..ba0abc0d7cdf0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.tsx @@ -0,0 +1,27 @@ +import {useCallback} from 'react'; +import {Stringify} from 'shared-runtime'; + +function Foo({arr1, arr2, foo}) { + const x = [arr1]; + + let y = []; + + const getVal1 = useCallback(() => { + return {x: 2}; + }, []); + + const getVal2 = useCallback(() => { + return [y]; + }, [foo ? (y = x.concat(arr2)) : y]); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{arr1: [1, 2], arr2: [3, 4], foo: true}], + sequentialRenders: [ + {arr1: [1, 2], arr2: [3, 4], foo: true}, + {arr1: [1, 2], arr2: [3, 4], foo: false}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.expect.md new file mode 100644 index 0000000000000..89a6ad80c3945 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.expect.md @@ -0,0 +1,77 @@ + +## Input + +```javascript +import {useCallback} from 'react'; +import {Stringify} from 'shared-runtime'; + +// We currently produce invalid output (incorrect scoping for `y` declaration) +function useFoo(arr1, arr2) { + const x = [arr1]; + + let y; + const getVal = useCallback(() => { + return {y}; + }, [((y = x.concat(arr2)), y)]); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [ + [1, 2], + [3, 4], + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useCallback } from "react"; +import { Stringify } from "shared-runtime"; + +// We currently produce invalid output (incorrect scoping for `y` declaration) +function useFoo(arr1, arr2) { + const $ = _c(5); + let t0; + if ($[0] !== arr1 || $[1] !== arr2) { + const x = [arr1]; + + let y; + t0 = () => ({ y }); + + (y = x.concat(arr2)), y; + $[0] = arr1; + $[1] = arr2; + $[2] = t0; + } else { + t0 = $[2]; + } + const getVal = t0; + let t1; + if ($[3] !== getVal) { + t1 = ; + $[3] = getVal; + $[4] = t1; + } else { + t1 = $[4]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [ + [1, 2], + [3, 4], + ], +}; + +``` + +### Eval output +(kind: ok)
{"getVal":{"kind":"Function","result":{"y":[[1,2],3,4]}},"shouldInvokeFns":true}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.tsx new file mode 100644 index 0000000000000..3ac3845c47f74 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.tsx @@ -0,0 +1,22 @@ +import {useCallback} from 'react'; +import {Stringify} from 'shared-runtime'; + +// We currently produce invalid output (incorrect scoping for `y` declaration) +function useFoo(arr1, arr2) { + const x = [arr1]; + + let y; + const getVal = useCallback(() => { + return {y}; + }, [((y = x.concat(arr2)), y)]); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [ + [1, 2], + [3, 4], + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.expect.md new file mode 100644 index 0000000000000..3fffec6a7dc20 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.expect.md @@ -0,0 +1,69 @@ + +## Input + +```javascript +import {useMemo} from 'react'; + +function useFoo(arr1, arr2) { + const x = [arr1]; + + let y; + return useMemo(() => { + return {y}; + }, [((y = x.concat(arr2)), y)]); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [ + [1, 2], + [3, 4], + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; + +function useFoo(arr1, arr2) { + const $ = _c(5); + let y; + if ($[0] !== arr1 || $[1] !== arr2) { + const x = [arr1]; + + (y = x.concat(arr2)), y; + $[0] = arr1; + $[1] = arr2; + $[2] = y; + } else { + y = $[2]; + } + let t0; + let t1; + if ($[3] !== y) { + t1 = { y }; + $[3] = y; + $[4] = t1; + } else { + t1 = $[4]; + } + t0 = t1; + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [ + [1, 2], + [3, 4], + ], +}; + +``` + +### Eval output +(kind: ok) {"y":[[1,2],3,4]} \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.ts new file mode 100644 index 0000000000000..8025d3680fb51 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.ts @@ -0,0 +1,18 @@ +import {useMemo} from 'react'; + +function useFoo(arr1, arr2) { + const x = [arr1]; + + let y; + return useMemo(() => { + return {y}; + }, [((y = x.concat(arr2)), y)]); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [ + [1, 2], + [3, 4], + ], +}; From 0cf6d0c929e14d4a3c5bcc11a7347b4cad250e7f Mon Sep 17 00:00:00 2001 From: Joseph Savona <6425824+josephsavona@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:01:56 -0700 Subject: [PATCH 045/144] [compiler] Update fixtures for new inference (#33496) --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33496). * #33571 * #33558 * #33547 * #33543 * #33533 * #33532 * #33530 * #33526 * #33522 * #33518 * #33514 * #33513 * #33512 * #33504 * #33500 * #33497 * __->__ #33496 --- ...iased-nested-scope-truncated-dep.expect.md | 16 ++-- .../aliased-nested-scope-truncated-dep.tsx | 1 + ...map-named-callback-cross-context.expect.md | 84 ++++++++++--------- .../array-map-named-callback-cross-context.js | 1 + ...ction-alias-computed-load-2-iife.expect.md | 23 +++-- ...ing-function-alias-computed-load-2-iife.js | 1 + ...ction-alias-computed-load-3-iife.expect.md | 26 ++++-- ...ing-function-alias-computed-load-3-iife.js | 1 + ...ction-alias-computed-load-4-iife.expect.md | 23 +++-- ...ing-function-alias-computed-load-4-iife.js | 1 + ...unction-alias-computed-load-iife.expect.md | 23 +++-- ...uring-function-alias-computed-load-iife.js | 1 + ...valid-impure-functions-in-render.expect.md | 4 +- ...rror.invalid-impure-functions-in-render.js | 2 +- ...n-local-variable-in-jsx-callback.expect.md | 15 ++-- ...reassign-local-variable-in-jsx-callback.js | 1 + .../error.mutate-hook-argument.expect.md | 16 ++-- .../error.mutate-hook-argument.js | 1 + ...or.not-useEffect-external-mutate.expect.md | 17 ++-- .../error.not-useEffect-external-mutate.js | 1 + ....reassignment-to-global-indirect.expect.md | 17 ++-- .../error.reassignment-to-global-indirect.js | 1 + .../error.reassignment-to-global.expect.md | 17 ++-- .../error.reassignment-to-global.js | 1 + ...on-with-shadowed-local-same-name.expect.md | 13 +-- ...-function-with-shadowed-local-same-name.js | 1 + ...e-after-useeffect-optional-chain.expect.md | 10 +-- .../mutate-after-useeffect-optional-chain.js | 2 +- ...utate-after-useeffect-ref-access.expect.md | 10 +-- .../mutate-after-useeffect-ref-access.js | 2 +- .../mutate-after-useeffect.expect.md | 10 +-- .../new-mutability/mutate-after-useeffect.js | 2 +- ...omputed-key-object-mutated-later.expect.md | 39 +++------ ...ssion-computed-key-object-mutated-later.js | 1 + ...bject-expression-computed-member.expect.md | 18 +++- .../object-expression-computed-member.js | 1 + .../reactive-setState.expect.md | 26 +++--- .../new-mutability/reactive-setState.js | 2 +- .../new-mutability/retry-no-emit.expect.md | 12 +-- .../compiler/new-mutability/retry-no-emit.js | 2 +- .../shared-hook-calls.expect.md | 81 ++++++++++-------- .../new-mutability/shared-hook-calls.js | 2 +- ...k-reordering-deplist-controlflow.expect.md | 54 +++++++----- ...allback-reordering-deplist-controlflow.tsx | 1 + ...k-reordering-depslist-assignment.expect.md | 42 ++++++---- ...allback-reordering-depslist-assignment.tsx | 1 + ...o-reordering-depslist-assignment.expect.md | 42 ++++++---- .../useMemo-reordering-depslist-assignment.ts | 1 + 48 files changed, 389 insertions(+), 280 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.expect.md index 933fafff5f1ba..8024676c65a32 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel import { Stringify, mutate, @@ -101,7 +102,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel import { Stringify, mutate, @@ -175,21 +176,14 @@ import { * and mutability. */ function Component(t0) { - const $ = _c(4); + const $ = _c(2); const { prop } = t0; let t1; if ($[0] !== prop) { const obj = shallowCopy(prop); const aliasedObj = identity(obj); - let t2; - if ($[2] !== obj) { - t2 = [obj.id]; - $[2] = obj; - $[3] = t2; - } else { - t2 = $[3]; - } - const id = t2; + + const id = [obj.id]; mutate(aliasedObj); setPropertyByKey(aliasedObj, "id", prop.id + 1); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.tsx index 4d9d7e78fb309..ecd5598cb0913 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.tsx +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.tsx @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel import { Stringify, mutate, diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.expect.md index c1a6dfb3eae11..a36b862052ab2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel import {Stringify} from 'shared-runtime'; /** @@ -43,7 +44,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel import { Stringify } from "shared-runtime"; /** @@ -57,62 +58,67 @@ import { Stringify } from "shared-runtime"; * - cb1 is not assumed to be called since it's only used as a call operand */ function useFoo(t0) { - const $ = _c(13); - const { arr1, arr2 } = t0; + const $ = _c(14); + let arr1; + let arr2; let t1; - if ($[0] !== arr1[0]) { - t1 = (e) => arr1[0].value + e.value; - $[0] = arr1[0]; - $[1] = t1; + if ($[0] !== t0) { + ({ arr1, arr2 } = t0); + let t2; + if ($[4] !== arr1[0]) { + t2 = (e) => arr1[0].value + e.value; + $[4] = arr1[0]; + $[5] = t2; + } else { + t2 = $[5]; + } + const cb1 = t2; + t1 = () => arr1.map(cb1); + $[0] = t0; + $[1] = arr1; + $[2] = arr2; + $[3] = t1; } else { - t1 = $[1]; + arr1 = $[1]; + arr2 = $[2]; + t1 = $[3]; } - const cb1 = t1; + const getArrMap1 = t1; let t2; - if ($[2] !== arr1 || $[3] !== cb1) { - t2 = () => arr1.map(cb1); - $[2] = arr1; - $[3] = cb1; - $[4] = t2; + if ($[6] !== arr2) { + t2 = (e_0) => arr2[0].value + e_0.value; + $[6] = arr2; + $[7] = t2; } else { - t2 = $[4]; + t2 = $[7]; } - const getArrMap1 = t2; + const cb2 = t2; let t3; - if ($[5] !== arr2) { - t3 = (e_0) => arr2[0].value + e_0.value; - $[5] = arr2; - $[6] = t3; + if ($[8] !== arr1 || $[9] !== cb2) { + t3 = () => arr1.map(cb2); + $[8] = arr1; + $[9] = cb2; + $[10] = t3; } else { - t3 = $[6]; + t3 = $[10]; } - const cb2 = t3; + const getArrMap2 = t3; let t4; - if ($[7] !== arr1 || $[8] !== cb2) { - t4 = () => arr1.map(cb2); - $[7] = arr1; - $[8] = cb2; - $[9] = t4; - } else { - t4 = $[9]; - } - const getArrMap2 = t4; - let t5; - if ($[10] !== getArrMap1 || $[11] !== getArrMap2) { - t5 = ( + if ($[11] !== getArrMap1 || $[12] !== getArrMap2) { + t4 = ( ); - $[10] = getArrMap1; - $[11] = getArrMap2; - $[12] = t5; + $[11] = getArrMap1; + $[12] = getArrMap2; + $[13] = t4; } else { - t5 = $[12]; + t4 = $[13]; } - return t5; + return t4; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.js index e9056562262e8..faa34747da188 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/array-map-named-callback-cross-context.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel import {Stringify} from 'shared-runtime'; /** diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.expect.md index 2afc5fd25dbac..d1434e95b827a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel function bar(a) { let x = [a]; let y = {}; @@ -23,19 +24,27 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel function bar(a) { - const $ = _c(2); - let y; + const $ = _c(4); + let t0; if ($[0] !== a) { - const x = [a]; + t0 = [a]; + $[0] = a; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; + let y; + if ($[2] !== x[0][1]) { y = {}; y = x[0][1]; - $[0] = a; - $[1] = y; + $[2] = x[0][1]; + $[3] = y; } else { - y = $[1]; + y = $[3]; } return y; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.js index 4c224e28415b1..a77287910a419 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-2-iife.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel function bar(a) { let x = [a]; let y = {}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.expect.md index f0267c3309f5b..80bb009ba25d3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel function bar(a, b) { let x = [a, b]; let y = {}; @@ -27,22 +28,31 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel function bar(a, b) { - const $ = _c(3); - let y; + const $ = _c(6); + let t0; if ($[0] !== a || $[1] !== b) { - const x = [a, b]; + t0 = [a, b]; + $[0] = a; + $[1] = b; + $[2] = t0; + } else { + t0 = $[2]; + } + const x = t0; + let y; + if ($[3] !== x[0][1] || $[4] !== x[1][0]) { y = {}; let t = {}; y = x[0][1]; t = x[1][0]; - $[0] = a; - $[1] = b; - $[2] = y; + $[3] = x[0][1]; + $[4] = x[1][0]; + $[5] = y; } else { - y = $[2]; + y = $[5]; } return y; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.js index 1afc28a9922ec..9afe5994b210b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-3-iife.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel function bar(a, b) { let x = [a, b]; let y = {}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.expect.md index 22728aaf4323d..663d1f3d567b3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel function bar(a) { let x = [a]; let y = {}; @@ -23,19 +24,27 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel function bar(a) { - const $ = _c(2); - let y; + const $ = _c(4); + let t0; if ($[0] !== a) { - const x = [a]; + t0 = [a]; + $[0] = a; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; + let y; + if ($[2] !== x[0].a[1]) { y = {}; y = x[0].a[1]; - $[0] = a; - $[1] = y; + $[2] = x[0].a[1]; + $[3] = y; } else { - y = $[1]; + y = $[3]; } return y; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.js index ca479a7458926..5a3cb878485d4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-4-iife.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel function bar(a) { let x = [a]; let y = {}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.expect.md index 60f829cdc4d66..58694faf57d33 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel function bar(a) { let x = [a]; let y = {}; @@ -22,19 +23,27 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel function bar(a) { - const $ = _c(2); - let y; + const $ = _c(4); + let t0; if ($[0] !== a) { - const x = [a]; + t0 = [a]; + $[0] = a; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; + let y; + if ($[2] !== x[0]) { y = {}; y = x[0]; - $[0] = a; - $[1] = y; + $[2] = x[0]; + $[3] = y; } else { - y = $[1]; + y = $[3]; } return y; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.js index 9a0c7c19aa700..0b95fc02a2b58 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capturing-function-alias-computed-load-iife.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel function bar(a) { let x = [a]; let y = {}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md index a67d467df8cfd..73dd12670f159 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @validateNoImpureFunctionsInRender +// @validateNoImpureFunctionsInRender @enableNewMutationAliasingModel function Component() { const date = Date.now(); @@ -20,7 +20,7 @@ function Component() { 2 | 3 | function Component() { > 4 | const date = Date.now(); - | ^^^^^^^^ InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `Date.now` is an impure function whose results may change on every call (4:4) + | ^^^^^^^^^^ InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `Date.now` is an impure function whose results may change on every call (4:4) InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `performance.now` is an impure function whose results may change on every call (5:5) diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.js index 6faf98caff702..83cf3e04f2b6a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-impure-functions-in-render.js @@ -1,4 +1,4 @@ -// @validateNoImpureFunctionsInRender +// @validateNoImpureFunctionsInRender @enableNewMutationAliasingModel function Component() { const date = Date.now(); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.expect.md index fe684586cbd33..0461bb4b7b4a4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel function Component() { let local; @@ -41,13 +42,13 @@ function Component() { ## Error ``` - 3 | - 4 | const reassignLocal = newValue => { -> 5 | local = newValue; - | ^^^^^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `local` cannot be reassigned after render (5:5) - 6 | }; - 7 | - 8 | const onClick = newValue => { + 4 | + 5 | const reassignLocal = newValue => { +> 6 | local = newValue; + | ^^^^^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `local` cannot be reassigned after render (6:6) + 7 | }; + 8 | + 9 | const onClick = newValue => { ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.js index 121495ac1e05c..2cfb336bcf5e3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-reassign-local-variable-in-jsx-callback.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel function Component() { let local; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.expect.md index 665fc7053b788..a26381d1d301c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel function useHook(a, b) { b.test = 1; a.test = 2; @@ -13,12 +14,15 @@ function useHook(a, b) { ## Error ``` - 1 | function useHook(a, b) { -> 2 | b.test = 1; - | ^ InvalidReact: Mutating component props or hook arguments is not allowed. Consider using a local variable instead (2:2) - 3 | a.test = 2; - 4 | } - 5 | + 1 | // @enableNewMutationAliasingModel + 2 | function useHook(a, b) { +> 3 | b.test = 1; + | ^ InvalidReact: Mutating component props or hook arguments is not allowed. Consider using a local variable instead (3:3) + +InvalidReact: Mutating component props or hook arguments is not allowed. Consider using a local variable instead (4:4) + 4 | a.test = 2; + 5 | } + 6 | ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.js index 321e9049cddbd..41c5b99132460 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel function useHook(a, b) { b.test = 1; a.test = 2; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.expect.md index 7d829fe9b013f..6f7d6b24831a8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel let x = {a: 42}; function Component(props) { @@ -17,13 +18,15 @@ function Component(props) { ## Error ``` - 3 | function Component(props) { - 4 | foo(() => { -> 5 | x.a = 10; - | ^ InvalidReact: Writing to a variable defined outside a component or hook is not allowed. Consider using an effect (5:5) - 6 | x.a = 20; - 7 | }); - 8 | } + 4 | function Component(props) { + 5 | foo(() => { +> 6 | x.a = 10; + | ^ InvalidReact: Writing to a variable defined outside a component or hook is not allowed. Consider using an effect (6:6) + +InvalidReact: Writing to a variable defined outside a component or hook is not allowed. Consider using an effect (7:7) + 7 | x.a = 20; + 8 | }); + 9 | } ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.js index 3b44c4c247760..ed51080726b5a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel let x = {a: 42}; function Component(props) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.expect.md index e4073947f7e15..b6f01488fc755 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel function Component() { const foo = () => { // Cannot assign to globals @@ -17,13 +18,15 @@ function Component() { ## Error ``` - 2 | const foo = () => { - 3 | // Cannot assign to globals -> 4 | someUnknownGlobal = true; - | ^^^^^^^^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (4:4) - 5 | moduleLocal = true; - 6 | }; - 7 | foo(); + 3 | const foo = () => { + 4 | // Cannot assign to globals +> 5 | someUnknownGlobal = true; + | ^^^^^^^^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (5:5) + +InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (6:6) + 6 | moduleLocal = true; + 7 | }; + 8 | foo(); ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.js index 708fe643d57df..6d6681e60ad34 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel function Component() { const foo = () => { // Cannot assign to globals diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.expect.md index 4619cd27cb24d..a75aa397eceec 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel function Component() { // Cannot assign to globals someUnknownGlobal = true; @@ -14,13 +15,15 @@ function Component() { ## Error ``` - 1 | function Component() { - 2 | // Cannot assign to globals -> 3 | someUnknownGlobal = true; - | ^^^^^^^^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (3:3) - 4 | moduleLocal = true; - 5 | } - 6 | + 2 | function Component() { + 3 | // Cannot assign to globals +> 4 | someUnknownGlobal = true; + | ^^^^^^^^^^^^^^^^^ InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (4:4) + +InvalidReact: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) (5:5) + 5 | moduleLocal = true; + 6 | } + 7 | ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.js index d0509a3d52a16..41b706866bf7c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel function Component() { // Cannot assign to globals someUnknownGlobal = true; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md index 2a935256d7a0d..3d9d0b5613857 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel function Component(props) { function hasErrors() { let hasErrors = false; @@ -19,12 +20,12 @@ function Component(props) { ## Error ``` - 7 | return hasErrors; - 8 | } -> 9 | return hasErrors(); - | ^^^^^^^^^ Invariant: [hoisting] Expected value for identifier to be initialized. hasErrors_0$15 (9:9) - 10 | } - 11 | + 8 | return hasErrors; + 9 | } +> 10 | return hasErrors(); + | ^^^^^^^^^ Invariant: [InferMutationAliasingEffects] Expected value kind to be initialized. hasErrors_0$15:TFunction (10:10) + 11 | } + 12 | ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js index b7a450ccba028..b58c0aea7daf7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.todo-repro-named-function-with-shadowed-local-same-name.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel function Component(props) { function hasErrors() { let hasErrors = false; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.expect.md index e4560848dd5f2..8dec2e3ebe94b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import {useEffect} from 'react'; import {print} from 'shared-runtime'; @@ -25,7 +25,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import { useEffect } from "react"; import { print } from "shared-runtime"; @@ -48,9 +48,9 @@ export const FIXTURE_ENTRYPOINT = { ## Logs ``` -{"kind":"CompileError","fnLoc":{"start":{"line":5,"column":0,"index":139},"end":{"line":12,"column":1,"index":384},"filename":"mutate-after-useeffect-optional-chain.ts"},"detail":{"reason":"This mutates a variable that React considers immutable","description":null,"loc":{"start":{"line":10,"column":2,"index":345},"end":{"line":10,"column":5,"index":348},"filename":"mutate-after-useeffect-optional-chain.ts","identifierName":"arr"},"suggestions":null,"severity":"InvalidReact"}} -{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":9,"column":2,"index":304},"end":{"line":9,"column":39,"index":341},"filename":"mutate-after-useeffect-optional-chain.ts"},"decorations":[{"start":{"line":9,"column":24,"index":326},"end":{"line":9,"column":27,"index":329},"filename":"mutate-after-useeffect-optional-chain.ts","identifierName":"arr"}]} -{"kind":"CompileSuccess","fnLoc":{"start":{"line":5,"column":0,"index":139},"end":{"line":12,"column":1,"index":384},"filename":"mutate-after-useeffect-optional-chain.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} +{"kind":"CompileError","fnLoc":{"start":{"line":5,"column":0,"index":171},"end":{"line":12,"column":1,"index":416},"filename":"mutate-after-useeffect-optional-chain.ts"},"detail":{"reason":"Updating a value used previously in an effect function or as an effect dependency is not allowed. Consider moving the mutation before calling useEffect()","description":null,"severity":"InvalidReact","suggestions":null,"loc":{"start":{"line":10,"column":2,"index":377},"end":{"line":10,"column":5,"index":380},"filename":"mutate-after-useeffect-optional-chain.ts","identifierName":"arr"}}} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":9,"column":2,"index":336},"end":{"line":9,"column":39,"index":373},"filename":"mutate-after-useeffect-optional-chain.ts"},"decorations":[{"start":{"line":9,"column":24,"index":358},"end":{"line":9,"column":27,"index":361},"filename":"mutate-after-useeffect-optional-chain.ts","identifierName":"arr"}]} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":5,"column":0,"index":171},"end":{"line":12,"column":1,"index":416},"filename":"mutate-after-useeffect-optional-chain.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} ``` ### Eval output diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.js index c435b72d1a9ef..dd8d6669885d2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-optional-chain.js @@ -1,4 +1,4 @@ -// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import {useEffect} from 'react'; import {print} from 'shared-runtime'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md index 5e6f19dd83e65..167c23c3476b5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import {useEffect, useRef} from 'react'; import {print} from 'shared-runtime'; @@ -24,7 +24,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import { useEffect, useRef } from "react"; import { print } from "shared-runtime"; @@ -47,9 +47,9 @@ export const FIXTURE_ENTRYPOINT = { ## Logs ``` -{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":148},"end":{"line":11,"column":1,"index":311},"filename":"mutate-after-useeffect-ref-access.ts"},"detail":{"reason":"Mutating component props or hook arguments is not allowed. Consider using a local variable instead","description":null,"loc":{"start":{"line":9,"column":2,"index":269},"end":{"line":9,"column":16,"index":283},"filename":"mutate-after-useeffect-ref-access.ts"},"suggestions":null,"severity":"InvalidReact"}} -{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":8,"column":2,"index":227},"end":{"line":8,"column":40,"index":265},"filename":"mutate-after-useeffect-ref-access.ts"},"decorations":[{"start":{"line":8,"column":24,"index":249},"end":{"line":8,"column":30,"index":255},"filename":"mutate-after-useeffect-ref-access.ts","identifierName":"arrRef"}]} -{"kind":"CompileSuccess","fnLoc":{"start":{"line":6,"column":0,"index":148},"end":{"line":11,"column":1,"index":311},"filename":"mutate-after-useeffect-ref-access.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} +{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":180},"end":{"line":11,"column":1,"index":343},"filename":"mutate-after-useeffect-ref-access.ts"},"detail":{"reason":"Mutating component props or hook arguments is not allowed. Consider using a local variable instead","description":null,"severity":"InvalidReact","suggestions":null,"loc":{"start":{"line":9,"column":2,"index":301},"end":{"line":9,"column":16,"index":315},"filename":"mutate-after-useeffect-ref-access.ts"}}} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":8,"column":2,"index":259},"end":{"line":8,"column":40,"index":297},"filename":"mutate-after-useeffect-ref-access.ts"},"decorations":[{"start":{"line":8,"column":24,"index":281},"end":{"line":8,"column":30,"index":287},"filename":"mutate-after-useeffect-ref-access.ts","identifierName":"arrRef"}]} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":6,"column":0,"index":180},"end":{"line":11,"column":1,"index":343},"filename":"mutate-after-useeffect-ref-access.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} ``` ### Eval output diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.js index bd3f6d1de54bd..f91bd14deb14c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect-ref-access.js @@ -1,4 +1,4 @@ -// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import {useEffect, useRef} from 'react'; import {print} from 'shared-runtime'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.expect.md index 3b61fbf834246..47a0124baa856 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import {useEffect} from 'react'; function Component({foo}) { @@ -24,7 +24,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import { useEffect } from "react"; function Component(t0) { @@ -47,9 +47,9 @@ export const FIXTURE_ENTRYPOINT = { ## Logs ``` -{"kind":"CompileError","fnLoc":{"start":{"line":4,"column":0,"index":101},"end":{"line":11,"column":1,"index":222},"filename":"mutate-after-useeffect.ts"},"detail":{"reason":"This mutates a variable that React considers immutable","description":null,"loc":{"start":{"line":9,"column":2,"index":194},"end":{"line":9,"column":5,"index":197},"filename":"mutate-after-useeffect.ts","identifierName":"arr"},"suggestions":null,"severity":"InvalidReact"}} -{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":6,"column":2,"index":149},"end":{"line":8,"column":4,"index":190},"filename":"mutate-after-useeffect.ts"},"decorations":[{"start":{"line":7,"column":4,"index":171},"end":{"line":7,"column":7,"index":174},"filename":"mutate-after-useeffect.ts","identifierName":"arr"},{"start":{"line":7,"column":4,"index":171},"end":{"line":7,"column":7,"index":174},"filename":"mutate-after-useeffect.ts","identifierName":"arr"},{"start":{"line":7,"column":13,"index":180},"end":{"line":7,"column":16,"index":183},"filename":"mutate-after-useeffect.ts","identifierName":"foo"}]} -{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":101},"end":{"line":11,"column":1,"index":222},"filename":"mutate-after-useeffect.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} +{"kind":"CompileError","fnLoc":{"start":{"line":4,"column":0,"index":133},"end":{"line":11,"column":1,"index":254},"filename":"mutate-after-useeffect.ts"},"detail":{"reason":"Updating a value used previously in an effect function or as an effect dependency is not allowed. Consider moving the mutation before calling useEffect()","description":null,"severity":"InvalidReact","suggestions":null,"loc":{"start":{"line":9,"column":2,"index":226},"end":{"line":9,"column":5,"index":229},"filename":"mutate-after-useeffect.ts","identifierName":"arr"}}} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":6,"column":2,"index":181},"end":{"line":8,"column":4,"index":222},"filename":"mutate-after-useeffect.ts"},"decorations":[{"start":{"line":7,"column":4,"index":203},"end":{"line":7,"column":7,"index":206},"filename":"mutate-after-useeffect.ts","identifierName":"arr"},{"start":{"line":7,"column":4,"index":203},"end":{"line":7,"column":7,"index":206},"filename":"mutate-after-useeffect.ts","identifierName":"arr"},{"start":{"line":7,"column":13,"index":212},"end":{"line":7,"column":16,"index":215},"filename":"mutate-after-useeffect.ts","identifierName":"foo"}]} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":133},"end":{"line":11,"column":1,"index":254},"filename":"mutate-after-useeffect.ts"},"fnName":"Component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} ``` ### Eval output diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.js index fbcbf004a308c..6f237c89b4d4f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-after-useeffect.js @@ -1,4 +1,4 @@ -// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import {useEffect} from 'react'; function Component({foo}) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.expect.md index bf0f9da6b1da1..5c73ce6d77adf 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel import {identity, mutate} from 'shared-runtime'; function Component(props) { @@ -23,38 +24,22 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel import { identity, mutate } from "shared-runtime"; function Component(props) { - const $ = _c(5); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = {}; - $[0] = t0; + const $ = _c(2); + let context; + if ($[0] !== props.value) { + const key = {}; + context = { [key]: identity([props.value]) }; + + mutate(key); + $[0] = props.value; + $[1] = context; } else { - t0 = $[0]; + context = $[1]; } - const key = t0; - let t1; - if ($[1] !== props.value) { - t1 = identity([props.value]); - $[1] = props.value; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== t1) { - t2 = { [key]: t1 }; - $[3] = t1; - $[4] = t2; - } else { - t2 = $[4]; - } - const context = t2; - - mutate(key); return context; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.js index 1edaaaef27e4b..923733b9c238d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-key-object-mutated-later.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel import {identity, mutate} from 'shared-runtime'; function Component(props) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.expect.md index 810b03e529e77..1ef3ed157f9fa 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel import {identity, mutate, mutateAndReturn} from 'shared-runtime'; function Component(props) { @@ -23,15 +24,26 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel import { identity, mutate, mutateAndReturn } from "shared-runtime"; function Component(props) { - const $ = _c(2); + const $ = _c(4); let context; if ($[0] !== props.value) { const key = { a: "key" }; - context = { [key.a]: identity([props.value]) }; + + const t0 = key.a; + const t1 = identity([props.value]); + let t2; + if ($[2] !== t1) { + t2 = { [t0]: t1 }; + $[2] = t1; + $[3] = t2; + } else { + t2 = $[3]; + } + context = t2; mutate(key); $[0] = props.value; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.js index 95a1d434624e9..516fdc1dbcf41 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.js @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel import {identity, mutate, mutateAndReturn} from 'shared-runtime'; function Component(props) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.expect.md index 3af2b9b8b1c89..de7fc2903ebd2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @inferEffectDependencies +// @inferEffectDependencies @enableNewMutationAliasingModel import {useEffect, useState} from 'react'; import {print} from 'shared-runtime'; @@ -26,7 +26,7 @@ function ReactiveRefInEffect(props) { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies @enableNewMutationAliasingModel import { useEffect, useState } from "react"; import { print } from "shared-runtime"; @@ -34,22 +34,28 @@ import { print } from "shared-runtime"; * setState types are not enough to determine to omit from deps. Must also take reactivity into account. */ function ReactiveRefInEffect(props) { - const $ = _c(2); + const $ = _c(4); const [, setState1] = useRef("initial value"); const [, setState2] = useRef("initial value"); let setState; - if (props.foo) { - setState = setState1; + if ($[0] !== props.foo) { + if (props.foo) { + setState = setState1; + } else { + setState = setState2; + } + $[0] = props.foo; + $[1] = setState; } else { - setState = setState2; + setState = $[1]; } let t0; - if ($[0] !== setState) { + if ($[2] !== setState) { t0 = () => print(setState); - $[0] = setState; - $[1] = t0; + $[2] = setState; + $[3] = t0; } else { - t0 = $[1]; + t0 = $[3]; } useEffect(t0, [setState]); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.js index 46a83d8ad42dd..158881eb0204d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.js @@ -1,4 +1,4 @@ -// @inferEffectDependencies +// @inferEffectDependencies @enableNewMutationAliasingModel import {useEffect, useState} from 'react'; import {print} from 'shared-runtime'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.expect.md index bd70c0138d7e2..053728ed17015 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @inferEffectDependencies @noEmit @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @noEmit @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import {print} from 'shared-runtime'; import useEffectWrapper from 'useEffectWrapper'; @@ -27,7 +27,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -// @inferEffectDependencies @noEmit @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @noEmit @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import { print } from "shared-runtime"; import useEffectWrapper from "useEffectWrapper"; @@ -52,10 +52,10 @@ export const FIXTURE_ENTRYPOINT = { ## Logs ``` -{"kind":"CompileError","fnLoc":{"start":{"line":5,"column":0,"index":163},"end":{"line":13,"column":1,"index":357},"filename":"retry-no-emit.ts"},"detail":{"reason":"This mutates a variable that React considers immutable","description":null,"loc":{"start":{"line":11,"column":2,"index":320},"end":{"line":11,"column":6,"index":324},"filename":"retry-no-emit.ts","identifierName":"arr2"},"suggestions":null,"severity":"InvalidReact"}} -{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":7,"column":2,"index":216},"end":{"line":7,"column":36,"index":250},"filename":"retry-no-emit.ts"},"decorations":[{"start":{"line":7,"column":31,"index":245},"end":{"line":7,"column":34,"index":248},"filename":"retry-no-emit.ts","identifierName":"arr"}]} -{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":10,"column":2,"index":274},"end":{"line":10,"column":44,"index":316},"filename":"retry-no-emit.ts"},"decorations":[{"start":{"line":10,"column":25,"index":297},"end":{"line":10,"column":29,"index":301},"filename":"retry-no-emit.ts","identifierName":"arr2"},{"start":{"line":10,"column":25,"index":297},"end":{"line":10,"column":29,"index":301},"filename":"retry-no-emit.ts","identifierName":"arr2"},{"start":{"line":10,"column":35,"index":307},"end":{"line":10,"column":42,"index":314},"filename":"retry-no-emit.ts","identifierName":"propVal"}]} -{"kind":"CompileSuccess","fnLoc":{"start":{"line":5,"column":0,"index":163},"end":{"line":13,"column":1,"index":357},"filename":"retry-no-emit.ts"},"fnName":"Foo","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} +{"kind":"CompileError","fnLoc":{"start":{"line":5,"column":0,"index":195},"end":{"line":13,"column":1,"index":389},"filename":"retry-no-emit.ts"},"detail":{"reason":"This mutates a variable that React considers immutable","description":null,"severity":"InvalidReact","suggestions":null,"loc":{"start":{"line":11,"column":2,"index":352},"end":{"line":11,"column":6,"index":356},"filename":"retry-no-emit.ts","identifierName":"arr2"}}} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":7,"column":2,"index":248},"end":{"line":7,"column":36,"index":282},"filename":"retry-no-emit.ts"},"decorations":[{"start":{"line":7,"column":31,"index":277},"end":{"line":7,"column":34,"index":280},"filename":"retry-no-emit.ts","identifierName":"arr"}]} +{"kind":"AutoDepsDecorations","fnLoc":{"start":{"line":10,"column":2,"index":306},"end":{"line":10,"column":44,"index":348},"filename":"retry-no-emit.ts"},"decorations":[{"start":{"line":10,"column":25,"index":329},"end":{"line":10,"column":29,"index":333},"filename":"retry-no-emit.ts","identifierName":"arr2"},{"start":{"line":10,"column":25,"index":329},"end":{"line":10,"column":29,"index":333},"filename":"retry-no-emit.ts","identifierName":"arr2"},{"start":{"line":10,"column":35,"index":339},"end":{"line":10,"column":42,"index":346},"filename":"retry-no-emit.ts","identifierName":"propVal"}]} +{"kind":"CompileSuccess","fnLoc":{"start":{"line":5,"column":0,"index":195},"end":{"line":13,"column":1,"index":389},"filename":"retry-no-emit.ts"},"fnName":"Foo","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0} ``` ### Eval output diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.js index d1dda06a044a2..c15f400d3114d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/retry-no-emit.js @@ -1,4 +1,4 @@ -// @inferEffectDependencies @noEmit @panicThreshold:"none" @loggerTestOnly +// @inferEffectDependencies @noEmit @panicThreshold:"none" @loggerTestOnly @enableNewMutationAliasingModel import {print} from 'shared-runtime'; import useEffectWrapper from 'useEffectWrapper'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.expect.md index 92dbf9843ad65..3f361c2019043 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @enableFire +// @enableFire @enableNewMutationAliasingModel import {fire} from 'react'; function Component({bar, baz}) { @@ -26,51 +26,64 @@ function Component({bar, baz}) { ## Code ```javascript -import { c as _c, useFire } from "react/compiler-runtime"; // @enableFire +import { c as _c, useFire } from "react/compiler-runtime"; // @enableFire @enableNewMutationAliasingModel import { fire } from "react"; function Component(t0) { - const $ = _c(9); - const { bar, baz } = t0; - let t1; - if ($[0] !== bar) { - t1 = () => { - console.log(bar); - }; - $[0] = bar; - $[1] = t1; + const $ = _c(13); + let bar; + let baz; + let foo; + if ($[0] !== t0) { + ({ bar, baz } = t0); + let t1; + if ($[4] !== bar) { + t1 = () => { + console.log(bar); + }; + $[4] = bar; + $[5] = t1; + } else { + t1 = $[5]; + } + foo = t1; + $[0] = t0; + $[1] = bar; + $[2] = baz; + $[3] = foo; } else { - t1 = $[1]; + bar = $[1]; + baz = $[2]; + foo = $[3]; } - const foo = t1; - const t2 = useFire(foo); - const t3 = useFire(baz); - let t4; - if ($[2] !== bar || $[3] !== t2 || $[4] !== t3) { - t4 = () => { + const t1 = useFire(foo); + const t2 = useFire(baz); + let t3; + if ($[6] !== bar || $[7] !== t1 || $[8] !== t2) { + t3 = () => { + t1(bar); t2(bar); - t3(bar); }; - $[2] = bar; - $[3] = t2; - $[4] = t3; - $[5] = t4; + $[6] = bar; + $[7] = t1; + $[8] = t2; + $[9] = t3; } else { - t4 = $[5]; + t3 = $[9]; } - useEffect(t4); - let t5; - if ($[6] !== bar || $[7] !== t2) { - t5 = () => { - t2(bar); + useEffect(t3); + let t4; + if ($[10] !== bar || $[11] !== t1) { + t4 = () => { + t1(bar); }; - $[6] = bar; - $[7] = t2; - $[8] = t5; + $[10] = bar; + $[11] = t1; + $[12] = t4; } else { - t5 = $[8]; + t4 = $[12]; } - useEffect(t5); + useEffect(t4); return null; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.js index 5cb51e9bd3c78..54d4cf83fe310 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.js @@ -1,4 +1,4 @@ -// @enableFire +// @enableFire @enableNewMutationAliasingModel import {fire} from 'react'; function Component({bar, baz}) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.expect.md index 080cc0a74a609..e33f52396d5e5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel import {useCallback} from 'react'; import {Stringify} from 'shared-runtime'; @@ -35,44 +36,51 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel import { useCallback } from "react"; import { Stringify } from "shared-runtime"; function Foo(t0) { - const $ = _c(8); + const $ = _c(10); const { arr1, arr2, foo } = t0; - let getVal1; let t1; - if ($[0] !== arr1 || $[1] !== arr2 || $[2] !== foo) { - const x = [arr1]; - + if ($[0] !== arr1) { + t1 = [arr1]; + $[0] = arr1; + $[1] = t1; + } else { + t1 = $[1]; + } + const x = t1; + let getVal1; + let t2; + if ($[2] !== arr2 || $[3] !== foo || $[4] !== x) { let y = []; getVal1 = _temp; - t1 = () => [y]; + t2 = () => [y]; foo ? (y = x.concat(arr2)) : y; - $[0] = arr1; - $[1] = arr2; - $[2] = foo; - $[3] = getVal1; - $[4] = t1; + $[2] = arr2; + $[3] = foo; + $[4] = x; + $[5] = getVal1; + $[6] = t2; } else { - getVal1 = $[3]; - t1 = $[4]; + getVal1 = $[5]; + t2 = $[6]; } - const getVal2 = t1; - let t2; - if ($[5] !== getVal1 || $[6] !== getVal2) { - t2 = ; - $[5] = getVal1; - $[6] = getVal2; - $[7] = t2; + const getVal2 = t2; + let t3; + if ($[7] !== getVal1 || $[8] !== getVal2) { + t3 = ; + $[7] = getVal1; + $[8] = getVal2; + $[9] = t3; } else { - t2 = $[7]; + t3 = $[9]; } - return t2; + return t3; } function _temp() { return { x: 2 }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.tsx index ba0abc0d7cdf0..08b9e4b2faa6c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.tsx +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.tsx @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel import {useCallback} from 'react'; import {Stringify} from 'shared-runtime'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.expect.md index 89a6ad80c3945..d37762bbac530 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel import {useCallback} from 'react'; import {Stringify} from 'shared-runtime'; @@ -30,37 +31,44 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel import { useCallback } from "react"; import { Stringify } from "shared-runtime"; // We currently produce invalid output (incorrect scoping for `y` declaration) function useFoo(arr1, arr2) { - const $ = _c(5); + const $ = _c(7); let t0; - if ($[0] !== arr1 || $[1] !== arr2) { - const x = [arr1]; - - let y; - t0 = () => ({ y }); - - (y = x.concat(arr2)), y; + if ($[0] !== arr1) { + t0 = [arr1]; $[0] = arr1; - $[1] = arr2; - $[2] = t0; + $[1] = t0; } else { - t0 = $[2]; + t0 = $[1]; } - const getVal = t0; + const x = t0; let t1; - if ($[3] !== getVal) { - t1 = ; - $[3] = getVal; + if ($[2] !== arr2 || $[3] !== x) { + let y; + t1 = () => ({ y }); + + (y = x.concat(arr2)), y; + $[2] = arr2; + $[3] = x; $[4] = t1; } else { t1 = $[4]; } - return t1; + const getVal = t1; + let t2; + if ($[5] !== getVal) { + t2 = ; + $[5] = getVal; + $[6] = t2; + } else { + t2 = $[6]; + } + return t2; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.tsx index 3ac3845c47f74..43e2dfbb0504a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.tsx +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.tsx @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel import {useCallback} from 'react'; import {Stringify} from 'shared-runtime'; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.expect.md index 3fffec6a7dc20..26445bf9207be 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.expect.md @@ -2,6 +2,7 @@ ## Input ```javascript +// @enableNewMutationAliasingModel import {useMemo} from 'react'; function useFoo(arr1, arr2) { @@ -26,33 +27,40 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel import { useMemo } from "react"; function useFoo(arr1, arr2) { - const $ = _c(5); + const $ = _c(7); + let t0; + if ($[0] !== arr1) { + t0 = [arr1]; + $[0] = arr1; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; let y; - if ($[0] !== arr1 || $[1] !== arr2) { - const x = [arr1]; - + if ($[2] !== arr2 || $[3] !== x) { (y = x.concat(arr2)), y; - $[0] = arr1; - $[1] = arr2; - $[2] = y; + $[2] = arr2; + $[3] = x; + $[4] = y; } else { - y = $[2]; + y = $[4]; } - let t0; let t1; - if ($[3] !== y) { - t1 = { y }; - $[3] = y; - $[4] = t1; + let t2; + if ($[5] !== y) { + t2 = { y }; + $[5] = y; + $[6] = t2; } else { - t1 = $[4]; + t2 = $[6]; } - t0 = t1; - return t0; + t1 = t2; + return t1; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.ts index 8025d3680fb51..5b7d799d68b13 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.ts @@ -1,3 +1,4 @@ +// @enableNewMutationAliasingModel import {useMemo} from 'react'; function useFoo(arr1, arr2) { From 90ccbd71c158a8aeb1bf3ec704011ddd58842b71 Mon Sep 17 00:00:00 2001 From: Joseph Savona <6425824+josephsavona@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:02:12 -0700 Subject: [PATCH 046/144] [compiler] Enable new inference by default (#33497) --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33497). * #33571 * #33558 * #33547 * #33543 * #33533 * #33532 * #33530 * #33526 * #33522 * #33518 * #33514 * #33513 * #33512 * #33504 * #33500 * __->__ #33497 * #33496 --- .../src/HIR/Environment.ts | 2 +- ...iased-nested-scope-truncated-dep.expect.md | 13 +-- ...ction-alias-computed-load-2-iife.expect.md | 20 +++-- ...ction-alias-computed-load-3-iife.expect.md | 23 ++++-- ...ction-alias-computed-load-4-iife.expect.md | 20 +++-- ...unction-alias-computed-load-iife.expect.md | 20 +++-- ...valid-impure-functions-in-render.expect.md | 2 +- ...d-reanimated-shared-value-writes.expect.md | 2 +- .../error.mutate-hook-argument.expect.md | 2 + ...or.not-useEffect-external-mutate.expect.md | 2 + ....reassignment-to-global-indirect.expect.md | 2 + .../error.reassignment-to-global.expect.md | 2 + ...on-with-shadowed-local-same-name.expect.md | 2 +- ...e-after-useeffect-optional-chain.expect.md | 2 +- ...utate-after-useeffect-ref-access.expect.md | 2 +- .../mutate-after-useeffect.expect.md | 2 +- .../no-emit/retry-no-emit.expect.md | 2 +- .../reactive-setState.expect.md | 22 +++-- ...map-named-callback-cross-context.expect.md | 81 ++++++++++--------- ...omputed-key-object-mutated-later.expect.md | 36 +++------ ...bject-expression-computed-member.expect.md | 15 +++- ...k-reordering-deplist-controlflow.expect.md | 51 +++++++----- ...k-reordering-depslist-assignment.expect.md | 39 +++++---- ...o-reordering-depslist-assignment.expect.md | 39 +++++---- .../shared-hook-calls.expect.md | 77 ++++++++++-------- 25 files changed, 277 insertions(+), 203 deletions(-) diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index 206bfc0bca1a4..90a352620ce35 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -246,7 +246,7 @@ export const EnvironmentConfigSchema = z.object({ /** * Enable a new model for mutability and aliasing inference */ - enableNewMutationAliasingModel: z.boolean().default(false), + enableNewMutationAliasingModel: z.boolean().default(true), /** * Enables inference of optional dependency chains. Without this flag diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/aliased-nested-scope-truncated-dep.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/aliased-nested-scope-truncated-dep.expect.md index 933fafff5f1ba..12c7b4d5eab93 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/aliased-nested-scope-truncated-dep.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/aliased-nested-scope-truncated-dep.expect.md @@ -175,21 +175,14 @@ import { * and mutability. */ function Component(t0) { - const $ = _c(4); + const $ = _c(2); const { prop } = t0; let t1; if ($[0] !== prop) { const obj = shallowCopy(prop); const aliasedObj = identity(obj); - let t2; - if ($[2] !== obj) { - t2 = [obj.id]; - $[2] = obj; - $[3] = t2; - } else { - t2 = $[3]; - } - const id = t2; + + const id = [obj.id]; mutate(aliasedObj); setPropertyByKey(aliasedObj, "id", prop.id + 1); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-2-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-2-iife.expect.md index 2afc5fd25dbac..50480f1b2515e 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-2-iife.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-2-iife.expect.md @@ -25,17 +25,25 @@ export const FIXTURE_ENTRYPOINT = { ```javascript import { c as _c } from "react/compiler-runtime"; function bar(a) { - const $ = _c(2); - let y; + const $ = _c(4); + let t0; if ($[0] !== a) { - const x = [a]; + t0 = [a]; + $[0] = a; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; + let y; + if ($[2] !== x[0][1]) { y = {}; y = x[0][1]; - $[0] = a; - $[1] = y; + $[2] = x[0][1]; + $[3] = y; } else { - y = $[1]; + y = $[3]; } return y; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-3-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-3-iife.expect.md index f0267c3309f5b..9678918b3d27f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-3-iife.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-3-iife.expect.md @@ -29,20 +29,29 @@ export const FIXTURE_ENTRYPOINT = { ```javascript import { c as _c } from "react/compiler-runtime"; function bar(a, b) { - const $ = _c(3); - let y; + const $ = _c(6); + let t0; if ($[0] !== a || $[1] !== b) { - const x = [a, b]; + t0 = [a, b]; + $[0] = a; + $[1] = b; + $[2] = t0; + } else { + t0 = $[2]; + } + const x = t0; + let y; + if ($[3] !== x[0][1] || $[4] !== x[1][0]) { y = {}; let t = {}; y = x[0][1]; t = x[1][0]; - $[0] = a; - $[1] = b; - $[2] = y; + $[3] = x[0][1]; + $[4] = x[1][0]; + $[5] = y; } else { - y = $[2]; + y = $[5]; } return y; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-4-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-4-iife.expect.md index 22728aaf4323d..edddf3715a453 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-4-iife.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-4-iife.expect.md @@ -25,17 +25,25 @@ export const FIXTURE_ENTRYPOINT = { ```javascript import { c as _c } from "react/compiler-runtime"; function bar(a) { - const $ = _c(2); - let y; + const $ = _c(4); + let t0; if ($[0] !== a) { - const x = [a]; + t0 = [a]; + $[0] = a; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; + let y; + if ($[2] !== x[0].a[1]) { y = {}; y = x[0].a[1]; - $[0] = a; - $[1] = y; + $[2] = x[0].a[1]; + $[3] = y; } else { - y = $[1]; + y = $[3]; } return y; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-iife.expect.md index 60f829cdc4d66..c9ce6dda9f627 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-iife.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/capturing-function-alias-computed-load-iife.expect.md @@ -24,17 +24,25 @@ export const FIXTURE_ENTRYPOINT = { ```javascript import { c as _c } from "react/compiler-runtime"; function bar(a) { - const $ = _c(2); - let y; + const $ = _c(4); + let t0; if ($[0] !== a) { - const x = [a]; + t0 = [a]; + $[0] = a; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; + let y; + if ($[2] !== x[0]) { y = {}; y = x[0]; - $[0] = a; - $[1] = y; + $[2] = x[0]; + $[3] = y; } else { - y = $[1]; + y = $[3]; } return y; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-impure-functions-in-render.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-impure-functions-in-render.expect.md index a67d467df8cfd..0fb17a8f6eab4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-impure-functions-in-render.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-impure-functions-in-render.expect.md @@ -20,7 +20,7 @@ function Component() { 2 | 3 | function Component() { > 4 | const date = Date.now(); - | ^^^^^^^^ InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `Date.now` is an impure function whose results may change on every call (4:4) + | ^^^^^^^^^^ InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `Date.now` is an impure function whose results may change on every call (4:4) InvalidReact: Calling an impure function can produce unstable results. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent). `performance.now` is an impure function whose results may change on every call (5:5) diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-non-imported-reanimated-shared-value-writes.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-non-imported-reanimated-shared-value-writes.expect.md index f1399a41b6fec..d3bb7f413622b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-non-imported-reanimated-shared-value-writes.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-non-imported-reanimated-shared-value-writes.expect.md @@ -27,7 +27,7 @@ function SomeComponent() { 9 | return ( 10 |