diff --git a/.eslintrc.js b/.eslintrc.js index 49846c1f5e9bc..f7f748516d9ab 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -496,6 +496,7 @@ module.exports = { 'packages/react-devtools-shared/src/devtools/views/**/*.js', 'packages/react-devtools-shared/src/hook.js', 'packages/react-devtools-shared/src/backend/console.js', + 'packages/react-devtools-shared/src/backend/fiber/renderer.js', 'packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js', 'packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js', ], @@ -504,6 +505,7 @@ module.exports = { __IS_FIREFOX__: 'readonly', __IS_EDGE__: 'readonly', __IS_NATIVE__: 'readonly', + __IS_INTERNAL_MCP_BUILD__: 'readonly', __IS_INTERNAL_VERSION__: 'readonly', chrome: 'readonly', }, @@ -559,6 +561,7 @@ module.exports = { ConsoleTask: 'readonly', // TOOD: Figure out what the official name of this will be. ReturnType: 'readonly', AnimationFrameID: 'readonly', + WeakRef: 'readonly', // For Flow type annotation. Only `BigInt` is valid at runtime. bigint: 'readonly', BigInt: 'readonly', @@ -579,6 +582,7 @@ module.exports = { JSONValue: 'readonly', JSResourceReference: 'readonly', MouseEventHandler: 'readonly', + NavigateEvent: 'readonly', PropagationPhases: 'readonly', PropertyDescriptor: 'readonly', React$AbstractComponent: 'readonly', @@ -608,6 +612,7 @@ module.exports = { TimeoutID: 'readonly', WheelEventHandler: 'readonly', FinalizationRegistry: 'readonly', + Exclude: 'readonly', Omit: 'readonly', Keyframe: 'readonly', PropertyIndexedKeyframes: 'readonly', @@ -617,6 +622,7 @@ module.exports = { ScrollTimeline: 'readonly', EventListenerOptionsOrUseCapture: 'readonly', FocusOptions: 'readonly', + OptionalEffectTiming: 'readonly', spyOnDev: 'readonly', spyOnDevAndProd: 'readonly', @@ -634,5 +640,6 @@ module.exports = { AsyncLocalStorage: 'readonly', async_hooks: 'readonly', globalThis: 'readonly', + navigation: 'readonly', }, }; diff --git a/.github/workflows/compiler_discord_notify.yml b/.github/workflows/compiler_discord_notify.yml index 71aea56e8492f..5a57cf6a32c19 100644 --- a/.github/workflows/compiler_discord_notify.yml +++ b/.github/workflows/compiler_discord_notify.yml @@ -11,10 +11,12 @@ 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 }} steps: + - run: echo ${{ github.event.pull_request.author_association }} - name: Check is member or collaborator id: check_is_member_or_collaborator if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }} diff --git a/.github/workflows/runtime_build_and_test.yml b/.github/workflows/runtime_build_and_test.yml index 1d0a896984e26..0762df8ed64dd 100644 --- a/.github/workflows/runtime_build_and_test.yml +++ b/.github/workflows/runtime_build_and_test.yml @@ -280,6 +280,37 @@ 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') }} + - name: Install runtime dependencies + run: yarn install --frozen-lockfile + if: steps.node_modules.outputs.cache-hit != 'true' + - name: Install compiler dependencies + run: yarn install --frozen-lockfile + working-directory: compiler + 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/.github/workflows/runtime_commit_artifacts.yml b/.github/workflows/runtime_commit_artifacts.yml index ab0e71b83cfc7..b982d561ed71c 100644 --- a/.github/workflows/runtime_commit_artifacts.yml +++ b/.github/workflows/runtime_commit_artifacts.yml @@ -332,10 +332,10 @@ jobs: git --no-pager diff -U0 --cached | grep '^[+-]' | head -n 100 echo "====================" # Ignore REVISION or lines removing @generated headers. - if git diff --cached ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" > /dev/null; then + if git diff --cached ':(exclude)*REVISION' ':(exclude)*/eslint-plugin-react-hooks/package.json' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" > /dev/null; then echo "Changes detected" echo "===== Changes =====" - git --no-pager diff --cached ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" | head -n 50 + git --no-pager diff --cached ':(exclude)*REVISION' ':(exclude)*/eslint-plugin-react-hooks/package.json' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" | head -n 50 echo "===================" echo "should_commit=true" >> "$GITHUB_OUTPUT" else diff --git a/.github/workflows/runtime_discord_notify.yml b/.github/workflows/runtime_discord_notify.yml index 44775fbe78880..8d047e697640d 100644 --- a/.github/workflows/runtime_discord_notify.yml +++ b/.github/workflows/runtime_discord_notify.yml @@ -11,10 +11,12 @@ 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 }} steps: + - run: echo ${{ github.event.pull_request.author_association }} - name: Check is member or collaborator id: check_is_member_or_collaborator if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }} diff --git a/.github/workflows/runtime_prereleases.yml b/.github/workflows/runtime_prereleases.yml index ee8dd72ce9665..ee340e079f3d3 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,15 +72,41 @@ 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 with: webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} embed-author-name: "GitHub Actions" - embed-title: 'Publish of $${{ inputs.release_channel }} release failed' + embed-title: '[Runtime] Publish of ${{ inputs.release_channel }}@${{ inputs.dist_tag}} release failed' embed-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} diff --git a/.github/workflows/runtime_prereleases_manual.yml b/.github/workflows/runtime_prereleases_manual.yml index 71e25ba073a83..407d931e90738 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 }} @@ -48,10 +88,15 @@ 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 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 }} diff --git a/.github/workflows/runtime_releases_from_npm_manual.yml b/.github/workflows/runtime_releases_from_npm_manual.yml index 51e38439553de..f164e9f080660 100644 --- a/.github/workflows/runtime_releases_from_npm_manual.yml +++ b/.github/workflows/runtime_releases_from_npm_manual.yml @@ -110,7 +110,7 @@ jobs: --tags=${{ inputs.tags }} \ --publishVersion=${{ inputs.version_to_publish }} \ --onlyPackages=${{ inputs.only_packages }} ${{ (inputs.dry && '') || '\'}} - ${{ inputs.dry && '--dry'}} + ${{ inputs.dry && '--dry' || '' }} - if: '${{ inputs.skip_packages }}' name: 'Publish all packages EXCEPT ${{ inputs.skip_packages }}' run: | @@ -119,7 +119,7 @@ jobs: --tags=${{ inputs.tags }} \ --publishVersion=${{ inputs.version_to_publish }} \ --skipPackages=${{ inputs.skip_packages }} ${{ (inputs.dry && '') || '\'}} - ${{ inputs.dry && '--dry'}} + ${{ inputs.dry && '--dry' || '' }} - name: Archive released package for debugging uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/shared_label_core_team_prs.yml b/.github/workflows/shared_label_core_team_prs.yml index fd4aa9399e386..cc10e87dcc2cf 100644 --- a/.github/workflows/shared_label_core_team_prs.yml +++ b/.github/workflows/shared_label_core_team_prs.yml @@ -17,6 +17,7 @@ jobs: outputs: is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }} steps: + - run: echo ${{ github.event.pull_request.author_association }} - name: Check is member or collaborator id: check_is_member_or_collaborator if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }} 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 diff --git a/.prettierrc.js b/.prettierrc.js index 37cf9c9d3a89b..aa54cbae1f9f8 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -3,13 +3,12 @@ const {esNextPaths} = require('./scripts/shared/pathsByLanguageVersion'); module.exports = { - plugins: ['prettier-plugin-hermes-parser'], bracketSpacing: false, singleQuote: true, bracketSameLine: true, trailingComma: 'es5', printWidth: 80, - parser: 'hermes', + parser: 'flow', arrowParens: 'avoid', overrides: [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index d5551df26c7de..a10cda01536b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,11 @@ An Owner Stack is a string representing the components that are directly respons * Updated `useId` to use valid CSS selectors, changing format from `:r123:` to `«r123»`. [#32001](https://github.com/facebook/react/pull/32001) * Added a dev-only warning for null/undefined created in useEffect, useInsertionEffect, and useLayoutEffect. [#32355](https://github.com/facebook/react/pull/32355) * Fixed a bug where dev-only methods were exported in production builds. React.act is no longer available in production builds. [#32200](https://github.com/facebook/react/pull/32200) -* Improved consistency across prod and dev to improve compatibility with Google Closure Complier and bindings [#31808](https://github.com/facebook/react/pull/31808) +* Improved consistency across prod and dev to improve compatibility with Google Closure Compiler and bindings [#31808](https://github.com/facebook/react/pull/31808) * Improve passive effect scheduling for consistent task yielding. [#31785](https://github.com/facebook/react/pull/31785) * Fixed asserts in React Native when passChildrenWhenCloningPersistedNodes is enabled for OffscreenComponent rendering. [#32528](https://github.com/facebook/react/pull/32528) * Fixed component name resolution for Portal [#32640](https://github.com/facebook/react/pull/32640) -* Added support for beforetoggle and toggle events on the dialog element. #32479 [#32479](https://github.com/facebook/react/pull/32479) +* Added support for beforetoggle and toggle events on the dialog element. [#32479](https://github.com/facebook/react/pull/32479) ### React DOM * Fixed double warning when the `href` attribute is an empty string [#31783](https://github.com/facebook/react/pull/31783) diff --git a/compiler/.gitignore b/compiler/.gitignore index d41f59333aa09..77e4c01bef707 100644 --- a/compiler/.gitignore +++ b/compiler/.gitignore @@ -1,28 +1,14 @@ .DS_Store .spr.yml -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ - -# These are backup files generated by rustfmt -**/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - node_modules .watchmanconfig .watchman-cookie-* dist .vscode !packages/playground/.vscode -.spr.yml testfilter.txt -bundle-oss.sh - # forgive *.vsix .vscode-test diff --git a/compiler/CHANGELOG.md b/compiler/CHANGELOG.md index 022d066b2202f..32e21efba0cd5 100644 --- a/compiler/CHANGELOG.md +++ b/compiler/CHANGELOG.md @@ -1,3 +1,9 @@ +## 19.1.0-rc.2 (May 14, 2025) + +## babel-plugin-react-compiler + +* Fix for string attribute values with emoji [#33096](https://github.com/facebook/react/pull/33096) by [@josephsavona](https://github.com/josephsavona) + ## 19.1.0-rc.1 (April 21, 2025) ## eslint-plugin-react-hooks 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/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts index c732e164101d4..0c23ceb345296 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts @@ -37,6 +37,14 @@ const PanicThresholdOptionsSchema = z.enum([ ]); export type PanicThresholdOptions = z.infer; +const DynamicGatingOptionsSchema = z.object({ + source: z.string(), +}); +export type DynamicGatingOptions = z.infer; +const CustomOptOutDirectiveSchema = z + .nullable(z.array(z.string())) + .default(null); +type CustomOptOutDirective = z.infer; export type PluginOptions = { environment: EnvironmentConfig; @@ -65,6 +73,28 @@ export type PluginOptions = { */ gating: ExternalFunction | null; + /** + * If specified, this enables dynamic gating which matches `use memo if(...)` + * directives. + * + * Example usage: + * ```js + * // @dynamicGating:{"source":"myModule"} + * export function MyComponent() { + * 'use memo if(isEnabled)'; + * return
...
; + * } + * ``` + * This will emit: + * ```js + * import {isEnabled} from 'myModule'; + * export const MyComponent = isEnabled() + * ? + * : ; + * ``` + */ + dynamicGating: DynamicGatingOptions | null; + panicThreshold: PanicThresholdOptions; /* @@ -106,6 +136,11 @@ export type PluginOptions = { */ ignoreUseNoForget: boolean; + /** + * Unstable / do not use + */ + customOptOutDirectives: CustomOptOutDirective; + sources: Array | ((filename: string) => boolean) | null; /** @@ -244,6 +279,7 @@ export const defaultOptions: PluginOptions = { logger: null, gating: null, noEmit: false, + dynamicGating: null, eslintSuppressionRules: null, flowSuppressions: true, ignoreUseNoForget: false, @@ -251,6 +287,7 @@ export const defaultOptions: PluginOptions = { return filename.indexOf('node_modules') === -1; }, enableReanimatedCheck: true, + customOptOutDirectives: null, target: '19', } as const; @@ -292,6 +329,40 @@ export function parsePluginOptions(obj: unknown): PluginOptions { } break; } + case 'dynamicGating': { + if (value == null) { + parsedOptions[key] = null; + } else { + const result = DynamicGatingOptionsSchema.safeParse(value); + if (result.success) { + parsedOptions[key] = result.data; + } else { + CompilerError.throwInvalidConfig({ + reason: + 'Could not parse dynamic gating. Update React Compiler config to fix the error', + description: `${fromZodError(result.error)}`, + loc: null, + suggestions: null, + }); + } + } + break; + } + case 'customOptOutDirectives': { + const result = CustomOptOutDirectiveSchema.safeParse(value); + if (result.success) { + parsedOptions[key] = result.data; + } else { + CompilerError.throwInvalidConfig({ + reason: + 'Could not parse custom opt out directives. Update React Compiler config to fix the error', + description: `${fromZodError(result.error)}`, + loc: null, + suggestions: null, + }); + } + break; + } default: { parsedOptions[key] = value; } 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 831d1ca38054e..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} @@ -130,6 +132,7 @@ function run( mode, config, contextIdentifiers, + func, logger, filename, code, @@ -226,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); @@ -248,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) { @@ -276,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/Entrypoint/Program.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts index 64abc110ea12f..de8d16fb12a6e 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts @@ -12,7 +12,7 @@ import { CompilerErrorDetail, ErrorSeverity, } from '../CompilerError'; -import {ReactFunctionType} from '../HIR/Environment'; +import {ExternalFunction, ReactFunctionType} from '../HIR/Environment'; import {CodegenFunction} from '../ReactiveScopes'; import {isComponentDeclaration} from '../Utils/ComponentDeclaration'; import {isHookDeclaration} from '../Utils/HookDeclaration'; @@ -31,6 +31,7 @@ import { suppressionsToCompilerError, } from './Suppression'; import {GeneratedSource} from '../HIR'; +import {Err, Ok, Result} from '../Utils/Result'; export type CompilerPass = { opts: PluginOptions; @@ -40,26 +41,102 @@ export type CompilerPass = { }; export const OPT_IN_DIRECTIVES = new Set(['use forget', 'use memo']); export const OPT_OUT_DIRECTIVES = new Set(['use no forget', 'use no memo']); +const DYNAMIC_GATING_DIRECTIVE = new RegExp('^use memo if\\(([^\\)]*)\\)$'); -export function findDirectiveEnablingMemoization( +export function tryFindDirectiveEnablingMemoization( directives: Array, -): t.Directive | null { - return ( - directives.find(directive => - OPT_IN_DIRECTIVES.has(directive.value.value), - ) ?? null + opts: PluginOptions, +): Result { + const optIn = directives.find(directive => + OPT_IN_DIRECTIVES.has(directive.value.value), ); + if (optIn != null) { + return Ok(optIn); + } + const dynamicGating = findDirectivesDynamicGating(directives, opts); + if (dynamicGating.isOk()) { + return Ok(dynamicGating.unwrap()?.directive ?? null); + } else { + return Err(dynamicGating.unwrapErr()); + } } export function findDirectiveDisablingMemoization( directives: Array, + {customOptOutDirectives}: PluginOptions, ): t.Directive | null { + if (customOptOutDirectives != null) { + return ( + directives.find( + directive => + customOptOutDirectives.indexOf(directive.value.value) !== -1, + ) ?? null + ); + } return ( directives.find(directive => OPT_OUT_DIRECTIVES.has(directive.value.value), ) ?? null ); } +function findDirectivesDynamicGating( + directives: Array, + opts: PluginOptions, +): Result< + { + gating: ExternalFunction; + directive: t.Directive; + } | null, + CompilerError +> { + if (opts.dynamicGating === null) { + return Ok(null); + } + const errors = new CompilerError(); + const result: Array<{directive: t.Directive; match: string}> = []; + + for (const directive of directives) { + const maybeMatch = DYNAMIC_GATING_DIRECTIVE.exec(directive.value.value); + if (maybeMatch != null && maybeMatch[1] != null) { + if (t.isValidIdentifier(maybeMatch[1])) { + result.push({directive, match: maybeMatch[1]}); + } else { + errors.push({ + reason: `Dynamic gating directive is not a valid JavaScript identifier`, + description: `Found '${directive.value.value}'`, + severity: ErrorSeverity.InvalidReact, + loc: directive.loc ?? null, + suggestions: null, + }); + } + } + } + if (errors.hasErrors()) { + return Err(errors); + } else if (result.length > 1) { + const error = new CompilerError(); + error.push({ + reason: `Multiple dynamic gating directives found`, + description: `Expected a single directive but found [${result + .map(r => r.directive.value.value) + .join(', ')}]`, + severity: ErrorSeverity.InvalidReact, + loc: result[0].directive.loc ?? null, + suggestions: null, + }); + return Err(error); + } else if (result.length === 1) { + return Ok({ + gating: { + source: opts.dynamicGating.source, + importSpecifierName: result[0].match, + }, + directive: result[0].directive, + }); + } else { + return Ok(null); + } +} function isCriticalError(err: unknown): boolean { return !(err instanceof CompilerError) || err.isCritical(); @@ -326,7 +403,8 @@ export function compileProgram( code: pass.code, suppressions, hasModuleScopeOptOut: - findDirectiveDisablingMemoization(program.node.directives) != null, + findDirectiveDisablingMemoization(program.node.directives, pass.opts) != + null, }); const queue: Array = findFunctionsToCompile( @@ -477,13 +555,36 @@ function processFn( fnType: ReactFunctionType, programContext: ProgramContext, ): null | CodegenFunction { - let directives; + let directives: { + optIn: t.Directive | null; + optOut: t.Directive | null; + }; if (fn.node.body.type !== 'BlockStatement') { - directives = {optIn: null, optOut: null}; + directives = { + optIn: null, + optOut: null, + }; } else { + const optIn = tryFindDirectiveEnablingMemoization( + fn.node.body.directives, + programContext.opts, + ); + if (optIn.isErr()) { + /** + * If parsing opt-in directive fails, it's most likely that React Compiler + * was not tested or rolled out on this function. In that case, we handle + * the error and fall back to the safest option which is to not optimize + * the function. + */ + handleError(optIn.unwrapErr(), programContext, fn.node.loc ?? null); + return null; + } directives = { - optIn: findDirectiveEnablingMemoization(fn.node.body.directives), - optOut: findDirectiveDisablingMemoization(fn.node.body.directives), + optIn: optIn.unwrapOr(null), + optOut: findDirectiveDisablingMemoization( + fn.node.body.directives, + programContext.opts, + ), }; } @@ -659,25 +760,31 @@ function applyCompiledFunctions( pass: CompilerPass, programContext: ProgramContext, ): void { - const referencedBeforeDeclared = - pass.opts.gating != null - ? getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns) - : null; + let referencedBeforeDeclared = null; for (const result of compiledFns) { const {kind, originalFn, compiledFn} = result; const transformedFn = createNewFunctionNode(originalFn, compiledFn); programContext.alreadyCompiled.add(transformedFn); - if (referencedBeforeDeclared != null && kind === 'original') { - CompilerError.invariant(pass.opts.gating != null, { - reason: "Expected 'gating' import to be present", - loc: null, - }); + let dynamicGating: ExternalFunction | null = null; + if (originalFn.node.body.type === 'BlockStatement') { + const result = findDirectivesDynamicGating( + originalFn.node.body.directives, + pass.opts, + ); + if (result.isOk()) { + dynamicGating = result.unwrap()?.gating ?? null; + } + } + const functionGating = dynamicGating ?? pass.opts.gating; + if (kind === 'original' && functionGating != null) { + referencedBeforeDeclared ??= + getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns); insertGatedFunctionDeclaration( originalFn, transformedFn, programContext, - pass.opts.gating, + functionGating, referencedBeforeDeclared.has(result), ); } else { @@ -733,8 +840,13 @@ function getReactFunctionType( ): ReactFunctionType | null { const hookPattern = pass.opts.environment.hookPattern; if (fn.node.body.type === 'BlockStatement') { - if (findDirectiveEnablingMemoization(fn.node.body.directives) != null) + const optInDirectives = tryFindDirectiveEnablingMemoization( + fn.node.body.directives, + pass.opts, + ); + if (optInDirectives.unwrapOr(null) != null) { return getComponentOrHookLike(fn, hookPattern) ?? 'Other'; + } } // Component and hook declarations are known components/hooks 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 b9f82eea18e9f..104b08068151c 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'; /* @@ -70,21 +70,23 @@ import {BuiltInArrayId} from './ObjectShape'; export function lower( func: NodePath, env: Environment, + // Bindings captured from the outer function, in case lower() is called recursively (for lambdas) bindings: Bindings | null = null, - capturedRefs: Array = [], - // the outermost function being compiled, in case lower() is called recursively (for lambdas) - parent: NodePath | null = null, + capturedRefs: Map = new Map(), ): Result { - const builder = new HIRBuilder(env, parent ?? func, bindings, capturedRefs); + const builder = new HIRBuilder(env, { + bindings, + context: capturedRefs, + }); const context: HIRFunction['context'] = []; - for (const ref of capturedRefs ?? []) { + for (const [ref, loc] of capturedRefs ?? []) { context.push({ kind: 'Identifier', identifier: builder.resolveBinding(ref), effect: Effect.Unknown, reactive: false, - loc: ref.loc ?? GeneratedSource, + loc, }); } @@ -179,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()) { @@ -208,6 +211,7 @@ export function lower( loc: GeneratedSource, }), id: makeInstructionId(0), + effects: null, }, null, ); @@ -215,9 +219,9 @@ export function lower( return Ok({ id, params, - fnType: parent == null ? env.fnType : 'Other', + 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, @@ -225,6 +229,7 @@ export function lower( loc: func.node.loc ?? GeneratedSource, env, effects: null, + aliasingEffects: null, directives, }); } @@ -285,6 +290,7 @@ function lowerStatement( loc: stmt.node.loc ?? GeneratedSource, value, id: makeInstructionId(0), + effects: null, }; builder.terminate(terminal, 'block'); return; @@ -1235,6 +1241,7 @@ function lowerStatement( kind: 'Debugger', loc, }, + effects: null, loc, }); return; @@ -1892,6 +1899,7 @@ function lowerExpression( place: leftValue, loc: exprLoc, }, + effects: null, loc: exprLoc, }); builder.terminateWithContinuation( @@ -2827,6 +2835,7 @@ function lowerOptionalCallExpression( args, loc, }, + effects: null, loc, }); } else { @@ -2840,6 +2849,7 @@ function lowerOptionalCallExpression( args, loc, }, + effects: null, loc, }); } @@ -3417,7 +3427,7 @@ function lowerFunction( | t.ObjectMethod >, ): LoweredFunction | null { - const componentScope: Scope = builder.parentFunction.scope; + const componentScope: Scope = builder.environment.parentFunction.scope; const capturedContext = gatherCapturedContext(expr, componentScope); /* @@ -3432,8 +3442,7 @@ function lowerFunction( expr, builder.environment, builder.bindings, - [...builder.context, ...capturedContext], - builder.parentFunction, + new Map([...builder.context, ...capturedContext]), ); let loweredFunc: HIRFunction; if (lowering.isErr()) { @@ -3456,7 +3465,7 @@ function lowerExpressionToTemporary( return lowerValueToTemporary(builder, value); } -function lowerValueToTemporary( +export function lowerValueToTemporary( builder: HIRBuilder, value: InstructionValue, ): Place { @@ -3466,9 +3475,10 @@ 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; } @@ -4151,6 +4161,11 @@ function captureScopes({from, to}: {from: Scope; to: Scope}): Set { return scopes; } +/** + * Returns a mapping of "context" identifiers — references to free variables that + * will become part of the function expression's `context` array — along with the + * source location of their first reference within the function. + */ function gatherCapturedContext( fn: NodePath< | t.FunctionExpression @@ -4159,8 +4174,8 @@ function gatherCapturedContext( | t.ObjectMethod >, componentScope: Scope, -): Array { - const capturedIds = new Set(); +): Map { + const capturedIds = new Map(); /* * Capture all the scopes from the parent of this function up to and including @@ -4203,8 +4218,15 @@ function gatherCapturedContext( // Add the base identifier binding as a dependency. const binding = baseIdentifier.scope.getBinding(baseIdentifier.node.name); - if (binding !== undefined && pureScopes.has(binding.scope)) { - capturedIds.add(binding.identifier); + if ( + binding !== undefined && + pureScopes.has(binding.scope) && + !capturedIds.has(binding.identifier) + ) { + capturedIds.set( + binding.identifier, + path.node.loc ?? binding.identifier.loc ?? GeneratedSource, + ); } } @@ -4241,7 +4263,7 @@ function gatherCapturedContext( }, }); - return [...capturedIds.keys()]; + return capturedIds; } function notNull(value: T | null): value is T { diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectHoistablePropertyLoads.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectHoistablePropertyLoads.ts index ea7268c573379..a11822538f54f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectHoistablePropertyLoads.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectHoistablePropertyLoads.ts @@ -241,7 +241,10 @@ type PropertyPathNode = class PropertyPathRegistry { roots: Map = new Map(); - getOrCreateIdentifier(identifier: Identifier): PropertyPathNode { + getOrCreateIdentifier( + identifier: Identifier, + reactive: boolean, + ): PropertyPathNode { /** * Reads from a statically scoped variable are always safe in JS, * with the exception of TDZ (not addressed by this pass). @@ -255,12 +258,19 @@ class PropertyPathRegistry { optionalProperties: new Map(), fullPath: { identifier, + reactive, path: [], }, hasOptional: false, parent: null, }; this.roots.set(identifier.id, rootNode); + } else { + CompilerError.invariant(reactive === rootNode.fullPath.reactive, { + reason: + '[HoistablePropertyLoads] Found inconsistencies in `reactive` flag when deduping identifier reads within the same scope', + loc: identifier.loc, + }); } return rootNode; } @@ -278,6 +288,7 @@ class PropertyPathRegistry { parent: parent, fullPath: { identifier: parent.fullPath.identifier, + reactive: parent.fullPath.reactive, path: parent.fullPath.path.concat(entry), }, hasOptional: parent.hasOptional || entry.optional, @@ -293,7 +304,7 @@ class PropertyPathRegistry { * so all subpaths of a PropertyLoad should already exist * (e.g. a.b is added before a.b.c), */ - let currNode = this.getOrCreateIdentifier(n.identifier); + let currNode = this.getOrCreateIdentifier(n.identifier, n.reactive); if (n.path.length === 0) { return currNode; } @@ -315,10 +326,11 @@ function getMaybeNonNullInInstruction( instr: InstructionValue, context: CollectHoistablePropertyLoadsContext, ): PropertyPathNode | null { - let path = null; + let path: ReactiveScopeDependency | null = null; if (instr.kind === 'PropertyLoad') { path = context.temporaries.get(instr.object.identifier.id) ?? { identifier: instr.object.identifier, + reactive: instr.object.reactive, path: [], }; } else if (instr.kind === 'Destructure') { @@ -381,7 +393,7 @@ function collectNonNullsInBlocks( ) { const identifier = fn.params[0].identifier; knownNonNullIdentifiers.add( - context.registry.getOrCreateIdentifier(identifier), + context.registry.getOrCreateIdentifier(identifier, true), ); } const nodes = new Map< @@ -616,9 +628,11 @@ function reduceMaybeOptionalChains( changed = false; for (const original of optionalChainNodes) { - let {identifier, path: origPath} = original.fullPath; - let currNode: PropertyPathNode = - registry.getOrCreateIdentifier(identifier); + let {identifier, path: origPath, reactive} = original.fullPath; + let currNode: PropertyPathNode = registry.getOrCreateIdentifier( + identifier, + reactive, + ); for (let i = 0; i < origPath.length; i++) { const entry = origPath[i]; // If the base is known to be non-null, replace with a non-optional load diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectOptionalChainDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectOptionalChainDependencies.ts index cb787d04d0623..75dad4c1bfe63 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectOptionalChainDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/CollectOptionalChainDependencies.ts @@ -290,6 +290,7 @@ function traverseOptionalBlock( ); baseObject = { identifier: maybeTest.instructions[0].value.place.identifier, + reactive: maybeTest.instructions[0].value.place.reactive, path, }; test = maybeTest.terminal; @@ -391,6 +392,7 @@ function traverseOptionalBlock( ); const load = { identifier: baseObject.identifier, + reactive: baseObject.reactive, path: [ ...baseObject.path, { diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/DeriveMinimalDependenciesHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/DeriveMinimalDependenciesHIR.ts index 7f6fb9e88f817..7819ab39b2c69 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/DeriveMinimalDependenciesHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/DeriveMinimalDependenciesHIR.ts @@ -25,8 +25,9 @@ export class ReactiveScopeDependencyTreeHIR { * `identifier.path`, or `identifier?.path` is in this map, it is safe to * evaluate (non-optional) PropertyLoads from. */ - #hoistableObjects: Map = new Map(); - #deps: Map = new Map(); + #hoistableObjects: Map = + new Map(); + #deps: Map = new Map(); /** * @param hoistableObjects a set of paths from which we can safely evaluate @@ -35,9 +36,10 @@ export class ReactiveScopeDependencyTreeHIR { * duplicates when traversing the CFG. */ constructor(hoistableObjects: Iterable) { - for (const {path, identifier} of hoistableObjects) { + for (const {path, identifier, reactive} of hoistableObjects) { let currNode = ReactiveScopeDependencyTreeHIR.#getOrCreateRoot( identifier, + reactive, this.#hoistableObjects, path.length > 0 && path[0].optional ? 'Optional' : 'NonNull', ); @@ -70,7 +72,8 @@ export class ReactiveScopeDependencyTreeHIR { static #getOrCreateRoot( identifier: Identifier, - roots: Map>, + reactive: boolean, + roots: Map & {reactive: boolean}>, defaultAccessType: T, ): TreeNode { // roots can always be accessed unconditionally in JS @@ -79,9 +82,16 @@ export class ReactiveScopeDependencyTreeHIR { if (rootNode === undefined) { rootNode = { properties: new Map(), + reactive, accessType: defaultAccessType, }; roots.set(identifier, rootNode); + } else { + CompilerError.invariant(reactive === rootNode.reactive, { + reason: '[DeriveMinimalDependenciesHIR] Conflicting reactive root flag', + description: `Identifier ${printIdentifier(identifier)}`, + loc: GeneratedSource, + }); } return rootNode; } @@ -92,9 +102,10 @@ export class ReactiveScopeDependencyTreeHIR { * safe-to-evaluate subpath */ addDependency(dep: ReactiveScopeDependency): void { - const {identifier, path} = dep; + const {identifier, reactive, path} = dep; let depCursor = ReactiveScopeDependencyTreeHIR.#getOrCreateRoot( identifier, + reactive, this.#deps, PropertyAccessType.UnconditionalAccess, ); @@ -172,7 +183,13 @@ export class ReactiveScopeDependencyTreeHIR { deriveMinimalDependencies(): Set { const results = new Set(); for (const [rootId, rootNode] of this.#deps.entries()) { - collectMinimalDependenciesInSubtree(rootNode, rootId, [], results); + collectMinimalDependenciesInSubtree( + rootNode, + rootNode.reactive, + rootId, + [], + results, + ); } return results; @@ -294,25 +311,24 @@ type HoistableNode = TreeNode<'Optional' | 'NonNull'>; type DependencyNode = TreeNode; /** - * TODO: this is directly pasted from DeriveMinimalDependencies. Since we no - * longer have conditionally accessed nodes, we can simplify - * * Recursively calculates minimal dependencies in a subtree. * @param node DependencyNode representing a dependency subtree. * @returns a minimal list of dependencies in this subtree. */ function collectMinimalDependenciesInSubtree( node: DependencyNode, + reactive: boolean, rootIdentifier: Identifier, path: Array, results: Set, ): void { if (isDependency(node.accessType)) { - results.add({identifier: rootIdentifier, path}); + results.add({identifier: rootIdentifier, reactive, path}); } else { for (const [childName, childNode] of node.properties) { collectMinimalDependenciesInSubtree( childNode, + reactive, rootIdentifier, [ ...path, 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 6e6643cd1d68f..90a352620ce35 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -47,7 +47,7 @@ import { ShapeRegistry, addHook, } from './ObjectShape'; -import {Scope as BabelScope} from '@babel/traverse'; +import {Scope as BabelScope, NodePath} from '@babel/traverse'; import {TypeSchema} from './TypeSchema'; export const ReactElementSymbolSchema = z.object({ @@ -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(true), + /** * Enables inference of optional dependency chains. Without this flag * a property chain such as `props?.items?.foo` will infer as a dep on @@ -675,6 +680,7 @@ export class Environment { #contextIdentifiers: Set; #hoistedIdentifiers: Set; + parentFunction: NodePath; constructor( scope: BabelScope, @@ -682,6 +688,7 @@ export class Environment { compilerMode: CompilerMode, config: EnvironmentConfig, contextIdentifiers: Set, + parentFunction: NodePath, // the outermost function being compiled logger: Logger | null, filename: string | null, code: string | null, @@ -740,6 +747,7 @@ export class Environment { this.#moduleTypes.set(REANIMATED_MODULE_NAME, reanimatedModuleType); } + this.parentFunction = parentFunction; this.#contextIdentifiers = contextIdentifiers; this.#hoistedIdentifiers = new Set(); } 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..13f87528b2185 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, @@ -642,6 +644,41 @@ const REACT_APIS: Array<[string, BuiltInType]> = [ calleeEffect: Effect.Read, hookKind: 'useEffect', returnValueKind: ValueKind.Frozen, + aliasing: { + receiver: '@receiver', + params: [], + rest: '@rest', + returns: '@returns', + temporaries: ['@effect'], + effects: [ + // Freezes the function and deps + { + kind: 'Freeze', + value: '@rest', + reason: ValueReason.Effect, + }, + // Internally creates an effect object that captures the function and deps + { + kind: 'Create', + into: '@effect', + value: ValueKind.Frozen, + reason: ValueReason.KnownReturnSignature, + }, + // The effect stores the function and dependencies + { + kind: 'Capture', + from: '@rest', + into: '@effect', + }, + // Returns undefined + { + kind: 'Create', + into: '@returns', + value: ValueKind.Primitive, + reason: ValueReason.KnownReturnSignature, + }, + ], + }, }, BuiltInUseEffectHookId, ), @@ -722,6 +759,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( @@ -847,6 +905,7 @@ export function installTypeConfig( noAlias: typeConfig.noAlias === true, mutableOnlyIfOperandsAreMutable: typeConfig.mutableOnlyIfOperandsAreMutable === true, + aliasing: typeConfig.aliasing, }); } case 'hook': { @@ -864,6 +923,7 @@ export function installTypeConfig( ), returnValueKind: typeConfig.returnValueKind ?? ValueKind.Frozen, noAlias: typeConfig.noAlias === true, + aliasing: typeConfig.aliasing, }); } case 'object': { 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 99b8c189ee0fd..deb725a0482e1 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; }; @@ -277,13 +279,14 @@ export type HIRFunction = { env: Environment; 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 +452,7 @@ export type ReturnTerminal = { value: Place; id: InstructionId; fallthrough?: never; + effects: Array | null; }; export type GotoTerminal = { @@ -609,6 +613,7 @@ export type MaybeThrowTerminal = { id: InstructionId; loc: SourceLocation; fallthrough?: never; + effects: Array | null; }; export type ReactiveScopeTerminal = { @@ -645,12 +650,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 +1387,21 @@ export enum ValueReason { */ JsxCaptured = 'jsx-captured', + /** + * Argument to a hook + */ + HookCaptured = 'hook-captured', + + /** + * Return value of a hook + */ + HookReturn = 'hook-return', + + /** + * Passed to an effect + */ + Effect = 'effect', + /** * Return value of a function with known frozen return value, e.g. `useState`. */ @@ -1430,6 +1452,20 @@ export const ValueKindSchema = z.enum([ ValueKind.Context, ]); +export const ValueReasonSchema = z.enum([ + ValueReason.Context, + ValueReason.Effect, + ValueReason.Global, + ValueReason.HookCaptured, + ValueReason.HookReturn, + ValueReason.JsxCaptured, + ValueReason.KnownReturnSignature, + ValueReason.Other, + ValueReason.ReactiveFunctionArgument, + ValueReason.ReducerState, + ValueReason.State, +]); + // The effect with which a value is modified. export enum Effect { // Default value: not allowed after lifetime inference @@ -1568,6 +1604,18 @@ export type DependencyPathEntry = { export type DependencyPath = Array; export type ReactiveScopeDependency = { identifier: Identifier; + /** + * Reflects whether the base identifier is reactive. Note that some reactive + * objects may have non-reactive properties, but we do not currently track + * this. + * + * ```js + * // Technically, result[0] is reactive and result[1] is not. + * // Currently, both dependencies would be marked as reactive. + * const result = useState(); + * ``` + */ + reactive: boolean; path: DependencyPath; }; @@ -1721,6 +1769,10 @@ export function isUseStateType(id: Identifier): boolean { return id.type.kind === 'Object' && id.type.shapeId === 'BuiltInUseState'; } +export function isJsxType(type: Type): boolean { + return type.kind === 'Object' && type.shapeId === 'BuiltInJsx'; +} + export function isRefOrRefValue(id: Identifier): boolean { return isUseRefType(id) || isRefValueType(id); } @@ -1773,6 +1825,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/HIRBuilder.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts index 44dd34b7d6cae..c3a6c18d3aaff 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts @@ -106,11 +106,10 @@ export default class HIRBuilder { #current: WipBlock; #entry: BlockId; #scopes: Array = []; - #context: Array; + #context: Map; #bindings: Bindings; #env: Environment; #exceptionHandlerStack: Array = []; - parentFunction: NodePath; errors: CompilerError = new CompilerError(); /** * Traversal context: counts the number of `fbt` tag parents @@ -122,7 +121,7 @@ export default class HIRBuilder { return this.#env.nextIdentifierId; } - get context(): Array { + get context(): Map { return this.#context; } @@ -136,16 +135,17 @@ export default class HIRBuilder { constructor( env: Environment, - parentFunction: NodePath, // the outermost function being compiled - bindings: Bindings | null = null, - context: Array | null = null, + options?: { + bindings?: Bindings | null; + context?: Map; + entryBlockKind?: BlockKind; + }, ) { this.#env = env; - this.#bindings = bindings ?? new Map(); - this.parentFunction = parentFunction; - this.#context = context ?? []; + this.#bindings = options?.bindings ?? new Map(); + this.#context = options?.context ?? new Map(); this.#entry = makeBlockId(env.nextBlockId); - this.#current = newBlock(this.#entry, 'block'); + this.#current = newBlock(this.#entry, options?.entryBlockKind ?? 'block'); } currentBlockKind(): BlockKind { @@ -165,6 +165,7 @@ export default class HIRBuilder { handler: exceptionHandler, id: makeInstructionId(0), loc: instruction.loc, + effects: null, }, continuationBlock, ); @@ -239,7 +240,7 @@ export default class HIRBuilder { // Check if the binding is from module scope const outerBinding = - this.parentFunction.scope.parent.getBinding(originalName); + this.#env.parentFunction.scope.parent.getBinding(originalName); if (babelBinding === outerBinding) { const path = babelBinding.path; if (path.isImportDefaultSpecifier()) { @@ -293,7 +294,7 @@ export default class HIRBuilder { const binding = this.#resolveBabelBinding(path); if (binding) { // Check if the binding is from module scope, if so return null - const outerBinding = this.parentFunction.scope.parent.getBinding( + const outerBinding = this.#env.parentFunction.scope.parent.getBinding( path.node.name, ); if (binding === outerBinding) { @@ -376,7 +377,7 @@ export default class HIRBuilder { } // Terminate the current block w the given terminal, and start a new block - terminate(terminal: Terminal, nextBlockKind: BlockKind | null): void { + terminate(terminal: Terminal, nextBlockKind: BlockKind | null): BlockId { const {id: blockId, kind, instructions} = this.#current; this.#completed.set(blockId, { kind, @@ -390,6 +391,7 @@ export default class HIRBuilder { const nextId = this.#env.nextBlockId; this.#current = newBlock(nextId, nextBlockKind); } + return blockId; } /* @@ -746,6 +748,11 @@ function getReversePostorderedBlocks(func: HIR): HIR['blocks'] { * (eg bb2 then bb1), we ensure that they get reversed back to the correct order. */ const block = func.blocks.get(blockId)!; + CompilerError.invariant(block != null, { + reason: '[HIRBuilder] Unexpected null block', + description: `expected block ${blockId} to exist`, + loc: GeneratedSource, + }); const successors = [...eachTerminalSuccessor(block.terminal)].reverse(); const fallthrough = terminalFallthrough(block.terminal); 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 03f4120149b0e..4b67dc647f72c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -6,14 +6,30 @@ */ import {CompilerError} from '../CompilerError'; -import {Effect, ValueKind, ValueReason} from './HIR'; +import {AliasingEffect, AliasingSignature} from '../Inference/AliasingEffects'; +import {assertExhaustive} from '../Utils/utils'; +import { + Effect, + GeneratedSource, + Hole, + makeDeclarationId, + makeIdentifierId, + makeInstructionId, + Place, + SourceLocation, + SpreadPattern, + ValueKind, + ValueReason, +} from './HIR'; import { BuiltInType, FunctionType, + makeType, ObjectType, PolyType, PrimitiveType, } from './Types'; +import {AliasingEffectConfig, AliasingSignatureConfig} from './TypeSchema'; /* * This file exports types and defaults for JavaScript object shapes. These are @@ -42,13 +58,20 @@ function createAnonId(): string { export function addFunction( registry: ShapeRegistry, properties: Iterable<[string, BuiltInType | PolyType]>, - fn: Omit, + fn: Omit & { + aliasing?: AliasingSignatureConfig | null | undefined; + }, id: string | null = null, isConstructor: boolean = false, ): FunctionType { const shapeId = id ?? createAnonId(); + const aliasing = + fn.aliasing != null + ? parseAliasingSignatureConfig(fn.aliasing, '', GeneratedSource) + : null; addShape(registry, shapeId, properties, { ...fn, + aliasing, hookKind: null, }); return { @@ -66,11 +89,18 @@ export function addFunction( */ export function addHook( registry: ShapeRegistry, - fn: FunctionSignature & {hookKind: HookKind}, + fn: Omit & { + hookKind: HookKind; + aliasing?: AliasingSignatureConfig | null | undefined; + }, id: string | null = null, ): FunctionType { const shapeId = id ?? createAnonId(); - addShape(registry, shapeId, [], fn); + const aliasing = + fn.aliasing != null + ? parseAliasingSignatureConfig(fn.aliasing, '', GeneratedSource) + : null; + addShape(registry, shapeId, [], {...fn, aliasing}); return { kind: 'Function', return: fn.returnType, @@ -79,6 +109,129 @@ export function addHook( }; } +function parseAliasingSignatureConfig( + typeConfig: AliasingSignatureConfig, + moduleName: string, + loc: SourceLocation, +): AliasingSignature { + const lifetimes = new Map(); + function define(temp: string): Place { + CompilerError.invariant(!lifetimes.has(temp), { + reason: `Invalid type configuration for module`, + description: `Expected aliasing signature to have unique names for receiver, params, rest, returns, and temporaries in module '${moduleName}'`, + loc, + }); + const place = signatureArgument(lifetimes.size); + lifetimes.set(temp, place); + return place; + } + function lookup(temp: string): Place { + const place = lifetimes.get(temp); + CompilerError.invariant(place != null, { + reason: `Invalid type configuration for module`, + description: `Expected aliasing signature effects to reference known names from receiver/params/rest/returns/temporaries, but '${temp}' is not a known name in '${moduleName}'`, + loc, + }); + return place; + } + const receiver = define(typeConfig.receiver); + const params = typeConfig.params.map(define); + const rest = typeConfig.rest != null ? define(typeConfig.rest) : null; + const returns = define(typeConfig.returns); + const temporaries = typeConfig.temporaries.map(define); + const effects = typeConfig.effects.map( + (effect: AliasingEffectConfig): AliasingEffect => { + switch (effect.kind) { + case 'CreateFrom': + case 'Capture': + case 'Alias': + case 'Assign': { + const from = lookup(effect.from); + const into = lookup(effect.into); + return { + kind: effect.kind, + from, + into, + }; + } + case 'Mutate': + case 'MutateTransitiveConditionally': { + const value = lookup(effect.value); + return {kind: effect.kind, value}; + } + case 'Create': { + const into = lookup(effect.into); + return { + kind: 'Create', + into, + reason: effect.reason, + value: effect.value, + }; + } + case 'Freeze': { + const value = lookup(effect.value); + return { + kind: 'Freeze', + value, + reason: effect.reason, + }; + } + case 'Impure': { + const place = lookup(effect.place); + return { + kind: 'Impure', + place, + error: CompilerError.throwTodo({ + reason: 'Support impure effect declarations', + loc: GeneratedSource, + }), + }; + } + case 'Apply': { + const receiver = lookup(effect.receiver); + const fn = lookup(effect.function); + const args: Array = effect.args.map( + arg => { + if (typeof arg === 'string') { + return lookup(arg); + } else if (arg.kind === 'Spread') { + return {kind: 'Spread', place: lookup(arg.place)}; + } else { + return arg; + } + }, + ); + const into = lookup(effect.into); + return { + kind: 'Apply', + receiver, + function: fn, + mutatesFunction: effect.mutatesFunction, + args, + into, + loc, + signature: null, + }; + } + default: { + assertExhaustive( + effect, + `Unexpected effect kind '${(effect as any).kind}'`, + ); + } + } + }, + ); + return { + receiver: receiver.identifier.id, + params: params.map(p => p.identifier.id), + rest: rest != null ? rest.identifier.id : null, + returns: returns.identifier.id, + temporaries, + effects, + }; +} + /* * Add an object to an existing ShapeRegistry. * @@ -131,6 +284,7 @@ export type HookKind = | 'useCallback' | 'useTransition' | 'useImperativeHandle' + | 'useEffectEvent' | 'Custom'; /* @@ -179,6 +333,8 @@ export type FunctionSignature = { impure?: boolean; canonicalName?: string; + + aliasing?: AliasingSignature | null | undefined; }; /* @@ -226,6 +382,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'; @@ -302,6 +460,30 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ returnType: PRIMITIVE_TYPE, calleeEffect: Effect.Store, returnValueKind: ValueKind.Primitive, + aliasing: { + receiver: '@receiver', + params: [], + rest: '@rest', + returns: '@returns', + temporaries: [], + effects: [ + // Push directly mutates the array itself + {kind: 'Mutate', value: '@receiver'}, + // The arguments are captured into the array + { + kind: 'Capture', + from: '@rest', + into: '@receiver', + }, + // Returns the new length, a primitive + { + kind: 'Create', + into: '@returns', + value: ValueKind.Primitive, + reason: ValueReason.KnownReturnSignature, + }, + ], + }, }), ], [ @@ -332,6 +514,60 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ returnValueKind: ValueKind.Mutable, noAlias: true, mutableOnlyIfOperandsAreMutable: true, + aliasing: { + receiver: '@receiver', + params: ['@callback'], + rest: null, + returns: '@returns', + temporaries: [ + // Temporary representing captured items of the receiver + '@item', + // Temporary representing the result of the callback + '@callbackReturn', + /* + * Undefined `this` arg to the callback. Note the signature does not + * support passing an explicit thisArg second param + */ + '@thisArg', + ], + effects: [ + // Map creates a new mutable array + { + kind: 'Create', + into: '@returns', + value: ValueKind.Mutable, + reason: ValueReason.KnownReturnSignature, + }, + // The first arg to the callback is an item extracted from the receiver array + { + kind: 'CreateFrom', + from: '@receiver', + into: '@item', + }, + // The undefined this for the callback + { + kind: 'Create', + into: '@thisArg', + value: ValueKind.Primitive, + reason: ValueReason.KnownReturnSignature, + }, + // calls the callback, returning the result into a temporary + { + kind: 'Apply', + receiver: '@thisArg', + args: ['@item', {kind: 'Hole'}, '@receiver'], + function: '@callback', + into: '@callbackReturn', + mutatesFunction: false, + }, + // captures the result of the callback into the return array + { + kind: 'Capture', + from: '@callbackReturn', + into: '@returns', + }, + ], + }, }), ], [ @@ -479,6 +715,32 @@ addObject(BUILTIN_SHAPES, BuiltInSetId, [ calleeEffect: Effect.Store, // returnValueKind is technically dependent on the ValueKind of the set itself returnValueKind: ValueKind.Mutable, + aliasing: { + receiver: '@receiver', + params: [], + rest: '@rest', + returns: '@returns', + temporaries: [], + effects: [ + // Set.add returns the receiver Set + { + kind: 'Assign', + from: '@receiver', + into: '@returns', + }, + // Set.add mutates the set itself + { + kind: 'Mutate', + value: '@receiver', + }, + // Captures the rest params into the set + { + kind: 'Capture', + from: '@rest', + into: '@receiver', + }, + ], + }, }), ], [ @@ -948,6 +1210,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 @@ -1166,6 +1441,53 @@ export const DefaultNonmutatingHook = addHook( calleeEffect: Effect.Read, hookKind: 'Custom', returnValueKind: ValueKind.Frozen, + aliasing: { + receiver: '@receiver', + params: [], + rest: '@rest', + returns: '@returns', + temporaries: [], + effects: [ + // Freeze the arguments + { + kind: 'Freeze', + value: '@rest', + reason: ValueReason.HookCaptured, + }, + // Returns a frozen value + { + kind: 'Create', + into: '@returns', + value: ValueKind.Frozen, + reason: ValueReason.HookReturn, + }, + // May alias any arguments into the return + { + kind: 'Alias', + from: '@rest', + into: '@returns', + }, + ], + }, }, '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..89591aca2dfb0 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; @@ -53,6 +54,8 @@ export function printFunction(fn: HIRFunction): string { let definition = ''; if (fn.id !== null) { definition += fn.id; + } else { + definition += '<>'; } if (fn.params.length !== 0) { definition += @@ -67,13 +70,13 @@ 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)); + definition += `: ${printPlace(fn.returns)}`; + output.push(definition); 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/PropagateScopeDependenciesHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PropagateScopeDependenciesHIR.ts index 96b9e51710887..91b7712b881fc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/PropagateScopeDependenciesHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PropagateScopeDependenciesHIR.ts @@ -316,6 +316,7 @@ function collectTemporariesSidemapImpl( ) { temporaries.set(lvalue.identifier.id, { identifier: value.place.identifier, + reactive: value.place.reactive, path: [], }); } @@ -369,11 +370,13 @@ function getProperty( if (resolvedDependency == null) { property = { identifier: object.identifier, + reactive: object.reactive, path: [{property: propertyName, optional}], }; } else { property = { identifier: resolvedDependency.identifier, + reactive: resolvedDependency.reactive, path: [...resolvedDependency.path, {property: propertyName, optional}], }; } @@ -532,6 +535,7 @@ export class DependencyCollectionContext { this.visitDependency( this.#temporaries.get(place.identifier.id) ?? { identifier: place.identifier, + reactive: place.reactive, path: [], }, ); @@ -596,6 +600,7 @@ export class DependencyCollectionContext { ) { maybeDependency = { identifier: maybeDependency.identifier, + reactive: maybeDependency.reactive, path: [], }; } @@ -617,7 +622,11 @@ export class DependencyCollectionContext { identifier => identifier.declarationId === place.identifier.declarationId, ) && - this.#checkValidDependency({identifier: place.identifier, path: []}) + this.#checkValidDependency({ + identifier: place.identifier, + reactive: place.reactive, + path: [], + }) ) { currentScope.reassignments.add(place.identifier); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ScopeDependencyUtils.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ScopeDependencyUtils.ts new file mode 100644 index 0000000000000..6e9ff08b86242 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ScopeDependencyUtils.ts @@ -0,0 +1,285 @@ +import { + Place, + ReactiveScopeDependency, + Identifier, + makeInstructionId, + InstructionKind, + GeneratedSource, + BlockId, + makeTemporaryIdentifier, + Effect, + GotoVariant, + HIR, +} from './HIR'; +import {CompilerError} from '../CompilerError'; +import {Environment} from './Environment'; +import HIRBuilder from './HIRBuilder'; +import {lowerValueToTemporary} from './BuildHIR'; + +type DependencyInstructions = { + place: Place; + value: HIR; + exitBlockId: BlockId; +}; + +export function buildDependencyInstructions( + dep: ReactiveScopeDependency, + env: Environment, +): DependencyInstructions { + const builder = new HIRBuilder(env, { + entryBlockKind: 'value', + }); + let dependencyValue: Identifier; + if (dep.path.every(path => !path.optional)) { + dependencyValue = writeNonOptionalDependency(dep, env, builder); + } else { + dependencyValue = writeOptionalDependency(dep, builder, null); + } + + const exitBlockId = builder.terminate( + { + kind: 'unsupported', + loc: GeneratedSource, + id: makeInstructionId(0), + }, + null, + ); + return { + place: { + kind: 'Identifier', + identifier: dependencyValue, + effect: Effect.Freeze, + reactive: dep.reactive, + loc: GeneratedSource, + }, + value: builder.build(), + exitBlockId, + }; +} + +/** + * Write instructions for a simple dependency (without optional chains) + */ +function writeNonOptionalDependency( + dep: ReactiveScopeDependency, + env: Environment, + builder: HIRBuilder, +): Identifier { + const loc = dep.identifier.loc; + let curr: Identifier = makeTemporaryIdentifier(env.nextIdentifierId, loc); + builder.push({ + lvalue: { + identifier: curr, + kind: 'Identifier', + effect: Effect.Mutate, + reactive: dep.reactive, + loc, + }, + value: { + kind: 'LoadLocal', + place: { + identifier: dep.identifier, + kind: 'Identifier', + effect: Effect.Freeze, + reactive: dep.reactive, + loc, + }, + loc, + }, + id: makeInstructionId(1), + loc: loc, + effects: null, + }); + + /** + * Iteratively build up dependency instructions by reading from the last written + * instruction. + */ + for (const path of dep.path) { + const next = makeTemporaryIdentifier(env.nextIdentifierId, loc); + builder.push({ + lvalue: { + identifier: next, + kind: 'Identifier', + effect: Effect.Mutate, + reactive: dep.reactive, + loc, + }, + value: { + kind: 'PropertyLoad', + object: { + identifier: curr, + kind: 'Identifier', + effect: Effect.Freeze, + reactive: dep.reactive, + loc, + }, + property: path.property, + loc, + }, + id: makeInstructionId(1), + loc: loc, + effects: null, + }); + curr = next; + } + return curr; +} + +/** + * Write a dependency into optional blocks. + * + * e.g. `a.b?.c.d` is written to an optional block that tests `a.b` and + * conditionally evaluates `c.d`. + */ +function writeOptionalDependency( + dep: ReactiveScopeDependency, + builder: HIRBuilder, + parentAlternate: BlockId | null, +): Identifier { + const env = builder.environment; + /** + * Reserve an identifier which will be used to store the result of this + * dependency. + */ + const dependencyValue: Place = { + kind: 'Identifier', + identifier: makeTemporaryIdentifier(env.nextIdentifierId, GeneratedSource), + effect: Effect.Mutate, + reactive: dep.reactive, + loc: GeneratedSource, + }; + + /** + * Reserve a block which is the fallthrough (and transitive successor) of this + * optional chain. + */ + const continuationBlock = builder.reserve(builder.currentBlockKind()); + let alternate; + if (parentAlternate != null) { + alternate = parentAlternate; + } else { + /** + * If an outermost alternate block has not been reserved, write one + * + * $N = Primitive undefined + * $M = StoreLocal $OptionalResult = $N + * goto fallthrough + */ + alternate = builder.enter('value', () => { + const temp = lowerValueToTemporary(builder, { + kind: 'Primitive', + value: undefined, + loc: GeneratedSource, + }); + lowerValueToTemporary(builder, { + kind: 'StoreLocal', + lvalue: {kind: InstructionKind.Const, place: {...dependencyValue}}, + value: {...temp}, + type: null, + loc: GeneratedSource, + }); + return { + kind: 'goto', + variant: GotoVariant.Break, + block: continuationBlock.id, + id: makeInstructionId(0), + loc: GeneratedSource, + }; + }); + } + + // Reserve the consequent block, which is the successor of the test block + const consequent = builder.reserve('value'); + + let testIdentifier: Identifier | null = null; + const testBlock = builder.enter('value', () => { + const testDependency = { + ...dep, + path: dep.path.slice(0, dep.path.length - 1), + }; + const firstOptional = dep.path.findIndex(path => path.optional); + CompilerError.invariant(firstOptional !== -1, { + reason: + '[ScopeDependencyUtils] Internal invariant broken: expected optional path', + loc: dep.identifier.loc, + description: null, + suggestions: null, + }); + if (firstOptional === dep.path.length - 1) { + // Base case: the test block is simple + testIdentifier = writeNonOptionalDependency(testDependency, env, builder); + } else { + // Otherwise, the test block is a nested optional chain + testIdentifier = writeOptionalDependency( + testDependency, + builder, + alternate, + ); + } + + return { + kind: 'branch', + test: { + identifier: testIdentifier, + effect: Effect.Freeze, + kind: 'Identifier', + loc: GeneratedSource, + reactive: dep.reactive, + }, + consequent: consequent.id, + alternate, + id: makeInstructionId(0), + loc: GeneratedSource, + fallthrough: continuationBlock.id, + }; + }); + + builder.enterReserved(consequent, () => { + CompilerError.invariant(testIdentifier !== null, { + reason: 'Satisfy type checker', + description: null, + loc: null, + suggestions: null, + }); + + lowerValueToTemporary(builder, { + kind: 'StoreLocal', + lvalue: {kind: InstructionKind.Const, place: {...dependencyValue}}, + value: lowerValueToTemporary(builder, { + kind: 'PropertyLoad', + object: { + identifier: testIdentifier, + kind: 'Identifier', + effect: Effect.Freeze, + reactive: dep.reactive, + loc: GeneratedSource, + }, + property: dep.path.at(-1)!.property, + loc: GeneratedSource, + }), + type: null, + loc: GeneratedSource, + }); + return { + kind: 'goto', + variant: GotoVariant.Break, + block: continuationBlock.id, + id: makeInstructionId(0), + loc: GeneratedSource, + }; + }); + builder.terminateWithContinuation( + { + kind: 'optional', + optional: dep.path.at(-1)!.optional, + test: testBlock, + fallthrough: continuationBlock.id, + id: makeInstructionId(0), + loc: GeneratedSource, + }, + continuationBlock, + ); + + return dependencyValue.identifier; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts index 9aac2a264f60a..5945e3a07822f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/TypeSchema.ts @@ -8,7 +8,12 @@ import {isValidIdentifier} from '@babel/types'; import {z} from 'zod'; import {Effect, ValueKind} from '..'; -import {EffectSchema, ValueKindSchema} from './HIR'; +import { + EffectSchema, + ValueKindSchema, + ValueReason, + ValueReasonSchema, +} from './HIR'; export type ObjectPropertiesConfig = {[key: string]: TypeConfig}; export const ObjectPropertiesSchema: z.ZodType = z @@ -31,6 +36,194 @@ export const ObjectTypeSchema: z.ZodType = z.object({ properties: ObjectPropertiesSchema.nullable(), }); +export const LifetimeIdSchema = z.string().refine(id => id.startsWith('@'), { + message: "Placeholder names must start with '@'", +}); + +export type FreezeEffectConfig = { + kind: 'Freeze'; + value: string; + reason: ValueReason; +}; + +export const FreezeEffectSchema: z.ZodType = z.object({ + kind: z.literal('Freeze'), + value: LifetimeIdSchema, + reason: ValueReasonSchema, +}); + +export type MutateEffectConfig = { + kind: 'Mutate'; + value: string; +}; + +export const MutateEffectSchema: z.ZodType = z.object({ + kind: z.literal('Mutate'), + value: LifetimeIdSchema, +}); + +export type MutateTransitiveConditionallyConfig = { + kind: 'MutateTransitiveConditionally'; + value: string; +}; + +export const MutateTransitiveConditionallySchema: z.ZodType = + z.object({ + kind: z.literal('MutateTransitiveConditionally'), + value: LifetimeIdSchema, + }); + +export type CreateEffectConfig = { + kind: 'Create'; + into: string; + value: ValueKind; + reason: ValueReason; +}; + +export const CreateEffectSchema: z.ZodType = z.object({ + kind: z.literal('Create'), + into: LifetimeIdSchema, + value: ValueKindSchema, + reason: ValueReasonSchema, +}); + +export type AssignEffectConfig = { + kind: 'Assign'; + from: string; + into: string; +}; + +export const AssignEffectSchema: z.ZodType = z.object({ + kind: z.literal('Assign'), + from: LifetimeIdSchema, + into: LifetimeIdSchema, +}); + +export type AliasEffectConfig = { + kind: 'Alias'; + from: string; + into: string; +}; + +export const AliasEffectSchema: z.ZodType = z.object({ + kind: z.literal('Alias'), + from: LifetimeIdSchema, + into: LifetimeIdSchema, +}); + +export type CaptureEffectConfig = { + kind: 'Capture'; + from: string; + into: string; +}; + +export const CaptureEffectSchema: z.ZodType = z.object({ + kind: z.literal('Capture'), + from: LifetimeIdSchema, + into: LifetimeIdSchema, +}); + +export type CreateFromEffectConfig = { + kind: 'CreateFrom'; + from: string; + into: string; +}; + +export const CreateFromEffectSchema: z.ZodType = + z.object({ + kind: z.literal('CreateFrom'), + from: LifetimeIdSchema, + into: LifetimeIdSchema, + }); + +export type ApplyArgConfig = + | string + | {kind: 'Spread'; place: string} + | {kind: 'Hole'}; + +export const ApplyArgSchema: z.ZodType = z.union([ + LifetimeIdSchema, + z.object({ + kind: z.literal('Spread'), + place: LifetimeIdSchema, + }), + z.object({ + kind: z.literal('Hole'), + }), +]); + +export type ApplyEffectConfig = { + kind: 'Apply'; + receiver: string; + function: string; + mutatesFunction: boolean; + args: Array; + into: string; +}; + +export const ApplyEffectSchema: z.ZodType = z.object({ + kind: z.literal('Apply'), + receiver: LifetimeIdSchema, + function: LifetimeIdSchema, + mutatesFunction: z.boolean(), + args: z.array(ApplyArgSchema), + into: LifetimeIdSchema, +}); + +export type ImpureEffectConfig = { + kind: 'Impure'; + place: string; +}; + +export const ImpureEffectSchema: z.ZodType = z.object({ + kind: z.literal('Impure'), + place: LifetimeIdSchema, +}); + +export type AliasingEffectConfig = + | FreezeEffectConfig + | CreateEffectConfig + | CreateFromEffectConfig + | AssignEffectConfig + | AliasEffectConfig + | CaptureEffectConfig + | ImpureEffectConfig + | MutateEffectConfig + | MutateTransitiveConditionallyConfig + | ApplyEffectConfig; + +export const AliasingEffectSchema: z.ZodType = z.union([ + FreezeEffectSchema, + CreateEffectSchema, + CreateFromEffectSchema, + AssignEffectSchema, + AliasEffectSchema, + CaptureEffectSchema, + ImpureEffectSchema, + MutateEffectSchema, + MutateTransitiveConditionallySchema, + ApplyEffectSchema, +]); + +export type AliasingSignatureConfig = { + receiver: string; + params: Array; + rest: string | null; + returns: string; + effects: Array; + temporaries: Array; +}; + +export const AliasingSignatureSchema: z.ZodType = + z.object({ + receiver: LifetimeIdSchema, + params: z.array(LifetimeIdSchema), + rest: LifetimeIdSchema.nullable(), + returns: LifetimeIdSchema, + effects: z.array(AliasingEffectSchema), + temporaries: z.array(LifetimeIdSchema), + }); + export type FunctionTypeConfig = { kind: 'function'; positionalParams: Array; @@ -42,6 +235,7 @@ export type FunctionTypeConfig = { mutableOnlyIfOperandsAreMutable?: boolean | null | undefined; impure?: boolean | null | undefined; canonicalName?: string | null | undefined; + aliasing?: AliasingSignatureConfig | null | undefined; }; export const FunctionTypeSchema: z.ZodType = z.object({ kind: z.literal('function'), @@ -54,6 +248,7 @@ export const FunctionTypeSchema: z.ZodType = z.object({ mutableOnlyIfOperandsAreMutable: z.boolean().nullable().optional(), impure: z.boolean().nullable().optional(), canonicalName: z.string().nullable().optional(), + aliasing: AliasingSignatureSchema.nullable().optional(), }); export type HookTypeConfig = { @@ -63,6 +258,7 @@ export type HookTypeConfig = { returnType: TypeConfig; returnValueKind?: ValueKind | null | undefined; noAlias?: boolean | null | undefined; + aliasing?: AliasingSignatureConfig | null | undefined; }; export const HookTypeSchema: z.ZodType = z.object({ kind: z.literal('hook'), @@ -71,6 +267,7 @@ export const HookTypeSchema: z.ZodType = z.object({ returnType: z.lazy(() => TypeSchema), returnValueKind: ValueKindSchema.nullable().optional(), noAlias: z.boolean().nullable().optional(), + aliasing: AliasingSignatureSchema.nullable().optional(), }); export type BuiltInTypeConfig = 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..f844129e26bde --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/AliasingEffects.ts @@ -0,0 +1,244 @@ +/** + * 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, + GeneratedSource, + Hole, + IdentifierId, + ObjectMethod, + Place, + SourceLocation, + SpreadPattern, + ValueKind, + ValueReason, +} from '../HIR'; +import {FunctionSignature} from '../HIR/ObjectShape'; +import {printSourceLocation} from '../HIR/PrintHIR'; + +/** + * `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': { + return [effect.kind, effect.place.identifier.id].join(':'); + } + case 'MutateFrozen': + case 'MutateGlobal': { + return [ + effect.kind, + effect.place.identifier.id, + effect.error.severity, + effect.error.reason, + effect.error.description, + printSourceLocation(effect.error.loc ?? GeneratedSource), + ].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..7052cb2dd8a52 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,9 @@ 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 {inferMutationAliasingRanges} from './InferMutationAliasingRanges'; export default function analyseFunctions(func: HIRFunction): void { for (const [_, block] of func.body.blocks) { @@ -26,15 +30,27 @@ 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 */ for (const operand of instr.value.loweredFunc.func.context) { - operand.identifier.mutableRange.start = makeInstructionId(0); - operand.identifier.mutableRange.end = makeInstructionId(0); + /** + * NOTE: inferReactiveScopeVariables makes identifiers in the scope + * point to the *same* mutableRange instance. Resetting start/end + * here is insufficient, because a later mutation of the range + * for any one identifier could affect the range for other identifiers. + */ + operand.identifier.mutableRange = { + start: makeInstructionId(0), + end: makeInstructionId(0), + }; operand.identifier.scope = null; } break; @@ -44,6 +60,86 @@ 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); + const functionEffects = inferMutationAliasingRanges(fn, { + isFunctionExpression: true, + }).unwrap(); + rewriteInstructionKindsBasedOnReassignment(fn); + inferReactiveScopeVariables(fn); + fn.aliasingEffects = functionEffects; + + /** + * 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 functionEffects) { + 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; + } + } + + fn.env.logger?.debugLogIRs?.({ + kind: 'hir', + name: 'AnalyseFunction (inner)', + value: fn, + }); +} + 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 f1a584341912b..4d4531e1cbe0c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts @@ -10,7 +10,6 @@ import {CompilerError, SourceLocation} from '..'; import { ArrayExpression, Effect, - Environment, FunctionExpression, GeneratedSource, HIRFunction, @@ -29,6 +28,10 @@ import { isSetStateType, isFireFunctionType, makeScopeId, + HIR, + BasicBlock, + BlockId, + isEffectEventFunctionType, } from '../HIR'; import {collectHoistablePropertyLoadsInInnerFn} from '../HIR/CollectHoistablePropertyLoads'; import {collectOptionalChainSidemap} from '../HIR/CollectOptionalChainDependencies'; @@ -38,13 +41,20 @@ import { createTemporaryPlace, fixScopeAndIdentifierRanges, markInstructionIds, + markPredecessors, + reversePostorderBlocks, } from '../HIR/HIRBuilder'; import { collectTemporariesSidemap, DependencyCollectionContext, handleInstruction, } from '../HIR/PropagateScopeDependenciesHIR'; -import {eachInstructionOperand, eachTerminalOperand} from '../HIR/visitors'; +import {buildDependencyInstructions} from '../HIR/ScopeDependencyUtils'; +import { + eachInstructionOperand, + eachTerminalOperand, + terminalFallthrough, +} from '../HIR/visitors'; import {empty} from '../Utils/Stack'; import {getOrInsertWith} from '../Utils/utils'; @@ -53,7 +63,6 @@ import {getOrInsertWith} from '../Utils/utils'; * a second argument to the useEffect call if no dependency array is provided. */ export function inferEffectDependencies(fn: HIRFunction): void { - let hasRewrite = false; const fnExpressions = new Map< IdentifierId, TInstruction @@ -86,6 +95,7 @@ export function inferEffectDependencies(fn: HIRFunction): void { * reactive(Identifier i) = Union_{reference of i}(reactive(reference)) */ const reactiveIds = inferReactiveIdentifiers(fn); + const rewriteBlocks: Array = []; for (const [, block] of fn.body.blocks) { if (block.terminal.kind === 'scope') { @@ -101,7 +111,7 @@ export function inferEffectDependencies(fn: HIRFunction): void { ); } } - const rewriteInstrs = new Map>(); + const rewriteInstrs: Array = []; for (const instr of block.instructions) { const {value, lvalue} = instr; if (value.kind === 'FunctionExpression') { @@ -165,7 +175,6 @@ export function inferEffectDependencies(fn: HIRFunction): void { ) { // We have a useEffect call with no deps array, so we need to infer the deps const effectDeps: Array = []; - const newInstructions: Array = []; const deps: ArrayExpression = { kind: 'ArrayExpression', elements: effectDeps, @@ -196,24 +205,29 @@ export function inferEffectDependencies(fn: HIRFunction): void { */ const usedDeps = []; - for (const dep of minimalDeps) { + for (const maybeDep of minimalDeps) { if ( - ((isUseRefType(dep.identifier) || - isSetStateType(dep.identifier)) && - !reactiveIds.has(dep.identifier.id)) || - isFireFunctionType(dep.identifier) + ((isUseRefType(maybeDep.identifier) || + isSetStateType(maybeDep.identifier)) && + !reactiveIds.has(maybeDep.identifier.id)) || + isFireFunctionType(maybeDep.identifier) || + isEffectEventFunctionType(maybeDep.identifier) ) { // exclude non-reactive hook results, which will never be in a memo block continue; } - const {place, instructions} = writeDependencyToInstructions( + const dep = truncateDepAtCurrent(maybeDep); + const {place, value, exitBlockId} = buildDependencyInstructions( dep, - reactiveIds.has(dep.identifier.id), fn.env, - fnExpr.loc, ); - newInstructions.push(...instructions); + rewriteInstrs.push({ + kind: 'block', + location: instr.id, + value, + exitBlockId: exitBlockId, + }); effectDeps.push(place); usedDeps.push(dep); } @@ -234,27 +248,34 @@ export function inferEffectDependencies(fn: HIRFunction): void { }); } - newInstructions.push({ - id: makeInstructionId(0), - loc: GeneratedSource, - lvalue: {...depsPlace, effect: Effect.Mutate}, - value: deps, - }); - // Step 2: push the inferred deps array as an argument of the useEffect + rewriteInstrs.push({ + kind: 'instr', + location: instr.id, + value: { + id: makeInstructionId(0), + loc: GeneratedSource, + lvalue: {...depsPlace, effect: Effect.Mutate}, + value: deps, + effects: null, + }, + }); value.args.push({...depsPlace, effect: Effect.Freeze}); - rewriteInstrs.set(instr.id, newInstructions); fn.env.inferredEffectLocations.add(callee.loc); } else if (loadGlobals.has(value.args[0].identifier.id)) { // Global functions have no reactive dependencies, so we can insert an empty array - newInstructions.push({ - id: makeInstructionId(0), - loc: GeneratedSource, - lvalue: {...depsPlace, effect: Effect.Mutate}, - value: deps, + rewriteInstrs.push({ + kind: 'instr', + location: instr.id, + value: { + id: makeInstructionId(0), + loc: GeneratedSource, + lvalue: {...depsPlace, effect: Effect.Mutate}, + value: deps, + effects: null, + }, }); value.args.push({...depsPlace, effect: Effect.Freeze}); - rewriteInstrs.set(instr.id, newInstructions); fn.env.inferredEffectLocations.add(callee.loc); } } else if ( @@ -285,85 +306,164 @@ export function inferEffectDependencies(fn: HIRFunction): void { } } } - if (rewriteInstrs.size > 0) { - hasRewrite = true; - const newInstrs = []; - for (const instr of block.instructions) { - const newInstr = rewriteInstrs.get(instr.id); - if (newInstr != null) { - newInstrs.push(...newInstr, instr); - } else { - newInstrs.push(instr); - } - } - block.instructions = newInstrs; - } + rewriteSplices(block, rewriteInstrs, rewriteBlocks); } - if (hasRewrite) { + + if (rewriteBlocks.length > 0) { + for (const block of rewriteBlocks) { + fn.body.blocks.set(block.id, block); + } + + /** + * Fixup the HIR to restore RPO, ensure correct predecessors, and renumber + * instructions. + */ + reversePostorderBlocks(fn.body); + markPredecessors(fn.body); // Renumber instructions and fix scope ranges markInstructionIds(fn.body); fixScopeAndIdentifierRanges(fn.body); + fn.env.hasInferredEffect = true; } } -function writeDependencyToInstructions( +function truncateDepAtCurrent( dep: ReactiveScopeDependency, - reactive: boolean, - env: Environment, - loc: SourceLocation, -): {place: Place; instructions: Array} { - const instructions: Array = []; - let currValue = createTemporaryPlace(env, GeneratedSource); - currValue.reactive = reactive; - instructions.push({ - id: makeInstructionId(0), - loc: GeneratedSource, - lvalue: {...currValue, effect: Effect.Mutate}, - value: { - kind: 'LoadLocal', - place: { - kind: 'Identifier', - identifier: dep.identifier, - effect: Effect.Capture, - reactive, - loc: loc, - }, - loc: loc, - }, - }); - for (const path of dep.path) { - if (path.optional) { - /** - * TODO: instead of truncating optional paths, reuse - * instructions from hoisted dependencies block(s) - */ - break; - } - if (path.property === 'current') { - /* - * Prune ref.current accesses. This may over-capture for non-ref values with - * a current property, but that's fine. - */ - break; +): ReactiveScopeDependency { + const idx = dep.path.findIndex(path => path.property === 'current'); + if (idx === -1) { + return dep; + } else { + return {...dep, path: dep.path.slice(0, idx)}; + } +} + +type SpliceInfo = + | {kind: 'instr'; location: InstructionId; value: Instruction} + | { + kind: 'block'; + location: InstructionId; + value: HIR; + exitBlockId: BlockId; + }; + +function rewriteSplices( + originalBlock: BasicBlock, + splices: Array, + rewriteBlocks: Array, +): void { + if (splices.length === 0) { + return; + } + /** + * Splice instructions or value blocks into the original block. + * --- original block --- + * bb_original + * instr1 + * ... + * instr2 <-- splice location + * instr3 + * ... + * + * + * If there is more than one block in the splice, this means that we're + * splicing in a set of value-blocks of the following structure: + * --- blocks we're splicing in --- + * bb_entry: + * instrEntry + * ... + * fallthrough=bb_exit + * + * bb1(value): + * ... + * + * bb_exit: + * instrExit + * ... + * + * + * + * --- rewritten blocks --- + * bb_original + * instr1 + * ... (original instructions) + * instr2 + * instrEntry + * ... (spliced instructions) + * fallthrough=bb_exit + * + * bb1(value): + * ... + * + * bb_exit: + * instrExit + * ... (spliced instructions) + * instr3 + * ... (original instructions) + * + */ + const originalInstrs = originalBlock.instructions; + let currBlock: BasicBlock = {...originalBlock, instructions: []}; + rewriteBlocks.push(currBlock); + + let cursor = 0; + for (const rewrite of splices) { + while (originalInstrs[cursor].id < rewrite.location) { + CompilerError.invariant( + originalInstrs[cursor].id < originalInstrs[cursor + 1].id, + { + reason: + '[InferEffectDependencies] Internal invariant broken: expected block instructions to be sorted', + loc: originalInstrs[cursor].loc, + }, + ); + currBlock.instructions.push(originalInstrs[cursor]); + cursor++; } - const nextValue = createTemporaryPlace(env, GeneratedSource); - nextValue.reactive = reactive; - instructions.push({ - id: makeInstructionId(0), - loc: GeneratedSource, - lvalue: {...nextValue, effect: Effect.Mutate}, - value: { - kind: 'PropertyLoad', - object: {...currValue, effect: Effect.Capture}, - property: path.property, - loc: loc, - }, + CompilerError.invariant(originalInstrs[cursor].id === rewrite.location, { + reason: + '[InferEffectDependencies] Internal invariant broken: splice location not found', + loc: originalInstrs[cursor].loc, }); - currValue = nextValue; + + if (rewrite.kind === 'instr') { + currBlock.instructions.push(rewrite.value); + } else { + const {entry, blocks} = rewrite.value; + const entryBlock = blocks.get(entry)!; + // splice in all instructions from the entry block + currBlock.instructions.push(...entryBlock.instructions); + if (blocks.size > 1) { + /** + * We're splicing in a set of value-blocks, which means we need + * to push new blocks and update terminals. + */ + CompilerError.invariant( + terminalFallthrough(entryBlock.terminal) === rewrite.exitBlockId, + { + reason: + '[InferEffectDependencies] Internal invariant broken: expected entry block to have a fallthrough', + loc: entryBlock.terminal.loc, + }, + ); + const originalTerminal = currBlock.terminal; + currBlock.terminal = entryBlock.terminal; + + for (const [id, block] of blocks) { + if (id === entry) { + continue; + } + if (id === rewrite.exitBlockId) { + block.terminal = originalTerminal; + currBlock = block; + } + rewriteBlocks.push(block); + } + } + } } - currValue.effect = Effect.Freeze; - return {place: currValue, instructions}; + currBlock.instructions.push(...originalInstrs.slice(cursor)); } function inferReactiveIdentifiers(fn: HIRFunction): Set { 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..9b347ebb6c4c9 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,12 @@ 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 if (abstractValue.reason.has(ValueReason.HookCaptured)) { + return 'Updating a value previously passed as an argument to a hook is not allowed. Consider moving the mutation before calling the hook'; + } else if (abstractValue.reason.has(ValueReason.HookReturn)) { + return 'Updating a value returned from a hook is not allowed. Consider moving the mutation into the hook where the value is constructed'; } 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..b91b606d507e6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts @@ -0,0 +1,2515 @@ +/** + * 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, + eachTerminalOperand, + eachTerminalSuccessor, +} from '../HIR/visitors'; +import {Ok, Result} from '../Utils/Result'; +import { + getArgumentEffect, + getFunctionCallSignature, + isKnownMutableEffect, + mergeValueKinds, +} from './InferReferenceEffects'; +import { + assertExhaustive, + getOrInsertDefault, + 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 iterationCount = 0; + while (queuedStates.size !== 0) { + iterationCount++; + if (iterationCount > 100) { + CompilerError.invariant(false, { + reason: `[InferMutationAliasingEffects] Potential infinite loop`, + description: `A value, temporary place, or effect was not cached properly`, + loc: fn.loc, + }); + } + 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, +): Map { + const hoisted = new Map(); + function visit(place: Place): void { + if ( + hoisted.has(place.identifier.declarationId) && + hoisted.get(place.identifier.declarationId) == null + ) { + // If this is the first load of the value, store the location + hoisted.set(place.identifier.declarationId, place); + } + } + 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.set(instr.value.lvalue.place.identifier.declarationId, null); + } + } else { + for (const operand of eachInstructionValueOperand(instr.value)) { + visit(operand); + } + } + } + for (const operand of eachTerminalOperand(block.terminal)) { + visit(operand); + } + } + return hoisted; +} + +class Context { + internedEffects: Map = new Map(); + instructionSignatureCache: Map = new Map(); + effectInstructionValueCache: Map = + new Map(); + applySignatureCache: Map< + AliasingSignature, + Map | null> + > = new Map(); + catchHandlers: Map = new Map(); + functionSignatureCache: Map = + new Map(); + isFuctionExpression: boolean; + fn: HIRFunction; + hoistedContextDeclarations: Map; + + constructor( + isFunctionExpression: boolean, + fn: HIRFunction, + hoistedContextDeclarations: Map, + ) { + this.isFuctionExpression = isFunctionExpression; + this.fn = fn; + this.hoistedContextDeclarations = hoistedContextDeclarations; + } + + cacheApplySignature( + signature: AliasingSignature, + effect: Extract, + f: () => Array | null, + ): Array | null { + const inner = getOrInsertDefault( + this.applySignatureCache, + signature, + new Map(), + ); + return getOrInsertWith(inner, effect, f); + } + + 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) { + CompilerError.invariant(state.kind(handlerParam) != null, { + reason: + 'Expected catch binding to be intialized with a DeclareLocal Catch instruction', + loc: terminal.loc, + }); + 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( + context.internEffect({ + kind: 'Alias', + from: instr.lvalue, + into: handlerParam, + }), + ); + } + } + } + terminal.effects = effects.length !== 0 ? effects : null; + } + } else if (terminal.kind === 'return') { + if (!context.isFuctionExpression) { + terminal.effects = [ + context.internEffect({ + 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 initialized = new Set(); + + if (DEBUG) { + console.log(printInstruction(instruction)); + } + + for (const effect of signature.effects) { + applyEffect(context, state, effect, initialized, 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, + initialized: 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': { + CompilerError.invariant(!initialized.has(effect.into.identifier.id), { + reason: `Cannot re-initialize variable within an instruction`, + description: `Re-initialized ${printPlace(effect.into)} in ${printAliasingEffect(effect)}`, + loc: effect.into.loc, + }); + initialized.add(effect.into.identifier.id); + + 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); + effects.push(effect); + 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': { + CompilerError.invariant(!initialized.has(effect.into.identifier.id), { + reason: `Cannot re-initialize variable within an instruction`, + description: `Re-initialized ${printPlace(effect.into)} in ${printAliasingEffect(effect)}`, + loc: effect.into.loc, + }); + initialized.add(effect.into.identifier.id); + + 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: { + effects.push({ + kind: 'Create', + value: fromValue.kind, + into: effect.into, + reason: [...fromValue.reason][0] ?? ValueReason.Other, + }); + break; + } + case ValueKind.Frozen: { + effects.push({ + kind: 'Create', + value: fromValue.kind, + into: effect.into, + reason: [...fromValue.reason][0] ?? ValueReason.Other, + }); + applyEffect( + context, + state, + { + kind: 'ImmutableCapture', + from: effect.from, + into: effect.into, + }, + initialized, + effects, + ); + break; + } + default: { + effects.push(effect); + } + } + break; + } + case 'CreateFunction': { + CompilerError.invariant(!initialized.has(effect.into.identifier.id), { + reason: `Cannot re-initialize variable within an instruction`, + description: `Re-initialized ${printPlace(effect.into)} in ${printAliasingEffect(effect)}`, + loc: effect.into.loc, + }); + initialized.add(effect.into.identifier.id); + + 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, + }, + initialized, + effects, + ); + } + break; + } + case 'Alias': + case 'Capture': { + CompilerError.invariant( + effect.kind === 'Capture' || initialized.has(effect.into.identifier.id), + { + reason: `Expected destination value to already be initialized within this instruction for Alias effect`, + description: `Destination ${printPlace(effect.into)} is not initialized in this instruction`, + loc: effect.into.loc, + }, + ); + /* + * 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; + applyEffect( + context, + state, + { + kind: 'ImmutableCapture', + from: effect.from, + into: effect.into, + }, + initialized, + effects, + ); + break; + } + default: { + isMutableReferenceType = true; + break; + } + } + if (isMutableDesination && isMutableReferenceType) { + effects.push(effect); + } + break; + } + case 'Assign': { + CompilerError.invariant(!initialized.has(effect.into.identifier.id), { + reason: `Cannot re-initialize variable within an instruction`, + description: `Re-initialized ${printPlace(effect.into)} in ${printAliasingEffect(effect)}`, + loc: effect.into.loc, + }); + initialized.add(effect.into.identifier.id); + + /* + * 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: { + applyEffect( + context, + state, + { + kind: 'ImmutableCapture', + from: effect.from, + into: effect.into, + }, + initialized, + effects, + ); + 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: { + state.assign(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' && + functionValues[0].loweredFunc.func.aliasingEffects != null + ) { + /* + * 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 functionExpr = functionValues[0]; + let signature = context.functionSignatureCache.get(functionExpr); + if (signature == null) { + signature = buildSignatureFromFunctionExpression( + state.env, + functionExpr, + ); + context.functionSignatureCache.set(functionExpr, signature); + } + if (DEBUG) { + console.log( + `constructed alias signature:\n${printAliasingSignature(signature)}`, + ); + } + const signatureEffects = context.cacheApplySignature( + signature, + effect, + () => + computeEffectsForSignature( + state.env, + signature, + effect.into, + effect.receiver, + effect.args, + functionExpr.loweredFunc.func.context, + effect.loc, + ), + ); + if (signatureEffects != null) { + applyEffect( + context, + state, + {kind: 'MutateTransitiveConditionally', value: effect.function}, + initialized, + effects, + ); + for (const signatureEffect of signatureEffects) { + applyEffect(context, state, signatureEffect, initialized, effects); + } + break; + } + } + let signatureEffects = null; + if (effect.signature?.aliasing != null) { + const signature = effect.signature.aliasing; + signatureEffects = context.cacheApplySignature( + effect.signature.aliasing, + effect, + () => + computeEffectsForSignature( + state.env, + signature, + effect.into, + effect.receiver, + effect.args, + [], + effect.loc, + ), + ); + } + if (signatureEffects != null) { + for (const signatureEffect of signatureEffects) { + applyEffect(context, state, signatureEffect, initialized, effects); + } + } else if (effect.signature != null) { + const legacyEffects = computeEffectsForLegacySignature( + state, + effect.signature, + effect.into, + effect.receiver, + effect.args, + effect.loc, + ); + for (const legacyEffect of legacyEffects) { + applyEffect(context, state, legacyEffect, initialized, effects); + } + } else { + applyEffect( + context, + state, + { + kind: 'Create', + into: effect.into, + value: ValueKind.Mutable, + reason: ValueReason.Other, + }, + initialized, + 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, + }, + initialized, + effects, + ); + } + const mutateIterator = + arg.kind === 'Spread' ? conditionallyMutateIterator(operand) : null; + if (mutateIterator) { + applyEffect(context, state, mutateIterator, initialized, effects); + } + applyEffect( + context, + state, + // OK: recording information flow + {kind: 'Alias', from: operand, into: effect.into}, + initialized, + 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, + }, + initialized, + 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))); + } + + if ( + mutationKind === 'mutate-frozen' && + context.hoistedContextDeclarations.has( + effect.value.identifier.declarationId, + ) + ) { + const description = + effect.value.identifier.name !== null && + effect.value.identifier.name.kind === 'named' + ? `Variable \`${effect.value.identifier.name.value}\` is accessed before it is declared` + : null; + const hoistedAccess = context.hoistedContextDeclarations.get( + effect.value.identifier.declarationId, + ); + if (hoistedAccess != null && hoistedAccess.loc != effect.value.loc) { + applyEffect( + context, + state, + { + kind: 'MutateFrozen', + place: effect.value, + error: { + severity: ErrorSeverity.InvalidReact, + reason: `This variable is accessed before it is declared, which may prevent it from updating as the assigned value changes over time`, + description, + loc: hoistedAccess.loc, + suggestions: null, + }, + }, + initialized, + effects, + ); + } + + applyEffect( + context, + state, + { + kind: 'MutateFrozen', + place: effect.value, + error: { + severity: ErrorSeverity.InvalidReact, + reason: `This variable is accessed before it is declared, which prevents the earlier access from updating when this value changes over time`, + description, + loc: effect.value.loc, + suggestions: null, + }, + }, + initialized, + effects, + ); + } else { + const reason = getWriteErrorReason({ + kind: value.kind, + reason: value.reason, + context: new Set(), + }); + const description = + effect.value.identifier.name !== null && + effect.value.identifier.name.kind === 'named' + ? `Found mutation of \`${effect.value.identifier.name.value}\`` + : null; + applyEffect( + context, + state, + { + kind: + value.kind === ValueKind.Frozen + ? 'MutateFrozen' + : 'MutateGlobal', + place: effect.value, + error: { + severity: ErrorSeverity.InvalidReact, + reason, + description, + loc: effect.value.loc, + suggestions: null, + }, + }, + initialized, + effects, + ); + } + } + 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. + assign(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 ( + 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: { + const mutateIterator = conditionallyMutateIterator(place); + if (mutateIterator != null) { + effects.push(mutateIterator); + } + effects.push({ + kind: 'Capture', + from: place, + into: lvalue, + }); + 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) + ) { + return null; + } + // Build substitutions + const mutableSpreads = new Set(); + 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) { + return null; + } + const place = arg.kind === 'Identifier' ? arg : arg.place; + getOrInsertWith(substitutions, signature.rest, () => []).push(place); + + if (arg.kind === 'Spread') { + const mutateIterator = conditionallyMutateIterator(arg.place); + if (mutateIterator != null) { + mutableSpreads.add(arg.place.identifier.id); + } + } + } 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) { + if (mutableSpreads.has(value.identifier.id)) { + CompilerError.throwTodo({ + reason: 'Support spread syntax for hook arguments', + loc: value.loc, + }); + } + 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) { + return null; + } + const applyFunction = substitutions.get(effect.function.identifier.id); + if (applyFunction == null || applyFunction.length !== 1) { + return null; + } + const applyInto = substitutions.get(effect.into.identifier.id); + if (applyInto == null || applyInto.length !== 1) { + 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) { + return null; + } + applyArgs.push(applyArg[0]); + } else { + const applyArg = substitutions.get(arg.place.identifier.id); + if (applyArg == null || applyArg.length !== 1) { + 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/InferMutationAliasingRanges.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts new file mode 100644 index 0000000000000..79f8cf8c0e85b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingRanges.ts @@ -0,0 +1,766 @@ +/** + * 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, SourceLocation} from '..'; +import { + BlockId, + Effect, + HIRFunction, + Identifier, + IdentifierId, + InstructionId, + isJsxType, + makeInstructionId, + ValueKind, + ValueReason, + Place, + isPrimitiveType, +} from '../HIR/HIR'; +import { + eachInstructionLValue, + eachInstructionValueOperand, + eachTerminalOperand, +} from '../HIR/visitors'; +import {assertExhaustive, getOrInsertWith} from '../Utils/utils'; +import {Err, Ok, Result} from '../Utils/Result'; +import {AliasingEffect} from './AliasingEffects'; + +/** + * This pass builds an abstract model of the heap and interprets the effects of the + * given function in order to determine the following: + * - The mutable ranges of all identifiers in the function + * - The externally-visible effects of the function, such as mutations of params and + * context-vars, aliasing between params/context-vars/return-value, and impure side + * effects. + * - The legacy `Effect` to store on each Place. + * + * 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. + * + * ## Example + * A function expression such as the following: + * + * ``` + * (x) => { x.y = true } + * ``` + * + * Would populate a `Mutate x` aliasing effect on the outer function. + * + * ## Returned Function Effects + * + * The function returns (if successful) a list of externally-visible effects. + * This is determined by simulating a conditional, transitive mutation against + * each param, context variable, and return value in turn, and seeing which other + * such values are affected. If they're affected, they must be captured, so we + * record a Capture. + * + * The only tricky bit is the return value, which could _alias_ (or even assign) + * one or more of the params/context-vars rather than just capturing. So we have + * to do a bit more tracking for returns. + */ +export function inferMutationAliasingRanges( + fn: HIRFunction, + {isFunctionExpression}: {isFunctionExpression: boolean}, +): Result, CompilerError> { + // The set of externally-visible effects + const functionEffects: Array = []; + + /** + * 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.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') { + /** + * TODO: Invariant that the node is not initialized yet + * + * InferFunctionExpressionAliasingEffectSignatures currently infers + * Assign effects in some places that should be Alias, leading to + * Assign effects that reinitialize a value. The end result appears to + * be fine, but we should fix that inference pass so that we add the + * invariant here. + */ + 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); + functionEffects.push(effect); + } else if (effect.kind === 'Render') { + renders.push({index: index++, place: effect.place}); + functionEffects.push(effect); + } + } + } + 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, + }); + } + } + } + } + + 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); + } + 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; + functionEffects.push({ + kind: 'MutateConditionally', + value: {...place, loc: node.local.loc}, + }); + } else if (node.local.kind === MutationKind.Definite) { + mutated = true; + functionEffects.push({ + kind: 'Mutate', + value: {...place, loc: node.local.loc}, + }); + } + } + if (node.transitive != null) { + if (node.transitive.kind === MutationKind.Conditional) { + mutated = true; + functionEffects.push({ + kind: 'MutateTransitiveConditionally', + value: {...place, loc: node.transitive.loc}, + }); + } else if (node.transitive.kind === MutationKind.Definite) { + mutated = true; + functionEffects.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; + } + } + } + + /** + * Part 3 + * Finish populating the externally visible effects. Above we bubble-up the side effects + * (MutateFrozen/MutableGlobal/Impure/Render) as well as mutations of context variables. + * Here we populate an effect to create the return value as well as populating alias/capture + * effects for how data flows between the params, context vars, and return. + */ + const returns = fn.returns.identifier; + functionEffects.push({ + kind: 'Create', + into: fn.returns, + value: isPrimitiveType(returns) + ? ValueKind.Primitive + : isJsxType(returns.type) + ? ValueKind.Frozen + : ValueKind.Mutable, + reason: ValueReason.KnownReturnSignature, + }); + /** + * Determine precise data-flow effects by simulating transitive mutations of the params/ + * captures and seeing what other params/context variables are affected. Anything that + * would be transitively mutated needs a capture relationship. + */ + const tracked: Array = []; + const ignoredErrors = new CompilerError(); + for (const param of [...fn.params, ...fn.context, fn.returns]) { + const place = param.kind === 'Identifier' ? param : param.place; + tracked.push(place); + } + for (const into of tracked) { + const mutationIndex = index++; + state.mutate( + mutationIndex, + into.identifier, + null, + true, + MutationKind.Conditional, + into.loc, + ignoredErrors, + ); + for (const from of tracked) { + if ( + from.identifier.id === into.identifier.id || + from.identifier.id === fn.returns.identifier.id + ) { + continue; + } + const fromNode = state.nodes.get(from.identifier); + CompilerError.invariant(fromNode != null, { + reason: `Expected a node to exist for all parameters and context variables`, + loc: into.loc, + }); + if (fromNode.lastMutated === mutationIndex) { + if (into.identifier.id === fn.returns.identifier.id) { + // The return value could be any of the params/context variables + functionEffects.push({ + kind: 'Alias', + from, + into, + }); + } else { + // Otherwise params/context-vars can only capture each other + functionEffects.push({ + kind: 'Capture', + from, + into, + }); + } + } + } + } + + if (errors.hasErrors() && !isFunctionExpression) { + return Err(errors); + } + return Ok(functionEffects); +} + +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; + } + } + } +} + +export enum MutationKind { + None = 0, + Conditional = 1, + Definite = 2, +} + +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; + lastMutated: number; + 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, + lastMutated: 0, + 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) { + 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) { + 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) { + 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, + // Null is used for simulated mutations + end: InstructionId | null, + transitive: boolean, + kind: MutationKind, + loc: SourceLocation, + errors: CompilerError, + ): void { + 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) { + continue; + } + node.lastMutated = Math.max(node.lastMutated, index); + if (end != null) { + 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'}); + } + } + } + } +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts index b05b292124c72..88faccd8cf3b6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts @@ -26,6 +26,7 @@ import { import {PostDominator} from '../HIR/Dominator'; import { eachInstructionLValue, + eachInstructionOperand, eachInstructionValueOperand, eachTerminalOperand, } from '../HIR/visitors'; @@ -292,7 +293,7 @@ export function inferReactivePlaces(fn: HIRFunction): void { let hasReactiveInput = false; /* * NOTE: we want to mark all operands as reactive or not, so we - * avoid short-circuting here + * avoid short-circuiting here */ for (const operand of eachInstructionValueOperand(value)) { const reactive = reactiveIdentifiers.isReactive(operand); @@ -375,6 +376,41 @@ export function inferReactivePlaces(fn: HIRFunction): void { } } } while (reactiveIdentifiers.snapshot()); + + function propagateReactivityToInnerFunctions( + fn: HIRFunction, + isOutermost: boolean, + ): void { + for (const [, block] of fn.body.blocks) { + for (const instr of block.instructions) { + if (!isOutermost) { + for (const operand of eachInstructionOperand(instr)) { + reactiveIdentifiers.isReactive(operand); + } + } + if ( + instr.value.kind === 'ObjectMethod' || + instr.value.kind === 'FunctionExpression' + ) { + propagateReactivityToInnerFunctions( + instr.value.loweredFunc.func, + false, + ); + } + } + if (!isOutermost) { + for (const operand of eachTerminalOperand(block.terminal)) { + reactiveIdentifiers.isReactive(operand); + } + } + } + } + + /** + * Propagate reactivity for inner functions, as we eventually hoist and dedupe + * dependency instructions for scopes. + */ + propagateReactivityToInnerFunctions(fn, true); } /* 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 c6c6f2f54fbef..a1d381899e20c 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); + } } } } @@ -235,6 +242,7 @@ function rewriteBlock( type: null, loc: terminal.loc, }, + effects: null, }); block.terminal = { kind: 'goto', @@ -263,5 +271,6 @@ function declareTemporary( type: null, loc: result.loc, }, + effects: null, }); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/MUTABILITY_ALIASING_MODEL.md b/compiler/packages/babel-plugin-react-compiler/src/Inference/MUTABILITY_ALIASING_MODEL.md new file mode 100644 index 0000000000000..60ef16a6245f3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/MUTABILITY_ALIASING_MODEL.md @@ -0,0 +1,544 @@ +# The Mutability & Aliasing Model + +This document describes the new (as of June 2025) mutability and aliasing model powering React Compiler. The mutability and aliasing system is a conceptual subcomponent whose primary role is to determine minimal sets of values that mutate together, and the range of instructions over which those mutations occur. These minimal sets of values that mutate together, and the corresponding instructions doing those mutations, are ultimately grouped into reactive scopes, which then translate into memoization blocks in the output (after substantial additional processing described in the comments of those passes). + +To build an intuition, consider the following example: + +```js +function Component() { + // a is created and mutated over the course of these two instructions: + const a = {}; + mutate(a); + + // b and c are created and mutated together — mutate might modify b via c + const b = {}; + const c = {b}; + mutate(c); + + // does not modify a/b/c + return +} +``` + +The goal of mutability and aliasing inference is to understand the set of instructions that create/modify a, b, and c. + +In code, the mutability and aliasing model is compromised of the following phases: + +* `InferMutationAliasingEffects`. Infers a set of mutation and aliasing effects for each instruction. The approach is to generate a set of candidate effects based purely on the semantics of each instruction and the types of the operands, then use abstract interpretation to determine the actual effects (or errros) that would apply. For example, an instruction that by default has a Capture effect might downgrade to an ImmutableCapture effect if the value is known to be frozen. +* `InferMutationAliasingRanges`. Infers a mutable range (start:end instruction ids) for each value in the program, and annotates each Place with its effect type for usage in later passes. This builds a graph of data flow through the program over time in order to understand which mutations effect which values. +* `InferReactiveScopeVariables`. Given the per-Place effects, determines disjoint sets of values that mutate together and assigns all identifiers in each set to a unique scope, and updates the range to include the ranges of all constituent values. + +Finally, `AnalyzeFunctions` needs to understand the mutation and aliasing semantics of nested FunctionExpression and ObjectMethod values. `AnalyzeFunctions` calls `InferFunctionExpressionAliasingEffectsSignature` to determine the publicly observable set of mutation/aliasing effects for nested functions. + +## Mutation and Aliasing Effects + +The inference model is based on a set of "effects" that describe subtle aspects of mutation, aliasing, and other changes to the state of values over time + +### Creation Effects + +#### Create + +```js +{ + kind: 'Create'; + into: Place; + value: ValueKind; + reason: ValueReason; +} +``` + +Describes the creation of a new value with the given kind, and reason for having that kind. For example, `x = 10` might have an effect like `Create x = ValueKind.Primitive [ValueReason.Other]`. + +#### CreateFunction + +```js +{ + kind: 'CreateFunction'; + captures: Array; + function: FunctionExpression | ObjectMethod; + into: Place; +} +``` + +Describes the creation of new function value, capturing the given set of mutable values. CreateFunction is used to specifically track function types so that we can precisely model calls to those functions with `Apply`. + +#### Apply + +```js +{ + kind: 'Apply'; + receiver: Place; + function: Place; // same as receiver for function calls + mutatesFunction: boolean; // indicates if this is a type that we consdier to mutate the function itself by default + args: Array; + into: Place; // where result is stored + signature: FunctionSignature | null; +} +``` + +Describes the potential creation of a value by calling a function. This models `new`, function calls, and method calls. The inference algorithm uses the most precise signature it can determine: + +* If the function is a locally created function expression, we use a signature inferred from the behavior of that function to interpret the effects of calling it with the given arguments. +* Else if the function has a known aliasing signature (new style precise effects signature), we apply the arguments to that signature to get a precise set of effects. +* Else if the function has a legacy style signature (with per-param effects) we convert the legacy per-Place effects into aliasing effects (described in this doc) and apply those. +* Else fall back to inferring a generic set of effects. + +The generic fallback is to assume: +- The return value may alias any of the arguments (Alias param -> return) +- Any arguments *may* be transitively mutated (MutateTransitiveConditionally param) +- Any argument may be captured into any other argument (Capture paramN -> paramM for all N,M where N != M) + +### Aliasing Effects + +These effects describe data-flow only, separately from mutation or other state-changing semantics. + +#### Assign + +```js +{ + kind: 'Assign'; + from: Place; + into: Place; +} +``` + +Describes an `x = y` assignment, where the receiving (into) value is overwritten with a new (from) value. After this effect, any previous assignments/aliases to the receiving value are dropped. Note that `Alias` initializes the receiving value. + +> TODO: InferMutationAliasingRanges may not fully reset aliases on encountering this effect + +#### Alias + +```js +{ + kind: 'Alias'; + from: Place; + into: Place; +} +``` + +Describes that an assignment _may_ occur, but that the possible assignment is non-exclusive. The canonical use-case for `Alias` is a function that may return more than one of its arguments, such as `(x, y, z) => x ? y : z`. Here, the result of this function may be `y` or `z`, but neither one overwrites the other. Note that `Alias` does _not_ initialize the receiving value: it should always be paired with an effect to create the receiving value. + +#### Capture + +```js +{ + kind: 'Capture'; + from: Place; + into: Place; +} +``` + +Describes that a reference to one variable (from) is stored within another value (into). Examples include: +- An array expression captures the items of the array (`array = [capturedValue]`) +- Array.prototype.push captures the pushed values into the array (`array.push(capturedValue)`) +- Property assignment captures the value onto the object (`object.property = capturedValue`) + +#### CreateFrom + +```js +{ + kind: 'CreateFrom'; + from: Place; + into: Place; +} +``` + +This is somewhat the inverse of `Capture`. The `CreateFrom` effect describes that a variable is initialized by extracting _part_ of another value, without taking a direct alias to the full other value. Examples include: + +- Indexing into an array (`createdFrom = array[0]`) +- Reading an object property (`createdFrom = object.property`) +- Getting a Map key (`createdFrom = map.get(key)`) + +#### ImmutableCapture + +Describes immutable data flow from one value to another. This is not currently used for anything, but is intended to eventually power a more sophisticated escape analysis. + +### State-Changing Effects + +The following effects describe state changes to specific values, not data flow. In many cases, JavaScript semantics will involve a combination of both data-flow effects *and* state-change effects. For example, `object.property = value` has data flow (`Capture object <- value`) and mutation (`Mutate object`). + +#### Freeze + +```js +{ + kind: 'Freeze', + // The reference being frozen + value: Place; + // The reason the value is frozen (passed to a hook, passed to jsx, etc) + reason: ValueReason; +} +``` + +Once a reference to a value has been passed to React, that value is generally not safe to mutate further. This is not a strictly required property of React, but is a natural consequence of making components and hooks composable without leaking implementation details. Concretely, once a value has been passed as a JSX prop, passed as argument to a hook, or returned from a hook, it must be assumed that the other "side" — receiver of the prop/argument/return value — will use that value as an input to an effect or memoization unit. Mutating that value (instead of creating a new value) will fail to cause the consuming computation to update: + +```js +// INVALID DO NOT DO THIS +function Component(props) { + const array = useArray(props.value); + // OOPS! this value is memoized, the array won't get re-created + // when `props.value` changes, so we might just keep pushing new + // values to the same array on every render! + array.push(props.otherValue); +} + +function useArray(a) { + return useMemo(() => [a], [a]); +} +``` + +The **Freeze** effect accepts a variable reference and a reason that the value is being frozen. Note: _freeze only applies to the reference, not the underlying value_. Our inference is conservative, and assumes that there may still be other references to the same underlying value which are mutated later. For example: + +```js +const x = {}; +const y = []; +x.y = y; +freeze(y); // y _reference_ is frozen +x.y.push(props.value); // but y is still considered mutable bc of this +``` + +#### Mutate (and MutateConditionally) + +```js +{ + kind: 'Mutate'; + value: Place; +} +``` + +Mutate indicates that a value is mutated, without modifying any of the values that it may transitively have captured. Canonical examples include: + +- Pushing an item onto an array modifies the array, but does not modify any items stored _within_ the array (unless the array has a reference to itself!) +- Assigning a value to an object property modifies the object, but not any values stored in the object's other properties. + +This helps explain the distinction between Assign/Alias and Capture: Mutate only affects assign/alias but not captures. + +`MutateConditionally` is an alternative in which the mutation _may_ happen depending on the type of the value. The conditional variant is not generally used and included for completeness. + + + +#### MutateTransitiveConditionally (and MutateTransitive) + +`MutateTransitiveConditionally` represents an operation that may mutate _any_ aspect of a value, including reaching arbitrarily deep into nested values to mutate them. This is the default semantic for unknown functions — we have no idea what they do, so we assume that they are idempotent but may mutate any aspect of the mutable values that are passed to them. + +There is also `MutateTransitive` for completeness, but this is not generally used. + +### Side Effects + +Finally, there are a few effects that describe error, or potential error, conditions: + +- `MutateFrozen` is always an error, because it indicates known mutation of a value that should not be mutated. +- `MutateGlobal` indicates known mutation of a global value, which is not safe during render. This effect is an error if reachable during render, but allowed if only reachable via an event handler or useEffect. +- `Impure` indicates calling some other logic that is impure/side-effecting. This is an error if reachable during render, but allowed if only reachable via an event handler or useEffect. + - TODO: we could probably merge this and MutateGlobal +- `Render` indicates a value that is not mutated, but is known to be called during render. It's used for a few particular places like JSX tags and JSX children, which we assume are accessed during render (while other props may be event handlers etc). This helps to detect more MutateGlobal/Impure effects and reject more invalid programs. + + +## Rules + +### Mutation of Alias Mutates the Source Value + +``` +Alias a <- b +Mutate a +=> +Mutate b +``` + +Example: + +```js +const a = maybeIdentity(b); // Alias a <- b +a.property = value; // a could be b, so this mutates b +``` + +### Mutation of Assignment Mutates the Source Value + +``` +Assign a <- b +Mutate a +=> +Mutate b +``` + +Example: + +```js +const a = b; +a.property = value // a _is_ b, this mutates b +``` + +### Mutation of CreateFrom Mutates the Source Value + +``` +CreateFrom a <- b +Mutate a +=> +Mutate b +``` + +Example: + +```js +const a = b[index]; +a.property = value // the contents of b are transitively mutated +``` + + +### Mutation of Capture Does *Not* Mutate the Source Value + +``` +Capture a <- b +Mutate a +!=> +~Mutate b~ +``` + +Example: + +```js +const a = {}; +a.b = b; +a.property = value; // mutates a, not b +``` + +### Mutation of Source Affects Alias, Assignment, CreateFrom, and Capture + +``` +Alias a <- b OR Assign a <- b OR CreateFrom a <- b OR Capture a <- b +Mutate b +=> +Mutate a +``` + +A derived value changes when it's source value is mutated. + +Example: + +```js +const x = {}; +const y = [x]; +x.y = true; // this changes the value within `y` ie mutates y +``` + + +### TransitiveMutation of Alias, Assignment, CreateFrom, or Capture Mutates the Source + +``` +Alias a <- b OR Assign a <- b OR CreateFrom a <- b OR Capture a <- b +MutateTransitive a +=> +MutateTransitive b +``` + +Remember, the intuition for a transitive mutation is that it's something that could traverse arbitrarily deep into an object and mutate whatever it finds. Imagine something that recurses into every nested object/array and sets `.field = value`. Given a function `mutate()` that does this, then: + +```js +const a = b; // assign +mutate(a); // clearly can transitively mutate b + +const a = maybeIdentity(b); // alias +mutate(a); // clearly can transitively mutate b + +const a = b[index]; // createfrom +mutate(a); // clearly can transitively mutate b + +const a = {}; +a.b = b; // capture +mutate(a); // can transitively mutate b +``` + +### Freeze Does Not Freeze the Value + +Freeze does not freeze the value itself: + +``` +Create x +Assign y <- x OR Alias y <- x OR CreateFrom y <- x OR Capture y <- x +Freeze y +!=> +~Freeze x~ +``` + +This means that subsequent mutations of the original value are valid: + +``` +Create x +Assign y <- x OR Alias y <- x OR CreateFrom y <- x OR Capture y <- x +Freeze y +Mutate x +=> +Mutate x (mutation is ok) +``` + +As well as mutations through other assignments/aliases/captures/createfroms of the original value: + +``` +Create x +Assign y <- x OR Alias y <- x OR CreateFrom y <- x OR Capture y <- x +Freeze y +Alias z <- x OR Capture z <- x OR CreateFrom z <- x OR Assign z <- x +Mutate z +=> +Mutate x (mutation is ok) +``` + +### Freeze Freezes The Reference + +Although freeze doesn't freeze the value, it does affect the reference. The reference cannot be used to mutate. + +Conditional mutations of the reference are no-ops: + +``` +Create x +Assign y <- x OR Alias y <- x OR CreateFrom y <- x OR Capture y <- x +Freeze y +MutateConditional y +=> +(no mutation) +``` + +And known mutations of the reference are errors: + +``` +Create x +Assign y <- x OR Alias y <- x OR CreateFrom y <- x OR Capture y <- x +Freeze y +MutateConditional y +=> +MutateFrozen y error=... +``` + +### Corollary: Transitivity of Assign/Alias/CreateFrom/Capture + +A key part of the inference model is inferring a signature for function expressions. The signature is a minimal set of effects that describes the publicly observable behavior of the function. This can include "global" effects like side effects (MutateGlobal/Impure) as well as mutations/aliasing of parameters and free variables. + +In order to determine the aliasing of params and free variables into each other and/or the return value, we may encounter chains of assign, alias, createfrom, and capture effects. For example: + +```js +const f = (x) => { + const y = [x]; // capture y <- x + const z = y[0]; // createfrom z <- y + return z; // assign return <- z +} +// return <- x +``` + +In this example we can see that there should be some effect on `f` that tracks the flow of data from `x` into the return value. The key constraint is preserving the semantics around how local/transitive mutations of the destination would affect the source. + +#### Each of the effects is transitive with itself + +``` +Assign b <- a +Assign c <- b +=> +Assign c <- a +``` + +``` +Alias b <- a +Alias c <- b +=> +Alias c <- a +``` + +``` +CreateFrom b <- a +CreateFrom c <- b +=> +CreateFrom c <- a +``` + +``` +Capture b <- a +Capture c <- b +=> +Capture c <- a +``` + +#### Alias > Assign + +``` +Assign b <- a +Alias c <- b +=> +Alias c <- a +``` + +``` +Alias b <- a +Assign c <- b +=> +Alias c <- a +``` + +### CreateFrom > Assign/Alias + +Intuition: + +``` +CreateFrom b <- a +Alias c <- b OR Assign c <- b +=> +CreateFrom c <- a +``` + +``` +Alias b <- a OR Assign b <- a +CreateFrom c <- b +=> +CreateFrom c <- a +``` + +### Capture > Assign/Alias + +Intuition: capturing means that a local mutation of the destination will not affect the source, so we preserve the capture. + +``` +Capture b <- a +Alias c <- b OR Assign c <- b +=> +Capture c <- a +``` + +``` +Alias b <- a OR Assign b <- a +Capture c <- b +=> +Capture c <- a +``` + +### Capture And CreateFrom + +Intuition: these effects are inverses of each other (capturing into an object, extracting from an object). The result is based on the order of operations: + +Capture then CreatFrom is equivalent to Alias: we have to assume that the result _is_ the original value and that a local mutation of the result could mutate the original. + +```js +const b = [a]; // capture +const c = b[0]; // createfrom +mutate(c); // this clearly can mutate a, so the result must be one of Assign/Alias/CreateFrom +``` + +We use Alias as the return type because the mutability kind of the result is not derived from the source value (there's a fresh object in between due to the capture), so the full set of effects in practice would be a Create+Alias. + +``` +Capture b <- a +CreateFrom c <- b +=> +Alias c <- a +``` + +Meanwhile the opposite direction preserves the capture, because the result is not the same as the source: + +```js +const b = a[0]; // createfrom +const c = [b]; // capture +mutate(c); // does not mutate a, so the result must be Capture +``` + +``` +CreateFrom b <- a +Capture c <- b +=> +Capture c <- a +``` \ No newline at end of file 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..921ec59ecd2a1 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/LowerContextAccess.ts @@ -25,7 +25,6 @@ import { makeBlockId, makeInstructionId, makePropertyLiteral, - makeType, markInstructionIds, promoteTemporary, reversePostorderBlocks, @@ -146,6 +145,7 @@ function emitLoadLoweredContextCallee( id: makeInstructionId(0), loc: GeneratedSource, lvalue: createTemporaryPlace(env, GeneratedSource), + effects: null, value: loadGlobal, }; } @@ -192,6 +192,7 @@ function emitPropertyLoad( lvalue: object, value: loadObj, id: makeInstructionId(0), + effects: null, loc: GeneratedSource, }; @@ -206,6 +207,7 @@ function emitPropertyLoad( lvalue: element, value: loadProp, id: makeInstructionId(0), + effects: null, loc: GeneratedSource, }; return { @@ -237,6 +239,7 @@ function emitSelectorFn(env: Environment, keys: Array): Instruction { kind: 'return', loc: GeneratedSource, value: arrayInstr.lvalue, + effects: null, }, preds: new Set(), phis: new Set(), @@ -249,7 +252,7 @@ function emitSelectorFn(env: Environment, keys: Array): Instruction { env, params: [obj], returnTypeAnnotation: null, - returnType: makeType(), + returns: createTemporaryPlace(env, GeneratedSource), context: [], effects: null, body: { @@ -278,6 +281,7 @@ function emitSelectorFn(env: Environment, keys: Array): Instruction { loc: GeneratedSource, }, lvalue: createTemporaryPlace(env, GeneratedSource), + effects: null, loc: GeneratedSource, }; return fnInstr; @@ -294,6 +298,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..b7590a570197a 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/OutlineJsx.ts @@ -21,7 +21,6 @@ import { makeBlockId, makeIdentifierName, makeInstructionId, - makeType, ObjectProperty, Place, promoteTemporary, @@ -297,6 +296,7 @@ function emitOutlinedJsx( }, loc: GeneratedSource, }, + effects: null, }; promoteTemporaryJsxTag(loadJsx.lvalue.identifier); const jsxExpr: Instruction = { @@ -312,6 +312,7 @@ function emitOutlinedJsx( openingLoc: GeneratedSource, closingLoc: GeneratedSource, }, + effects: null, }; return [loadJsx, jsxExpr]; @@ -353,6 +354,7 @@ function emitOutlinedFn( kind: 'return', loc: GeneratedSource, value: instructions.at(-1)!.lvalue, + effects: null, }, preds: new Set(), phis: new Set(), @@ -365,7 +367,7 @@ function emitOutlinedFn( env, params: [propsObj], returnTypeAnnotation: null, - returnType: makeType(), + returns: createTemporaryPlace(env, GeneratedSource), context: [], effects: null, body: { @@ -517,6 +519,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 33a124dcec6e2..f7da5229548ab 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'; @@ -349,11 +349,9 @@ function codegenReactiveFunction( fn: ReactiveFunction, ): Result { for (const param of fn.params) { - if (param.kind === 'Identifier') { - cx.temp.set(param.identifier.declarationId, null); - } else { - cx.temp.set(param.place.identifier.declarationId, null); - } + const place = param.kind === 'Identifier' ? param : param.place; + cx.temp.set(place.identifier.declarationId, null); + cx.declare(place.identifier); } const params = fn.params.map(param => convertParameter(param)); @@ -1183,7 +1181,7 @@ function codegenTerminal( ? codegenPlaceToExpression(cx, case_.test) : null; const block = codegenBlock(cx, case_.block!); - return t.switchCase(test, [block]); + return t.switchCase(test, block.body.length === 0 ? [] : [block]); }), ); } @@ -1310,7 +1308,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, }); @@ -1726,7 +1724,7 @@ function codegenInstructionValue( } case 'UnaryExpression': { value = t.unaryExpression( - instrValue.operator as 'throw', // todo + instrValue.operator, codegenPlaceToExpression(cx, instrValue.value), ); break; @@ -2582,7 +2580,16 @@ function codegenValue( value: boolean | number | string | null | undefined, ): t.Expression { if (typeof value === 'number') { - return t.numericLiteral(value); + if (value < 0) { + /** + * Babel's code generator produces invalid JS for negative numbers when + * run with { compact: true }. + * See repro https://codesandbox.io/p/devbox/5d47fr + */ + return t.unaryExpression('-', t.numericLiteral(-value), false); + } else { + return t.numericLiteral(value); + } } else if (typeof value === 'boolean') { return t.booleanLiteral(value); } else if (typeof value === 'string') { diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/ExtractScopeDeclarationsFromDestructuring.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/ExtractScopeDeclarationsFromDestructuring.ts index eb2caa424e417..642b89747e6ea 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/ExtractScopeDeclarationsFromDestructuring.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/ExtractScopeDeclarationsFromDestructuring.ts @@ -79,6 +79,10 @@ export function extractScopeDeclarationsFromDestructuring( fn: ReactiveFunction, ): void { const state = new State(fn.env); + for (const param of fn.params) { + const place = param.kind === 'Identifier' ? param : param.place; + state.declared.add(place.identifier.declarationId); + } visitReactiveFunction(fn, new Visitor(), state); } diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts index 08d2212d86b95..6f2d97ff8e528 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts @@ -456,6 +456,7 @@ function canMergeScopes( new Set( [...current.scope.declarations.values()].map(declaration => ({ identifier: declaration.identifier, + reactive: true, path: [], })), ), 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/TypeInference/InferTypes.ts b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts index 69812fc130ded..859c871c263ad 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts @@ -90,7 +90,8 @@ function apply(func: HIRFunction, unifier: Unifier): void { } } } - func.returnType = unifier.get(func.returnType); + const returns = func.returns.identifier; + returns.type = unifier.get(returns.type); } type TypeEquation = { @@ -143,12 +144,12 @@ function* generate( } } if (returnTypes.length > 1) { - yield equation(func.returnType, { + yield equation(func.returns.identifier.type, { kind: 'Phi', operands: returnTypes, }); } else if (returnTypes.length === 1) { - yield equation(func.returnType, returnTypes[0]!); + yield equation(func.returns.identifier.type, returnTypes[0]!); } } @@ -407,7 +408,7 @@ function* generateInstructionTypes( yield equation(left, { kind: 'Function', shapeId: BuiltInFunctionId, - return: value.loweredFunc.func.returnType, + return: value.loweredFunc.func.returns.identifier.type, isConstructor: false, }); break; diff --git a/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts b/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts index b4484331dec80..6c2cfd5d07490 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts @@ -93,6 +93,21 @@ const testComplexConfigDefaults: PartialEnvironmentConfig = { }, ], }; + +function* splitPragma( + pragma: string, +): Generator<{key: string; value: string | null}> { + for (const entry of pragma.split('@')) { + const keyVal = entry.trim(); + const valIdx = keyVal.indexOf(':'); + if (valIdx === -1) { + yield {key: keyVal.split(' ', 1)[0], value: null}; + } else { + yield {key: keyVal.slice(0, valIdx), value: keyVal.slice(valIdx + 1)}; + } + } +} + /** * For snap test fixtures and playground only. */ @@ -101,19 +116,11 @@ function parseConfigPragmaEnvironmentForTest( ): EnvironmentConfig { const maybeConfig: Partial> = {}; - for (const token of pragma.split(' ')) { - if (!token.startsWith('@')) { - continue; - } - const keyVal = token.slice(1); - const valIdx = keyVal.indexOf(':'); - const key = valIdx === -1 ? keyVal : keyVal.slice(0, valIdx); - const val = valIdx === -1 ? undefined : keyVal.slice(valIdx + 1); - const isSet = val === undefined || val === 'true'; + for (const {key, value: val} of splitPragma(pragma)) { if (!hasOwnProperty(EnvironmentConfigSchema.shape, key)) { continue; } - + const isSet = val == null || val === 'true'; if (isSet && key in testComplexConfigDefaults) { maybeConfig[key] = testComplexConfigDefaults[key]; } else if (isSet) { @@ -176,18 +183,11 @@ export function parseConfigPragmaForTests( compilationMode: defaults.compilationMode, environment, }; - for (const token of pragma.split(' ')) { - if (!token.startsWith('@')) { - continue; - } - const keyVal = token.slice(1); - const idx = keyVal.indexOf(':'); - const key = idx === -1 ? keyVal : keyVal.slice(0, idx); - const val = idx === -1 ? undefined : keyVal.slice(idx + 1); + for (const {key, value: val} of splitPragma(pragma)) { if (!hasOwnProperty(defaultOptions, key)) { continue; } - const isSet = val === undefined || val === 'true'; + const isSet = val == null || val === 'true'; if (isSet && key in testComplexPluginOptionDefaults) { options[key] = testComplexPluginOptionDefaults[key]; } else if (isSet) { 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/ValidateHooksUsage.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateHooksUsage.ts index e90f33c74076c..b28228339ce5f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateHooksUsage.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateHooksUsage.ts @@ -452,7 +452,7 @@ function visitFunctionExpression(errors: CompilerError, fn: HIRFunction): void { reason: 'Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)', loc: callee.loc, - description: `Cannot call ${hookKind} within a function component`, + description: `Cannot call ${hookKind === 'Custom' ? 'hook' : hookKind} within a function expression`, suggestions: null, }), ); 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/Validation/ValidateNoRefAccesInRender.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccesInRender.ts rename to compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoRefAccessInRender.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/index.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/index.ts index 92d53cbd421a0..3bf03f362fa19 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/index.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/index.ts @@ -9,7 +9,7 @@ export {validateContextVariableLValues} from './ValidateContextVariableLValues'; export {validateHooksUsage} from './ValidateHooksUsage'; export {validateMemoizedEffectDependencies} from './ValidateMemoizedEffectDependencies'; export {validateNoCapitalizedCalls} from './ValidateNoCapitalizedCalls'; -export {validateNoRefAccessInRender} from './ValidateNoRefAccesInRender'; +export {validateNoRefAccessInRender} from './ValidateNoRefAccessInRender'; export {validateNoSetStateInRender} from './ValidateNoSetStateInRender'; export {validatePreservedManualMemoization} from './ValidatePreservedManualMemoization'; export {validateUseMemo} from './ValidateUseMemo'; 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/babel-repro-compact-negative-number.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/babel-repro-compact-negative-number.expect.md new file mode 100644 index 0000000000000..70e19e0744a4a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/babel-repro-compact-negative-number.expect.md @@ -0,0 +1,56 @@ + +## Input + +```javascript +import {Stringify} from 'shared-runtime'; + +function Repro(props) { + const MY_CONST = -2; + return {props.arg - MY_CONST}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Repro, + params: [ + { + arg: 3, + }, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { Stringify } from "shared-runtime"; + +function Repro(props) { + const $ = _c(2); + + const t0 = props.arg - -2; + let t1; + if ($[0] !== t0) { + t1 = {t0}; + $[0] = t0; + $[1] = t1; + } else { + t1 = $[1]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Repro, + params: [ + { + arg: 3, + }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"children":5}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/babel-repro-compact-negative-number.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/babel-repro-compact-negative-number.js new file mode 100644 index 0000000000000..891589bc981d4 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/babel-repro-compact-negative-number.js @@ -0,0 +1,15 @@ +import {Stringify} from 'shared-runtime'; + +function Repro(props) { + const MY_CONST = -2; + return {props.arg - MY_CONST}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Repro, + params: [ + { + arg: 3, + }, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/block-scoping-switch-variable-scoping.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/block-scoping-switch-variable-scoping.expect.md index d5bf094ef4ff9..a225812dbd306 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/block-scoping-switch-variable-scoping.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/block-scoping-switch-variable-scoping.expect.md @@ -50,8 +50,7 @@ function Component(props) { console.log(handlers.value); break bb0; } - default: { - } + default: } t0 = handlers; 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-ref-prefix-postfix-operator.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-ref-prefix-postfix-operator.expect.md new file mode 100644 index 0000000000000..ccfc451750004 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-ref-prefix-postfix-operator.expect.md @@ -0,0 +1,132 @@ + +## Input + +```javascript +import {useRef, useEffect} from 'react'; + +/** + * The postfix increment operator should return the value before incrementing. + * ```js + * const id = count.current; // 0 + * count.current = count.current + 1; // 1 + * return id; + * ``` + * The bug is that we currently increment the value before the expression is evaluated. + * This bug does not trigger when the incremented value is a plain primitive. + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"} + * logs: ['id = 0','count = 1'] + * Forget: + * (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"} + * logs: ['id = 1','count = 1'] + */ +function useFoo() { + const count = useRef(0); + const updateCountPostfix = () => { + const id = count.current++; + return id; + }; + const updateCountPrefix = () => { + const id = ++count.current; + return id; + }; + useEffect(() => { + const id = updateCountPostfix(); + console.log(`id = ${id}`); + console.log(`count = ${count.current}`); + }, []); + return {count, updateCountPostfix, updateCountPrefix}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useRef, useEffect } from "react"; + +/** + * The postfix increment operator should return the value before incrementing. + * ```js + * const id = count.current; // 0 + * count.current = count.current + 1; // 1 + * return id; + * ``` + * The bug is that we currently increment the value before the expression is evaluated. + * This bug does not trigger when the incremented value is a plain primitive. + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"} + * logs: ['id = 0','count = 1'] + * Forget: + * (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"} + * logs: ['id = 1','count = 1'] + */ +function useFoo() { + const $ = _c(5); + const count = useRef(0); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = () => { + count.current = count.current + 1; + const id = count.current; + return id; + }; + $[0] = t0; + } else { + t0 = $[0]; + } + const updateCountPostfix = t0; + let t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = () => { + const id_0 = (count.current = count.current + 1); + return id_0; + }; + $[1] = t1; + } else { + t1 = $[1]; + } + const updateCountPrefix = t1; + let t2; + let t3; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t2 = () => { + const id_1 = updateCountPostfix(); + console.log(`id = ${id_1}`); + console.log(`count = ${count.current}`); + }; + t3 = []; + $[2] = t2; + $[3] = t3; + } else { + t2 = $[2]; + t3 = $[3]; + } + useEffect(t2, t3); + let t4; + if ($[4] === Symbol.for("react.memo_cache_sentinel")) { + t4 = { count, updateCountPostfix, updateCountPrefix }; + $[4] = t4; + } else { + t4 = $[4]; + } + return t4; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-ref-prefix-postfix-operator.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-ref-prefix-postfix-operator.js new file mode 100644 index 0000000000000..a7c1fad8bf231 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-ref-prefix-postfix-operator.js @@ -0,0 +1,42 @@ +import {useRef, useEffect} from 'react'; + +/** + * The postfix increment operator should return the value before incrementing. + * ```js + * const id = count.current; // 0 + * count.current = count.current + 1; // 1 + * return id; + * ``` + * The bug is that we currently increment the value before the expression is evaluated. + * This bug does not trigger when the incremented value is a plain primitive. + * + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"} + * logs: ['id = 0','count = 1'] + * Forget: + * (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"} + * logs: ['id = 1','count = 1'] + */ +function useFoo() { + const count = useRef(0); + const updateCountPostfix = () => { + const id = count.current++; + return id; + }; + const updateCountPrefix = () => { + const id = ++count.current; + return id; + }; + useEffect(() => { + const id = updateCountPostfix(); + console.log(`id = ${id}`); + console.log(`count = ${count.current}`); + }, []); + return {count, updateCountPostfix, updateCountPrefix}; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; 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/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/custom-opt-out-directive.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/custom-opt-out-directive.expect.md new file mode 100644 index 0000000000000..7875137a88f55 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/custom-opt-out-directive.expect.md @@ -0,0 +1,35 @@ + +## Input + +```javascript +// @customOptOutDirectives:["use todo memo"] +function Component() { + 'use todo memo'; + return
hello world!
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; + +``` + +## Code + +```javascript +// @customOptOutDirectives:["use todo memo"] +function Component() { + "use todo memo"; + return
hello world!
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; + +``` + +### Eval output +(kind: ok)
hello world!
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/custom-opt-out-directive.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/custom-opt-out-directive.tsx new file mode 100644 index 0000000000000..225559618386f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/custom-opt-out-directive.tsx @@ -0,0 +1,10 @@ +// @customOptOutDirectives:["use todo memo"] +function Component() { + 'use todo memo'; + return
hello world!
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dominator.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dominator.expect.md index e878d4fb7f825..508a7b62581d7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dominator.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/dominator.expect.md @@ -67,8 +67,7 @@ function Component(props) { case "b": { break bb1; } - case "c": { - } + case "c": default: { x = 6; } 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/error.hook-call-freezes-captured-identifier.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.hook-call-freezes-captured-identifier.expect.md index 7babe57b000e2..5e0a9886272bc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.hook-call-freezes-captured-identifier.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.hook-call-freezes-captured-identifier.expect.md @@ -32,7 +32,7 @@ export const FIXTURE_ENTRYPOINT = { 11 | }); 12 | > 13 | x.value += count; - | ^ InvalidReact: This mutates a variable that React considers immutable (13:13) + | ^ InvalidReact: Updating a value previously passed as an argument to a hook is not allowed. Consider moving the mutation before calling the hook (13:13) 14 | return ; 15 | } 16 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.hook-call-freezes-captured-memberexpr.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.hook-call-freezes-captured-memberexpr.expect.md index fcc47ddc2b14f..c5af59d642426 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.hook-call-freezes-captured-memberexpr.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.hook-call-freezes-captured-memberexpr.expect.md @@ -32,7 +32,7 @@ export const FIXTURE_ENTRYPOINT = { 11 | }); 12 | > 13 | x.value += count; - | ^ InvalidReact: This mutates a variable that React considers immutable (13:13) + | ^ InvalidReact: Updating a value previously passed as an argument to a hook is not allowed. Consider moving the mutation before calling the hook (13:13) 14 | return ; 15 | } 16 | 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..1be37ef830989 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,19 @@ 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 + 17 | * $2 = Function context=setState + 18 | */ +> 19 | useEffect(() => setState(2), []); + | ^^^^^^^^ InvalidReact: This variable is accessed before it is declared, which may prevent it from updating as the assigned value changes over time. Variable `setState` is accessed before it is declared (19:19) + +InvalidReact: This variable is accessed before it is declared, which prevents the earlier access from updating when this value changes over time. Variable `setState` is accessed before it is declared (21:21) + 20 | + 21 | const [state, setState] = useState(0); + 22 | return ; +``` + + \ 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-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-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 | 11 | onPress={() => (sharedVal.value = Math.random())} - | ^^^^^^^^^ InvalidReact: Mutating a value returned from a function whose return value should not be mutated. Found mutation of `sharedVal` (11:11) + | ^^^^^^^^^ InvalidReact: Updating a value returned from a hook is not allowed. Consider moving the mutation into the hook where the value is constructed. Found mutation of `sharedVal` (11:11) 12 | title="Randomize" 13 | /> 14 | ); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.expect.md index 0d4742f26c604..85905b48dfa66 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-pass-mutable-function-as-prop.expect.md @@ -20,7 +20,7 @@ function Component() { 5 | cache.set('key', 'value'); 6 | }; > 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.mutate-hook-argument.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutate-hook-argument.expect.md index 665fc7053b788..dd98f22530713 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutate-hook-argument.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.mutate-hook-argument.expect.md @@ -16,6 +16,8 @@ function useHook(a, b) { 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) + +InvalidReact: Mutating component props or hook arguments is not allowed. Consider using a local variable instead (3:3) 3 | a.test = 2; 4 | } 5 | diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.not-useEffect-external-mutate.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.not-useEffect-external-mutate.expect.md index 7d829fe9b013f..8c08efee8d894 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.not-useEffect-external-mutate.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.not-useEffect-external-mutate.expect.md @@ -21,6 +21,8 @@ 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) + +InvalidReact: Writing to a variable defined outside a component or hook is not allowed. Consider using an effect (6:6) 6 | x.a = 20; 7 | }); 8 | } 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.reassignment-to-global-indirect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.reassignment-to-global-indirect.expect.md index e4073947f7e15..503ca7df7be46 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.reassignment-to-global-indirect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.reassignment-to-global-indirect.expect.md @@ -21,6 +21,8 @@ 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 | foo(); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.reassignment-to-global.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.reassignment-to-global.expect.md index 4619cd27cb24d..ec764b7ff27e3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.reassignment-to-global.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.reassignment-to-global.expect.md @@ -18,6 +18,8 @@ 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) + +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) 4 | moduleLocal = true; 5 | } 6 | 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..8dd58594ecc64 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: [InferMutationAliasingEffects] Expected value kind to be initialized. hasErrors_0$15:TFunction (9:9) 10 | } 11 | ``` 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/error.todo-valid-functiondecl-hoisting.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-valid-functiondecl-hoisting.expect.md index fb8988c8f878d..e057305d05e70 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-valid-functiondecl-hoisting.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-valid-functiondecl-hoisting.expect.md @@ -34,13 +34,13 @@ export const FIXTURE_ENTRYPOINT = { ## Error ``` - 13 | return bar(); + 11 | + 12 | function foo() { +> 13 | return bar(); + | ^^^ Todo: [PruneHoistedContexts] Rewrite hoisted function references (13:13) 14 | } -> 15 | function bar() { - | ^^^ Todo: [PruneHoistedContexts] Rewrite hoisted function references (15:15) + 15 | function bar() { 16 | return 42; - 17 | } - 18 | ``` \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-annotation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-annotation.expect.md new file mode 100644 index 0000000000000..364239e4e3a2d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-annotation.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @compilationMode:"annotation" + +function Foo() { + 'use memo if(getTrue)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { getTrue } from "shared-runtime"; // @dynamicGating:{"source":"shared-runtime"} @compilationMode:"annotation" +const Foo = getTrue() + ? function Foo() { + "use memo if(getTrue)"; + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 =
hello world
; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; + } + : function Foo() { + "use memo if(getTrue)"; + return
hello world
; + }; + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
hello world
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-annotation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-annotation.js new file mode 100644 index 0000000000000..c30b30fe6f5f1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-annotation.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} @compilationMode:"annotation" + +function Foo() { + 'use memo if(getTrue)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.expect.md new file mode 100644 index 0000000000000..dc3cc2b98de9a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly + +import {useMemo} from 'react'; +import {identity} from 'shared-runtime'; + +function Foo({value}) { + 'use memo if(getTrue)'; + + const initialValue = useMemo(() => identity(value), []); + return ( + <> +
initial value {initialValue}
+
current value {value}
+ + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{value: 1}], + sequentialRenders: [{value: 1}, {value: 2}], +}; + +``` + +## Code + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly + +import { useMemo } from "react"; +import { identity } from "shared-runtime"; + +function Foo({ value }) { + "use memo if(getTrue)"; + + const initialValue = useMemo(() => identity(value), []); + return ( + <> +
initial value {initialValue}
+
current value {value}
+ + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ value: 1 }], + sequentialRenders: [{ value: 1 }, { value: 2 }], +}; + +``` + +## Logs + +``` +{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":206},"end":{"line":16,"column":1,"index":433},"filename":"dynamic-gating-bailout-nopanic.ts"},"detail":{"reason":"React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected","description":"The inferred dependency was `value`, but the source dependencies were []. Inferred dependency not present in source","severity":"CannotPreserveMemoization","suggestions":null,"loc":{"start":{"line":9,"column":31,"index":288},"end":{"line":9,"column":52,"index":309},"filename":"dynamic-gating-bailout-nopanic.ts"}}} +``` + +### Eval output +(kind: ok)
initial value 1
current value 1
+
initial value 1
current value 2
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.js new file mode 100644 index 0000000000000..ceddbefdd1b72 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.js @@ -0,0 +1,22 @@ +// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly + +import {useMemo} from 'react'; +import {identity} from 'shared-runtime'; + +function Foo({value}) { + 'use memo if(getTrue)'; + + const initialValue = useMemo(() => identity(value), []); + return ( + <> +
initial value {initialValue}
+
current value {value}
+ + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{value: 1}], + sequentialRenders: [{value: 1}, {value: 2}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.expect.md new file mode 100644 index 0000000000000..7d95b54317d48 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(getFalse)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { getFalse } from "shared-runtime"; // @dynamicGating:{"source":"shared-runtime"} +const Foo = getFalse() + ? function Foo() { + "use memo if(getFalse)"; + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 =
hello world
; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; + } + : function Foo() { + "use memo if(getFalse)"; + return
hello world
; + }; + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
hello world
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.js new file mode 100644 index 0000000000000..be29f10568754 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(getFalse)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.expect.md new file mode 100644 index 0000000000000..272c5a57143bf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(getTrue)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { getTrue } from "shared-runtime"; // @dynamicGating:{"source":"shared-runtime"} +const Foo = getTrue() + ? function Foo() { + "use memo if(getTrue)"; + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 =
hello world
; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; + } + : function Foo() { + "use memo if(getTrue)"; + return
hello world
; + }; + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
hello world
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.js new file mode 100644 index 0000000000000..9280e25d116fe --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(getTrue)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.expect.md new file mode 100644 index 0000000000000..c8c91910b02bc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.expect.md @@ -0,0 +1,37 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" + +function Foo() { + 'use memo if(true)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" + +function Foo() { + "use memo if(true)"; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
hello world
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.js new file mode 100644 index 0000000000000..4d0d9c3bb86f5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" + +function Foo() { + 'use memo if(true)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-multiple.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-multiple.expect.md new file mode 100644 index 0000000000000..327adbe792ed3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-multiple.expect.md @@ -0,0 +1,45 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" @loggerTestOnly + +function Foo() { + 'use memo if(getTrue)'; + 'use memo if(getFalse)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" @loggerTestOnly + +function Foo() { + "use memo if(getTrue)"; + "use memo if(getFalse)"; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Logs + +``` +{"kind":"CompileError","fnLoc":{"start":{"line":3,"column":0,"index":86},"end":{"line":7,"column":1,"index":190},"filename":"dynamic-gating-invalid-multiple.ts"},"detail":{"reason":"Multiple dynamic gating directives found","description":"Expected a single directive but found [use memo if(getTrue), use memo if(getFalse)]","severity":"InvalidReact","suggestions":null,"loc":{"start":{"line":4,"column":2,"index":105},"end":{"line":4,"column":25,"index":128},"filename":"dynamic-gating-invalid-multiple.ts"}}} +``` + +### Eval output +(kind: ok)
hello world
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-multiple.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-multiple.js new file mode 100644 index 0000000000000..867ac8ee34b1b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-multiple.js @@ -0,0 +1,12 @@ +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" @loggerTestOnly + +function Foo() { + 'use memo if(getTrue)'; + 'use memo if(getFalse)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-noemit.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-noemit.expect.md new file mode 100644 index 0000000000000..81ebd6dd9fad9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-noemit.expect.md @@ -0,0 +1,37 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @noEmit + +function Foo() { + 'use memo if(getTrue)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @noEmit + +function Foo() { + "use memo if(getTrue)"; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
hello world
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-noemit.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-noemit.js new file mode 100644 index 0000000000000..97cf777a552df --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-noemit.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} @noEmit + +function Foo() { + 'use memo if(getTrue)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.expect.md new file mode 100644 index 0000000000000..7f9f608383bdd --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.expect.md @@ -0,0 +1,35 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" @inferEffectDependencies +import {useEffect} from 'react'; +import {print} from 'shared-runtime'; + +function ReactiveVariable({propVal}) { + 'use memo if(invalid identifier)'; + const arr = [propVal]; + useEffect(() => print(arr)); +} + +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveVariable, + params: [{}], +}; + +``` + + +## Error + +``` + 6 | 'use memo if(invalid identifier)'; + 7 | const arr = [propVal]; +> 8 | useEffect(() => print(arr)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ InvalidReact: [InferEffectDependencies] React Compiler is unable to infer dependencies of this effect. This will break your build! To resolve, either pass your own dependency array or fix reported compiler bailout diagnostics. (8:8) + 9 | } + 10 | + 11 | export const FIXTURE_ENTRYPOINT = { +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.js new file mode 100644 index 0000000000000..7d5b74acc7960 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.js @@ -0,0 +1,14 @@ +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" @inferEffectDependencies +import {useEffect} from 'react'; +import {print} from 'shared-runtime'; + +function ReactiveVariable({propVal}) { + 'use memo if(invalid identifier)'; + const arr = [propVal]; + useEffect(() => print(arr)); +} + +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveVariable, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.expect.md new file mode 100644 index 0000000000000..c824afd680618 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.expect.md @@ -0,0 +1,32 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(true)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + + +## Error + +``` + 2 | + 3 | function Foo() { +> 4 | 'use memo if(true)'; + | ^^^^^^^^^^^^^^^^^^^^ InvalidReact: Dynamic gating directive is not a valid JavaScript identifier. Found 'use memo if(true)' (4:4) + 5 | return
hello world
; + 6 | } + 7 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.js new file mode 100644 index 0000000000000..c400554497235 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(true)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; 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/infer-effect-dependencies/bailout-retry/error.todo-dynamic-gating.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-dynamic-gating.expect.md new file mode 100644 index 0000000000000..ec5ef238b7828 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-dynamic-gating.expect.md @@ -0,0 +1,42 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @inferEffectDependencies @panicThreshold:"none" + +import useEffectWrapper from 'useEffectWrapper'; + +/** + * TODO: run the non-forget enabled version through the effect inference + * pipeline. + */ +function Component({foo}) { + 'use memo if(getTrue)'; + const arr = []; + useEffectWrapper(() => arr.push(foo)); + arr.push(2); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 1}], + sequentialRenders: [{foo: 1}, {foo: 2}], +}; + +``` + + +## Error + +``` + 10 | 'use memo if(getTrue)'; + 11 | const arr = []; +> 12 | useEffectWrapper(() => arr.push(foo)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ InvalidReact: [InferEffectDependencies] React Compiler is unable to infer dependencies of this effect. This will break your build! To resolve, either pass your own dependency array or fix reported compiler bailout diagnostics. (12:12) + 13 | arr.push(2); + 14 | return arr; + 15 | } +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-dynamic-gating.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-dynamic-gating.js new file mode 100644 index 0000000000000..4d1ceb92b78a8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-dynamic-gating.js @@ -0,0 +1,21 @@ +// @dynamicGating:{"source":"shared-runtime"} @inferEffectDependencies @panicThreshold:"none" + +import useEffectWrapper from 'useEffectWrapper'; + +/** + * TODO: run the non-forget enabled version through the effect inference + * pipeline. + */ +function Component({foo}) { + 'use memo if(getTrue)'; + const arr = []; + useEffectWrapper(() => arr.push(foo)); + arr.push(2); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 1}], + sequentialRenders: [{foo: 1}, {foo: 2}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-gating.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-gating.expect.md new file mode 100644 index 0000000000000..e071e37cb99d3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-gating.expect.md @@ -0,0 +1,40 @@ + +## Input + +```javascript +// @gating @inferEffectDependencies @panicThreshold:"none" +import useEffectWrapper from 'useEffectWrapper'; + +/** + * TODO: run the non-forget enabled version through the effect inference + * pipeline. + */ +function Component({foo}) { + const arr = []; + useEffectWrapper(() => arr.push(foo)); + arr.push(2); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 1}], + sequentialRenders: [{foo: 1}, {foo: 2}], +}; + +``` + + +## Error + +``` + 8 | function Component({foo}) { + 9 | const arr = []; +> 10 | useEffectWrapper(() => arr.push(foo)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ InvalidReact: [InferEffectDependencies] React Compiler is unable to infer dependencies of this effect. This will break your build! To resolve, either pass your own dependency array or fix reported compiler bailout diagnostics. (10:10) + 11 | arr.push(2); + 12 | return arr; + 13 | } +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-gating.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-gating.js new file mode 100644 index 0000000000000..651b24074f2bc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/error.todo-gating.js @@ -0,0 +1,19 @@ +// @gating @inferEffectDependencies @panicThreshold:"none" +import useEffectWrapper from 'useEffectWrapper'; + +/** + * TODO: run the non-forget enabled version through the effect inference + * pipeline. + */ +function Component({foo}) { + const arr = []; + useEffectWrapper(() => arr.push(foo)); + arr.push(2); + return arr; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 1}], + sequentialRenders: [{foo: 1}, {foo: 2}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-optional-chain.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-optional-chain.expect.md new file mode 100644 index 0000000000000..b9aafe41df26e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/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":"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":345},"end":{"line":10,"column":5,"index":348},"filename":"mutate-after-useeffect-optional-chain.ts","identifierName":"arr"}}} +{"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/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-optional-chain.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/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/infer-effect-dependencies/bailout-retry/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/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.expect.md index 05ef40c150833..0d159211a9082 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @inferEffectDependencies @panicThreshold:"none" +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly import {useEffect, useRef} from 'react'; import {print} from 'shared-runtime'; @@ -14,12 +14,17 @@ function Component({arrRef}) { return arrRef; } +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{arrRef: {current: {val: 'initial ref value'}}}], +}; + ``` ## Code ```javascript -// @inferEffectDependencies @panicThreshold:"none" +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly import { useEffect, useRef } from "react"; import { print } from "shared-runtime"; @@ -32,7 +37,21 @@ function Component(t0) { 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,"severity":"InvalidReact","suggestions":null,"loc":{"start":{"line":9,"column":2,"index":269},"end":{"line":9,"column":16,"index":283},"filename":"mutate-after-useeffect-ref-access.ts"}}} +{"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: exception) Fixture not implemented \ No newline at end of file +(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/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.js index f497d7e595e3b..bd3f6d1de54bd 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect-ref-access.js @@ -1,4 +1,4 @@ -// @inferEffectDependencies @panicThreshold:"none" +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly import {useEffect, useRef} from 'react'; import {print} from 'shared-runtime'; @@ -9,3 +9,8 @@ function Component({arrRef}) { 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/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.expect.md index fa1df3ef88722..d48d98a1e67f7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.expect.md @@ -2,33 +2,55 @@ ## Input ```javascript -// @inferEffectDependencies @panicThreshold:"none" +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly import {useEffect} from 'react'; function Component({foo}) { const arr = []; - useEffect(() => arr.push(foo)); + useEffect(() => { + arr.push(foo); + }); arr.push(2); return arr; } +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 1}], +}; + ``` ## Code ```javascript -// @inferEffectDependencies @panicThreshold:"none" +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly import { useEffect } from "react"; function Component(t0) { const { foo } = t0; const arr = []; - useEffect(() => arr.push(foo), [arr, foo]); + 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":"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":194},"end":{"line":9,"column":5,"index":197},"filename":"mutate-after-useeffect.ts","identifierName":"arr"}}} +{"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: exception) Fixture not implemented \ No newline at end of file +(kind: ok) [2] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.js index 2e2eb7bc08d3d..fbcbf004a308c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/bailout-retry/mutate-after-useeffect.js @@ -1,9 +1,16 @@ -// @inferEffectDependencies @panicThreshold:"none" +// @inferEffectDependencies @panicThreshold:"none" @loggerTestOnly import {useEffect} from 'react'; function Component({foo}) { const arr = []; - useEffect(() => arr.push(foo)); + 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/infer-effect-dependencies/no-emit/retry-no-emit.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/no-emit/retry-no-emit.expect.md index bd70c0138d7e2..3e559339d0632 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/no-emit/retry-no-emit.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/no-emit/retry-no-emit.expect.md @@ -52,7 +52,7 @@ 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":"CompileError","fnLoc":{"start":{"line":5,"column":0,"index":163},"end":{"line":13,"column":1,"index":357},"filename":"retry-no-emit.ts"},"detail":{"reason":"Updating a value previously passed as an argument to a hook is not allowed. Consider moving the mutation before calling the hook","description":null,"severity":"InvalidReact","suggestions":null,"loc":{"start":{"line":11,"column":2,"index":320},"end":{"line":11,"column":6,"index":324},"filename":"retry-no-emit.ts","identifierName":"arr2"}}} {"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} 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()); +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain-complex.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain-complex.expect.md new file mode 100644 index 0000000000000..a840ab81a5eee --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain-complex.expect.md @@ -0,0 +1,99 @@ + +## Input + +```javascript +// @inferEffectDependencies +import {useEffect} from 'react'; +import {print, shallowCopy} from 'shared-runtime'; + +function ReactiveMemberExpr({cond, propVal}) { + const obj = {a: cond ? {b: propVal} : null, c: null}; + const other = shallowCopy({a: {b: {c: {d: {e: {f: propVal + 1}}}}}}); + const primitive = shallowCopy(propVal); + useEffect(() => + print(obj.a?.b, other?.a?.b?.c?.d?.e.f, primitive.a?.b.c?.d?.e.f) + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveMemberExpr, + params: [{cond: true, propVal: 1}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies +import { useEffect } from "react"; +import { print, shallowCopy } from "shared-runtime"; + +function ReactiveMemberExpr(t0) { + const $ = _c(13); + const { cond, propVal } = t0; + let t1; + if ($[0] !== cond || $[1] !== propVal) { + t1 = cond ? { b: propVal } : null; + $[0] = cond; + $[1] = propVal; + $[2] = t1; + } else { + t1 = $[2]; + } + let t2; + if ($[3] !== t1) { + t2 = { a: t1, c: null }; + $[3] = t1; + $[4] = t2; + } else { + t2 = $[4]; + } + const obj = t2; + const t3 = propVal + 1; + let t4; + if ($[5] !== t3) { + t4 = shallowCopy({ a: { b: { c: { d: { e: { f: t3 } } } } } }); + $[5] = t3; + $[6] = t4; + } else { + t4 = $[6]; + } + const other = t4; + let t5; + if ($[7] !== propVal) { + t5 = shallowCopy(propVal); + $[7] = propVal; + $[8] = t5; + } else { + t5 = $[8]; + } + const primitive = t5; + let t6; + if ( + $[9] !== obj.a?.b || + $[10] !== other?.a?.b?.c?.d?.e.f || + $[11] !== primitive.a?.b.c?.d?.e.f + ) { + t6 = () => + print(obj.a?.b, other?.a?.b?.c?.d?.e.f, primitive.a?.b.c?.d?.e.f); + $[9] = obj.a?.b; + $[10] = other?.a?.b?.c?.d?.e.f; + $[11] = primitive.a?.b.c?.d?.e.f; + $[12] = t6; + } else { + t6 = $[12]; + } + useEffect(t6, [obj.a?.b, other?.a?.b?.c?.d?.e.f, primitive.a?.b.c?.d?.e.f]); +} + +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveMemberExpr, + params: [{ cond: true, propVal: 1 }], +}; + +``` + +### Eval output +(kind: ok) +logs: [1,2,undefined] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain-complex.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain-complex.js new file mode 100644 index 0000000000000..93e10250cf389 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain-complex.js @@ -0,0 +1,17 @@ +// @inferEffectDependencies +import {useEffect} from 'react'; +import {print, shallowCopy} from 'shared-runtime'; + +function ReactiveMemberExpr({cond, propVal}) { + const obj = {a: cond ? {b: propVal} : null, c: null}; + const other = shallowCopy({a: {b: {c: {d: {e: {f: propVal + 1}}}}}}); + const primitive = shallowCopy(propVal); + useEffect(() => + print(obj.a?.b, other?.a?.b?.c?.d?.e.f, primitive.a?.b.c?.d?.e.f) + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveMemberExpr, + params: [{cond: true, propVal: 1}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain.expect.md index 7c9f21b85cdab..ffd81732c6e96 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain.expect.md @@ -6,12 +6,17 @@ import {useEffect} from 'react'; import {print} from 'shared-runtime'; -// TODO: take optional chains as dependencies function ReactiveMemberExpr({cond, propVal}) { - const obj = {a: cond ? {b: propVal} : null}; + const obj = {a: cond ? {b: propVal} : null, c: null}; useEffect(() => print(obj.a?.b)); + useEffect(() => print(obj.c?.d)); } +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveMemberExpr, + params: [{cond: true, propVal: 1}], +}; + ``` ## Code @@ -21,9 +26,8 @@ import { c as _c } from "react/compiler-runtime"; // @inferEffectDependencies import { useEffect } from "react"; import { print } from "shared-runtime"; -// TODO: take optional chains as dependencies function ReactiveMemberExpr(t0) { - const $ = _c(7); + const $ = _c(9); const { cond, propVal } = t0; let t1; if ($[0] !== cond || $[1] !== propVal) { @@ -36,7 +40,7 @@ function ReactiveMemberExpr(t0) { } let t2; if ($[3] !== t1) { - t2 = { a: t1 }; + t2 = { a: t1, c: null }; $[3] = t1; $[4] = t2; } else { @@ -51,10 +55,25 @@ function ReactiveMemberExpr(t0) { } else { t3 = $[6]; } - useEffect(t3, [obj.a]); + useEffect(t3, [obj.a?.b]); + let t4; + if ($[7] !== obj.c?.d) { + t4 = () => print(obj.c?.d); + $[7] = obj.c?.d; + $[8] = t4; + } else { + t4 = $[8]; + } + useEffect(t4, [obj.c?.d]); } +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveMemberExpr, + params: [{ cond: true, propVal: 1 }], +}; + ``` ### Eval output -(kind: exception) Fixture not implemented \ No newline at end of file +(kind: ok) +logs: [1,undefined] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain.js index 8a76784e241b5..d81f9029a4e3c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-optional-chain.js @@ -2,8 +2,13 @@ import {useEffect} from 'react'; import {print} from 'shared-runtime'; -// TODO: take optional chains as dependencies function ReactiveMemberExpr({cond, propVal}) { - const obj = {a: cond ? {b: propVal} : null}; + const obj = {a: cond ? {b: propVal} : null, c: null}; useEffect(() => print(obj.a?.b)); + useEffect(() => print(obj.c?.d)); } + +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveMemberExpr, + params: [{cond: true, propVal: 1}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-setState.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-setState.expect.md index 3af2b9b8b1c89..2329aaed21ad9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-setState.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/infer-effect-dependencies/reactive-setState.expect.md @@ -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/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/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..8024676c65a32 --- /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,215 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +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(2); + const { prop } = t0; + let t1; + if ($[0] !== prop) { + const obj = shallowCopy(prop); + const aliasedObj = identity(obj); + + const id = [obj.id]; + + 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..ecd5598cb0913 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/aliased-nested-scope-truncated-dep.tsx @@ -0,0 +1,94 @@ +// @enableNewMutationAliasingModel +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-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-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..7bc2c193cf7cc --- /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,134 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +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..faa34747da188 --- /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,36 @@ +// @enableNewMutationAliasingModel +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/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/capture-in-function-expression-indirect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-in-function-expression-indirect.expect.md new file mode 100644 index 0000000000000..326cd9f7e0d90 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-in-function-expression-indirect.expect.md @@ -0,0 +1,81 @@ + +## Input + +```javascript +import {Stringify, mutate} from 'shared-runtime'; + +function Component({foo, bar}) { + let x = {foo}; + let y = {bar}; + const f0 = function () { + let a = {y}; + let b = {x}; + a.y.x = b; + }; + f0(); + mutate(y); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 2, bar: 3}], + sequentialRenders: [ + {foo: 2, bar: 3}, + {foo: 2, bar: 3}, + {foo: 2, bar: 4}, + {foo: 3, bar: 4}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { Stringify, mutate } from "shared-runtime"; + +function Component(t0) { + const $ = _c(3); + const { foo, bar } = t0; + let t1; + if ($[0] !== bar || $[1] !== foo) { + const x = { foo }; + const y = { bar }; + const f0 = function () { + const a = { y }; + const b = { x }; + a.y.x = b; + }; + + f0(); + mutate(y); + t1 = ; + $[0] = bar; + $[1] = foo; + $[2] = t1; + } else { + t1 = $[2]; + } + return t1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ foo: 2, bar: 3 }], + sequentialRenders: [ + { foo: 2, bar: 3 }, + { foo: 2, bar: 3 }, + { foo: 2, bar: 4 }, + { foo: 3, bar: 4 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"x":{"bar":3,"x":{"x":{"foo":2}},"wat0":"joe"}}
+
{"x":{"bar":3,"x":{"x":{"foo":2}},"wat0":"joe"}}
+
{"x":{"bar":4,"x":{"x":{"foo":2}},"wat0":"joe"}}
+
{"x":{"bar":4,"x":{"x":{"foo":3}},"wat0":"joe"}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-in-function-expression-indirect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-in-function-expression-indirect.js new file mode 100644 index 0000000000000..5aa39d3ffb36b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/capture-in-function-expression-indirect.js @@ -0,0 +1,25 @@ +import {Stringify, mutate} from 'shared-runtime'; + +function Component({foo, bar}) { + let x = {foo}; + let y = {bar}; + const f0 = function () { + let a = {y}; + let b = {x}; + a.y.x = b; + }; + f0(); + mutate(y); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{foo: 2, bar: 3}], + sequentialRenders: [ + {foo: 2, bar: 3}, + {foo: 2, bar: 3}, + {foo: 2, bar: 4}, + {foo: 3, bar: 4}, + ], +}; 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..d1434e95b827a --- /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,61 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +function bar(a) { + const $ = _c(4); + let t0; + if ($[0] !== 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]; + $[2] = x[0][1]; + $[3] = y; + } else { + y = $[3]; + } + 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..a77287910a419 --- /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,16 @@ +// @enableNewMutationAliasingModel +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..80bb009ba25d3 --- /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,71 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +function bar(a, b) { + const $ = _c(6); + let t0; + if ($[0] !== a || $[1] !== 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]; + $[3] = x[0][1]; + $[4] = x[1][0]; + $[5] = y; + } else { + y = $[5]; + } + 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..9afe5994b210b --- /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,20 @@ +// @enableNewMutationAliasingModel +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..663d1f3d567b3 --- /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,61 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +function bar(a) { + const $ = _c(4); + let t0; + if ($[0] !== 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]; + $[2] = x[0].a[1]; + $[3] = y; + } else { + y = $[3]; + } + 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..5a3cb878485d4 --- /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,16 @@ +// @enableNewMutationAliasingModel +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..58694faf57d33 --- /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,59 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +function bar(a) { + const $ = _c(4); + let t0; + if ($[0] !== a) { + t0 = [a]; + $[0] = a; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; + let y; + if ($[2] !== x[0]) { + y = {}; + + y = x[0]; + $[2] = x[0]; + $[3] = y; + } else { + y = $[3]; + } + 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..0b95fc02a2b58 --- /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,15 @@ +// @enableNewMutationAliasingModel +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..73dd12670f159 --- /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 @enableNewMutationAliasingModel + +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..83cf3e04f2b6a --- /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 @enableNewMutationAliasingModel + +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.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..0461bb4b7b4a4 --- /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,54 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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 + +``` + 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 new file mode 100644 index 0000000000000..2cfb336bcf5e3 --- /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,33 @@ +// @enableNewMutationAliasingModel +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-referencing-frozen-hoisted-storecontext-const.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-referencing-frozen-hoisted-storecontext-const.expect.md new file mode 100644 index 0000000000000..a95ace1df5dd4 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-referencing-frozen-hoisted-storecontext-const.expect.md @@ -0,0 +1,45 @@ + +## Input + +```javascript +//@flow @validatePreserveExistingMemoizationGuarantees @enableNewMutationAliasingModel + +import {useCallback} from 'react'; +import {useIdentity} from 'shared-runtime'; + +function Component({content, refetch}) { + // This callback function accesses a hoisted const as a dependency, + // but it cannot reference it as a dependency since that would be a + // TDZ violation! + const onRefetch = useCallback(() => { + refetch(data); + }, [refetch]); + + // The context variable gets frozen here since it's passed to a hook + const onSubmit = useIdentity(onRefetch); + + // This has to error: onRefetch needs to memoize with `content` as a + // dependency, but the dependency comes later + const {data = null} = content; + + return ; +} + +``` + + +## Error + +``` + 9 | // TDZ violation! + 10 | const onRefetch = useCallback(() => { +> 11 | refetch(data); + | ^^^^ InvalidReact: This variable is accessed before it is declared, which may prevent it from updating as the assigned value changes over time. Variable `data` is accessed before it is declared (11:11) + +InvalidReact: This variable is accessed before it is declared, which prevents the earlier access from updating when this value changes over time. Variable `data` is accessed before it is declared (19:19) + 12 | }, [refetch]); + 13 | + 14 | // The context variable gets frozen here since it's passed to a hook +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-referencing-frozen-hoisted-storecontext-const.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-referencing-frozen-hoisted-storecontext-const.js new file mode 100644 index 0000000000000..30d1e0e35e3ff --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.invalid-referencing-frozen-hoisted-storecontext-const.js @@ -0,0 +1,22 @@ +//@flow @validatePreserveExistingMemoizationGuarantees @enableNewMutationAliasingModel + +import {useCallback} from 'react'; +import {useIdentity} from 'shared-runtime'; + +function Component({content, refetch}) { + // This callback function accesses a hoisted const as a dependency, + // but it cannot reference it as a dependency since that would be a + // TDZ violation! + const onRefetch = useCallback(() => { + refetch(data); + }, [refetch]); + + // The context variable gets frozen here since it's passed to a hook + const onSubmit = useIdentity(onRefetch); + + // This has to error: onRefetch needs to memoize with `content` as a + // dependency, but the dependency comes later + const {data = null} = content; + + 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..d54d2b35e241e --- /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: Updating a value previously passed as an argument to a hook is not allowed. Consider moving the mutation before calling the hook (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/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..a26381d1d301c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.expect.md @@ -0,0 +1,28 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function useHook(a, b) { + b.test = 1; + a.test = 2; +} + +``` + + +## Error + +``` + 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 new file mode 100644 index 0000000000000..41c5b99132460 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.mutate-hook-argument.js @@ -0,0 +1,5 @@ +// @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 new file mode 100644 index 0000000000000..6f7d6b24831a8 --- /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,32 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +let x = {a: 42}; + +function Component(props) { + foo(() => { + x.a = 10; + x.a = 20; + }); +} + +``` + + +## Error + +``` + 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 new file mode 100644 index 0000000000000..ed51080726b5a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.not-useEffect-external-mutate.js @@ -0,0 +1,9 @@ +// @enableNewMutationAliasingModel +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..b6f01488fc755 --- /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,32 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component() { + const foo = () => { + // Cannot assign to globals + someUnknownGlobal = true; + moduleLocal = true; + }; + foo(); +} + +``` + + +## Error + +``` + 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 new file mode 100644 index 0000000000000..6d6681e60ad34 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global-indirect.js @@ -0,0 +1,9 @@ +// @enableNewMutationAliasingModel +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..a75aa397eceec --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.expect.md @@ -0,0 +1,29 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component() { + // Cannot assign to globals + someUnknownGlobal = true; + moduleLocal = true; +} + +``` + + +## Error + +``` + 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 new file mode 100644 index 0000000000000..41b706866bf7c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/error.reassignment-to-global.js @@ -0,0 +1,6 @@ +// @enableNewMutationAliasingModel +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..3d9d0b5613857 --- /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,31 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +function Component(props) { + function hasErrors() { + let hasErrors = false; + if (props.items == null) { + hasErrors = true; + } + return hasErrors; + } + return hasErrors(); +} + +``` + + +## Error + +``` + 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 new file mode 100644 index 0000000000000..b58c0aea7daf7 --- /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,11 @@ +// @enableNewMutationAliasingModel +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/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-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..8dec2e3ebe94b --- /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 @enableNewMutationAliasingModel +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 @enableNewMutationAliasingModel +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":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 +(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..dd8d6669885d2 --- /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 @enableNewMutationAliasingModel +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..167c23c3476b5 --- /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 @enableNewMutationAliasingModel + +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 @enableNewMutationAliasingModel + +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":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 +(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..f91bd14deb14c --- /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 @enableNewMutationAliasingModel + +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..47a0124baa856 --- /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 @enableNewMutationAliasingModel +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 @enableNewMutationAliasingModel +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":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 +(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..6f237c89b4d4f --- /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 @enableNewMutationAliasingModel +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/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-identity-function-expression.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity-function-expression.expect.md new file mode 100644 index 0000000000000..c24c16b50dc1a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity-function-expression.expect.md @@ -0,0 +1,97 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +import {identity, ValidateMemoization} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => ({a}), [a, b]); + const f = () => { + return identity(x); + }; + const x2 = f(); + x2.b = b; + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +import { identity, ValidateMemoization } from "shared-runtime"; + +function Component(t0) { + const $ = _c(10); + const { a, b } = t0; + let t1; + let x; + if ($[0] !== a || $[1] !== b) { + t1 = { a }; + x = t1; + const f = () => identity(x); + + const x2 = f(); + x2.b = b; + $[0] = a; + $[1] = b; + $[2] = x; + $[3] = t1; + } else { + x = $[2]; + t1 = $[3]; + } + let t2; + if ($[4] !== a || $[5] !== b) { + t2 = [a, b]; + $[4] = a; + $[5] = b; + $[6] = t2; + } else { + t2 = $[6]; + } + let t3; + if ($[7] !== t2 || $[8] !== x) { + t3 = ; + $[7] = t2; + $[8] = x; + $[9] = t3; + } else { + t3 = $[9]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 0, b: 1 }, + { a: 1, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0,0],"output":{"a":0,"b":0}}
+
{"inputs":[0,1],"output":{"a":0,"b":1}}
+
{"inputs":[1,1],"output":{"a":1,"b":1}}
+
{"inputs":[0,0],"output":{"a":0,"b":0}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity-function-expression.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity-function-expression.js new file mode 100644 index 0000000000000..c7770ffcdce2b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity-function-expression.js @@ -0,0 +1,24 @@ +import {useMemo} from 'react'; +import {identity, ValidateMemoization} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => ({a}), [a, b]); + const f = () => { + return identity(x); + }; + const x2 = f(); + x2.b = b; + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity.expect.md new file mode 100644 index 0000000000000..59403c64951b8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity.expect.md @@ -0,0 +1,92 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +import {identity, ValidateMemoization} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => ({a}), [a, b]); + const x2 = identity(x); + x2.b = b; + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +import { identity, ValidateMemoization } from "shared-runtime"; + +function Component(t0) { + const $ = _c(10); + const { a, b } = t0; + let t1; + let x; + if ($[0] !== a || $[1] !== b) { + t1 = { a }; + x = t1; + const x2 = identity(x); + x2.b = b; + $[0] = a; + $[1] = b; + $[2] = x; + $[3] = t1; + } else { + x = $[2]; + t1 = $[3]; + } + let t2; + if ($[4] !== a || $[5] !== b) { + t2 = [a, b]; + $[4] = a; + $[5] = b; + $[6] = t2; + } else { + t2 = $[6]; + } + let t3; + if ($[7] !== t2 || $[8] !== x) { + t3 = ; + $[7] = t2; + $[8] = x; + $[9] = t3; + } else { + t3 = $[9]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 0, b: 1 }, + { a: 1, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0,0],"output":{"a":0,"b":0}}
+
{"inputs":[0,1],"output":{"a":0,"b":1}}
+
{"inputs":[1,1],"output":{"a":1,"b":1}}
+
{"inputs":[0,0],"output":{"a":0,"b":0}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity.js new file mode 100644 index 0000000000000..bd928634a29bf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/mutate-through-identity.js @@ -0,0 +1,21 @@ +import {useMemo} from 'react'; +import {identity, ValidateMemoization} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => ({a}), [a, b]); + const x2 = identity(x); + x2.b = b; + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; 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/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..5c73ce6d77adf --- /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,54 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +import { identity, mutate } from "shared-runtime"; + +function Component(props) { + const $ = _c(2); + let context; + if ($[0] !== props.value) { + const key = {}; + context = { [key]: 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) {"[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..923733b9c238d --- /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,16 @@ +// @enableNewMutationAliasingModel +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..1ef3ed157f9fa --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.expect.md @@ -0,0 +1,65 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +import { identity, mutate, mutateAndReturn } from "shared-runtime"; + +function Component(props) { + const $ = _c(4); + let context; + if ($[0] !== props.value) { + const key = { a: "key" }; + + 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; + $[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..516fdc1dbcf41 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/object-expression-computed-member.js @@ -0,0 +1,16 @@ +// @enableNewMutationAliasingModel +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/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/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..de7fc2903ebd2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @inferEffectDependencies @enableNewMutationAliasingModel +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 @enableNewMutationAliasingModel +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(4); + const [, setState1] = useRef("initial value"); + const [, setState2] = useRef("initial value"); + let setState; + if ($[0] !== props.foo) { + if (props.foo) { + setState = setState1; + } else { + setState = setState2; + } + $[0] = props.foo; + $[1] = setState; + } else { + setState = $[1]; + } + let t0; + if ($[2] !== setState) { + t0 = () => print(setState); + $[2] = setState; + $[3] = t0; + } else { + t0 = $[3]; + } + 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..158881eb0204d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/reactive-setState.js @@ -0,0 +1,18 @@ +// @inferEffectDependencies @enableNewMutationAliasingModel +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/repro-compiler-infinite-loop.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-compiler-infinite-loop.expect.md new file mode 100644 index 0000000000000..dfc4ed988309a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-compiler-infinite-loop.expect.md @@ -0,0 +1,54 @@ + +## Input + +```javascript +// @flow @enableNewMutationAliasingModel + +import fbt from 'fbt'; + +component Component() { + const sections = Object.keys(items); + + for (let i = 0; i < sections.length; i += 3) { + chunks.push( + sections.slice(i, i + 3).map(section => { + return ; + }) + ); + } + + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; + +import fbt from "fbt"; + +function Component() { + const $ = _c(1); + const sections = Object.keys(items); + for (let i = 0; i < sections.length; i = i + 3, i) { + chunks.push(sections.slice(i, i + 3).map(_temp)); + } + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = ; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} +function _temp(section) { + return ; +} + +``` + +### 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/repro-compiler-infinite-loop.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-compiler-infinite-loop.js new file mode 100644 index 0000000000000..d03a44618ea01 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-compiler-infinite-loop.js @@ -0,0 +1,17 @@ +// @flow @enableNewMutationAliasingModel + +import fbt from 'fbt'; + +component Component() { + const sections = Object.keys(items); + + for (let i = 0; i < sections.length; i += 3) { + chunks.push( + sections.slice(i, i + 3).map(section => { + return ; + }) + ); + } + + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-function-expression-effects-stack-overflow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-function-expression-effects-stack-overflow.expect.md new file mode 100644 index 0000000000000..9d168c9e5c1ea --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-function-expression-effects-stack-overflow.expect.md @@ -0,0 +1,60 @@ + +## Input + +```javascript +function Component() { + const x = {}; + const fn = () => { + new Object() + .build(x) + .build({}) + .build({}) + .build({}) + .build({}) + .build({}) + .build({}); + }; + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component() { + const $ = _c(2); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = {}; + $[0] = t0; + } else { + t0 = $[0]; + } + const x = t0; + let t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + const fn = () => { + new Object() + .build(x) + .build({}) + .build({}) + .build({}) + .build({}) + .build({}) + .build({}); + }; + + t1 = ; + $[1] = t1; + } else { + t1 = $[1]; + } + 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/repro-function-expression-effects-stack-overflow.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-function-expression-effects-stack-overflow.js new file mode 100644 index 0000000000000..6e67ed7bab695 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-function-expression-effects-stack-overflow.js @@ -0,0 +1,14 @@ +function Component() { + const x = {}; + const fn = () => { + new Object() + .build(x) + .build({}) + .build({}) + .build({}) + .build({}) + .build({}) + .build({}); + }; + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-internal-compiler-shared-mutablerange-bug.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-internal-compiler-shared-mutablerange-bug.expect.md new file mode 100644 index 0000000000000..9a0c82a3ccca0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-internal-compiler-shared-mutablerange-bug.expect.md @@ -0,0 +1,80 @@ + +## Input + +```javascript +//@flow @validatePreserveExistingMemoizationGuarantees @enableNewMutationAliasingModel +component Component( + onAsyncSubmit?: (() => void) => void, + onClose: (isConfirmed: boolean) => void +) { + // When running inferReactiveScopeVariables, + // onAsyncSubmit and onClose update to share + // a mutableRange instance. + const onSubmit = useCallback(() => { + if (onAsyncSubmit) { + onAsyncSubmit(() => { + onClose(true); + }); + return; + } + }, [onAsyncSubmit, onClose]); + // When running inferReactiveScopeVariables here, + // first the existing range gets updated (affecting + // onAsyncSubmit) and then onClose gets assigned a + // different mutable range instance, which is the + // one reset after AnalyzeFunctions. + // The fix is to fully reset mutable ranges *instances* + // after AnalyzeFunctions visit a function expression + return onClose(false)} />; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component(t0) { + const $ = _c(8); + const { onAsyncSubmit, onClose } = t0; + let t1; + if ($[0] !== onAsyncSubmit || $[1] !== onClose) { + t1 = () => { + if (onAsyncSubmit) { + onAsyncSubmit(() => { + onClose(true); + }); + return; + } + }; + $[0] = onAsyncSubmit; + $[1] = onClose; + $[2] = t1; + } else { + t1 = $[2]; + } + const onSubmit = t1; + let t2; + if ($[3] !== onClose) { + t2 = () => onClose(false); + $[3] = onClose; + $[4] = t2; + } else { + t2 = $[4]; + } + let t3; + if ($[5] !== onSubmit || $[6] !== t2) { + t3 = ; + $[5] = onSubmit; + $[6] = t2; + $[7] = t3; + } else { + t3 = $[7]; + } + 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/repro-internal-compiler-shared-mutablerange-bug.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-internal-compiler-shared-mutablerange-bug.js new file mode 100644 index 0000000000000..20cad06e97e2e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-internal-compiler-shared-mutablerange-bug.js @@ -0,0 +1,25 @@ +//@flow @validatePreserveExistingMemoizationGuarantees @enableNewMutationAliasingModel +component Component( + onAsyncSubmit?: (() => void) => void, + onClose: (isConfirmed: boolean) => void +) { + // When running inferReactiveScopeVariables, + // onAsyncSubmit and onClose update to share + // a mutableRange instance. + const onSubmit = useCallback(() => { + if (onAsyncSubmit) { + onAsyncSubmit(() => { + onClose(true); + }); + return; + } + }, [onAsyncSubmit, onClose]); + // When running inferReactiveScopeVariables here, + // first the existing range gets updated (affecting + // onAsyncSubmit) and then onClose gets assigned a + // different mutable range instance, which is the + // one reset after AnalyzeFunctions. + // The fix is to fully reset mutable ranges *instances* + // after AnalyzeFunctions visit a function expression + return onClose(false)} />; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-invalid-function-expression-effects-phi.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-invalid-function-expression-effects-phi.expect.md new file mode 100644 index 0000000000000..73cf419be14d3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-invalid-function-expression-effects-phi.expect.md @@ -0,0 +1,60 @@ + +## Input + +```javascript +function Component({a, b}) { + const y = {a}; + const x = {b}; + const f = () => { + let z = null; + while (z == null) { + z = x; + } + // z is a phi with a backedge, and we don't realize it could be x, + // and therefore fail to record a Capture x <- y effect for this + // function expression + z.y = y; + }; + f(); + mutate(x); + return
{x}
; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component(t0) { + const $ = _c(3); + const { a, b } = t0; + let t1; + if ($[0] !== a || $[1] !== b) { + const y = { a }; + const x = { b }; + const f = () => { + let z = null; + while (z == null) { + z = x; + } + + z.y = y; + }; + + f(); + mutate(x); + 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/repro-invalid-function-expression-effects-phi.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-invalid-function-expression-effects-phi.js new file mode 100644 index 0000000000000..31a51b45aa384 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-invalid-function-expression-effects-phi.js @@ -0,0 +1,17 @@ +function Component({a, b}) { + const y = {a}; + const x = {b}; + const f = () => { + let z = null; + while (z == null) { + z = x; + } + // z is a phi with a backedge, and we don't realize it could be x, + // and therefore fail to record a Capture x <- y effect for this + // function expression + z.y = y; + }; + f(); + mutate(x); + return
{x}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-jsx-captures-value-mutated-later.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-jsx-captures-value-mutated-later.expect.md new file mode 100644 index 0000000000000..109219e03ada0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-jsx-captures-value-mutated-later.expect.md @@ -0,0 +1,53 @@ + +## Input + +```javascript +// @flow @enableNewMutationAliasingModel + +import {identity, Stringify, useFragment} from 'shared-runtime'; + +component Example() { + const data = useFragment(); + + const {a, b} = identity(data); + + const el = ; + + identity(a.at(0)); + + return ; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; + +import { identity, Stringify, useFragment } from "shared-runtime"; + +function Example() { + const $ = _c(2); + const data = useFragment(); + let t0; + if ($[0] !== data) { + const { a, b } = identity(data); + + const el = ; + + identity(a.at(0)); + + t0 = ; + $[0] = data; + $[1] = t0; + } else { + t0 = $[1]; + } + return 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/repro-jsx-captures-value-mutated-later.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-jsx-captures-value-mutated-later.js new file mode 100644 index 0000000000000..7ab6dbc30ab2c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-jsx-captures-value-mutated-later.js @@ -0,0 +1,15 @@ +// @flow @enableNewMutationAliasingModel + +import {identity, Stringify, useFragment} from 'shared-runtime'; + +component Example() { + const data = useFragment(); + + const {a, b} = identity(data); + + const el = ; + + identity(a.at(0)); + + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-mutate-new-set-of-frozen-items-in-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-mutate-new-set-of-frozen-items-in-callback.expect.md new file mode 100644 index 0000000000000..28fc8b601f7ff --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-mutate-new-set-of-frozen-items-in-callback.expect.md @@ -0,0 +1,74 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel:true + +export const App = () => { + const [selected, setSelected] = useState(new Set()); + const onSelectedChange = (value: string) => { + const newSelected = new Set(selected); + if (newSelected.has(value)) { + // This should not count as a mutation of `selected` + newSelected.delete(value); + } else { + // This should not count as a mutation of `selected` + newSelected.add(value); + } + setSelected(newSelected); + }; + + return ; +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel:true + +export const App = () => { + const $ = _c(6); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = new Set(); + $[0] = t0; + } else { + t0 = $[0]; + } + const [selected, setSelected] = useState(t0); + let t1; + if ($[1] !== selected) { + t1 = (value) => { + const newSelected = new Set(selected); + if (newSelected.has(value)) { + newSelected.delete(value); + } else { + newSelected.add(value); + } + + setSelected(newSelected); + }; + $[1] = selected; + $[2] = t1; + } else { + t1 = $[2]; + } + const onSelectedChange = t1; + let t2; + if ($[3] !== onSelectedChange || $[4] !== selected) { + t2 = ; + $[3] = onSelectedChange; + $[4] = selected; + $[5] = t2; + } else { + t2 = $[5]; + } + 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/repro-mutate-new-set-of-frozen-items-in-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-mutate-new-set-of-frozen-items-in-callback.js new file mode 100644 index 0000000000000..c5a404a66c371 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/repro-mutate-new-set-of-frozen-items-in-callback.js @@ -0,0 +1,18 @@ +// @enableNewMutationAliasingModel:true + +export const App = () => { + const [selected, setSelected] = useState(new Set()); + const onSelectedChange = (value: string) => { + const newSelected = new Set(selected); + if (newSelected.has(value)) { + // This should not count as a mutation of `selected` + newSelected.delete(value); + } else { + // This should not count as a mutation of `selected` + newSelected.add(value); + } + setSelected(newSelected); + }; + + return ; +}; 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..2e678796cd0fb --- /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 @enableNewMutationAliasingModel +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 @enableNewMutationAliasingModel +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":195},"end":{"line":13,"column":1,"index":389},"filename":"retry-no-emit.ts"},"detail":{"reason":"Updating a value previously passed as an argument to a hook is not allowed. Consider moving the mutation before calling the hook","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 +(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..c15f400d3114d --- /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 @enableNewMutationAliasingModel +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/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/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..39bd61aaf34a7 --- /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 @enableNewMutationAliasingModel +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 @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; + } 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..54d4cf83fe310 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/shared-hook-calls.js @@ -0,0 +1,18 @@ +// @enableFire @enableNewMutationAliasingModel +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/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/todo-control-flow-sensitive-mutation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-control-flow-sensitive-mutation.expect.md new file mode 100644 index 0000000000000..a3cf9f638aefc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-control-flow-sensitive-mutation.expect.md @@ -0,0 +1,161 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +import { + mutate, + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b, c}: {a: number; b: number; c: number}) { + const x = useMemo(() => [{value: a}], [a, b, c]); + if (b === 0) { + // This object should only depend on c, it cannot be affected by the later mutation + x.push({value: c}); + } else { + // This mutation shouldn't affect the object in the consequent + mutate(x); + } + + return ( + <> + ; + {/* TODO: should only depend on c */} + ; + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0, c: 0}], + sequentialRenders: [ + {a: 0, b: 0, c: 0}, + {a: 0, b: 1, c: 0}, + {a: 1, b: 1, c: 0}, + {a: 1, b: 1, c: 1}, + {a: 1, b: 1, c: 0}, + {a: 1, b: 0, c: 0}, + {a: 0, b: 0, c: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +import { + mutate, + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from "shared-runtime"; + +function Component(t0) { + const $ = _c(22); + const { a, b, c } = t0; + let t1; + let x; + if ($[0] !== a || $[1] !== b || $[2] !== c) { + t1 = [{ value: a }]; + x = t1; + if (b === 0) { + x.push({ value: c }); + } else { + mutate(x); + } + $[0] = a; + $[1] = b; + $[2] = c; + $[3] = x; + $[4] = t1; + } else { + x = $[3]; + t1 = $[4]; + } + let t2; + if ($[5] !== a || $[6] !== b || $[7] !== c) { + t2 = [a, b, c]; + $[5] = a; + $[6] = b; + $[7] = c; + $[8] = t2; + } else { + t2 = $[8]; + } + let t3; + if ($[9] !== t2 || $[10] !== x) { + t3 = ; + $[9] = t2; + $[10] = x; + $[11] = t3; + } else { + t3 = $[11]; + } + let t4; + if ($[12] !== a || $[13] !== b || $[14] !== c) { + t4 = [a, b, c]; + $[12] = a; + $[13] = b; + $[14] = c; + $[15] = t4; + } else { + t4 = $[15]; + } + let t5; + if ($[16] !== t4 || $[17] !== x[0]) { + t5 = ; + $[16] = t4; + $[17] = x[0]; + $[18] = t5; + } else { + t5 = $[18]; + } + let t6; + if ($[19] !== t3 || $[20] !== t5) { + t6 = ( + <> + {t3};{t5}; + + ); + $[19] = t3; + $[20] = t5; + $[21] = t6; + } else { + t6 = $[21]; + } + return t6; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0, c: 0 }], + sequentialRenders: [ + { a: 0, b: 0, c: 0 }, + { a: 0, b: 1, c: 0 }, + { a: 1, b: 1, c: 0 }, + { a: 1, b: 1, c: 1 }, + { a: 1, b: 1, c: 0 }, + { a: 1, b: 0, c: 0 }, + { a: 0, b: 0, c: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0,0,0],"output":[{"value":0},{"value":0}]}
;
{"inputs":[0,0,0],"output":{"value":0}}
; +
{"inputs":[0,1,0],"output":[{"value":0},"joe"]}
;
{"inputs":[0,1,0],"output":{"value":0}}
; +
{"inputs":[1,1,0],"output":[{"value":1},"joe"]}
;
{"inputs":[1,1,0],"output":{"value":1}}
; +
{"inputs":[1,1,1],"output":[{"value":1},"joe"]}
;
{"inputs":[1,1,1],"output":{"value":1}}
; +
{"inputs":[1,1,0],"output":[{"value":1},"joe"]}
;
{"inputs":[1,1,0],"output":{"value":1}}
; +
{"inputs":[1,0,0],"output":[{"value":1},{"value":0}]}
;
{"inputs":[1,0,0],"output":{"value":1}}
; +
{"inputs":[0,0,0],"output":[{"value":0},{"value":0}]}
;
{"inputs":[0,0,0],"output":{"value":0}}
; \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-control-flow-sensitive-mutation.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-control-flow-sensitive-mutation.tsx new file mode 100644 index 0000000000000..61f8c47e453d4 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-control-flow-sensitive-mutation.tsx @@ -0,0 +1,41 @@ +import {useMemo} from 'react'; +import { + mutate, + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b, c}: {a: number; b: number; c: number}) { + const x = useMemo(() => [{value: a}], [a, b, c]); + if (b === 0) { + // This object should only depend on c, it cannot be affected by the later mutation + x.push({value: c}); + } else { + // This mutation shouldn't affect the object in the consequent + mutate(x); + } + + return ( + <> + ; + {/* TODO: should only depend on c */} + ; + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0, c: 0}], + sequentialRenders: [ + {a: 0, b: 0, c: 0}, + {a: 0, b: 1, c: 0}, + {a: 1, b: 1, c: 0}, + {a: 1, b: 1, c: 1}, + {a: 1, b: 1, c: 0}, + {a: 1, b: 0, c: 0}, + {a: 0, b: 0, c: 0}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-transitivity-createfrom-capture-lambda.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-transitivity-createfrom-capture-lambda.expect.md new file mode 100644 index 0000000000000..9dba055973f52 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-transitivity-createfrom-capture-lambda.expect.md @@ -0,0 +1,116 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => [{a}], [a]); + const f = () => { + const y = typedCreateFrom(x); + const z = typedCapture(y); + return z; + }; + const z = f(); + // does not mutate x, so x should not depend on b + typedMutate(z, b); + + // TODO: this *should* only depend on `a` + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from "shared-runtime"; + +function Component(t0) { + const $ = _c(10); + const { a, b } = t0; + let t1; + let x; + if ($[0] !== a || $[1] !== b) { + t1 = [{ a }]; + x = t1; + const f = () => { + const y = typedCreateFrom(x); + const z = typedCapture(y); + return z; + }; + + const z_0 = f(); + + typedMutate(z_0, b); + $[0] = a; + $[1] = b; + $[2] = x; + $[3] = t1; + } else { + x = $[2]; + t1 = $[3]; + } + let t2; + if ($[4] !== a || $[5] !== b) { + t2 = [a, b]; + $[4] = a; + $[5] = b; + $[6] = t2; + } else { + t2 = $[6]; + } + let t3; + if ($[7] !== t2 || $[8] !== x) { + t3 = ; + $[7] = t2; + $[8] = x; + $[9] = t3; + } else { + t3 = $[9]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 0, b: 1 }, + { a: 1, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0,0],"output":[{"a":0}]}
+
{"inputs":[0,1],"output":[{"a":0}]}
+
{"inputs":[1,1],"output":[{"a":1}]}
+
{"inputs":[0,0],"output":[{"a":0}]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-transitivity-createfrom-capture-lambda.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-transitivity-createfrom-capture-lambda.tsx new file mode 100644 index 0000000000000..d6bd1690f6557 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/todo-transitivity-createfrom-capture-lambda.tsx @@ -0,0 +1,33 @@ +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => [{a}], [a]); + const f = () => { + const y = typedCreateFrom(x); + const z = typedCapture(y); + return z; + }; + const z = f(); + // does not mutate x, so x should not depend on b + typedMutate(z, b); + + // TODO: this *should* only depend on `a` + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; 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/new-mutability/transitivity-add-captured-array-to-itself.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-add-captured-array-to-itself.expect.md new file mode 100644 index 0000000000000..fb3c8d0a8958b --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-add-captured-array-to-itself.expect.md @@ -0,0 +1,153 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + const o: any = useMemo(() => ({a}), [a]); + const x: Array = useMemo(() => [o], [o, b]); + const y = typedCapture(x); + const z = typedCapture(y); + x.push(z); + x.push(b); + + return ( + <> + ; + ; + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from "shared-runtime"; + +function Component(t0) { + const $ = _c(20); + const { a, b } = t0; + let t1; + let t2; + if ($[0] !== a) { + t2 = { a }; + $[0] = a; + $[1] = t2; + } else { + t2 = $[1]; + } + t1 = t2; + const o = t1; + let t3; + let x; + if ($[2] !== b || $[3] !== o) { + t3 = [o]; + x = t3; + const y = typedCapture(x); + const z = typedCapture(y); + x.push(z); + x.push(b); + $[2] = b; + $[3] = o; + $[4] = x; + $[5] = t3; + } else { + x = $[4]; + t3 = $[5]; + } + let t4; + if ($[6] !== a) { + t4 = [a]; + $[6] = a; + $[7] = t4; + } else { + t4 = $[7]; + } + let t5; + if ($[8] !== o || $[9] !== t4) { + t5 = ; + $[8] = o; + $[9] = t4; + $[10] = t5; + } else { + t5 = $[10]; + } + let t6; + if ($[11] !== a || $[12] !== b) { + t6 = [a, b]; + $[11] = a; + $[12] = b; + $[13] = t6; + } else { + t6 = $[13]; + } + let t7; + if ($[14] !== t6 || $[15] !== x) { + t7 = ; + $[14] = t6; + $[15] = x; + $[16] = t7; + } else { + t7 = $[16]; + } + let t8; + if ($[17] !== t5 || $[18] !== t7) { + t8 = ( + <> + {t5};{t7}; + + ); + $[17] = t5; + $[18] = t7; + $[19] = t8; + } else { + t8 = $[19]; + } + return t8; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 0, b: 1 }, + { a: 1, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0],"output":{"a":0}}
;
{"inputs":[0,0],"output":[{"a":0},[["[[ cyclic ref *2 ]]"]],0]}
; +
{"inputs":[0],"output":{"a":0}}
;
{"inputs":[0,1],"output":[{"a":0},[["[[ cyclic ref *2 ]]"]],1]}
; +
{"inputs":[1],"output":{"a":1}}
;
{"inputs":[1,1],"output":[{"a":1},[["[[ cyclic ref *2 ]]"]],1]}
; +
{"inputs":[0],"output":{"a":0}}
;
{"inputs":[0,0],"output":[{"a":0},[["[[ cyclic ref *2 ]]"]],0]}
; \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-add-captured-array-to-itself.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-add-captured-array-to-itself.tsx new file mode 100644 index 0000000000000..d81c069e336ba --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-add-captured-array-to-itself.tsx @@ -0,0 +1,34 @@ +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + const o: any = useMemo(() => ({a}), [a]); + const x: Array = useMemo(() => [o], [o, b]); + const y = typedCapture(x); + const z = typedCapture(y); + x.push(z); + x.push(b); + + return ( + <> + ; + ; + + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom-lambda.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom-lambda.expect.md new file mode 100644 index 0000000000000..bfb4ede0ad08c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom-lambda.expect.md @@ -0,0 +1,115 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}: {a: number; b: number}) { + const x = useMemo(() => ({a}), [a, b]); + const f = () => { + const y = typedCapture(x); + const z = typedCreateFrom(y); + return z; + }; + const z = f(); + // mutates x + typedMutate(z, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from "shared-runtime"; + +function Component(t0) { + const $ = _c(10); + const { a, b } = t0; + let t1; + let x; + if ($[0] !== a || $[1] !== b) { + t1 = { a }; + x = t1; + const f = () => { + const y = typedCapture(x); + const z = typedCreateFrom(y); + return z; + }; + + const z_0 = f(); + + typedMutate(z_0, b); + $[0] = a; + $[1] = b; + $[2] = x; + $[3] = t1; + } else { + x = $[2]; + t1 = $[3]; + } + let t2; + if ($[4] !== a || $[5] !== b) { + t2 = [a, b]; + $[4] = a; + $[5] = b; + $[6] = t2; + } else { + t2 = $[6]; + } + let t3; + if ($[7] !== t2 || $[8] !== x) { + t3 = ; + $[7] = t2; + $[8] = x; + $[9] = t3; + } else { + t3 = $[9]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 0, b: 1 }, + { a: 1, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0,0],"output":{"a":0,"property":0}}
+
{"inputs":[0,1],"output":{"a":0,"property":1}}
+
{"inputs":[1,1],"output":{"a":1,"property":1}}
+
{"inputs":[0,0],"output":{"a":0,"property":0}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom-lambda.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom-lambda.tsx new file mode 100644 index 0000000000000..72289eb833571 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom-lambda.tsx @@ -0,0 +1,32 @@ +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}: {a: number; b: number}) { + const x = useMemo(() => ({a}), [a, b]); + const f = () => { + const y = typedCapture(x); + const z = typedCreateFrom(y); + return z; + }; + const z = f(); + // mutates x + typedMutate(z, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom.expect.md new file mode 100644 index 0000000000000..bd0a76965d42c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom.expect.md @@ -0,0 +1,106 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}: {a: number; b: number}) { + const x = useMemo(() => ({a}), [a, b]); + const y = typedCapture(x); + const z = typedCreateFrom(y); + // mutates x + typedMutate(z, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from "shared-runtime"; + +function Component(t0) { + const $ = _c(10); + const { a, b } = t0; + let t1; + let x; + if ($[0] !== a || $[1] !== b) { + t1 = { a }; + x = t1; + const y = typedCapture(x); + const z = typedCreateFrom(y); + + typedMutate(z, b); + $[0] = a; + $[1] = b; + $[2] = x; + $[3] = t1; + } else { + x = $[2]; + t1 = $[3]; + } + let t2; + if ($[4] !== a || $[5] !== b) { + t2 = [a, b]; + $[4] = a; + $[5] = b; + $[6] = t2; + } else { + t2 = $[6]; + } + let t3; + if ($[7] !== t2 || $[8] !== x) { + t3 = ; + $[7] = t2; + $[8] = x; + $[9] = t3; + } else { + t3 = $[9]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 0, b: 1 }, + { a: 1, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0,0],"output":{"a":0,"property":0}}
+
{"inputs":[0,1],"output":{"a":0,"property":1}}
+
{"inputs":[1,1],"output":{"a":1,"property":1}}
+
{"inputs":[0,0],"output":{"a":0,"property":0}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom.tsx new file mode 100644 index 0000000000000..d06ad11eb5753 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-capture-createfrom.tsx @@ -0,0 +1,28 @@ +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}: {a: number; b: number}) { + const x = useMemo(() => ({a}), [a, b]); + const y = typedCapture(x); + const z = typedCreateFrom(y); + // mutates x + typedMutate(z, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-createfrom-capture.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-createfrom-capture.expect.md new file mode 100644 index 0000000000000..742a233e80195 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-createfrom-capture.expect.md @@ -0,0 +1,103 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => [{a}], [a]); + const y = typedCreateFrom(x); + const z = typedCapture(y); + // does not mutate x, so x should not depend on b + typedMutate(z, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from "shared-runtime"; + +function Component(t0) { + const $ = _c(7); + const { a, b } = t0; + let t1; + let t2; + if ($[0] !== a) { + t2 = [{ a }]; + $[0] = a; + $[1] = t2; + } else { + t2 = $[1]; + } + t1 = t2; + const x = t1; + const y = typedCreateFrom(x); + const z = typedCapture(y); + + typedMutate(z, b); + let t3; + if ($[2] !== a) { + t3 = [a]; + $[2] = a; + $[3] = t3; + } else { + t3 = $[3]; + } + let t4; + if ($[4] !== t3 || $[5] !== x) { + t4 = ; + $[4] = t3; + $[5] = x; + $[6] = t4; + } else { + t4 = $[6]; + } + return t4; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 0, b: 1 }, + { a: 1, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0],"output":[{"a":0}]}
+
{"inputs":[0],"output":[{"a":0}]}
+
{"inputs":[1],"output":[{"a":1}]}
+
{"inputs":[0],"output":[{"a":0}]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-createfrom-capture.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-createfrom-capture.tsx new file mode 100644 index 0000000000000..32d65e61e01ec --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-createfrom-capture.tsx @@ -0,0 +1,28 @@ +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => [{a}], [a]); + const y = typedCreateFrom(x); + const z = typedCapture(y); + // does not mutate x, so x should not depend on b + typedMutate(z, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-phi-assign-or-capture.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-phi-assign-or-capture.expect.md new file mode 100644 index 0000000000000..c50fe47aed6f9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-phi-assign-or-capture.expect.md @@ -0,0 +1,122 @@ + +## Input + +```javascript +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => [{a}], [a, b]); + let z: any; + if (b) { + z = x; + } else { + z = typedCapture(x); + } + // could mutate x + typedMutate(z, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useMemo } from "react"; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from "shared-runtime"; + +function Component(t0) { + const $ = _c(12); + const { a, b } = t0; + let t1; + let t2; + if ($[0] !== a) { + t2 = { a }; + $[0] = a; + $[1] = t2; + } else { + t2 = $[1]; + } + let x; + if ($[2] !== b || $[3] !== t2) { + t1 = [t2]; + x = t1; + let z; + if (b) { + z = x; + } else { + z = typedCapture(x); + } + + typedMutate(z, b); + $[2] = b; + $[3] = t2; + $[4] = x; + $[5] = t1; + } else { + x = $[4]; + t1 = $[5]; + } + let t3; + if ($[6] !== a || $[7] !== b) { + t3 = [a, b]; + $[6] = a; + $[7] = b; + $[8] = t3; + } else { + t3 = $[8]; + } + let t4; + if ($[9] !== t3 || $[10] !== x) { + t4 = ; + $[9] = t3; + $[10] = x; + $[11] = t4; + } else { + t4 = $[11]; + } + return t4; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 0, b: 1 }, + { a: 1, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0,0],"output":[{"a":0}]}
+
{"inputs":[0,1],"output":[{"a":0}]}
+
{"inputs":[1,1],"output":[{"a":1}]}
+
{"inputs":[0,0],"output":[{"a":0}]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-phi-assign-or-capture.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-phi-assign-or-capture.tsx new file mode 100644 index 0000000000000..90b7597694607 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/transitivity-phi-assign-or-capture.tsx @@ -0,0 +1,32 @@ +import {useMemo} from 'react'; +import { + typedCapture, + typedCreateFrom, + typedMutate, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + const x = useMemo(() => [{a}], [a, b]); + let z: any; + if (b) { + z = x; + } else { + z = typedCapture(x); + } + // could mutate x + typedMutate(z, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 0, b: 1}, + {a: 1, b: 1}, + {a: 0, b: 0}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-frozen-input.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-frozen-input.expect.md new file mode 100644 index 0000000000000..dc8bedaf83522 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-frozen-input.expect.md @@ -0,0 +1,121 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel + +import {useMemo} from 'react'; +import { + identity, + makeObject_Primitives, + typedIdentity, + useIdentity, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + // create a mutable value with input `a` + const x = useMemo(() => makeObject_Primitives(a), [a]); + + // freeze the value + useIdentity(x); + + // known to pass-through via aliasing signature + const x2 = typedIdentity(x); + + // Unknown function so we assume it conditionally mutates, + // but x2 is frozen so this downgrades to a read. + // x should *not* take b as a dependency + identity(x2, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 1, b: 0}, + {a: 1, b: 1}, + {a: 0, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel + +import { useMemo } from "react"; +import { + identity, + makeObject_Primitives, + typedIdentity, + useIdentity, + ValidateMemoization, +} from "shared-runtime"; + +function Component(t0) { + const $ = _c(7); + const { a, b } = t0; + let t1; + let t2; + if ($[0] !== a) { + t2 = makeObject_Primitives(a); + $[0] = a; + $[1] = t2; + } else { + t2 = $[1]; + } + t1 = t2; + const x = t1; + + useIdentity(x); + + const x2 = typedIdentity(x); + + identity(x2, b); + let t3; + if ($[2] !== a) { + t3 = [a]; + $[2] = a; + $[3] = t3; + } else { + t3 = $[3]; + } + let t4; + if ($[4] !== t3 || $[5] !== x) { + t4 = ; + $[4] = t3; + $[5] = x; + $[6] = t4; + } else { + t4 = $[6]; + } + return t4; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 1, b: 0 }, + { a: 1, b: 1 }, + { a: 0, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0],"output":{"a":0,"b":"value1","c":true}}
+
{"inputs":[1],"output":{"a":0,"b":"value1","c":true}}
+
{"inputs":[1],"output":{"a":0,"b":"value1","c":true}}
+
{"inputs":[0],"output":{"a":0,"b":"value1","c":true}}
+
{"inputs":[0],"output":{"a":0,"b":"value1","c":true}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-frozen-input.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-frozen-input.js new file mode 100644 index 0000000000000..d0f677ee4df17 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-frozen-input.js @@ -0,0 +1,40 @@ +// @enableNewMutationAliasingModel + +import {useMemo} from 'react'; +import { + identity, + makeObject_Primitives, + typedIdentity, + useIdentity, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + // create a mutable value with input `a` + const x = useMemo(() => makeObject_Primitives(a), [a]); + + // freeze the value + useIdentity(x); + + // known to pass-through via aliasing signature + const x2 = typedIdentity(x); + + // Unknown function so we assume it conditionally mutates, + // but x2 is frozen so this downgrades to a read. + // x should *not* take b as a dependency + identity(x2, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 1, b: 0}, + {a: 1, b: 1}, + {a: 0, b: 1}, + {a: 0, b: 0}, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-mutable-input.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-mutable-input.expect.md new file mode 100644 index 0000000000000..17fed05d93d4d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-mutable-input.expect.md @@ -0,0 +1,112 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel + +import { + identity, + makeObject_Primitives, + typedIdentity, + useIdentity, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + // create a mutable value with input `a` + const x = makeObject_Primitives(a); + + // known to pass-through via aliasing signature + const x2 = typedIdentity(x); + + // Unknown function so we assume it conditionally mutates, + // and x is still mutable so + identity(x2, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 1, b: 0}, + {a: 1, b: 1}, + {a: 0, b: 1}, + {a: 0, b: 0}, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel + +import { + identity, + makeObject_Primitives, + typedIdentity, + useIdentity, + ValidateMemoization, +} from "shared-runtime"; + +function Component(t0) { + const $ = _c(9); + const { a, b } = t0; + let x; + if ($[0] !== a || $[1] !== b) { + x = makeObject_Primitives(a); + + const x2 = typedIdentity(x); + + identity(x2, b); + $[0] = a; + $[1] = b; + $[2] = x; + } else { + x = $[2]; + } + let t1; + if ($[3] !== a || $[4] !== b) { + t1 = [a, b]; + $[3] = a; + $[4] = b; + $[5] = t1; + } else { + t1 = $[5]; + } + let t2; + if ($[6] !== t1 || $[7] !== x) { + t2 = ; + $[6] = t1; + $[7] = x; + $[8] = t2; + } else { + t2 = $[8]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: 0 }], + sequentialRenders: [ + { a: 0, b: 0 }, + { a: 1, b: 0 }, + { a: 1, b: 1 }, + { a: 0, b: 1 }, + { a: 0, b: 0 }, + ], +}; + +``` + +### Eval output +(kind: ok)
{"inputs":[0,0],"output":{"a":0,"b":"value1","c":true}}
+
{"inputs":[1,0],"output":{"a":0,"b":"value1","c":true}}
+
{"inputs":[1,1],"output":{"a":0,"b":"value1","c":true}}
+
{"inputs":[0,1],"output":{"a":0,"b":"value1","c":true}}
+
{"inputs":[0,0],"output":{"a":0,"b":"value1","c":true}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-mutable-input.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-mutable-input.js new file mode 100644 index 0000000000000..719c89d11de2f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/typed-identity-function-mutable-input.js @@ -0,0 +1,35 @@ +// @enableNewMutationAliasingModel + +import { + identity, + makeObject_Primitives, + typedIdentity, + useIdentity, + ValidateMemoization, +} from 'shared-runtime'; + +function Component({a, b}) { + // create a mutable value with input `a` + const x = makeObject_Primitives(a); + + // known to pass-through via aliasing signature + const x2 = typedIdentity(x); + + // Unknown function so we assume it conditionally mutates, + // and x is still mutable so + identity(x2, b); + + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 0}], + sequentialRenders: [ + {a: 0, b: 0}, + {a: 1, b: 0}, + {a: 1, b: 1}, + {a: 0, b: 1}, + {a: 0, b: 0}, + ], +}; 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..e33f52396d5e5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.expect.md @@ -0,0 +1,102 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +import { useCallback } from "react"; +import { Stringify } from "shared-runtime"; + +function Foo(t0) { + const $ = _c(10); + const { arr1, arr2, foo } = t0; + let t1; + 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; + + t2 = () => [y]; + foo ? (y = x.concat(arr2)) : y; + $[2] = arr2; + $[3] = foo; + $[4] = x; + $[5] = getVal1; + $[6] = t2; + } else { + getVal1 = $[5]; + t2 = $[6]; + } + const getVal2 = t2; + let t3; + if ($[7] !== getVal1 || $[8] !== getVal2) { + t3 = ; + $[7] = getVal1; + $[8] = getVal2; + $[9] = t3; + } else { + t3 = $[9]; + } + return t3; +} +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..08b9e4b2faa6c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-deplist-controlflow.tsx @@ -0,0 +1,28 @@ +// @enableNewMutationAliasingModel +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..d37762bbac530 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.expect.md @@ -0,0 +1,85 @@ + +## Input + +```javascript +// @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 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"; // @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(7); + let t0; + if ($[0] !== arr1) { + t0 = [arr1]; + $[0] = arr1; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; + let t1; + if ($[2] !== arr2 || $[3] !== x) { + let y; + t1 = () => ({ y }); + + (y = x.concat(arr2)), y; + $[2] = arr2; + $[3] = x; + $[4] = t1; + } else { + t1 = $[4]; + } + const getVal = t1; + let t2; + if ($[5] !== getVal) { + t2 = ; + $[5] = getVal; + $[6] = t2; + } else { + t2 = $[6]; + } + return t2; +} + +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..43e2dfbb0504a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useCallback-reordering-depslist-assignment.tsx @@ -0,0 +1,23 @@ +// @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 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..26445bf9207be --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.expect.md @@ -0,0 +1,77 @@ + +## Input + +```javascript +// @enableNewMutationAliasingModel +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"; // @enableNewMutationAliasingModel +import { useMemo } from "react"; + +function useFoo(arr1, arr2) { + const $ = _c(7); + let t0; + if ($[0] !== arr1) { + t0 = [arr1]; + $[0] = arr1; + $[1] = t0; + } else { + t0 = $[1]; + } + const x = t0; + let y; + if ($[2] !== arr2 || $[3] !== x) { + (y = x.concat(arr2)), y; + $[2] = arr2; + $[3] = x; + $[4] = y; + } else { + y = $[4]; + } + let t1; + let t2; + if ($[5] !== y) { + t2 = { y }; + $[5] = y; + $[6] = t2; + } else { + t2 = $[6]; + } + t1 = t2; + return t1; +} + +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..5b7d799d68b13 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/new-mutability/useMemo-reordering-depslist-assignment.ts @@ -0,0 +1,19 @@ +// @enableNewMutationAliasingModel +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], + ], +}; 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/object-expression-computed-key-object-mutated-later.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-expression-computed-key-object-mutated-later.expect.md index bf0f9da6b1da1..f187c8c79d369 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-expression-computed-key-object-mutated-later.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-expression-computed-key-object-mutated-later.expect.md @@ -27,34 +27,18 @@ 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; + 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/object-expression-computed-member.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-expression-computed-member.expect.md index 810b03e529e77..01d91de8d4fb7 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-expression-computed-member.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-expression-computed-member.expect.md @@ -27,11 +27,22 @@ import { c as _c } from "react/compiler-runtime"; 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/object-method-shorthand-3.expect.md~051f3e57 ([hir] Do not memoize object methods separately) b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-method-shorthand-3.expect.md~051f3e57 ([hir] Do not memoize object methods separately) deleted file mode 100644 index f4354f427cf87..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-method-shorthand-3.expect.md~051f3e57 ([hir] Do not memoize object methods separately) +++ /dev/null @@ -1,47 +0,0 @@ - -## Input - -```javascript -import { mutate } from "shared-runtime"; - -function Component(a) { - const x = { a }; - let obj = { - method() { - mutate(x); - return x; - }, - }; - return obj.method(); -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ x: 1 }, { a: 2 }, { b: 2 }], -}; - -``` - -## Code - -```javascript -import { mutate } from "shared-runtime"; - -function Component(a) { - const x = { a }; - const obj = { - method() { - mutate(x); - return x; - }, - }; - return obj.method(); -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ x: 1 }, { a: 2 }, { b: 2 }], -}; - -``` - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md index 080cc0a74a609..18e0621d62669 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md @@ -40,39 +40,46 @@ 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/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md index 89a6ad80c3945..f7ebdf0ca59dc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md @@ -36,31 +36,38 @@ 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/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md index 3fffec6a7dc20..6a3197de54ea2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md @@ -30,29 +30,36 @@ import { c as _c } from "react/compiler-runtime"; 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/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-reassign-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-reassign-props.expect.md new file mode 100644 index 0000000000000..19c85c943e711 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-reassign-props.expect.md @@ -0,0 +1,65 @@ + +## Input + +```javascript +import {Stringify, useIdentity} from 'shared-runtime'; + +function Component({other, ...props}, ref) { + [props, ref] = useIdentity([props, ref]); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 'hello', children:
Hello
}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { Stringify, useIdentity } from "shared-runtime"; + +function Component(t0, ref) { + const $ = _c(7); + let props; + if ($[0] !== t0) { + let { other, ...t1 } = t0; + props = t1; + $[0] = t0; + $[1] = props; + } else { + props = $[1]; + } + let t1; + if ($[2] !== props || $[3] !== ref) { + t1 = [props, ref]; + $[2] = props; + $[3] = ref; + $[4] = t1; + } else { + t1 = $[4]; + } + [props, ref] = useIdentity(t1); + let t2; + if ($[5] !== props) { + t2 = ; + $[5] = props; + $[6] = t2; + } else { + t2 = $[6]; + } + return t2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 0, b: "hello", children:
Hello
}], +}; + +``` + +### Eval output +(kind: ok)
{"props":{"a":0,"b":"hello","children":{"type":"div","key":null,"props":{"children":"Hello"},"_owner":"[[ cyclic ref *3 ]]","_store":{}}}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-reassign-props.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-reassign-props.js new file mode 100644 index 0000000000000..329000aedb084 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-reassign-props.js @@ -0,0 +1,11 @@ +import {Stringify, useIdentity} from 'shared-runtime'; + +function Component({other, ...props}, ref) { + [props, ref] = useIdentity([props, ref]); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{a: 0, b: 'hello', children:
Hello
}], +}; 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/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reverse-postorder.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reverse-postorder.expect.md index c0ff0f7e7db78..98f2cd2190c20 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reverse-postorder.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/reverse-postorder.expect.md @@ -50,10 +50,8 @@ function Component(props) { case 1: { break bb0; } - case 2: { - } - default: { - } + case 2: + default: } } else { if (props.cond2) { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/rules-of-hooks/error.bail.rules-of-hooks-3d692676194b.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/rules-of-hooks/error.bail.rules-of-hooks-3d692676194b.expect.md index 04808379b7f14..ffd91cc8a2a85 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/rules-of-hooks/error.bail.rules-of-hooks-3d692676194b.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/rules-of-hooks/error.bail.rules-of-hooks-3d692676194b.expect.md @@ -23,7 +23,7 @@ const ComponentWithHookInsideCallback = React.forwardRef((props, ref) => { 6 | const ComponentWithHookInsideCallback = React.forwardRef((props, ref) => { 7 | useEffect(() => { > 8 | useHookInsideCallback(); - | ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call Custom within a function component (8:8) + | ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning). Cannot call hook within a function expression (8:8) 9 | }); 10 | return + + ); +} diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js index aa19871a9dcbb..0b9b9c315d647 100644 --- a/fixtures/flight/src/actions.js +++ b/fixtures/flight/src/actions.js @@ -2,7 +2,13 @@ import {setServerState} from './ServerState.js'; +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + export async function like() { + // Test loading state + await sleep(1000); setServerState('Liked!'); return new Promise((resolve, reject) => resolve('Liked')); } @@ -20,5 +26,7 @@ export async function greet(formData) { } export async function increment(n) { + // Test loading state + await sleep(1000); return n + 1; } diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index f08f7a110bf61..3fd921a1bb5b4 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -42,17 +42,43 @@ function Shell({data}) { } async function hydrateApp() { - const {root, returnValue, formState} = await createFromFetch( - fetch('/', { - headers: { - Accept: 'text/x-component', - }, - }), - { - callServer, - findSourceMapURL, - } - ); + let response; + if ( + process.env.NODE_ENV === 'development' && + typeof WebSocketStream === 'function' + ) { + const requestId = crypto.randomUUID(); + const wss = new WebSocketStream( + 'ws://localhost:3001/debug-channel?' + requestId + ); + const debugChannel = await wss.opened; + response = createFromFetch( + fetch('/', { + headers: { + Accept: 'text/x-component', + 'rsc-request-id': requestId, + }, + }), + { + callServer, + debugChannel, + findSourceMapURL, + } + ); + } else { + response = createFromFetch( + fetch('/', { + headers: { + Accept: 'text/x-component', + }, + }), + { + callServer, + findSourceMapURL, + } + ); + } + const {root, returnValue, formState} = await response; ReactDOM.hydrateRoot( document, diff --git a/fixtures/owner-stacks/package.json b/fixtures/owner-stacks/package.json index cd288c8baeac7..2798aab568ecf 100644 --- a/fixtures/owner-stacks/package.json +++ b/fixtures/owner-stacks/package.json @@ -9,7 +9,7 @@ "web-vitals": "^2.1.0" }, "scripts": { - "prestart": "cp -a ../../build/oss-experimental/. node_modules", + "prestart": "cp -a ../../build/oss-experimental/. node_modules && rm -rf node_modules/.cache;", "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", diff --git a/fixtures/ssr/src/components/LargeContent.js b/fixtures/ssr/src/components/LargeContent.js index a5af3064b4917..f5c8adb03e233 100644 --- a/fixtures/ssr/src/components/LargeContent.js +++ b/fixtures/ssr/src/components/LargeContent.js @@ -1,8 +1,12 @@ -import React, {Fragment, Suspense} from 'react'; +import React, { + Fragment, + Suspense, + unstable_SuspenseList as SuspenseList, +} from 'react'; export default function LargeContent() { return ( - +

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris @@ -286,6 +290,6 @@ export default function LargeContent() { interdum a. Proin nec odio in nulla vestibulum.

-
+ ); } diff --git a/fixtures/view-transition/loader/package.json b/fixtures/view-transition/loader/package.json new file mode 100644 index 0000000000000..3dbc1ca591c05 --- /dev/null +++ b/fixtures/view-transition/loader/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/fixtures/view-transition/loader/server.js b/fixtures/view-transition/loader/server.js new file mode 100644 index 0000000000000..f56ac9fd039b5 --- /dev/null +++ b/fixtures/view-transition/loader/server.js @@ -0,0 +1,54 @@ +import babel from '@babel/core'; + +const babelOptions = { + babelrc: false, + ignore: [/\/(build|node_modules)\//], + plugins: [ + '@babel/plugin-syntax-import-meta', + '@babel/plugin-transform-react-jsx', + ], +}; + +export async function load(url, context, defaultLoad) { + if (url.endsWith('.css')) { + return {source: 'export default {}', format: 'module', shortCircuit: true}; + } + const {format} = context; + const result = await defaultLoad(url, context, defaultLoad); + if (result.format === 'module') { + const opt = Object.assign({filename: url}, babelOptions); + const newResult = await babel.transformAsync(result.source, opt); + if (!newResult) { + if (typeof result.source === 'string') { + return result; + } + return { + source: Buffer.from(result.source).toString('utf8'), + format: 'module', + }; + } + return {source: newResult.code, format: 'module'}; + } + return defaultLoad(url, context, defaultLoad); +} + +async function babelTransformSource(source, context, defaultTransformSource) { + const {format} = context; + if (format === 'module') { + const opt = Object.assign({filename: context.url}, babelOptions); + const newResult = await babel.transformAsync(source, opt); + if (!newResult) { + if (typeof source === 'string') { + return {source}; + } + return { + source: Buffer.from(source).toString('utf8'), + }; + } + return {source: newResult.code}; + } + return defaultTransformSource(source, context, defaultTransformSource); +} + +export const transformSource = + process.version < 'v16' ? babelTransformSource : undefined; diff --git a/fixtures/view-transition/package.json b/fixtures/view-transition/package.json index 7b9af04096027..44a8ff0bfa541 100644 --- a/fixtures/view-transition/package.json +++ b/fixtures/view-transition/package.json @@ -13,7 +13,8 @@ "express": "^4.14.0", "ignore-styles": "^5.0.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "animation-timelines": "^0.0.4" }, "eslintConfig": { "extends": [ @@ -22,13 +23,13 @@ ] }, "scripts": { - "predev": "cp -r ../../build/oss-experimental/* ./node_modules/", - "prestart": "cp -r ../../build/oss-experimental/* ./node_modules/", - "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/", + "predev": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;", + "prestart": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;", + "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;", "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "dev:client": "BROWSER=none PORT=3001 react-scripts start", - "dev:server": "NODE_ENV=development node server", - "start": "react-scripts build && NODE_ENV=production node server", + "dev:server": "NODE_ENV=development node --experimental-loader ./loader/server.js server", + "start": "react-scripts build && NODE_ENV=production node --experimental-loader ./loader/server.js server", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" diff --git a/fixtures/view-transition/server/index.js b/fixtures/view-transition/server/index.js index 3f542b8f6e67d..e13d4706b9ef9 100644 --- a/fixtures/view-transition/server/index.js +++ b/fixtures/view-transition/server/index.js @@ -20,13 +20,15 @@ if (process.env.NODE_ENV === 'development') { for (var key in require.cache) { delete require.cache[key]; } - const render = require('./render').default; - render(req.url, res); + import('./render.js').then(({default: render}) => { + render(req.url, res); + }); }); } else { - const render = require('./render').default; - app.get('/', function (req, res) { - render(req.url, res); + import('./render.js').then(({default: render}) => { + app.get('/', function (req, res) { + render(req.url, res); + }); }); } diff --git a/fixtures/view-transition/server/render.js b/fixtures/view-transition/server/render.js index 11d352eabdd72..08224a57c4da2 100644 --- a/fixtures/view-transition/server/render.js +++ b/fixtures/view-transition/server/render.js @@ -1,7 +1,7 @@ import React from 'react'; import {renderToPipeableStream} from 'react-dom/server'; -import App from '../src/components/App'; +import App from '../src/components/App.js'; let assets; if (process.env.NODE_ENV === 'development') { diff --git a/fixtures/view-transition/src/components/App.js b/fixtures/view-transition/src/components/App.js index 275e594d87a1d..6b41bdf4eac2a 100644 --- a/fixtures/view-transition/src/components/App.js +++ b/fixtures/view-transition/src/components/App.js @@ -6,8 +6,8 @@ import React, { unstable_addTransitionType as addTransitionType, } from 'react'; -import Chrome from './Chrome'; -import Page from './Page'; +import Chrome from './Chrome.js'; +import Page from './Page.js'; const enableNavigationAPI = typeof navigation === 'object'; diff --git a/fixtures/view-transition/src/components/NestedReveal.js b/fixtures/view-transition/src/components/NestedReveal.js new file mode 100644 index 0000000000000..497f4430f6cfb --- /dev/null +++ b/fixtures/view-transition/src/components/NestedReveal.js @@ -0,0 +1,36 @@ +import React, {Suspense, use} from 'react'; + +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function Use({useable}) { + use(useable); + return null; +} + +let delay1; +let delay2; + +export default function NestedReveal({}) { + if (!delay1) { + delay1 = sleep(100); + // Needs to happen before the throttled reveal of delay 1 + delay2 = sleep(200); + } + + return ( +
+ Shell + +
Level 1
+ + + +
Level 2
+ +
+
+
+ ); +} diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 9744313c4f5ea..ef1a855320634 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -8,15 +8,21 @@ import React, { useId, useOptimistic, startTransition, + Suspense, } from 'react'; import {createPortal} from 'react-dom'; -import SwipeRecognizer from './SwipeRecognizer'; +import SwipeRecognizer from './SwipeRecognizer.js'; import './Page.css'; import transitions from './Transitions.module.css'; +import NestedReveal from './NestedReveal.js'; + +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} const a = (
@@ -56,6 +62,12 @@ function Id() { return ; } +let wait; +function Suspend() { + if (!wait) wait = sleep(500); + return React.use(wait); +} + export default function Page({url, navigate}) { const [renderedUrl, optimisticNavigate] = useOptimistic( url, @@ -89,7 +101,7 @@ export default function Page({url, navigate}) { // a flushSync will. // Promise.resolve().then(() => { // flushSync(() => { - setCounter(c => c + 10); + // setCounter(c => c + 10); // }); // }); }, [show]); @@ -106,7 +118,13 @@ export default function Page({url, navigate}) { document.body ) ) : ( - ); @@ -183,22 +201,48 @@ export default function Page({url, navigate}) {
!!
-

these

-

rows

-

exist

-

to

-

test

-

scrolling

-

content

-

out

-

of

- {portal} -

the

-

viewport

+ +
+ +

█████

+
+

████

+

███████

+

████

+

██

+

██████

+

███

+

████

+
+ + }> + +
+

these

+

rows

+ +

exist

+
+

to

+

test

+

scrolling

+

content

+

out

+

of

+ {portal} +

the

+

viewport

+ +
+
+
{show ? : null}
+
); } diff --git a/fixtures/view-transition/src/components/SwipeRecognizer.js b/fixtures/view-transition/src/components/SwipeRecognizer.js index 7e7176d194d83..df4d743e1ba7f 100644 --- a/fixtures/view-transition/src/components/SwipeRecognizer.js +++ b/fixtures/view-transition/src/components/SwipeRecognizer.js @@ -5,6 +5,16 @@ import React, { unstable_startGestureTransition as startGestureTransition, } from 'react'; +import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline'; +import TouchPanTimeline from 'animation-timelines/touch-pan-timeline'; + +const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent; +const isSafariMobile = + ua.indexOf('Safari') !== -1 && + (ua.indexOf('iPhone') !== -1 || + ua.indexOf('iPad') !== -1 || + ua.indexOf('iPod') !== -1); + // Example of a Component that can recognize swipe gestures using a ScrollTimeline // without scrolling its own content. Allowing it to be used as an inert gesture // recognizer to drive a View Transition. @@ -21,18 +31,72 @@ export default function SwipeRecognizer({ const scrollRef = useRef(null); const activeGesture = useRef(null); - function onScroll() { - if (activeGesture.current !== null) { + const touchTimeline = useRef(null); + + function onTouchStart(event) { + if (!isSafariMobile && typeof ScrollTimeline === 'function') { + // If not Safari and native ScrollTimeline is supported, then we use that. return; } - if (typeof ScrollTimeline !== 'function') { + if (touchTimeline.current) { + // We can catch the gesture before it settles. return; } - // eslint-disable-next-line no-undef - const scrollTimeline = new ScrollTimeline({ - source: scrollRef.current, + const scrollElement = scrollRef.current; + const bounds = + axis === 'x' ? scrollElement.clientWidth : scrollElement.clientHeight; + const range = + direction === 'left' || direction === 'up' ? [bounds, 0] : [0, -bounds]; + const timeline = new TouchPanTimeline({ + touch: event, + source: scrollElement, axis: axis, + range: range, + snap: range, }); + touchTimeline.current = timeline; + timeline.settled.then(() => { + if (touchTimeline.current !== timeline) { + return; + } + touchTimeline.current = null; + const changed = + direction === 'left' || direction === 'up' + ? timeline.currentTime < 50 + : timeline.currentTime > 50; + onGestureEnd(changed); + }); + } + + function onTouchEnd() { + if (activeGesture.current === null) { + // If we didn't start a gesture before we release, we can release our + // timeline. + touchTimeline.current = null; + } + } + + function onScroll() { + if (activeGesture.current !== null) { + return; + } + + let scrollTimeline; + if (touchTimeline.current) { + // We're in a polyfilled touch gesture. Let's use that timeline instead. + scrollTimeline = touchTimeline.current; + } else if (typeof ScrollTimeline === 'function') { + // eslint-disable-next-line no-undef + scrollTimeline = new ScrollTimeline({ + source: scrollRef.current, + axis: axis, + }); + } else { + scrollTimeline = new ScrollTimelinePolyfill({ + source: scrollRef.current, + axis: axis, + }); + } activeGesture.current = startGestureTransition( scrollTimeline, () => { @@ -49,7 +113,23 @@ export default function SwipeRecognizer({ } ); } + function onGestureEnd(changed) { + // Reset scroll + if (changed) { + // Trigger side-effects + startTransition(action); + } + if (activeGesture.current !== null) { + const cancelGesture = activeGesture.current; + activeGesture.current = null; + cancelGesture(); + } + } function onScrollEnd() { + if (touchTimeline.current) { + // We have a touch gesture controlling the swipe. + return; + } let changed; const scrollElement = scrollRef.current; if (axis === 'x') { @@ -67,16 +147,7 @@ export default function SwipeRecognizer({ ? scrollElement.scrollTop < halfway : scrollElement.scrollTop > halfway; } - // Reset scroll - if (changed) { - // Trigger side-effects - startTransition(action); - } - if (activeGesture.current !== null) { - const cancelGesture = activeGesture.current; - activeGesture.current = null; - cancelGesture(); - } + onGestureEnd(changed); } useEffect(() => { @@ -168,6 +239,9 @@ export default function SwipeRecognizer({ return (
diff --git a/fixtures/view-transition/src/index.js b/fixtures/view-transition/src/index.js index 8c2fac3e67ada..29b53bf037928 100644 --- a/fixtures/view-transition/src/index.js +++ b/fixtures/view-transition/src/index.js @@ -1,7 +1,7 @@ import React from 'react'; import {hydrateRoot} from 'react-dom/client'; -import App from './components/App'; +import App from './components/App.js'; hydrateRoot( document, diff --git a/fixtures/view-transition/yarn.lock b/fixtures/view-transition/yarn.lock index 76a6af00ca2ef..3efb208f1ec1a 100644 --- a/fixtures/view-transition/yarn.lock +++ b/fixtures/view-transition/yarn.lock @@ -2427,6 +2427,11 @@ ajv@^8.0.0, ajv@^8.6.0, ajv@^8.9.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" +animation-timelines@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/animation-timelines/-/animation-timelines-0.0.4.tgz#7ac4614bae73c4d1ea2ff18d5d87a518793258af" + integrity sha512-HwCE3m1nM8ZdLbwDwD1j5ZNKmY+3J2CliXJNIsf3y1Si927SIaWpfxkycTg5nWLJSHgjsYxrmOy2Jbo4JR1e9A== + ansi-escapes@^4.2.1, ansi-escapes@^4.3.1: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" diff --git a/package.json b/package.json index 875986ebdaf6a..9e7eb8dc5334d 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,6 @@ "ncp": "^2.0.0", "prettier": "^3.3.3", "prettier-2": "npm:prettier@^2", - "prettier-plugin-hermes-parser": "^0.23.0", "pretty-format": "^29.4.1", "prop-types": "^15.6.2", "random-seed": "^0.3.0", @@ -130,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/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js index 055474ea321e0..555c54148ec1e 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js @@ -515,6 +515,22 @@ const tests = { `, options: [{additionalHooks: 'useCustomEffect'}], }, + { + // behaves like no deps + code: normalizeIndent` + function MyComponent(props) { + useSpecialEffect(() => { + console.log(props.foo); + }, null); + } + `, + options: [ + { + additionalHooks: 'useSpecialEffect', + experimental_autoDependenciesHooks: ['useSpecialEffect'], + }, + ], + }, { code: normalizeIndent` function MyComponent(props) { @@ -1470,6 +1486,38 @@ const tests = { }, ], invalid: [ + { + code: normalizeIndent` + function MyComponent(props) { + useSpecialEffect(() => { + console.log(props.foo); + }, null); + } + `, + options: [{additionalHooks: 'useSpecialEffect'}], + errors: [ + { + message: + "React Hook useSpecialEffect was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies.", + }, + { + message: + "React Hook useSpecialEffect has a missing dependency: 'props.foo'. Either include it or remove the dependency array.", + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useSpecialEffect(() => { + console.log(props.foo); + }, [props.foo]); + } + `, + }, + ], + }, + ], + }, { code: normalizeIndent` function MyComponent(props) { @@ -7746,6 +7794,34 @@ const testsFlow = { }, ], invalid: [ + { + code: normalizeIndent` + hook useExample(a) { + useEffect(() => { + console.log(a); + }, []); + } + `, + errors: [ + { + message: + "React Hook useEffect has a missing dependency: 'a'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [a]', + output: normalizeIndent` + hook useExample(a) { + useEffect(() => { + console.log(a); + }, [a]); + } + `, + }, + ], + }, + ], + }, { code: normalizeIndent` function Foo() { @@ -7793,6 +7869,24 @@ const testsTypescript = { } `, }, + { + code: normalizeIndent` + function MyComponent() { + const [state, setState] = React.useState(0); + + useSpecialEffect(() => { + const someNumber: typeof state = 2; + setState(prevState => prevState + someNumber); + }) + } + `, + options: [ + { + additionalHooks: 'useSpecialEffect', + experimental_autoDependenciesHooks: ['useSpecialEffect'], + }, + ], + }, { code: normalizeIndent` function App() { @@ -8148,6 +8242,48 @@ const testsTypescript = { function MyComponent() { const [state, setState] = React.useState(0); + useSpecialEffect(() => { + const someNumber: typeof state = 2; + setState(prevState => prevState + someNumber + state); + }, []) + } + `, + options: [ + { + additionalHooks: 'useSpecialEffect', + experimental_autoDependenciesHooks: ['useSpecialEffect'], + }, + ], + errors: [ + { + message: + "React Hook useSpecialEffect has a missing dependency: 'state'. " + + 'Either include it or remove the dependency array. ' + + `You can also do a functional update 'setState(s => ...)' ` + + `if you only need 'state' in the 'setState' call.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [state]', + output: normalizeIndent` + function MyComponent() { + const [state, setState] = React.useState(0); + + useSpecialEffect(() => { + const someNumber: typeof state = 2; + setState(prevState => prevState + someNumber + state); + }, [state]) + } + `, + }, + ], + }, + ], + }, + { + code: normalizeIndent` + function MyComponent() { + const [state, setState] = React.useState(0); + useMemo(() => { const someNumber: typeof state = 2; console.log(someNumber); @@ -8208,6 +8344,23 @@ const testsTypescript = { }, ], }, + { + code: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }); + } + `, + options: [{requireExplicitEffectDeps: true}], + errors: [ + { + message: + 'React Hook useEffect always requires dependencies. Please add a dependency array or an explicit `undefined`', + suggestions: undefined, + }, + ], + }, ], }; @@ -8311,7 +8464,9 @@ describe('rules-of-hooks/exhaustive-deps', () => { }, }; - const testsBabelEslint = { + const testsBabelEslint = tests; + + const testsHermesParser = { valid: [...testsFlow.valid, ...tests.valid], invalid: [...testsFlow.invalid, ...tests.invalid], }; @@ -8336,6 +8491,33 @@ describe('rules-of-hooks/exhaustive-deps', () => { testsBabelEslint ); + new ESLintTesterV7({ + parser: require.resolve('hermes-eslint'), + parserOptions: { + sourceType: 'module', + enableExperimentalComponentSyntax: true, + }, + }).run( + 'eslint: v7, parser: hermes-eslint', + ReactHooksESLintRule, + testsHermesParser + ); + + new ESLintTesterV9({ + languageOptions: { + ...languageOptionsV9, + parser: require('hermes-eslint'), + parserOptions: { + sourceType: 'module', + enableExperimentalComponentSyntax: true, + }, + }, + }).run( + 'eslint: v9, parser: hermes-eslint', + ReactHooksESLintRule, + testsHermesParser + ); + const testsTypescriptEslintParser = { valid: [...testsTypescript.valid, ...tests.valid], invalid: [...testsTypescript.invalid, ...tests.invalid], diff --git a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts index 1b0059757278f..d59a1ff79202c 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts @@ -61,28 +61,45 @@ const rule = { enableDangerousAutofixThisMayCauseInfiniteLoops: { type: 'boolean', }, + experimental_autoDependenciesHooks: { + type: 'array', + items: { + type: 'string', + }, + }, + requireExplicitEffectDeps: { + type: 'boolean', + } }, }, ], }, create(context: Rule.RuleContext) { + const rawOptions = context.options && context.options[0]; + // Parse the `additionalHooks` regex. const additionalHooks = - context.options && - context.options[0] && - context.options[0].additionalHooks - ? new RegExp(context.options[0].additionalHooks) + rawOptions && rawOptions.additionalHooks + ? new RegExp(rawOptions.additionalHooks) : undefined; const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean = - (context.options && - context.options[0] && - context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) || + (rawOptions && + rawOptions.enableDangerousAutofixThisMayCauseInfiniteLoops) || false; + const experimental_autoDependenciesHooks: ReadonlyArray = + rawOptions && Array.isArray(rawOptions.experimental_autoDependenciesHooks) + ? rawOptions.experimental_autoDependenciesHooks + : []; + + const requireExplicitEffectDeps: boolean = rawOptions && rawOptions.requireExplicitEffectDeps || false; + const options = { additionalHooks, + experimental_autoDependenciesHooks, enableDangerousAutofixThisMayCauseInfiniteLoops, + requireExplicitEffectDeps, }; function reportProblem(problem: Rule.ReportDescriptor) { @@ -162,6 +179,7 @@ const rule = { reactiveHook: Node, reactiveHookName: string, isEffect: boolean, + isAutoDepsHook: boolean, ): void { if (isEffect && node.async) { reportProblem({ @@ -203,7 +221,13 @@ const rule = { let currentScope = scope.upper; while (currentScope) { pureScopes.add(currentScope); - if (currentScope.type === 'function') { + if ( + currentScope.type === 'function' || + // @ts-expect-error incorrect TS types + currentScope.type === 'hook' || + // @ts-expect-error incorrect TS types + currentScope.type === 'component' + ) { break; } currentScope = currentScope.upper; @@ -643,6 +667,9 @@ const rule = { } if (!declaredDependenciesNode) { + if (isAutoDepsHook) { + return; + } // Check if there are any top-level setState() calls. // Those tend to lead to infinite loops. let setStateInsideEffectWithoutDeps: string | null = null; @@ -705,6 +732,13 @@ const rule = { } return; } + if ( + isAutoDepsHook && + declaredDependenciesNode.type === 'Literal' && + declaredDependenciesNode.value === null + ) { + return; + } const declaredDependencies: Array = []; const externalDependencies = new Set(); @@ -1312,10 +1346,28 @@ const rule = { return; } + if (!maybeNode && isEffect && options.requireExplicitEffectDeps) { + reportProblem({ + node: reactiveHook, + message: + `React Hook ${reactiveHookName} always requires dependencies. ` + + `Please add a dependency array or an explicit \`undefined\`` + }); + } + + const isAutoDepsHook = + options.experimental_autoDependenciesHooks.includes(reactiveHookName); + // Check the declared dependencies for this reactive hook. If there is no // second argument then the reactive callback will re-run on every render. // So no need to check for dependency inclusion. - if (!declaredDependenciesNode && !isEffect) { + if ( + (!declaredDependenciesNode || + (isAutoDepsHook && + declaredDependenciesNode.type === 'Literal' && + declaredDependenciesNode.value === null)) && + !isEffect + ) { // These are only used for optimization. if ( reactiveHookName === 'useMemo' || @@ -1349,11 +1401,17 @@ const rule = { reactiveHook, reactiveHookName, isEffect, + isAutoDepsHook, ); return; // Handled case 'Identifier': - if (!declaredDependenciesNode) { - // No deps, no problems. + if ( + !declaredDependenciesNode || + (isAutoDepsHook && + declaredDependenciesNode.type === 'Literal' && + declaredDependenciesNode.value === null) + ) { + // Always runs, no problems. return; // Handled } // The function passed as a callback is not written inline. @@ -1402,6 +1460,7 @@ const rule = { reactiveHook, reactiveHookName, isEffect, + isAutoDepsHook, ); return; // Handled case 'VariableDeclarator': @@ -1421,6 +1480,7 @@ const rule = { reactiveHook, reactiveHookName, isEffect, + isAutoDepsHook, ); return; // Handled } 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/packages/internal-test-utils/internalAct.js b/packages/internal-test-utils/internalAct.js index 752725eeb842f..1a420bcf203b4 100644 --- a/packages/internal-test-utils/internalAct.js +++ b/packages/internal-test-utils/internalAct.js @@ -138,6 +138,7 @@ export async function act(scope: () => Thenable): Thenable { // those will also fire now, too, which is not ideal. (The public // version of `act` doesn't do this.) For this reason, we should try // to avoid using timers in our internal tests. + j.runAllTicks(); j.runOnlyPendingTimers(); // If a committing a fallback triggers another update, it might not // get scheduled until a microtask. So wait one more time. @@ -194,6 +195,39 @@ export async function act(scope: () => Thenable): Thenable { } } +async function waitForTasksAndTimers(error: Error) { + do { + // Wait until end of current task/microtask. + await waitForMicrotasks(); + + // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object + if (jest.isEnvironmentTornDown()) { + error.message = + 'The Jest environment was torn down before `act` completed. This ' + + 'probably means you forgot to `await` an `act` call.'; + throw error; + } + + // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object + const j = jest; + if (j.getTimerCount() > 0) { + // There's a pending timer. Flush it now. We only do this in order to + // force Suspense fallbacks to display; the fact that it's a timer + // is an implementation detail. If there are other timers scheduled, + // those will also fire now, too, which is not ideal. (The public + // version of `act` doesn't do this.) For this reason, we should try + // to avoid using timers in our internal tests. + j.runAllTicks(); + j.runOnlyPendingTimers(); + // If a committing a fallback triggers another update, it might not + // get scheduled until a microtask. So wait one more time. + await waitForMicrotasks(); + } else { + break; + } + } while (true); +} + export async function serverAct(scope: () => Thenable): Thenable { // We require every `act` call to assert console logs // with one of the assertion helpers. Fails if not empty. @@ -233,37 +267,17 @@ export async function serverAct(scope: () => Thenable): Thenable { } try { - const result = await scope(); - - do { - // Wait until end of current task/microtask. - await waitForMicrotasks(); - - // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object - if (jest.isEnvironmentTornDown()) { - error.message = - 'The Jest environment was torn down before `act` completed. This ' + - 'probably means you forgot to `await` an `act` call.'; - throw error; - } - - // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object - const j = jest; - if (j.getTimerCount() > 0) { - // There's a pending timer. Flush it now. We only do this in order to - // force Suspense fallbacks to display; the fact that it's a timer - // is an implementation detail. If there are other timers scheduled, - // those will also fire now, too, which is not ideal. (The public - // version of `act` doesn't do this.) For this reason, we should try - // to avoid using timers in our internal tests. - j.runOnlyPendingTimers(); - // If a committing a fallback triggers another update, it might not - // get scheduled until a microtask. So wait one more time. - await waitForMicrotasks(); - } else { - break; - } - } while (true); + const promise = scope(); + // $FlowFixMe[prop-missing] + if (promise && typeof promise.catch === 'function') { + // $FlowFixMe[incompatible-use] + promise.catch(() => {}); // Handle below + } + // See if we need to do some work to unblock the promise first. + await waitForTasksAndTimers(error); + const result = await promise; + // Then wait to flush the result. + await waitForTasksAndTimers(error); if (thrownErrors.length > 0) { // Rethrow any errors logged by the global error handling. diff --git a/packages/react-client/src/ReactClientConsoleConfigBrowser.js b/packages/react-client/src/ReactClientConsoleConfigBrowser.js index 355edc9c08197..bc39763275240 100644 --- a/packages/react-client/src/ReactClientConsoleConfigBrowser.js +++ b/packages/react-client/src/ReactClientConsoleConfigBrowser.js @@ -7,6 +7,7 @@ * @flow */ +// Keep in sync with ReactServerConsoleConfig const badgeFormat = '%c%s%c '; // Same badge styling as DevTools. const badgeStyle = diff --git a/packages/react-client/src/ReactClientConsoleConfigPlain.js b/packages/react-client/src/ReactClientConsoleConfigPlain.js index 6b41ad4effe98..5fe553744a9fd 100644 --- a/packages/react-client/src/ReactClientConsoleConfigPlain.js +++ b/packages/react-client/src/ReactClientConsoleConfigPlain.js @@ -7,6 +7,7 @@ * @flow */ +// Keep in sync with ReactServerConsoleConfig const badgeFormat = '[%s] '; const pad = ' '; diff --git a/packages/react-client/src/ReactClientConsoleConfigServer.js b/packages/react-client/src/ReactClientConsoleConfigServer.js index efbcd2865d712..1978a4bc8b8de 100644 --- a/packages/react-client/src/ReactClientConsoleConfigServer.js +++ b/packages/react-client/src/ReactClientConsoleConfigServer.js @@ -7,6 +7,7 @@ * @flow */ +// Keep in sync with ReactServerConsoleConfig // This flips color using ANSI, then sets a color styling, then resets. const badgeFormat = '\x1b[0m\x1b[7m%c%s\x1b[0m%c '; // Same badge styling as DevTools. diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 7d6bbd5c1fbb0..d538e077abb1c 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -13,6 +13,7 @@ import type { ReactComponentInfo, ReactEnvironmentInfo, ReactAsyncInfo, + ReactIOInfo, ReactTimeInfo, ReactStackTrace, ReactFunctionLocation, @@ -47,6 +48,7 @@ import { enablePostpone, enableProfilerTimer, enableComponentPerformanceTrack, + enableAsyncDebugInfo, } from 'shared/ReactFeatureFlags'; import { @@ -75,7 +77,13 @@ import { markAllTracksInOrder, logComponentRender, logDedupedComponentRender, + logComponentAborted, logComponentErrored, + logIOInfo, + logIOInfoErrored, + logComponentAwait, + logComponentAwaitAborted, + logComponentAwaitErrored, } from './ReactFlightPerformanceTrack'; import { @@ -92,6 +100,8 @@ import {getOwnerStackByComponentInfoInDev} from 'shared/ReactComponentInfoStack' import {injectInternals} from './ReactFlightClientDevToolsHook'; +import {OMITTED_PROP_ERROR} from 'shared/ReactFlightPropertyAccess'; + import ReactVersion from 'shared/ReactVersion'; import isArray from 'shared/isArray'; @@ -151,21 +161,20 @@ const RESOLVED_MODEL = 'resolved_model'; const RESOLVED_MODULE = 'resolved_module'; const INITIALIZED = 'fulfilled'; const ERRORED = 'rejected'; +const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes. type PendingChunk = { status: 'pending', - value: null | Array<(T) => mixed>, - reason: null | Array<(mixed) => mixed>, - _response: Response, + value: null | Array mixed)>, + reason: null | Array mixed)>, _children: Array> | ProfilingResult, // Profiling-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type BlockedChunk = { status: 'blocked', - value: null | Array<(T) => mixed>, - reason: null | Array<(mixed) => mixed>, - _response: Response, + value: null | Array mixed)>, + reason: null | Array mixed)>, _children: Array> | ProfilingResult, // Profiling-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, @@ -173,8 +182,7 @@ type BlockedChunk = { type ResolvedModelChunk = { status: 'resolved_model', value: UninitializedModel, - reason: null, - _response: Response, + reason: Response, _children: Array> | ProfilingResult, // Profiling-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, @@ -183,7 +191,6 @@ type ResolvedModuleChunk = { status: 'resolved_module', value: ClientReference, reason: null, - _response: Response, _children: Array> | ProfilingResult, // Profiling-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, @@ -192,7 +199,6 @@ type InitializedChunk = { status: 'fulfilled', value: T, reason: null | FlightStreamController, - _response: Response, _children: Array> | ProfilingResult, // Profiling-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, @@ -203,7 +209,6 @@ type InitializedStreamChunk< status: 'fulfilled', value: T, reason: FlightStreamController, - _response: Response, _children: Array> | ProfilingResult, // Profiling-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (ReadableStream) => mixed, reject?: (mixed) => mixed): void, @@ -212,7 +217,14 @@ type ErroredChunk = { status: 'rejected', value: null, reason: mixed, - _response: Response, + _children: Array> | ProfilingResult, // Profiling-only + _debugInfo?: null | ReactDebugInfo, // DEV-only + then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, +}; +type HaltedChunk = { + status: 'halted', + value: null, + reason: null, _children: Array> | ProfilingResult, // Profiling-only _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, @@ -223,19 +235,14 @@ type SomeChunk = | ResolvedModelChunk | ResolvedModuleChunk | InitializedChunk - | ErroredChunk; + | ErroredChunk + | HaltedChunk; // $FlowFixMe[missing-this-annot] -function ReactPromise( - status: any, - value: any, - reason: any, - response: Response, -) { +function ReactPromise(status: any, value: any, reason: any) { this.status = status; this.value = value; this.reason = reason; - this._response = response; if (enableProfilerTimer && enableComponentPerformanceTrack) { this._children = []; } @@ -262,28 +269,56 @@ ReactPromise.prototype.then = function ( initializeModuleChunk(chunk); break; } + if (__DEV__ && enableAsyncDebugInfo) { + // Because only native Promises get picked up when we're awaiting we need to wrap + // this in a native Promise in DEV. This means that these callbacks are no longer sync + // but the lazy initialization is still sync and the .value can be inspected after, + // allowing it to be read synchronously anyway. + const resolveCallback = resolve; + const rejectCallback = reject; + const wrapperPromise: Promise = new Promise((res, rej) => { + resolve = value => { + // $FlowFixMe + wrapperPromise._debugInfo = this._debugInfo; + res(value); + }; + reject = reason => { + // $FlowFixMe + wrapperPromise._debugInfo = this._debugInfo; + rej(reason); + }; + }); + wrapperPromise.then(resolveCallback, rejectCallback); + } // The status might have changed after initialization. switch (chunk.status) { case INITIALIZED: - resolve(chunk.value); + if (typeof resolve === 'function') { + resolve(chunk.value); + } break; case PENDING: case BLOCKED: - if (resolve) { + if (typeof resolve === 'function') { if (chunk.value === null) { - chunk.value = ([]: Array<(T) => mixed>); + chunk.value = ([]: Array mixed)>); } chunk.value.push(resolve); } - if (reject) { + if (typeof reject === 'function') { if (chunk.reason === null) { - chunk.reason = ([]: Array<(mixed) => mixed>); + chunk.reason = ([]: Array< + InitializationReference | (mixed => mixed), + >); } chunk.reason.push(reject); } break; + case HALTED: { + break; + } default: - if (reject) { + if (typeof reject === 'function') { reject(chunk.reason); } break; @@ -295,6 +330,8 @@ export type FindSourceMapURLCallback = ( environmentName: string, ) => null | string; +export type DebugChannelCallback = (message: string) => void; + export type Response = { _bundlerConfig: ServerConsumerModuleMap, _serverReferenceConfig: null | ServerManifest, @@ -318,6 +355,7 @@ export type Response = { _debugRootStack?: null | Error, // DEV-only _debugRootTask?: null | ConsoleTask, // DEV-only _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only + _debugChannel?: void | DebugChannelCallback, // DEV-only _replayConsole: boolean, // DEV-only _rootEnvironmentName: string, // DEV-only, the requested environment name. }; @@ -339,6 +377,7 @@ function readChunk(chunk: SomeChunk): T { return chunk.value; case PENDING: case BLOCKED: + case HALTED: // eslint-disable-next-line no-throw-literal throw ((chunk: any): Thenable); default: @@ -353,12 +392,12 @@ export function getRoot(response: Response): Thenable { function createPendingChunk(response: Response): PendingChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise(PENDING, null, null, response); + return new ReactPromise(PENDING, null, null); } function createBlockedChunk(response: Response): BlockedChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise(BLOCKED, null, null, response); + return new ReactPromise(BLOCKED, null, null); } function createErrorChunk( @@ -366,27 +405,99 @@ function createErrorChunk( error: mixed, ): ErroredChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise(ERRORED, null, error, response); + return new ReactPromise(ERRORED, null, error); +} + +function wakeChunk( + listeners: Array mixed)>, + value: T, +): void { + for (let i = 0; i < listeners.length; i++) { + const listener = listeners[i]; + if (typeof listener === 'function') { + listener(value); + } else { + fulfillReference(listener, value); + } + } } -function wakeChunk(listeners: Array<(T) => mixed>, value: T): void { +function rejectChunk( + listeners: Array mixed)>, + error: mixed, +): void { for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; - listener(value); + if (typeof listener === 'function') { + listener(error); + } else { + rejectReference(listener, error); + } + } +} + +function resolveBlockedCycle( + resolvedChunk: SomeChunk, + reference: InitializationReference, +): null | InitializationHandler { + const referencedChunk = reference.handler.chunk; + if (referencedChunk === null) { + return null; + } + if (referencedChunk === resolvedChunk) { + // We found the cycle. We can resolve the blocked cycle now. + return reference.handler; + } + const resolveListeners = referencedChunk.value; + if (resolveListeners !== null) { + for (let i = 0; i < resolveListeners.length; i++) { + const listener = resolveListeners[i]; + if (typeof listener !== 'function') { + const foundHandler = resolveBlockedCycle(resolvedChunk, listener); + if (foundHandler !== null) { + return foundHandler; + } + } + } } + return null; } function wakeChunkIfInitialized( chunk: SomeChunk, - resolveListeners: Array<(T) => mixed>, - rejectListeners: null | Array<(mixed) => mixed>, + resolveListeners: Array mixed)>, + rejectListeners: null | Array mixed)>, ): void { switch (chunk.status) { case INITIALIZED: wakeChunk(resolveListeners, chunk.value); break; - case PENDING: case BLOCKED: + // It is possible that we're blocked on our own chunk if it's a cycle. + // Before adding back the listeners to the chunk, let's check if it would + // result in a cycle. + for (let i = 0; i < resolveListeners.length; i++) { + const listener = resolveListeners[i]; + if (typeof listener !== 'function') { + const reference: InitializationReference = listener; + const cyclicHandler = resolveBlockedCycle(chunk, reference); + if (cyclicHandler !== null) { + // This reference points back to this chunk. We can resolve the cycle by + // using the value from that handler. + fulfillReference(reference, cyclicHandler.value); + resolveListeners.splice(i, 1); + i--; + if (rejectListeners !== null) { + const rejectionIdx = rejectListeners.indexOf(reference); + if (rejectionIdx !== -1) { + rejectListeners.splice(rejectionIdx, 1); + } + } + } + } + } + // Fallthrough + case PENDING: if (chunk.value) { for (let i = 0; i < resolveListeners.length; i++) { chunk.value.push(resolveListeners[i]); @@ -408,7 +519,7 @@ function wakeChunkIfInitialized( break; case ERRORED: if (rejectListeners) { - wakeChunk(rejectListeners, chunk.reason); + rejectChunk(rejectListeners, chunk.reason); } break; } @@ -429,7 +540,7 @@ function triggerErrorOnChunk(chunk: SomeChunk, error: mixed): void { erroredChunk.status = ERRORED; erroredChunk.reason = error; if (listeners !== null) { - wakeChunk(listeners, error); + rejectChunk(listeners, error); } } @@ -438,7 +549,7 @@ function createResolvedModelChunk( value: UninitializedModel, ): ResolvedModelChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise(RESOLVED_MODEL, value, null, response); + return new ReactPromise(RESOLVED_MODEL, value, response); } function createResolvedModuleChunk( @@ -446,7 +557,7 @@ function createResolvedModuleChunk( value: ClientReference, ): ResolvedModuleChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise(RESOLVED_MODULE, value, null, response); + return new ReactPromise(RESOLVED_MODULE, value, null); } function createInitializedTextChunk( @@ -454,7 +565,7 @@ function createInitializedTextChunk( value: string, ): InitializedChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise(INITIALIZED, value, null, response); + return new ReactPromise(INITIALIZED, value, null); } function createInitializedBufferChunk( @@ -462,7 +573,7 @@ function createInitializedBufferChunk( value: $ArrayBufferView | ArrayBuffer, ): InitializedChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise(INITIALIZED, value, null, response); + return new ReactPromise(INITIALIZED, value, null); } function createInitializedIteratorResultChunk( @@ -471,12 +582,7 @@ function createInitializedIteratorResultChunk( done: boolean, ): InitializedChunk> { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise( - INITIALIZED, - {done: done, value: value}, - null, - response, - ); + return new ReactPromise(INITIALIZED, {done: done, value: value}, null); } function createInitializedStreamChunk< @@ -489,7 +595,7 @@ function createInitializedStreamChunk< // We use the reason field to stash the controller since we already have that // field. It's a bit of a hack but efficient. // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise(INITIALIZED, value, controller, response); + return new ReactPromise(INITIALIZED, value, controller); } function createResolvedIteratorResultChunk( @@ -501,10 +607,11 @@ function createResolvedIteratorResultChunk( const iteratorResultJSON = (done ? '{"done":true,"value":' : '{"done":false,"value":') + value + '}'; // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise(RESOLVED_MODEL, iteratorResultJSON, null, response); + return new ReactPromise(RESOLVED_MODEL, iteratorResultJSON, response); } function resolveIteratorResultChunk( + response: Response, chunk: SomeChunk>, value: UninitializedModel, done: boolean, @@ -512,10 +619,11 @@ function resolveIteratorResultChunk( // To reuse code as much code as possible we add the wrapper element as part of the JSON. const iteratorResultJSON = (done ? '{"done":true,"value":' : '{"done":false,"value":') + value + '}'; - resolveModelChunk(chunk, iteratorResultJSON); + resolveModelChunk(response, chunk, iteratorResultJSON); } function resolveModelChunk( + response: Response, chunk: SomeChunk, value: UninitializedModel, ): void { @@ -532,6 +640,7 @@ function resolveModelChunk( const resolvedChunk: ResolvedModelChunk = (chunk: any); resolvedChunk.status = RESOLVED_MODEL; resolvedChunk.value = value; + resolvedChunk.reason = response; if (resolveListeners !== null) { // This is unfortunate that we're reading this eagerly if // we already have listeners attached since they might no @@ -561,6 +670,19 @@ function resolveModuleChunk( } } +type InitializationReference = { + response: Response, // TODO: Remove Response from here and pass it through instead. + handler: InitializationHandler, + parentObject: Object, + key: string, + map: ( + response: Response, + model: any, + parentObject: Object, + key: string, + ) => any, + path: Array, +}; type InitializationHandler = { parent: null | InitializationHandler, chunk: null | BlockedChunk, @@ -577,6 +699,7 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { initializingHandler = null; const resolvedModel = chunk.value; + const response = chunk.reason; // We go to the BLOCKED state until we've fully resolved this. // We do this before parsing in case we try to initialize the same chunk @@ -591,7 +714,7 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { } try { - const value: T = parseModel(chunk._response, resolvedModel); + const value: T = parseModel(response, resolvedModel); // Invoke any listeners added while resolving this model. I.e. cyclic // references. This may or may not fully resolve the model depending on // if they were blocked. @@ -654,15 +777,26 @@ export function reportGlobalError(response: Response, error: Error): void { triggerErrorOnChunk(chunk, error); } }); + if (__DEV__) { + const debugChannel = response._debugChannel; + if (debugChannel !== undefined) { + // If we don't have any more ways of reading data, we don't have to send any + // more neither. So we close the writable side. + debugChannel(''); + response._debugChannel = undefined; + } + } if (enableProfilerTimer && enableComponentPerformanceTrack) { - markAllTracksInOrder(); - flushComponentPerformance( - response, - getChunk(response, 0), - 0, - -Infinity, - -Infinity, - ); + if (response._replayConsole) { + markAllTracksInOrder(); + flushComponentPerformance( + response, + getChunk(response, 0), + 0, + -Infinity, + -Infinity, + ); + } } } @@ -672,6 +806,14 @@ function nullRefGetter() { } } +function getIOInfoTaskName(ioInfo: ReactIOInfo): string { + return ioInfo.name || 'unknown'; +} + +function getAsyncInfoTaskName(asyncInfo: ReactAsyncInfo): string { + return 'await ' + getIOInfoTaskName(asyncInfo.awaited); +} + function getServerComponentTaskName(componentInfo: ReactComponentInfo): string { return '<' + (componentInfo.name || '...') + '>'; } @@ -709,13 +851,85 @@ function getTaskName(type: mixed): string { } } +function initializeElement(response: Response, element: any): void { + if (!__DEV__) { + return; + } + const stack = element._debugStack; + const owner = element._owner; + if (owner === null) { + element._owner = response._debugRootOwner; + } + let env = response._rootEnvironmentName; + if (owner !== null && owner.env != null) { + // Interestingly we don't actually have the environment name of where + // this JSX was created if it doesn't have an owner but if it does + // it must be the same environment as the owner. We could send it separately + // but it seems a bit unnecessary for this edge case. + env = owner.env; + } + let normalizedStackTrace: null | Error = null; + if (owner === null && response._debugRootStack != 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. + normalizedStackTrace = response._debugRootStack; + } else if (stack !== null) { + // We create a fake stack and then create an Error object inside of it. + // This means that the stack trace is now normalized into the native format + // of the browser and the stack frames will have been registered with + // source mapping information. + // This can unfortunately happen within a user space callstack which will + // remain on the stack. + normalizedStackTrace = createFakeJSXCallStackInDEV(response, stack, env); + } + element._debugStack = normalizedStackTrace; + let task: null | ConsoleTask = null; + if (supportsCreateTask && stack !== null) { + const createTaskFn = (console: any).createTask.bind( + console, + getTaskName(element.type), + ); + const callStack = buildFakeCallStack( + response, + stack, + env, + false, + createTaskFn, + ); + // 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); + if (ownerTask === null) { + const rootTask = response._debugRootTask; + if (rootTask != null) { + task = rootTask.run(callStack); + } else { + task = callStack(); + } + } else { + task = ownerTask.run(callStack); + } + } + element._debugTask = task; + + // This owner should ideally have already been initialized to avoid getting + // user stack frames on the stack. + if (owner !== null) { + initializeFakeStack(response, owner); + } + // TODO: We should be freezing the element but currently, we might write into + // _debugInfo later. We could move it into _store which remains mutable. + Object.freeze(element.props); +} + function createElement( response: Response, type: mixed, key: mixed, props: mixed, - owner: null | ReactComponentInfo, // DEV-only - stack: null | ReactStackTrace, // DEV-only + owner: ?ReactComponentInfo, // DEV-only + stack: ?ReactStackTrace, // DEV-only validated: number, // DEV-only ): | React$Element @@ -728,7 +942,7 @@ function createElement( type, key, props, - _owner: __DEV__ && owner === null ? response._debugRootOwner : owner, + _owner: owner === undefined ? null : owner, }: any); Object.defineProperty(element, 'ref', { enumerable: false, @@ -766,69 +980,18 @@ function createElement( writable: true, value: null, }); - let env = response._rootEnvironmentName; - if (owner !== null && owner.env != null) { - // Interestingly we don't actually have the environment name of where - // this JSX was created if it doesn't have an owner but if it does - // it must be the same environment as the owner. We could send it separately - // but it seems a bit unnecessary for this edge case. - env = owner.env; - } - let normalizedStackTrace: null | Error = null; - if (owner === null && response._debugRootStack != 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. - normalizedStackTrace = response._debugRootStack; - } else if (stack !== null) { - // We create a fake stack and then create an Error object inside of it. - // This means that the stack trace is now normalized into the native format - // of the browser and the stack frames will have been registered with - // source mapping information. - // This can unfortunately happen within a user space callstack which will - // remain on the stack. - normalizedStackTrace = createFakeJSXCallStackInDEV(response, stack, env); - } Object.defineProperty(element, '_debugStack', { configurable: false, enumerable: false, writable: true, - value: normalizedStackTrace, + value: stack === undefined ? null : stack, }); - - let task: null | ConsoleTask = null; - if (supportsCreateTask && stack !== null) { - const createTaskFn = (console: any).createTask.bind( - console, - getTaskName(type), - ); - const callStack = buildFakeCallStack(response, stack, env, createTaskFn); - // 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); - if (ownerTask === null) { - const rootTask = response._debugRootTask; - if (rootTask != null) { - task = rootTask.run(callStack); - } else { - task = callStack(); - } - } else { - task = ownerTask.run(callStack); - } - } Object.defineProperty(element, '_debugTask', { configurable: false, enumerable: false, writable: true, - value: task, + value: null, }); - - // This owner should ideally have already been initialized to avoid getting - // user stack frames on the stack. - if (owner !== null) { - initializeFakeStack(response, owner); - } } if (initializingHandler !== null) { @@ -844,6 +1007,7 @@ function createElement( handler.value, ); if (__DEV__) { + initializeElement(response, element); // Conceptually the error happened inside this Element but right before // it was rendered. We don't have a client side component to render but // we can add some DebugInfo to explain that this was conceptually a @@ -872,15 +1036,15 @@ function createElement( handler.value = element; handler.chunk = blockedChunk; if (__DEV__) { - const freeze = Object.freeze.bind(Object, element.props); - blockedChunk.then(freeze, freeze); + /// After we have initialized any blocked references, initialize stack etc. + const init = initializeElement.bind(null, response, element); + blockedChunk.then(init, init); } return createLazyChunkWrapper(blockedChunk); } - } else if (__DEV__) { - // TODO: We should be freezing the element but currently, we might write into - // _debugInfo later. We could move it into _store which remains mutable. - Object.freeze(element.props); + } + if (__DEV__) { + initializeElement(response, element); } return element; @@ -919,8 +1083,191 @@ function getChunk(response: Response, id: number): SomeChunk { return chunk; } +function fulfillReference( + reference: InitializationReference, + value: any, +): void { + const {response, handler, parentObject, key, map, path} = reference; + + for (let i = 1; i < path.length; i++) { + while (value.$$typeof === REACT_LAZY_TYPE) { + // We never expect to see a Lazy node on this path because we encode those as + // separate models. This must mean that we have inserted an extra lazy node + // e.g. to replace a blocked element. We must instead look for it inside. + const referencedChunk: SomeChunk = value._payload; + if (referencedChunk === handler.chunk) { + // This is a reference to the thing we're currently blocking. We can peak + // inside of it to get the value. + value = handler.value; + continue; + } else { + switch (referencedChunk.status) { + case RESOLVED_MODEL: + initializeModelChunk(referencedChunk); + break; + case RESOLVED_MODULE: + initializeModuleChunk(referencedChunk); + break; + } + switch (referencedChunk.status) { + case INITIALIZED: { + value = referencedChunk.value; + continue; + } + case BLOCKED: { + // It is possible that we're blocked on our own chunk if it's a cycle. + // Before adding the listener to the inner chunk, let's check if it would + // result in a cycle. + const cyclicHandler = resolveBlockedCycle( + referencedChunk, + reference, + ); + if (cyclicHandler !== null) { + // This reference points back to this chunk. We can resolve the cycle by + // using the value from that handler. + value = cyclicHandler.value; + continue; + } + // Fallthrough + } + case PENDING: { + // If we're not yet initialized we need to skip what we've already drilled + // through and then wait for the next value to become available. + path.splice(0, i - 1); + // Add "listener" to our new chunk dependency. + if (referencedChunk.value === null) { + referencedChunk.value = [reference]; + } else { + referencedChunk.value.push(reference); + } + if (referencedChunk.reason === null) { + referencedChunk.reason = [reference]; + } else { + referencedChunk.reason.push(reference); + } + return; + } + case HALTED: { + // Do nothing. We couldn't fulfill. + // TODO: Mark downstreams as halted too. + return; + } + default: { + rejectReference(reference, referencedChunk.reason); + return; + } + } + } + } + value = value[path[i]]; + } + const mappedValue = map(response, value, parentObject, key); + parentObject[key] = mappedValue; + + // If this is the root object for a model reference, where `handler.value` + // is a stale `null`, the resolved value can be used directly. + if (key === '' && handler.value === null) { + handler.value = mappedValue; + } + + // If the parent object is an unparsed React element tuple, we also need to + // update the props and owner of the parsed element object (i.e. + // handler.value). + if ( + parentObject[0] === REACT_ELEMENT_TYPE && + typeof handler.value === 'object' && + handler.value !== null && + handler.value.$$typeof === REACT_ELEMENT_TYPE + ) { + const element: any = handler.value; + switch (key) { + case '3': + element.props = mappedValue; + break; + case '4': + if (__DEV__) { + element._owner = mappedValue; + } + break; + case '5': + if (__DEV__) { + element._debugStack = mappedValue; + } + break; + } + } + + handler.deps--; + + if (handler.deps === 0) { + const chunk = handler.chunk; + if (chunk === null || chunk.status !== BLOCKED) { + return; + } + const resolveListeners = chunk.value; + const initializedChunk: InitializedChunk = (chunk: any); + initializedChunk.status = INITIALIZED; + initializedChunk.value = handler.value; + if (resolveListeners !== null) { + wakeChunk(resolveListeners, handler.value); + } + } +} + +function rejectReference( + reference: InitializationReference, + error: mixed, +): void { + const {handler} = reference; + + if (handler.errored) { + // We've already errored. We could instead build up an AggregateError + // but if there are multiple errors we just take the first one like + // Promise.all. + return; + } + const blockedValue = handler.value; + handler.errored = true; + handler.value = error; + const chunk = handler.chunk; + if (chunk === null || chunk.status !== BLOCKED) { + return; + } + + if (__DEV__) { + if ( + typeof blockedValue === 'object' && + blockedValue !== null && + blockedValue.$$typeof === REACT_ELEMENT_TYPE + ) { + const element = blockedValue; + // Conceptually the error happened inside this Element but right before + // it was rendered. We don't have a client side component to render but + // we can add some DebugInfo to explain that this was conceptually a + // Server side error that errored inside this element. That way any stack + // traces will point to the nearest JSX that errored - e.g. during + // serialization. + const erroredComponent: ReactComponentInfo = { + name: getComponentNameFromType(element.type) || '', + owner: element._owner, + }; + // $FlowFixMe[cannot-write] + erroredComponent.debugStack = element._debugStack; + if (supportsCreateTask) { + // $FlowFixMe[cannot-write] + erroredComponent.debugTask = element._debugTask; + } + const chunkDebugInfo: ReactDebugInfo = + chunk._debugInfo || (chunk._debugInfo = []); + chunkDebugInfo.push(erroredComponent); + } + } + + triggerErrorOnChunk(chunk, error); +} + function waitForReference( - referencedChunk: SomeChunk, + referencedChunk: PendingChunk | BlockedChunk, parentObject: Object, key: string, response: Response, @@ -941,128 +1288,27 @@ function waitForReference( }; } - function fulfill(value: any): void { - for (let i = 1; i < path.length; i++) { - while (value.$$typeof === REACT_LAZY_TYPE) { - // We never expect to see a Lazy node on this path because we encode those as - // separate models. This must mean that we have inserted an extra lazy node - // e.g. to replace a blocked element. We must instead look for it inside. - const chunk: SomeChunk = value._payload; - if (chunk === handler.chunk) { - // This is a reference to the thing we're currently blocking. We can peak - // inside of it to get the value. - value = handler.value; - continue; - } else if (chunk.status === INITIALIZED) { - value = chunk.value; - continue; - } else { - // If we're not yet initialized we need to skip what we've already drilled - // through and then wait for the next value to become available. - path.splice(0, i - 1); - chunk.then(fulfill, reject); - return; - } - } - value = value[path[i]]; - } - const mappedValue = map(response, value, parentObject, key); - parentObject[key] = mappedValue; - - // If this is the root object for a model reference, where `handler.value` - // is a stale `null`, the resolved value can be used directly. - if (key === '' && handler.value === null) { - handler.value = mappedValue; - } - - // If the parent object is an unparsed React element tuple, we also need to - // update the props and owner of the parsed element object (i.e. - // handler.value). - if ( - parentObject[0] === REACT_ELEMENT_TYPE && - typeof handler.value === 'object' && - handler.value !== null && - handler.value.$$typeof === REACT_ELEMENT_TYPE - ) { - const element: any = handler.value; - switch (key) { - case '3': - element.props = mappedValue; - break; - case '4': - if (__DEV__) { - element._owner = mappedValue; - } - break; - } - } - - handler.deps--; + const reference: InitializationReference = { + response, + handler, + parentObject, + key, + map, + path, + }; - if (handler.deps === 0) { - const chunk = handler.chunk; - if (chunk === null || chunk.status !== BLOCKED) { - return; - } - const resolveListeners = chunk.value; - const initializedChunk: InitializedChunk = (chunk: any); - initializedChunk.status = INITIALIZED; - initializedChunk.value = handler.value; - if (resolveListeners !== null) { - wakeChunk(resolveListeners, handler.value); - } - } + // Add "listener". + if (referencedChunk.value === null) { + referencedChunk.value = [reference]; + } else { + referencedChunk.value.push(reference); } - - function reject(error: mixed): void { - if (handler.errored) { - // We've already errored. We could instead build up an AggregateError - // but if there are multiple errors we just take the first one like - // Promise.all. - return; - } - const blockedValue = handler.value; - handler.errored = true; - handler.value = error; - const chunk = handler.chunk; - if (chunk === null || chunk.status !== BLOCKED) { - return; - } - - if (__DEV__) { - if ( - typeof blockedValue === 'object' && - blockedValue !== null && - blockedValue.$$typeof === REACT_ELEMENT_TYPE - ) { - const element = blockedValue; - // Conceptually the error happened inside this Element but right before - // it was rendered. We don't have a client side component to render but - // we can add some DebugInfo to explain that this was conceptually a - // Server side error that errored inside this element. That way any stack - // traces will point to the nearest JSX that errored - e.g. during - // serialization. - const erroredComponent: ReactComponentInfo = { - name: getComponentNameFromType(element.type) || '', - owner: element._owner, - }; - // $FlowFixMe[cannot-write] - erroredComponent.debugStack = element._debugStack; - if (supportsCreateTask) { - // $FlowFixMe[cannot-write] - erroredComponent.debugTask = element._debugTask; - } - const chunkDebugInfo: ReactDebugInfo = - chunk._debugInfo || (chunk._debugInfo = []); - chunkDebugInfo.push(erroredComponent); - } - } - - triggerErrorOnChunk(chunk, error); + if (referencedChunk.reason === null) { + referencedChunk.reason = [reference]; + } else { + referencedChunk.reason.push(reference); } - referencedChunk.then(fulfill, reject); - // Return a place holder value for now. return (null: any); } @@ -1275,17 +1521,65 @@ function getOutlinedModel( for (let i = 1; i < path.length; i++) { while (value.$$typeof === REACT_LAZY_TYPE) { const referencedChunk: SomeChunk = value._payload; - if (referencedChunk.status === INITIALIZED) { - value = referencedChunk.value; - } else { - return waitForReference( - referencedChunk, - parentObject, - key, - response, - map, - path.slice(i - 1), - ); + switch (referencedChunk.status) { + case RESOLVED_MODEL: + initializeModelChunk(referencedChunk); + break; + case RESOLVED_MODULE: + initializeModuleChunk(referencedChunk); + break; + } + switch (referencedChunk.status) { + case INITIALIZED: { + value = referencedChunk.value; + break; + } + case BLOCKED: + case PENDING: { + return waitForReference( + referencedChunk, + parentObject, + key, + response, + map, + path.slice(i - 1), + ); + } + case HALTED: { + // Add a dependency that will never resolve. + // TODO: Mark downstreams as halted too. + let handler: InitializationHandler; + if (initializingHandler) { + handler = initializingHandler; + handler.deps++; + } else { + handler = initializingHandler = { + parent: null, + chunk: null, + value: null, + deps: 1, + errored: false, + }; + } + return (null: any); + } + default: { + // This is an error. Instead of erroring directly, we're going to encode this on + // an initialization handler so that we can catch it at the nearest Element. + if (initializingHandler) { + initializingHandler.errored = true; + initializingHandler.value = referencedChunk.reason; + } else { + initializingHandler = { + parent: null, + chunk: null, + value: referencedChunk.reason, + deps: 0, + errored: true, + }; + } + return (null: any); + } } } value = value[path[i]]; @@ -1321,6 +1615,24 @@ function getOutlinedModel( case PENDING: case BLOCKED: return waitForReference(chunk, parentObject, key, response, map, path); + case HALTED: { + // Add a dependency that will never resolve. + // TODO: Mark downstreams as halted too. + let handler: InitializationHandler; + if (initializingHandler) { + handler = initializingHandler; + handler.deps++; + } else { + handler = initializingHandler = { + parent: null, + chunk: null, + value: null, + deps: 1, + errored: false, + }; + } + return (null: any); + } default: // This is an error. Instead of erroring directly, we're going to encode this on // an initialization handler so that we can catch it at the nearest Element. @@ -1367,6 +1679,17 @@ function createFormData( return formData; } +function applyConstructor( + response: Response, + model: Function, + parentObject: Object, + key: string, +): void { + Object.setPrototypeOf(parentObject, model.prototype); + // Delete the property. It was just a placeholder. + return undefined; +} + function extractIterator(response: Response, model: Array): Iterator { // $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array. return model[Symbol.iterator](); @@ -1423,10 +1746,6 @@ function parseModelString( } case '@': { // Promise - if (value.length === 2) { - // Infinite promise that never resolves. - return new Promise(() => {}); - } const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); if (enableProfilerTimer && enableComponentPerformanceTrack) { @@ -1547,16 +1866,60 @@ function parseModelString( // BigInt return BigInt(value.slice(2)); } + case 'P': { + if (__DEV__) { + // In DEV mode we allow debug objects to specify themselves as instances of + // another constructor. + const ref = value.slice(2); + return getOutlinedModel( + response, + ref, + parentObject, + key, + applyConstructor, + ); + } + //Fallthrough + } case 'E': { if (__DEV__) { // In DEV mode we allow indirect eval to produce functions for logging. // This should not compile to eval() because then it has local scope access. + const code = value.slice(2); try { // eslint-disable-next-line no-eval - return (0, eval)(value.slice(2)); + return (0, eval)(code); } catch (x) { // We currently use this to express functions so we fail parsing it, // let's just return a blank function as a place holder. + if (code.startsWith('(async function')) { + const idx = code.indexOf('(', 15); + if (idx !== -1) { + const name = code.slice(15, idx).trim(); + // eslint-disable-next-line no-eval + return (0, eval)( + '({' + JSON.stringify(name) + ':async function(){}})', + )[name]; + } + } else if (code.startsWith('(function')) { + const idx = code.indexOf('(', 9); + if (idx !== -1) { + const name = code.slice(9, idx).trim(); + // eslint-disable-next-line no-eval + return (0, eval)( + '({' + JSON.stringify(name) + ':function(){}})', + )[name]; + } + } else if (code.startsWith('(class')) { + const idx = code.indexOf('{', 6); + if (idx !== -1) { + const name = code.slice(6, idx).trim(); + // eslint-disable-next-line no-eval + return (0, eval)('({' + JSON.stringify(name) + ':class{}})')[ + name + ]; + } + } return function () {}; } } @@ -1564,17 +1927,21 @@ function parseModelString( } case 'Y': { if (__DEV__) { + if (value.length > 2) { + const debugChannel = response._debugChannel; + if (debugChannel) { + const ref = value.slice(2); + debugChannel('R:' + ref); // Release this reference immediately + } + } + // In DEV mode we encode omitted objects in logs as a getter that throws // so that when you try to access it on the client, you know why that // happened. Object.defineProperty(parentObject, key, { get: function () { // TODO: We should ideally throw here to indicate a difference. - return ( - 'This object has been omitted by React in the console log ' + - 'to avoid sending too much data from the server. Try logging smaller ' + - 'or more specific objects.' - ); + return OMITTED_PROP_ERROR; }, enumerable: true, configurable: false, @@ -1631,9 +1998,10 @@ function ResponseInstance( encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, temporaryReferences: void | TemporaryReferenceSet, - findSourceMapURL: void | FindSourceMapURLCallback, - replayConsole: boolean, - environmentName: void | string, + findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only + replayConsole: boolean, // DEV-only + environmentName: void | string, // DEV-only + debugChannel: void | DebugChannelCallback, // DEV-only ) { const chunks: Map> = new Map(); this._bundlerConfig = bundlerConfig; @@ -1688,9 +2056,18 @@ function ResponseInstance( ); } this._debugFindSourceMapURL = findSourceMapURL; + this._debugChannel = debugChannel; this._replayConsole = replayConsole; this._rootEnvironmentName = rootEnv; } + if (enableProfilerTimer && enableComponentPerformanceTrack) { + // Since we don't know when recording of profiles will start and stop, we have to + // mark the order over and over again. + if (replayConsole) { + markAllTracksInOrder(); + } + } + // Don't inline this call because it causes closure to outline the call above. this._fromJSON = createFromJSONCallback(this); } @@ -1703,9 +2080,10 @@ export function createResponse( encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, temporaryReferences: void | TemporaryReferenceSet, - findSourceMapURL: void | FindSourceMapURLCallback, - replayConsole: boolean, - environmentName: void | string, + findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only + replayConsole: boolean, // DEV-only + environmentName: void | string, // DEV-only + debugChannel: void | DebugChannelCallback, // DEV-only ): Response { // $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors return new ResponseInstance( @@ -1719,9 +2097,26 @@ export function createResponse( findSourceMapURL, replayConsole, environmentName, + debugChannel, ); } +function resolveDebugHalt(response: Response, id: number): void { + const chunks = response._chunks; + let chunk = chunks.get(id); + if (!chunk) { + chunks.set(id, (chunk = createPendingChunk(response))); + } else { + } + if (chunk.status !== PENDING && chunk.status !== BLOCKED) { + return; + } + const haltedChunk: HaltedChunk = (chunk: any); + haltedChunk.status = HALTED; + haltedChunk.value = null; + haltedChunk.reason = null; +} + function resolveModel( response: Response, id: number, @@ -1732,7 +2127,7 @@ function resolveModel( if (!chunk) { chunks.set(id, createResolvedModelChunk(response, model)); } else { - resolveModelChunk(chunk, model); + resolveModelChunk(response, chunk, model); } } @@ -1906,7 +2301,7 @@ function startReadableStream( // to synchronous emitting. previousBlockedChunk = null; } - resolveModelChunk(chunk, json); + resolveModelChunk(response, chunk, json); }); } }, @@ -1994,7 +2389,12 @@ function startAsyncIterable( false, ); } else { - resolveIteratorResultChunk(buffer[nextWriteIndex], value, false); + resolveIteratorResultChunk( + response, + buffer[nextWriteIndex], + value, + false, + ); } nextWriteIndex++; }, @@ -2007,12 +2407,18 @@ function startAsyncIterable( true, ); } else { - resolveIteratorResultChunk(buffer[nextWriteIndex], value, true); + resolveIteratorResultChunk( + response, + buffer[nextWriteIndex], + value, + true, + ); } nextWriteIndex++; while (nextWriteIndex < buffer.length) { // In generators, any extra reads from the iterator have the value undefined. resolveIteratorResultChunk( + response, buffer[nextWriteIndex++], '"$undefined"', true, @@ -2030,32 +2436,33 @@ function startAsyncIterable( } }, }; - const iterable: $AsyncIterable = { - [ASYNC_ITERATOR](): $AsyncIterator { - let nextReadIndex = 0; - return createIterator(arg => { - if (arg !== undefined) { - throw new Error( - 'Values cannot be passed to next() of AsyncIterables passed to Client Components.', + + const iterable: $AsyncIterable = ({}: any); + // $FlowFixMe[cannot-write] + iterable[ASYNC_ITERATOR] = (): $AsyncIterator => { + let nextReadIndex = 0; + return createIterator(arg => { + if (arg !== undefined) { + throw new Error( + 'Values cannot be passed to next() of AsyncIterables passed to Client Components.', + ); + } + if (nextReadIndex === buffer.length) { + if (closed) { + // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors + return new ReactPromise( + INITIALIZED, + {done: true, value: undefined}, + null, ); } - if (nextReadIndex === buffer.length) { - if (closed) { - // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new ReactPromise( - INITIALIZED, - {done: true, value: undefined}, - null, - response, - ); - } - buffer[nextReadIndex] = - createPendingChunk>(response); - } - return buffer[nextReadIndex++]; - }); - }, + buffer[nextReadIndex] = + createPendingChunk>(response); + } + return buffer[nextReadIndex++]; + }); }; + // TODO: If it's a single shot iterator we can optimize memory by cleaning up the buffer after // reading through the end, but currently we favor code size over this optimization. resolveStream( @@ -2122,6 +2529,7 @@ function resolveErrorDev( response, stack, env, + false, // $FlowFixMe[incompatible-use] Error.bind( null, @@ -2184,6 +2592,7 @@ function resolvePostponeDev( response, stack, env, + false, // $FlowFixMe[incompatible-use] Error.bind(null, reason || ''), ); @@ -2392,12 +2801,17 @@ function buildFakeCallStack( response: Response, stack: ReactStackTrace, environmentName: string, + useEnclosingLine: boolean, innerCall: () => T, ): () => T { let callStack = innerCall; for (let i = 0; i < stack.length; i++) { const frame = stack[i]; - const frameKey = frame.join('-') + '-' + environmentName; + const frameKey = + frame.join('-') + + '-' + + environmentName + + (useEnclosingLine ? '-e' : '-n'); let fn = fakeFunctionCache.get(frameKey); if (fn === undefined) { const [name, filename, line, col, enclosingLine, enclosingCol] = frame; @@ -2411,8 +2825,8 @@ function buildFakeCallStack( sourceMap, line, col, - enclosingLine, - enclosingCol, + useEnclosingLine ? line : enclosingLine, + useEnclosingLine ? col : enclosingCol, environmentName, ); // TODO: This cache should technically live on the response since the _debugFindSourceMapURL @@ -2447,55 +2861,61 @@ function getRootTask( function initializeFakeTask( response: Response, - debugInfo: ReactComponentInfo | ReactAsyncInfo, - childEnvironmentName: string, + debugInfo: ReactComponentInfo | ReactAsyncInfo | ReactIOInfo, ): null | ConsoleTask { if (!supportsCreateTask) { return null; } - const componentInfo: ReactComponentInfo = (debugInfo: any); // Refined if (debugInfo.stack == null) { // If this is an error, we should've really already initialized the task. // 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 + // to use the fake task for is the Performance tracking so we encode the enclosing line/ + // column at the callsite to get a better line number. We could do this for Components too + // but we're going to use those for other things too like console logs and it's not worth + // duplicating. If this bug is every fixed in Chrome, this should be set to false. + const useEnclosingLine = debugInfo.key === undefined; + const stack = debugInfo.stack; const env: string = - componentInfo.env == null + debugInfo.env == null ? response._rootEnvironmentName : debugInfo.env; + const ownerEnv: string = + debugInfo.owner == null || debugInfo.owner.env == null ? response._rootEnvironmentName - : componentInfo.env; - if (env !== childEnvironmentName) { + : 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 = - componentInfo.owner == null - ? null - : initializeFakeTask(response, componentInfo.owner, env); - return buildFakeTask( - response, - ownerTask, - stack, - '"use ' + childEnvironmentName.toLowerCase() + '"', - env, - ); - } else { - const cachedEntry = componentInfo.debugTask; - if (cachedEntry !== undefined) { - return cachedEntry; - } - const ownerTask = - componentInfo.owner == null - ? null - : initializeFakeTask(response, componentInfo.owner, env); - // $FlowFixMe[cannot-write]: We consider this part of initialization. - return (componentInfo.debugTask = buildFakeTask( - response, - ownerTask, - stack, - getServerComponentTaskName(componentInfo), - env, - )); - } + // 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, + ownerEnv, + useEnclosingLine, + )); } function buildFakeTask( @@ -2504,9 +2924,16 @@ function buildFakeTask( stack: ReactStackTrace, taskName: string, env: string, + useEnclosingLine: boolean, ): ConsoleTask { const createTaskFn = (console: any).createTask.bind(console, taskName); - const callStack = buildFakeCallStack(response, stack, env, createTaskFn); + const callStack = buildFakeCallStack( + response, + stack, + env, + useEnclosingLine, + createTaskFn, + ); if (ownerTask === null) { const rootTask = getRootTask(response, env); if (rootTask != null) { @@ -2520,7 +2947,7 @@ function buildFakeTask( } const createFakeJSXCallStack = { - 'react-stack-bottom-frame': function ( + react_stack_bottom_frame: function ( response: Response, stack: ReactStackTrace, environmentName: string, @@ -2529,6 +2956,7 @@ const createFakeJSXCallStack = { response, stack, environmentName, + false, fakeJSXCallSite, ); return callStackForError(); @@ -2541,7 +2969,7 @@ const createFakeJSXCallStackInDEV: ( environmentName: string, ) => Error = __DEV__ ? // We use this technique to trick minifiers to preserve the function name. - (createFakeJSXCallStack['react-stack-bottom-frame'].bind( + (createFakeJSXCallStack.react_stack_bottom_frame.bind( createFakeJSXCallStack, ): any) : (null: any); @@ -2555,7 +2983,7 @@ function fakeJSXCallSite() { function initializeFakeStack( response: Response, - debugInfo: ReactComponentInfo | ReactAsyncInfo, + debugInfo: ReactComponentInfo | ReactAsyncInfo | ReactIOInfo, ): void { const cachedEntry = debugInfo.debugStack; if (cachedEntry !== undefined) { @@ -2567,9 +2995,15 @@ function initializeFakeStack( // $FlowFixMe[cannot-write] debugInfo.debugStack = createFakeJSXCallStackInDEV(response, stack, env); } - if (debugInfo.owner != null) { + const owner = debugInfo.owner; + if (owner != null) { // Initialize any owners not yet initialized. - initializeFakeStack(response, debugInfo.owner); + initializeFakeStack(response, owner); + if (owner.debugLocation === undefined && debugInfo.debugStack != null) { + // If we are the child of this owner, then the owner should be the bottom frame + // our stack. We can use it as the implied location of the owner. + owner.debugLocation = debugInfo.debugStack; + } } } @@ -2589,26 +3023,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) { - // $FlowFixMe[prop-missing] By narrowing `owner` to `null`, we narrowed `debugInfo` to `ReactComponentInfo` - const componentInfo: ReactComponentInfo = debugInfo; + 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] - componentInfo.owner = response._debugRootOwner; + 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] - componentInfo.debugStack = response._debugRootStack; + componentInfoOrAsyncInfo.debugStack = response._debugRootStack; + // $FlowFixMe[cannot-write] + componentInfoOrAsyncInfo.debugTask = response._debugRootTask; } else if (debugInfo.stack !== undefined) { const componentInfoOrAsyncInfo: ReactComponentInfo | ReactAsyncInfo = // $FlowFixMe[incompatible-type] @@ -2645,7 +3083,7 @@ function getCurrentStackInDEV(): string { } const replayConsoleWithCallStack = { - 'react-stack-bottom-frame': function ( + react_stack_bottom_frame: function ( response: Response, methodName: string, stackTrace: ReactStackTrace, @@ -2664,10 +3102,11 @@ const replayConsoleWithCallStack = { response, stackTrace, env, + false, 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); @@ -2696,7 +3135,7 @@ const replayConsoleWithCallStackInDEV: ( args: Array, ) => void = __DEV__ ? // We use this technique to trick minifiers to preserve the function name. - (replayConsoleWithCallStack['react-stack-bottom-frame'].bind( + (replayConsoleWithCallStack.react_stack_bottom_frame.bind( replayConsoleWithCallStack, ): any) : (null: any); @@ -2740,6 +3179,75 @@ function resolveConsoleEntry( ); } +function initializeIOInfo(response: Response, ioInfo: ReactIOInfo): void { + if (ioInfo.stack !== undefined) { + initializeFakeTask(response, ioInfo); + initializeFakeStack(response, ioInfo); + } + // Adjust the time to the current environment's time space. + // $FlowFixMe[cannot-write] + ioInfo.start += response._timeOrigin; + // $FlowFixMe[cannot-write] + ioInfo.end += response._timeOrigin; + + if (response._replayConsole) { + const env = response._rootEnvironmentName; + const promise = ioInfo.value; + if (promise) { + const thenable: Thenable = (promise: any); + switch (thenable.status) { + case INITIALIZED: + logIOInfo(ioInfo, env, thenable.value); + break; + case ERRORED: + logIOInfoErrored(ioInfo, env, thenable.reason); + break; + default: + // If we haven't resolved the Promise yet, wait to log until have so we can include + // its data in the log. + promise.then( + logIOInfo.bind(null, ioInfo, env), + logIOInfoErrored.bind(null, ioInfo, env), + ); + break; + } + } else { + logIOInfo(ioInfo, env, undefined); + } + } +} + +function resolveIOInfo( + response: Response, + id: number, + model: UninitializedModel, +): void { + const chunks = response._chunks; + let chunk = chunks.get(id); + if (!chunk) { + chunk = createResolvedModelChunk(response, model); + chunks.set(id, chunk); + initializeModelChunk(chunk); + } else { + resolveModelChunk(response, chunk, model); + if (chunk.status === RESOLVED_MODEL) { + initializeModelChunk(chunk); + } + } + if (chunk.status === INITIALIZED) { + initializeIOInfo(response, chunk.value); + } else { + chunk.then( + v => { + initializeIOInfo(response, v); + }, + e => { + // Ignore debug info errors for now. Unnecessary noise. + }, + ); + } +} + function mergeBuffer( buffer: Array, lastChunk: Uint8Array, @@ -2792,6 +3300,46 @@ function resolveTypedArray( resolveBuffer(response, id, view); } +function logComponentInfo( + response: Response, + root: SomeChunk, + componentInfo: ReactComponentInfo, + trackIdx: number, + startTime: number, + componentEndTime: number, + childrenEndTime: number, + isLastComponent: boolean, +): void { + // $FlowFixMe: Refined. + if ( + isLastComponent && + root.status === ERRORED && + root.reason !== response._closedReason + ) { + // If this is the last component to render before this chunk rejected, then conceptually + // this component errored. If this was a cancellation then it wasn't this component that + // errored. + logComponentErrored( + componentInfo, + trackIdx, + startTime, + componentEndTime, + childrenEndTime, + response._rootEnvironmentName, + root.reason, + ); + } else { + logComponentRender( + componentInfo, + trackIdx, + startTime, + componentEndTime, + childrenEndTime, + response._rootEnvironmentName, + ); + } +} + function flushComponentPerformance( response: Response, root: SomeChunk, @@ -2828,6 +3376,7 @@ function flushComponentPerformance( trackIdx, parentEndTime, previousEndTime, + response._rootEnvironmentName, ); } // Since we didn't bump the track this time, we just return the same track. @@ -2844,23 +3393,22 @@ function flushComponentPerformance( // First find the start time of the first component to know if it was running // in parallel with the previous. - const debugInfo = root._debugInfo; + const debugInfo = __DEV__ && root._debugInfo; if (debugInfo) { - for (let i = 1; i < debugInfo.length; i++) { + let startTime = 0; + for (let i = 0; i < debugInfo.length; i++) { const info = debugInfo[i]; + if (typeof info.time === 'number') { + startTime = info.time; + } if (typeof info.name === 'string') { - // $FlowFixMe: Refined. - const startTimeInfo = debugInfo[i - 1]; - if (typeof startTimeInfo.time === 'number') { - const startTime = startTimeInfo.time; - if (startTime < trackTime) { - // The start time of this component is before the end time of the previous - // component on this track so we need to bump the next one to a parallel track. - trackIdx++; - } - trackTime = startTime; - break; + if (startTime < trackTime) { + // The start time of this component is before the end time of the previous + // component on this track so we need to bump the next one to a parallel track. + trackIdx++; } + trackTime = startTime; + break; } } for (let i = debugInfo.length - 1; i >= 0; i--) { @@ -2868,6 +3416,7 @@ function flushComponentPerformance( if (typeof info.time === 'number') { if (info.time > parentEndTime) { parentEndTime = info.time; + break; // We assume the highest number is at the end. } } } @@ -2895,61 +3444,152 @@ function flushComponentPerformance( } childTrackIdx = childResult.track; const childEndTime = childResult.endTime; - childTrackTime = childEndTime; + if (childEndTime > childTrackTime) { + childTrackTime = childEndTime; + } if (childEndTime > childrenEndTime) { childrenEndTime = childEndTime; } } if (debugInfo) { - let endTime = 0; + // Write debug info in reverse order (just like stack traces). + let componentEndTime = 0; let isLastComponent = true; + let endTime = -1; + let endTimeIdx = -1; for (let i = debugInfo.length - 1; i >= 0; i--) { const info = debugInfo[i]; - if (typeof info.time === 'number') { - endTime = info.time; - if (endTime > childrenEndTime) { - childrenEndTime = endTime; - } + if (typeof info.time !== 'number') { + continue; } - if (typeof info.name === 'string' && i > 0) { - // $FlowFixMe: Refined. - const componentInfo: ReactComponentInfo = info; - const startTimeInfo = debugInfo[i - 1]; - if (typeof startTimeInfo.time === 'number') { - const startTime = startTimeInfo.time; - if ( - isLastComponent && - root.status === ERRORED && - root.reason !== response._closedReason - ) { - // If this is the last component to render before this chunk rejected, then conceptually - // this component errored. If this was a cancellation then it wasn't this component that - // errored. - logComponentErrored( + if (componentEndTime === 0) { + // Last timestamp is the end of the last component. + componentEndTime = info.time; + } + const time = info.time; + if (endTimeIdx > -1) { + // Now that we know the start and end time, we can emit the entries between. + for (let j = endTimeIdx - 1; j > i; j--) { + const candidateInfo = debugInfo[j]; + if (typeof candidateInfo.name === 'string') { + if (componentEndTime > childrenEndTime) { + childrenEndTime = componentEndTime; + } + // $FlowFixMe: Refined. + const componentInfo: ReactComponentInfo = candidateInfo; + logComponentInfo( + response, + root, componentInfo, trackIdx, - startTime, - endTime, + time, + componentEndTime, childrenEndTime, - response._rootEnvironmentName, - root.reason, + isLastComponent, ); - } else { - logComponentRender( + componentEndTime = time; // The end time of previous component is the start time of the next. + // Track the root most component of the result for deduping logging. + result.component = componentInfo; + isLastComponent = false; + } else if (candidateInfo.awaited) { + if (endTime > childrenEndTime) { + childrenEndTime = endTime; + } + // $FlowFixMe: Refined. + const asyncInfo: ReactAsyncInfo = candidateInfo; + const env = response._rootEnvironmentName; + const promise = asyncInfo.awaited.value; + if (promise) { + const thenable: Thenable = (promise: any); + switch (thenable.status) { + case INITIALIZED: + logComponentAwait( + asyncInfo, + trackIdx, + time, + endTime, + env, + thenable.value, + ); + break; + case ERRORED: + logComponentAwaitErrored( + asyncInfo, + trackIdx, + time, + endTime, + env, + thenable.reason, + ); + break; + default: + // We assume that we should have received the data by now since this is logged at the + // end of the response stream. This is more sensitive to ordering so we don't wait + // to log it. + logComponentAwait( + asyncInfo, + trackIdx, + time, + endTime, + env, + undefined, + ); + break; + } + } else { + logComponentAwait( + asyncInfo, + trackIdx, + time, + endTime, + env, + undefined, + ); + } + } + } + } else { + // Anything between the end and now was aborted if it has no end time. + // Either because the client stream was aborted reading it or the server stream aborted. + endTime = time; // If we don't find anything else the endTime is the start time. + for (let j = debugInfo.length - 1; j > i; j--) { + const candidateInfo = debugInfo[j]; + if (typeof candidateInfo.name === 'string') { + if (componentEndTime > childrenEndTime) { + childrenEndTime = componentEndTime; + } + // $FlowFixMe: Refined. + const componentInfo: ReactComponentInfo = candidateInfo; + const env = response._rootEnvironmentName; + logComponentAborted( componentInfo, trackIdx, - startTime, - endTime, + time, + componentEndTime, childrenEndTime, - response._rootEnvironmentName, + env, ); + componentEndTime = time; // The end time of previous component is the start time of the next. + // Track the root most component of the result for deduping logging. + result.component = componentInfo; + isLastComponent = false; + } else if (candidateInfo.awaited) { + // If we don't have an end time for an await, that means we aborted. + const asyncInfo: ReactAsyncInfo = candidateInfo; + const env = response._rootEnvironmentName; + if (asyncInfo.awaited.end > endTime) { + endTime = asyncInfo.awaited.end; // Take the end time of the I/O as the await end. + } + if (endTime > childrenEndTime) { + childrenEndTime = endTime; + } + logComponentAwaitAborted(asyncInfo, trackIdx, time, endTime, env); } - // Track the root most component of the result for deduping logging. - result.component = componentInfo; } - isLastComponent = false; } + endTime = time; // The end time of the next entry is this time. + endTimeIdx = i; } } result.endTime = childrenEndTime; @@ -3101,6 +3741,17 @@ function processFullStringRow( } // Fallthrough to share the error with Console entries. } + case 74 /* "J" */: { + if ( + enableProfilerTimer && + enableComponentPerformanceTrack && + enableAsyncDebugInfo + ) { + resolveIOInfo(response, id, row); + return; + } + // Fallthrough to share the error with Console entries. + } case 87 /* "W" */: { if (__DEV__) { resolveConsoleEntry(response, row); @@ -3156,6 +3807,10 @@ function processFullStringRow( } // Fallthrough default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ { + if (__DEV__ && row === '') { + resolveDebugHalt(response, id); + return; + } // We assume anything else is JSON. resolveModel(response, id, row); return; diff --git a/packages/react-client/src/ReactFlightPerformanceTrack.js b/packages/react-client/src/ReactFlightPerformanceTrack.js index ed0f67a4f313e..8022a5ad7768f 100644 --- a/packages/react-client/src/ReactFlightPerformanceTrack.js +++ b/packages/react-client/src/ReactFlightPerformanceTrack.js @@ -9,15 +9,28 @@ /* eslint-disable react-internal/no-production-logging */ -import type {ReactComponentInfo} from 'shared/ReactTypes'; +import type { + ReactComponentInfo, + ReactIOInfo, + ReactAsyncInfo, +} from 'shared/ReactTypes'; import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; +import { + addValueToProperties, + addObjectToProperties, +} from 'shared/ReactPerformanceTrackProperties'; + const supportsUserTiming = enableProfilerTimer && typeof console !== 'undefined' && - typeof console.timeStamp === 'function'; + typeof console.timeStamp === 'function' && + typeof performance !== 'undefined' && + // $FlowFixMe[method-unbinding] + typeof performance.measure === 'function'; +const IO_TRACK = 'Server Requests ⚛'; const COMPONENTS_TRACK = 'Server Components ⚛'; export function markAllTracksInOrder() { @@ -25,6 +38,14 @@ export function markAllTracksInOrder() { // Ensure we create the Server Component track groups earlier than the Client Scheduler // and Client Components. We can always add the 0 time slot even if it's in the past. // That's still considered for ordering. + console.timeStamp( + 'Server Requests Track', + 0.001, + 0.001, + IO_TRACK, + undefined, + 'primary-light', + ); console.timeStamp( 'Server Components Track', 0.001, @@ -80,17 +101,27 @@ export function logComponentRender( isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']'; const debugTask = componentInfo.debugTask; if (__DEV__ && debugTask) { + const properties: Array<[string, string]> = []; + if (componentInfo.key != null) { + addValueToProperties('key', componentInfo.key, properties, 0, ''); + } + if (componentInfo.props != null) { + addObjectToProperties(componentInfo.props, properties, 0, ''); + } debugTask.run( // $FlowFixMe[method-unbinding] - console.timeStamp.bind( - console, - entryName, - startTime < 0 ? 0 : startTime, - childrenEndTime, - trackNames[trackIdx], - COMPONENTS_TRACK, - color, - ), + performance.measure.bind(performance, entryName, { + start: startTime < 0 ? 0 : startTime, + end: childrenEndTime, + detail: { + devtools: { + color: color, + track: trackNames[trackIdx], + trackGroup: COMPONENTS_TRACK, + properties, + }, + }, + }), ); } else { console.timeStamp( @@ -105,6 +136,59 @@ export function logComponentRender( } } +export function logComponentAborted( + componentInfo: ReactComponentInfo, + trackIdx: number, + startTime: number, + endTime: number, + childrenEndTime: number, + rootEnv: string, +): void { + if (supportsUserTiming) { + const env = componentInfo.env; + const name = componentInfo.name; + const isPrimaryEnv = env === rootEnv; + const entryName = + isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']'; + if (__DEV__) { + const properties = [ + [ + 'Aborted', + 'The stream was aborted before this Component finished rendering.', + ], + ]; + if (componentInfo.key != null) { + addValueToProperties('key', componentInfo.key, properties, 0, ''); + } + if (componentInfo.props != null) { + addObjectToProperties(componentInfo.props, properties, 0, ''); + } + performance.measure(entryName, { + start: startTime < 0 ? 0 : startTime, + end: childrenEndTime, + detail: { + devtools: { + color: 'warning', + track: trackNames[trackIdx], + trackGroup: COMPONENTS_TRACK, + tooltipText: entryName + ' Aborted', + properties, + }, + }, + }); + } else { + console.timeStamp( + entryName, + startTime < 0 ? 0 : startTime, + childrenEndTime, + trackNames[trackIdx], + COMPONENTS_TRACK, + 'warning', + ); + } + } +} + export function logComponentErrored( componentInfo: ReactComponentInfo, trackIdx: number, @@ -120,12 +204,7 @@ export function logComponentErrored( const isPrimaryEnv = env === rootEnv; const entryName = isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']'; - if ( - __DEV__ && - typeof performance !== 'undefined' && - // $FlowFixMe[method-unbinding] - typeof performance.measure === 'function' - ) { + if (__DEV__) { const message = typeof error === 'object' && error !== null && @@ -135,6 +214,12 @@ export function logComponentErrored( : // eslint-disable-next-line react-internal/safe-string-coercion String(error); const properties = [['Error', message]]; + if (componentInfo.key != null) { + addValueToProperties('key', componentInfo.key, properties, 0, ''); + } + if (componentInfo.props != null) { + addObjectToProperties(componentInfo.props, properties, 0, ''); + } performance.measure(entryName, { start: startTime < 0 ? 0 : startTime, end: childrenEndTime, @@ -166,9 +251,13 @@ export function logDedupedComponentRender( trackIdx: number, startTime: number, endTime: number, + rootEnv: string, ): void { if (supportsUserTiming && endTime >= 0 && trackIdx < 10) { + const env = componentInfo.env; const name = componentInfo.name; + const isPrimaryEnv = env === rootEnv; + const color = isPrimaryEnv ? 'primary-light' : 'secondary-light'; const entryName = name + ' [deduped]'; const debugTask = componentInfo.debugTask; if (__DEV__ && debugTask) { @@ -181,7 +270,7 @@ export function logDedupedComponentRender( endTime, trackNames[trackIdx], COMPONENTS_TRACK, - 'tertiary-light', + color, ), ); } else { @@ -191,7 +280,408 @@ export function logDedupedComponentRender( endTime, trackNames[trackIdx], COMPONENTS_TRACK, - 'tertiary-light', + color, + ); + } + } +} + +function getIOColor( + functionName: string, +): 'tertiary-light' | 'tertiary' | 'tertiary-dark' { + // Add some color variation to be able to distinguish various sources. + switch (functionName.charCodeAt(0) % 3) { + case 0: + return 'tertiary-light'; + case 1: + return 'tertiary'; + default: + return 'tertiary-dark'; + } +} + +function getIODescription(value: any): string { + if (!__DEV__) { + return ''; + } + try { + switch (typeof value) { + case 'object': + // Test the object for a bunch of common property names that are useful identifiers. + // While we only have the return value here, it should ideally be a name that + // describes the arguments requested. + if (value === null) { + return ''; + } else if (value instanceof Error) { + // eslint-disable-next-line react-internal/safe-string-coercion + return String(value.message); + } else if (typeof value.url === 'string') { + return value.url; + } else if (typeof value.command === 'string') { + return value.command; + } else if ( + typeof value.request === 'object' && + typeof value.request.url === 'string' + ) { + return value.request.url; + } else if ( + typeof value.response === 'object' && + typeof value.response.url === 'string' + ) { + return value.response.url; + } else if ( + typeof value.id === 'string' || + typeof value.id === 'number' || + typeof value.id === 'bigint' + ) { + // eslint-disable-next-line react-internal/safe-string-coercion + return String(value.id); + } else if (typeof value.name === 'string') { + return value.name; + } else { + const str = value.toString(); + if (str.startWith('[object ') || str.length < 5 || str.length > 500) { + // This is probably not a useful description. + return ''; + } + return str; + } + case 'string': + if (value.length < 5 || value.length > 500) { + return ''; + } + return value; + case 'number': + case 'bigint': + // eslint-disable-next-line react-internal/safe-string-coercion + return String(value); + default: + // Not useful descriptors. + return ''; + } + } catch (x) { + return ''; + } +} + +function getIOLongName( + ioInfo: ReactIOInfo, + description: string, + env: void | string, + rootEnv: string, +): string { + const name = ioInfo.name; + const longName = description === '' ? name : name + ' (' + description + ')'; + const isPrimaryEnv = env === rootEnv; + return isPrimaryEnv || env === undefined + ? longName + : longName + ' [' + env + ']'; +} + +function getIOShortName( + ioInfo: ReactIOInfo, + description: string, + env: void | string, + rootEnv: string, +): string { + const name = ioInfo.name; + const isPrimaryEnv = env === rootEnv; + const envSuffix = isPrimaryEnv || env === undefined ? '' : ' [' + env + ']'; + let desc = ''; + const descMaxLength = 30 - name.length - envSuffix.length; + if (descMaxLength > 1) { + const l = description.length; + if (l > 0 && l <= descMaxLength) { + // We can fit the full description + desc = ' (' + description + ')'; + } else if ( + description.startsWith('http://') || + description.startsWith('https://') || + description.startsWith('/') + ) { + // Looks like a URL. Let's see if we can extract something shorter. + // We don't have to do a full parse so let's try something cheaper. + let queryIdx = description.indexOf('?'); + if (queryIdx === -1) { + queryIdx = description.length; + } + if (description.charCodeAt(queryIdx - 1) === 47 /* "/" */) { + // Ends with slash. Look before that. + queryIdx--; + } + const slashIdx = description.lastIndexOf('/', queryIdx - 1); + if (queryIdx - slashIdx < descMaxLength) { + // This may now be either the file name or the host. + desc = ' (' + description.slice(slashIdx + 1, queryIdx) + ')'; + } + } + } + return name + desc + envSuffix; +} + +export function logComponentAwaitAborted( + asyncInfo: ReactAsyncInfo, + trackIdx: number, + startTime: number, + endTime: number, + rootEnv: string, +): void { + if (supportsUserTiming && endTime > 0) { + const entryName = + 'await ' + getIOShortName(asyncInfo.awaited, '', asyncInfo.env, rootEnv); + const debugTask = asyncInfo.debugTask || asyncInfo.awaited.debugTask; + if (__DEV__ && debugTask) { + const properties = [ + ['Aborted', 'The stream was aborted before this Promise resolved.'], + ]; + const tooltipText = + getIOLongName(asyncInfo.awaited, '', asyncInfo.env, rootEnv) + + ' Aborted'; + debugTask.run( + // $FlowFixMe[method-unbinding] + performance.measure.bind(performance, entryName, { + start: startTime < 0 ? 0 : startTime, + end: endTime, + detail: { + devtools: { + color: 'warning', + track: trackNames[trackIdx], + trackGroup: COMPONENTS_TRACK, + properties, + tooltipText, + }, + }, + }), + ); + } else { + console.timeStamp( + entryName, + startTime < 0 ? 0 : startTime, + endTime, + trackNames[trackIdx], + COMPONENTS_TRACK, + 'warning', + ); + } + } +} + +export function logComponentAwaitErrored( + asyncInfo: ReactAsyncInfo, + trackIdx: number, + startTime: number, + endTime: number, + rootEnv: string, + error: mixed, +): void { + if (supportsUserTiming && endTime > 0) { + const description = getIODescription(error); + const entryName = + 'await ' + + getIOShortName(asyncInfo.awaited, description, asyncInfo.env, rootEnv); + const debugTask = asyncInfo.debugTask || asyncInfo.awaited.debugTask; + if (__DEV__ && debugTask) { + const message = + typeof error === 'object' && + error !== null && + typeof error.message === 'string' + ? // eslint-disable-next-line react-internal/safe-string-coercion + String(error.message) + : // eslint-disable-next-line react-internal/safe-string-coercion + String(error); + const properties = [['Rejected', message]]; + const tooltipText = + getIOLongName(asyncInfo.awaited, description, asyncInfo.env, rootEnv) + + ' Rejected'; + debugTask.run( + // $FlowFixMe[method-unbinding] + performance.measure.bind(performance, entryName, { + start: startTime < 0 ? 0 : startTime, + end: endTime, + detail: { + devtools: { + color: 'error', + track: trackNames[trackIdx], + trackGroup: COMPONENTS_TRACK, + properties, + tooltipText, + }, + }, + }), + ); + } else { + console.timeStamp( + entryName, + startTime < 0 ? 0 : startTime, + endTime, + trackNames[trackIdx], + COMPONENTS_TRACK, + 'error', + ); + } + } +} + +export function logComponentAwait( + asyncInfo: ReactAsyncInfo, + trackIdx: number, + startTime: number, + endTime: number, + rootEnv: string, + value: mixed, +): void { + if (supportsUserTiming && endTime > 0) { + const description = getIODescription(value); + const name = getIOShortName( + asyncInfo.awaited, + description, + asyncInfo.env, + rootEnv, + ); + const entryName = 'await ' + name; + const color = getIOColor(name); + const debugTask = asyncInfo.debugTask || asyncInfo.awaited.debugTask; + if (__DEV__ && debugTask) { + const properties: Array<[string, string]> = []; + if (typeof value === 'object' && value !== null) { + addObjectToProperties(value, properties, 0, ''); + } else if (value !== undefined) { + addValueToProperties('Resolved', value, properties, 0, ''); + } + const tooltipText = getIOLongName( + asyncInfo.awaited, + description, + asyncInfo.env, + rootEnv, + ); + debugTask.run( + // $FlowFixMe[method-unbinding] + performance.measure.bind(performance, entryName, { + start: startTime < 0 ? 0 : startTime, + end: endTime, + detail: { + devtools: { + color: color, + track: trackNames[trackIdx], + trackGroup: COMPONENTS_TRACK, + properties, + tooltipText, + }, + }, + }), + ); + } else { + console.timeStamp( + entryName, + startTime < 0 ? 0 : startTime, + endTime, + trackNames[trackIdx], + COMPONENTS_TRACK, + color, + ); + } + } +} + +export function logIOInfoErrored( + ioInfo: ReactIOInfo, + rootEnv: string, + error: mixed, +): void { + const startTime = ioInfo.start; + const endTime = ioInfo.end; + if (supportsUserTiming && endTime >= 0) { + const description = getIODescription(error); + const entryName = getIOShortName(ioInfo, description, ioInfo.env, rootEnv); + const debugTask = ioInfo.debugTask; + if (__DEV__ && debugTask) { + const message = + typeof error === 'object' && + error !== null && + typeof error.message === 'string' + ? // eslint-disable-next-line react-internal/safe-string-coercion + String(error.message) + : // eslint-disable-next-line react-internal/safe-string-coercion + String(error); + const properties = [['Rejected', message]]; + const tooltipText = + getIOLongName(ioInfo, description, ioInfo.env, rootEnv) + ' Rejected'; + debugTask.run( + // $FlowFixMe[method-unbinding] + performance.measure.bind(performance, entryName, { + start: startTime < 0 ? 0 : startTime, + end: endTime, + detail: { + devtools: { + color: 'error', + track: IO_TRACK, + properties, + tooltipText, + }, + }, + }), + ); + } else { + console.timeStamp( + entryName, + startTime < 0 ? 0 : startTime, + endTime, + IO_TRACK, + undefined, + 'error', + ); + } + } +} + +export function logIOInfo( + ioInfo: ReactIOInfo, + rootEnv: string, + value: mixed, +): void { + const startTime = ioInfo.start; + const endTime = ioInfo.end; + if (supportsUserTiming && endTime >= 0) { + const description = getIODescription(value); + const entryName = getIOShortName(ioInfo, description, ioInfo.env, rootEnv); + const color = getIOColor(entryName); + const debugTask = ioInfo.debugTask; + if (__DEV__ && debugTask) { + const properties: Array<[string, string]> = []; + if (typeof value === 'object' && value !== null) { + addObjectToProperties(value, properties, 0, ''); + } else if (value !== undefined) { + addValueToProperties('Resolved', value, properties, 0, ''); + } + const tooltipText = getIOLongName( + ioInfo, + description, + ioInfo.env, + rootEnv, + ); + debugTask.run( + // $FlowFixMe[method-unbinding] + performance.measure.bind(performance, entryName, { + start: startTime < 0 ? 0 : startTime, + end: endTime, + detail: { + devtools: { + color: color, + track: IO_TRACK, + properties, + tooltipText, + }, + }, + }), + ); + } else { + console.timeStamp( + entryName, + startTime < 0 ? 0 : startTime, + endTime, + IO_TRACK, + undefined, + color, ); } } diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 6a0a37b787d34..d22a894b5157c 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -18,13 +18,10 @@ import type { import type {LazyComponent} from 'react/src/ReactLazy'; import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences'; -import {enableRenderableContext} from 'shared/ReactFeatureFlags'; - import { REACT_ELEMENT_TYPE, REACT_LAZY_TYPE, REACT_CONTEXT_TYPE, - REACT_PROVIDER_TYPE, getIteratorFn, ASYNC_ITERATOR, } from 'shared/ReactSymbols'; @@ -699,10 +696,7 @@ export function processReply( return serializeTemporaryReferenceMarker(); } if (__DEV__) { - if ( - (value: any).$$typeof === - (enableRenderableContext ? REACT_CONTEXT_TYPE : REACT_PROVIDER_TYPE) - ) { + if ((value: any).$$typeof === REACT_CONTEXT_TYPE) { console.error( 'React Context Providers cannot be passed to Server Functions from the Client.%s', describeObjectForErrorMessage(parent, key), @@ -1112,7 +1106,7 @@ function createFakeServerFunction, T>( '\n//# sourceURL=rsc://React/' + encodeURIComponent(environmentName) + '/' + - filename + + encodeURI(filename) + '?s' + // We add an extra s here to distinguish from the fake stack frames fakeServerFunctionIdx++; code += '\n//# sourceMappingURL=' + sourceMap; diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index b954f32ecd674..d79977beec0d5 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -69,7 +69,7 @@ function getErrorForJestMatcher(error) { function normalizeComponentInfo(debugInfo) { if (Array.isArray(debugInfo.stack)) { - const {debugTask, debugStack, ...copy} = debugInfo; + const {debugTask, debugStack, debugLocation, ...copy} = debugInfo; copy.stack = formatV8Stack(debugInfo.stack); if (debugInfo.owner) { copy.owner = normalizeComponentInfo(debugInfo.owner); @@ -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', @@ -1308,6 +1306,9 @@ describe('ReactFlight', () => { ' at file:///testing.js:42:3', // async anon function (https://github.com/ChromeDevTools/devtools-frontend/blob/831be28facb4e85de5ee8c1acc4d98dfeda7a73b/test/unittests/front_end/panels/console/ErrorStackParser_test.ts#L130C9-L130C41) ' at async file:///testing.js:42:3', + // third-party RSC frame + // Ideally this would be a real frame produced by React not a mocked one. + ' at ThirdParty (rsc://React/ThirdParty/file:///code/%5Broot%2520of%2520the%2520server%5D.js?42:1:1)', // host component in parent stack ' at div ()', ...originalStackLines.slice(2), @@ -1362,7 +1363,10 @@ describe('ReactFlight', () => { return functionName === 'div'; } return ( - !filename.startsWith('node:') && !filename.includes('node_modules') + !filename.startsWith('node:') && + !filename.includes('node_modules') && + // sourceURL from an ES module in `/code/[root of the server].js` + filename !== 'file:///code/[root%20of%20the%20server].js' ); }, }); @@ -1957,8 +1961,8 @@ describe('ReactFlight', () => { }); expect(ReactNoop).toMatchRenderedOutput( <> -
-
+
+
, ); }); @@ -1981,8 +1985,8 @@ describe('ReactFlight', () => { }); expect(ReactNoop).toMatchRenderedOutput( <> -
-
+
+
, ); }); @@ -2021,8 +2025,8 @@ describe('ReactFlight', () => { assertLog(['ClientDoubler']); expect(ReactNoop).toMatchRenderedOutput( <> -
«S1»
-
«S1»
+
_S_1_
+
_S_1_
, ); }); @@ -2812,7 +2816,6 @@ describe('ReactFlight', () => { name: 'ServerComponent', env: 'Server', key: null, - owner: null, stack: ' in Object. (at **)', props: { transport: expect.arrayContaining([]), @@ -2829,16 +2832,15 @@ describe('ReactFlight', () => { expect(getDebugInfo(thirdPartyChildren[0])).toEqual( __DEV__ ? [ - {time: 14}, + {time: 22}, // Clamped to the start { name: 'ThirdPartyComponent', env: 'third-party', key: null, - owner: null, stack: ' in Object. (at **)', props: {}, }, - {time: 15}, + {time: 22}, {time: 23}, // This last one is when the promise resolved into the first party. ] : undefined, @@ -2846,32 +2848,30 @@ describe('ReactFlight', () => { expect(getDebugInfo(thirdPartyChildren[1])).toEqual( __DEV__ ? [ - {time: 16}, + {time: 22}, // Clamped to the start { name: 'ThirdPartyLazyComponent', env: 'third-party', key: null, - owner: null, 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', key: '3', - owner: null, stack: ' in Object. (at **)', props: {}, }, - {time: 13}, + {time: 22}, ] : undefined, ); @@ -2941,7 +2941,6 @@ describe('ReactFlight', () => { name: 'ServerComponent', env: 'Server', key: null, - owner: null, stack: ' in Object. (at **)', props: { transport: expect.arrayContaining([]), @@ -2961,7 +2960,6 @@ describe('ReactFlight', () => { name: 'Keyed', env: 'Server', key: 'keyed', - owner: null, stack: ' in ServerComponent (at **)', props: { children: {}, @@ -2975,16 +2973,15 @@ describe('ReactFlight', () => { expect(getDebugInfo(thirdPartyFragment.props.children)).toEqual( __DEV__ ? [ - {time: 12}, + {time: 19}, // Clamp to the start { name: 'ThirdPartyAsyncIterableComponent', env: 'third-party', key: null, - owner: null, stack: ' in Object. (at **)', props: {}, }, - {time: 13}, + {time: 19}, ] : undefined, ); @@ -3000,6 +2997,64 @@ describe('ReactFlight', () => { ); }); + // @gate !__DEV__ || enableComponentPerformanceTrack + it('preserves debug info for server-to-server through use()', async () => { + function ThirdPartyComponent() { + return 'hi'; + } + + function ServerComponent({transport}) { + // This is a Server Component that receives other Server Components from a third party. + const text = ReactServer.use(ReactNoopFlightClient.read(transport)); + return
{text.toUpperCase()}
; + } + + const thirdPartyTransport = ReactNoopFlightServer.render( + , + { + environmentName: 'third-party', + }, + ); + + const transport = ReactNoopFlightServer.render( + , + ); + + await act(async () => { + const promise = ReactNoopFlightClient.read(transport); + expect(getDebugInfo(promise)).toEqual( + __DEV__ + ? [ + {time: 16}, + { + name: 'ServerComponent', + env: 'Server', + key: null, + stack: ' in Object. (at **)', + props: { + transport: expect.arrayContaining([]), + }, + }, + {time: 16}, + { + name: 'ThirdPartyComponent', + env: 'third-party', + key: null, + stack: ' in Object. (at **)', + props: {}, + }, + {time: 16}, + {time: 17}, + ] + : undefined, + ); + const result = await promise; + ReactNoop.render(result); + }); + + expect(ReactNoop).toMatchRenderedOutput(
HI
); + }); + it('preserves error stacks passed through server-to-server with source maps', async () => { async function ServerComponent({transport}) { // This is a Server Component that receives other Server Components from a third party. @@ -3137,7 +3192,6 @@ describe('ReactFlight', () => { name: 'Component', env: 'A', key: null, - owner: null, stack: ' in Object. (at **)', props: {}, }, @@ -3160,12 +3214,29 @@ describe('ReactFlight', () => { return 'hello'; } + class MyClass { + constructor() { + this.x = 1; + } + method() {} + get y() { + return this.x + 1; + } + get z() { + return this.x + 5; + } + } + Object.defineProperty(MyClass.prototype, 'y', {enumerable: true}); + function ServerComponent() { console.log('hi', { prop: 123, fn: foo, map: new Map([['foo', foo]]), - promise: new Promise(() => {}), + promise: Promise.resolve('yo'), + infinitePromise: new Promise(() => {}), + Class: MyClass, + instance: new MyClass(), }); throw new Error('err'); } @@ -3210,9 +3281,14 @@ describe('ReactFlight', () => { }); ownerStacks = []; + // Let the Promises resolve. + await 0; + await 0; + await 0; + // The error should not actually get logged because we're not awaiting the root // so it's not thrown but the server log also shouldn't be replayed. - await ReactNoopFlightClient.read(transport); + await ReactNoopFlightClient.read(transport, {close: true}); expect(mockConsoleLog).toHaveBeenCalledTimes(1); expect(mockConsoleLog.mock.calls[0][0]).toBe('hi'); @@ -3228,9 +3304,40 @@ describe('ReactFlight', () => { expect(typeof loggedFn2).toBe('function'); expect(loggedFn2).not.toBe(foo); expect(loggedFn2.toString()).toBe(foo.toString()); + expect(loggedFn2).toBe(loggedFn); const promise = mockConsoleLog.mock.calls[0][1].promise; expect(promise).toBeInstanceOf(Promise); + expect(await promise).toBe('yo'); + + const infinitePromise = mockConsoleLog.mock.calls[0][1].infinitePromise; + expect(infinitePromise).toBeInstanceOf(Promise); + let resolved = false; + infinitePromise.then( + () => (resolved = true), + x => { + console.error(x); + resolved = true; + }, + ); + await 0; + await 0; + await 0; + // This should not reject upon aborting the stream. + expect(resolved).toBe(false); + + const Class = mockConsoleLog.mock.calls[0][1].Class; + const instance = mockConsoleLog.mock.calls[0][1].instance; + expect(typeof Class).toBe('function'); + expect(Class.prototype.constructor).toBe(Class); + expect(instance instanceof Class).toBe(true); + expect(Object.getPrototypeOf(instance)).toBe(Class.prototype); + expect(instance.x).toBe(1); + expect(instance.hasOwnProperty('y')).toBe(true); + expect(instance.y).toBe(2); // Enumerable getter was reified + expect(instance.hasOwnProperty('z')).toBe(false); + expect(instance.z).toBe(6); // Not enumerable getter was transferred as part of the toString() of the class + expect(typeof instance.method).toBe('function'); // Methods are included only if they're part of the toString() expect(ownerStacks).toEqual(['\n in App (at **)']); }); @@ -3279,19 +3386,10 @@ describe('ReactFlight', () => { await ReactNoopFlightClient.read(transport); expect(mockConsoleLog).toHaveBeenCalledTimes(1); - // TODO: Support cyclic objects in console encoding. - // expect(mockConsoleLog.mock.calls[0][0]).toBe('hi'); - // const cyclic2 = mockConsoleLog.mock.calls[0][1].cyclic; - // expect(cyclic2).not.toBe(cyclic); // Was serialized and therefore cloned - // expect(cyclic2.cycle).toBe(cyclic2); - expect(mockConsoleLog.mock.calls[0][0]).toBe( - 'Unknown Value: React could not send it from the server.', - ); - expect(mockConsoleLog.mock.calls[0][1].message).toBe( - 'Converting circular structure to JSON\n' + - " --> starting at object with constructor 'Object'\n" + - " --- property 'cycle' closes the circle", - ); + expect(mockConsoleLog.mock.calls[0][0]).toBe('hi'); + const cyclic2 = mockConsoleLog.mock.calls[0][1].cyclic; + expect(cyclic2).not.toBe(cyclic); // Was serialized and therefore cloned + expect(cyclic2.cycle).toBe(cyclic2); }); // @gate !__DEV__ || enableComponentPerformanceTrack @@ -3325,7 +3423,6 @@ describe('ReactFlight', () => { name: 'Greeting', env: 'Server', key: null, - owner: null, stack: ' in Object. (at **)', props: { firstName: 'Seb', diff --git a/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js b/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js new file mode 100644 index 0000000000000..e9428c3ba4074 --- /dev/null +++ b/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js @@ -0,0 +1,139 @@ +/** + * 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'; + +if (typeof Blob === 'undefined') { + global.Blob = require('buffer').Blob; +} +if (typeof File === 'undefined' || typeof FormData === 'undefined') { + global.File = require('undici').File; + global.FormData = require('undici').FormData; +} + +function formatV8Stack(stack) { + let v8StyleStack = ''; + if (stack) { + for (let i = 0; i < stack.length; i++) { + const [name] = stack[i]; + if (v8StyleStack !== '') { + v8StyleStack += '\n'; + } + v8StyleStack += ' in ' + name + ' (at **)'; + } + } + return v8StyleStack; +} + +function normalizeComponentInfo(debugInfo) { + if (Array.isArray(debugInfo.stack)) { + const {debugTask, debugStack, ...copy} = debugInfo; + copy.stack = formatV8Stack(debugInfo.stack); + if (debugInfo.owner) { + copy.owner = normalizeComponentInfo(debugInfo.owner); + } + return copy; + } else { + return debugInfo; + } +} + +function getDebugInfo(obj) { + const debugInfo = obj._debugInfo; + if (debugInfo) { + const copy = []; + for (let i = 0; i < debugInfo.length; i++) { + copy.push(normalizeComponentInfo(debugInfo[i])); + } + return copy; + } + return debugInfo; +} + +let act; +let React; +let ReactNoop; +let ReactNoopFlightServer; +let ReactNoopFlightClient; + +describe('ReactFlight', () => { + beforeEach(() => { + // Mock performance.now for timing tests + let time = 10; + const now = jest.fn().mockImplementation(() => { + return time++; + }); + Object.defineProperty(performance, 'timeOrigin', { + value: time, + configurable: true, + }); + Object.defineProperty(performance, 'now', { + value: now, + configurable: true, + }); + + jest.resetModules(); + jest.mock('react', () => require('react/react.react-server')); + ReactNoopFlightServer = require('react-noop-renderer/flight-server'); + // This stores the state so we need to preserve it + const flightModules = require('react-noop-renderer/flight-modules'); + jest.resetModules(); + __unmockReact(); + jest.mock('react-noop-renderer/flight-modules', () => flightModules); + React = require('react'); + ReactNoop = require('react-noop-renderer'); + ReactNoopFlightClient = require('react-noop-renderer/flight-client'); + act = require('internal-test-utils').act; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // @gate __DEV__ && enableComponentPerformanceTrack + it('can render deep but cut off JSX in debug info', async () => { + function createDeepJSX(n) { + if (n <= 0) { + return null; + } + return
{createDeepJSX(n - 1)}
; + } + + function ServerComponent(props) { + return
not using props
; + } + + const debugChannel = {onMessage(message) {}}; + + const transport = ReactNoopFlightServer.render( + { + root: ( + + {createDeepJSX(100) /* deper than objectLimit */} + + ), + }, + {debugChannel}, + ); + + await act(async () => { + const rootModel = await ReactNoopFlightClient.read(transport, { + debugChannel, + }); + const root = rootModel.root; + const children = getDebugInfo(root)[1].props.children; + expect(children.type).toBe('div'); + expect(children.props.children.type).toBe('div'); + ReactNoop.render(root); + }); + + expect(ReactNoop).toMatchRenderedOutput(
not using props
); + }); +}); diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index efa59d8605ed2..e245adcb1380a 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -14,7 +14,6 @@ let React; let ReactTestRenderer; let ReactDebugTools; let act; -let assertConsoleErrorDev; let useMemoCache; function normalizeSourceLoc(tree) { @@ -34,7 +33,7 @@ describe('ReactHooksInspectionIntegration', () => { jest.resetModules(); React = require('react'); ReactTestRenderer = require('react-test-renderer'); - ({act, assertConsoleErrorDev} = require('internal-test-utils')); + ({act} = require('internal-test-utils')); ReactDebugTools = require('react-debug-tools'); useMemoCache = require('react/compiler-runtime').c; }); @@ -1553,7 +1552,7 @@ describe('ReactHooksInspectionIntegration', () => { expect(tree[0].id).toEqual(0); expect(tree[0].isStateEditable).toEqual(false); expect(tree[0].name).toEqual('Id'); - expect(String(tree[0].value).startsWith('\u00ABr')).toBe(true); + expect(String(tree[0].value).startsWith('_r_')).toBe(true); expect(normalizeSourceLoc(tree)[1]).toMatchInlineSnapshot(` { @@ -2321,57 +2320,6 @@ describe('ReactHooksInspectionIntegration', () => { }); }); - // @gate !disableDefaultPropsExceptForClasses - it('should support defaultProps and lazy', async () => { - const Suspense = React.Suspense; - - function Foo(props) { - const [value] = React.useState(props.defaultValue.slice(0, 3)); - return
{value}
; - } - Foo.defaultProps = { - defaultValue: 'default', - }; - - async function fakeImport(result) { - return {default: result}; - } - - const LazyFoo = React.lazy(() => fakeImport(Foo)); - - const renderer = ReactTestRenderer.create( - - - , - ); - - await act(async () => await LazyFoo); - assertConsoleErrorDev([ - 'Foo: Support for defaultProps will be removed from function components in a future major release. Use JavaScript default parameters instead.', - ]); - - const childFiber = renderer.root._currentFiber(); - const tree = ReactDebugTools.inspectHooksOfFiber(childFiber); - expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(` - [ - { - "debugInfo": null, - "hookSource": { - "columnNumber": 0, - "fileName": "**", - "functionName": "Foo", - "lineNumber": 0, - }, - "id": 0, - "isStateEditable": true, - "name": "State", - "subHooks": [], - "value": "def", - }, - ] - `); - }); - // This test case is based on an open source bug report: // https://github.com/facebookincubator/redux-react-hook/issues/34#issuecomment-466693787 it('should properly advance the current hook for useContext', async () => { diff --git a/packages/react-devtools-core/package.json b/packages/react-devtools-core/package.json index 581a24edba342..8ce8f436e815d 100644 --- a/packages/react-devtools-core/package.json +++ b/packages/react-devtools-core/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools-core", - "version": "6.1.2", + "version": "6.1.5", "description": "Use react-devtools outside of the browser", "license": "MIT", "main": "./dist/backend.js", diff --git a/packages/react-devtools-core/webpack.backend.js b/packages/react-devtools-core/webpack.backend.js index 32d4fadcb5884..c1312fc6d8ec8 100644 --- a/packages/react-devtools-core/webpack.backend.js +++ b/packages/react-devtools-core/webpack.backend.js @@ -72,6 +72,7 @@ module.exports = { __IS_CHROME__: false, __IS_EDGE__: false, __IS_NATIVE__: true, + __IS_INTERNAL_MCP_BUILD__: false, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-core"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 'process.env.GITHUB_URL': `"${GITHUB_URL}"`, diff --git a/packages/react-devtools-core/webpack.standalone.js b/packages/react-devtools-core/webpack.standalone.js index 8caadec10b070..6a9636c6911b1 100644 --- a/packages/react-devtools-core/webpack.standalone.js +++ b/packages/react-devtools-core/webpack.standalone.js @@ -91,6 +91,7 @@ module.exports = { __IS_FIREFOX__: false, __IS_CHROME__: false, __IS_EDGE__: false, + __IS_INTERNAL_MCP_BUILD__: false, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-core"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null, diff --git a/packages/react-devtools-extensions/chrome/manifest.json b/packages/react-devtools-extensions/chrome/manifest.json index fa4607d1c69cd..127185f73e430 100644 --- a/packages/react-devtools-extensions/chrome/manifest.json +++ b/packages/react-devtools-extensions/chrome/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Chrome Developer Tools.", - "version": "6.1.2", - "version_name": "6.1.2", + "version": "6.1.5", + "version_name": "6.1.5", "minimum_chrome_version": "114", "icons": { "16": "icons/16-production.png", diff --git a/packages/react-devtools-extensions/edge/manifest.json b/packages/react-devtools-extensions/edge/manifest.json index 59d4a8b1ff996..f930a6d0ac7cf 100644 --- a/packages/react-devtools-extensions/edge/manifest.json +++ b/packages/react-devtools-extensions/edge/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Microsoft Edge Developer Tools.", - "version": "6.1.2", - "version_name": "6.1.2", + "version": "6.1.5", + "version_name": "6.1.5", "minimum_chrome_version": "114", "icons": { "16": "icons/16-production.png", diff --git a/packages/react-devtools-extensions/firefox/manifest.json b/packages/react-devtools-extensions/firefox/manifest.json index 3080eadbb003b..24b5b11323f69 100644 --- a/packages/react-devtools-extensions/firefox/manifest.json +++ b/packages/react-devtools-extensions/firefox/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Firefox Developer Tools.", - "version": "6.1.2", + "version": "6.1.5", "browser_specific_settings": { "gecko": { "id": "@react-devtools", diff --git a/packages/react-devtools-extensions/webpack.backend.js b/packages/react-devtools-extensions/webpack.backend.js index effa6cc330bb0..4bfa05183067e 100644 --- a/packages/react-devtools-extensions/webpack.backend.js +++ b/packages/react-devtools-extensions/webpack.backend.js @@ -78,6 +78,7 @@ module.exports = { __IS_FIREFOX__: IS_FIREFOX, __IS_EDGE__: IS_EDGE, __IS_NATIVE__: false, + __IS_INTERNAL_MCP_BUILD__: false, }), new Webpack.SourceMapDevToolPlugin({ filename: '[file].map', diff --git a/packages/react-devtools-extensions/webpack.config.js b/packages/react-devtools-extensions/webpack.config.js index 51b8f4e2105e3..4a3052517c851 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -33,6 +33,8 @@ const IS_FIREFOX = process.env.IS_FIREFOX === 'true'; const IS_EDGE = process.env.IS_EDGE === 'true'; const IS_INTERNAL_VERSION = process.env.FEATURE_FLAG_TARGET === 'extension-fb'; +const IS_INTERNAL_MCP_BUILD = process.env.IS_INTERNAL_MCP_BUILD === 'true'; + const featureFlagTarget = process.env.FEATURE_FLAG_TARGET || 'extension-oss'; const babelOptions = { @@ -113,6 +115,7 @@ module.exports = { __IS_FIREFOX__: IS_FIREFOX, __IS_EDGE__: IS_EDGE, __IS_NATIVE__: false, + __IS_INTERNAL_MCP_BUILD__: IS_INTERNAL_MCP_BUILD, __IS_INTERNAL_VERSION__: IS_INTERNAL_VERSION, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-extensions"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, diff --git a/packages/react-devtools-fusebox/webpack.config.frontend.js b/packages/react-devtools-fusebox/webpack.config.frontend.js index ab7906ca84d63..ea04f4dad2d0d 100644 --- a/packages/react-devtools-fusebox/webpack.config.frontend.js +++ b/packages/react-devtools-fusebox/webpack.config.frontend.js @@ -86,6 +86,7 @@ module.exports = { __IS_CHROME__: false, __IS_FIREFOX__: false, __IS_EDGE__: false, + __IS_INTERNAL_MCP_BUILD__: false, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-fusebox"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null, diff --git a/packages/react-devtools-inline/package.json b/packages/react-devtools-inline/package.json index bfa564b73bb91..8d3f1e71c10ff 100644 --- a/packages/react-devtools-inline/package.json +++ b/packages/react-devtools-inline/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools-inline", - "version": "6.1.2", + "version": "6.1.5", "description": "Embed react-devtools within a website", "license": "MIT", "main": "./dist/backend.js", diff --git a/packages/react-devtools-inline/webpack.config.js b/packages/react-devtools-inline/webpack.config.js index 3a92dff1f2195..9fa900dfa65f2 100644 --- a/packages/react-devtools-inline/webpack.config.js +++ b/packages/react-devtools-inline/webpack.config.js @@ -78,6 +78,7 @@ module.exports = { __IS_FIREFOX__: false, __IS_EDGE__: false, __IS_NATIVE__: false, + __IS_INTERNAL_MCP_BUILD__: false, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-inline"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null, diff --git a/packages/react-devtools-shared/package.json b/packages/react-devtools-shared/package.json index 94f3f9411b0db..a8daa42a0d0c2 100644 --- a/packages/react-devtools-shared/package.json +++ b/packages/react-devtools-shared/package.json @@ -20,7 +20,7 @@ "clipboard-js": "^0.3.6", "compare-versions": "^5.0.3", "jsc-safe-url": "^0.2.4", - "json5": "^2.1.3", + "json5": "^2.2.3", "local-storage-fallback": "^4.1.1", "react-virtualized-auto-sizer": "^1.0.23", "react-window": "^1.8.10" diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 711d76c92d075..e24734b0ab032 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -50,6 +50,7 @@ import { gt, gte, parseSourceFromComponentStack, + parseSourceFromOwnerStack, serializeToString, } from 'react-devtools-shared/src/backend/utils'; import { @@ -5805,15 +5806,13 @@ export function attach( function getSourceForFiberInstance( fiberInstance: FiberInstance, ): Source | null { - const unresolvedSource = fiberInstance.source; - if ( - unresolvedSource !== null && - typeof unresolvedSource === 'object' && - !isError(unresolvedSource) - ) { - // $FlowFixMe: isError should have refined it. - return unresolvedSource; + // Favor the owner source if we have one. + const ownerSource = getSourceForInstance(fiberInstance); + if (ownerSource !== null) { + return ownerSource; } + + // Otherwise fallback to the throwing trick. const dispatcherRef = getDispatcherRef(renderer); const stackFrame = dispatcherRef == null @@ -5824,10 +5823,7 @@ export function attach( dispatcherRef, ); if (stackFrame === null) { - // If we don't find a source location by throwing, try to get one - // from an owned child if possible. This is the same branch as - // for virtual instances. - return getSourceForInstance(fiberInstance); + return null; } const source = parseSourceFromComponentStack(stackFrame); fiberInstance.source = source; @@ -5842,13 +5838,23 @@ export function attach( return null; } + if (instance.kind === VIRTUAL_INSTANCE) { + // We might have found one on the virtual instance. + const debugLocation = instance.data.debugLocation; + if (debugLocation != null) { + unresolvedSource = debugLocation; + } + } + // If we have the debug stack (the creation stack of the JSX) for any owned child of this // component, then at the bottom of that stack will be a stack frame that is somewhere within // the component's function body. Typically it would be the callsite of the JSX unless there's // any intermediate utility functions. This won't point to the top of the component function // but it's at least somewhere within it. if (isError(unresolvedSource)) { - unresolvedSource = formatOwnerStack((unresolvedSource: any)); + return (instance.source = parseSourceFromOwnerStack( + (unresolvedSource: any), + )); } if (typeof unresolvedSource === 'string') { const idx = unresolvedSource.lastIndexOf('\n'); @@ -5861,6 +5867,86 @@ export function attach( return unresolvedSource; } + type InternalMcpFunctions = { + __internal_only_getComponentTree?: Function, + }; + + const internalMcpFunctions: InternalMcpFunctions = {}; + if (__IS_INTERNAL_MCP_BUILD__) { + // eslint-disable-next-line no-inner-declarations + function __internal_only_getComponentTree(): string { + let treeString = ''; + + function buildTreeString( + instance: DevToolsInstance, + prefix: string = '', + isLastChild: boolean = true, + ): void { + if (!instance) return; + + const name = + (instance.kind !== VIRTUAL_INSTANCE + ? getDisplayNameForFiber(instance.data) + : instance.data.name) || 'Unknown'; + + const id = instance.id !== undefined ? instance.id : 'unknown'; + + if (name !== 'createRoot()') { + treeString += + prefix + + (isLastChild ? '└── ' : '├── ') + + name + + ' (id: ' + + id + + ')\n'; + } + + const childPrefix = prefix + (isLastChild ? ' ' : '│ '); + + let childCount = 0; + let tempChild = instance.firstChild; + while (tempChild !== null) { + childCount++; + tempChild = tempChild.nextSibling; + } + + let child = instance.firstChild; + let currentChildIndex = 0; + + while (child !== null) { + currentChildIndex++; + const isLastSibling = currentChildIndex === childCount; + buildTreeString(child, childPrefix, isLastSibling); + child = child.nextSibling; + } + } + + const rootInstances: Array = []; + idToDevToolsInstanceMap.forEach(instance => { + if (instance.parent === null || instance.parent.parent === null) { + rootInstances.push(instance); + } + }); + + if (rootInstances.length > 0) { + for (let i = 0; i < rootInstances.length; i++) { + const isLast = i === rootInstances.length - 1; + buildTreeString(rootInstances[i], '', isLast); + if (!isLast) { + treeString += '\n'; + } + } + } else { + treeString = 'No component tree found.'; + } + + return treeString; + } + + internalMcpFunctions.__internal_only_getComponentTree = + __internal_only_getComponentTree; + } + return { cleanup, clearErrorsAndWarnings, @@ -5900,5 +5986,6 @@ export function attach( storeAsGlobal, updateComponentFilters, getEnvironmentNames, + ...internalMcpFunctions, }; } diff --git a/packages/react-devtools-shared/src/backend/profilingHooks.js b/packages/react-devtools-shared/src/backend/profilingHooks.js index c84bb88250375..ca58cf655ce53 100644 --- a/packages/react-devtools-shared/src/backend/profilingHooks.js +++ b/packages/react-devtools-shared/src/backend/profilingHooks.js @@ -298,14 +298,16 @@ export function createProfilingHooks({ } function markCommitStarted(lanes: Lanes): void { - if (isProfiling) { - recordReactMeasureStarted('commit', lanes); - - // TODO (timeline) Re-think this approach to "batching"; I don't think it works for Suspense or pre-rendering. - // This issue applies to the User Timing data also. - nextRenderShouldStartNewBatch = true; + if (!isProfiling) { + return; } + recordReactMeasureStarted('commit', lanes); + + // TODO (timeline) Re-think this approach to "batching"; I don't think it works for Suspense or pre-rendering. + // This issue applies to the User Timing data also. + nextRenderShouldStartNewBatch = true; + if (supportsUserTimingV3) { markAndClear(`--commit-start-${lanes}`); @@ -318,54 +320,55 @@ export function createProfilingHooks({ } function markCommitStopped(): void { - if (isProfiling) { - recordReactMeasureCompleted('commit'); - recordReactMeasureCompleted('render-idle'); + if (!isProfiling) { + return; } + recordReactMeasureCompleted('commit'); + recordReactMeasureCompleted('render-idle'); if (supportsUserTimingV3) { markAndClear('--commit-stop'); } } function markComponentRenderStarted(fiber: Fiber): void { - if (isProfiling || supportsUserTimingV3) { - const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; + if (!isProfiling) { + return; + } - if (isProfiling) { - // TODO (timeline) Record and cache component stack - if (isProfiling) { - currentReactComponentMeasure = { - componentName, - duration: 0, - timestamp: getRelativeTime(), - type: 'render', - warning: null, - }; - } - } + const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; - if (supportsUserTimingV3) { - markAndClear(`--component-render-start-${componentName}`); - } + // TODO (timeline) Record and cache component stack + currentReactComponentMeasure = { + componentName, + duration: 0, + timestamp: getRelativeTime(), + type: 'render', + warning: null, + }; + + if (supportsUserTimingV3) { + markAndClear(`--component-render-start-${componentName}`); } } function markComponentRenderStopped(): void { - if (isProfiling) { - if (currentReactComponentMeasure) { - if (currentTimelineData) { - currentTimelineData.componentMeasures.push( - currentReactComponentMeasure, - ); - } + if (!isProfiling) { + return; + } - // $FlowFixMe[incompatible-use] found when upgrading Flow - currentReactComponentMeasure.duration = - // $FlowFixMe[incompatible-use] found when upgrading Flow - getRelativeTime() - currentReactComponentMeasure.timestamp; - currentReactComponentMeasure = null; + if (currentReactComponentMeasure) { + if (currentTimelineData) { + currentTimelineData.componentMeasures.push( + currentReactComponentMeasure, + ); } + + // $FlowFixMe[incompatible-use] found when upgrading Flow + currentReactComponentMeasure.duration = + // $FlowFixMe[incompatible-use] found when upgrading Flow + getRelativeTime() - currentReactComponentMeasure.timestamp; + currentReactComponentMeasure = null; } if (supportsUserTimingV3) { @@ -374,43 +377,43 @@ export function createProfilingHooks({ } function markComponentLayoutEffectMountStarted(fiber: Fiber): void { - if (isProfiling || supportsUserTimingV3) { - const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; + if (!isProfiling) { + return; + } - if (isProfiling) { - // TODO (timeline) Record and cache component stack - if (isProfiling) { - currentReactComponentMeasure = { - componentName, - duration: 0, - timestamp: getRelativeTime(), - type: 'layout-effect-mount', - warning: null, - }; - } - } + const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; - if (supportsUserTimingV3) { - markAndClear(`--component-layout-effect-mount-start-${componentName}`); - } + // TODO (timeline) Record and cache component stack + currentReactComponentMeasure = { + componentName, + duration: 0, + timestamp: getRelativeTime(), + type: 'layout-effect-mount', + warning: null, + }; + + if (supportsUserTimingV3) { + markAndClear(`--component-layout-effect-mount-start-${componentName}`); } } function markComponentLayoutEffectMountStopped(): void { - if (isProfiling) { - if (currentReactComponentMeasure) { - if (currentTimelineData) { - currentTimelineData.componentMeasures.push( - currentReactComponentMeasure, - ); - } + if (!isProfiling) { + return; + } - // $FlowFixMe[incompatible-use] found when upgrading Flow - currentReactComponentMeasure.duration = - // $FlowFixMe[incompatible-use] found when upgrading Flow - getRelativeTime() - currentReactComponentMeasure.timestamp; - currentReactComponentMeasure = null; + if (currentReactComponentMeasure) { + if (currentTimelineData) { + currentTimelineData.componentMeasures.push( + currentReactComponentMeasure, + ); } + + // $FlowFixMe[incompatible-use] found when upgrading Flow + currentReactComponentMeasure.duration = + // $FlowFixMe[incompatible-use] found when upgrading Flow + getRelativeTime() - currentReactComponentMeasure.timestamp; + currentReactComponentMeasure = null; } if (supportsUserTimingV3) { @@ -419,45 +422,43 @@ export function createProfilingHooks({ } function markComponentLayoutEffectUnmountStarted(fiber: Fiber): void { - if (isProfiling || supportsUserTimingV3) { - const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; + if (!isProfiling) { + return; + } - if (isProfiling) { - // TODO (timeline) Record and cache component stack - if (isProfiling) { - currentReactComponentMeasure = { - componentName, - duration: 0, - timestamp: getRelativeTime(), - type: 'layout-effect-unmount', - warning: null, - }; - } - } + const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; - if (supportsUserTimingV3) { - markAndClear( - `--component-layout-effect-unmount-start-${componentName}`, - ); - } + // TODO (timeline) Record and cache component stack + currentReactComponentMeasure = { + componentName, + duration: 0, + timestamp: getRelativeTime(), + type: 'layout-effect-unmount', + warning: null, + }; + + if (supportsUserTimingV3) { + markAndClear(`--component-layout-effect-unmount-start-${componentName}`); } } function markComponentLayoutEffectUnmountStopped(): void { - if (isProfiling) { - if (currentReactComponentMeasure) { - if (currentTimelineData) { - currentTimelineData.componentMeasures.push( - currentReactComponentMeasure, - ); - } + if (!isProfiling) { + return; + } - // $FlowFixMe[incompatible-use] found when upgrading Flow - currentReactComponentMeasure.duration = - // $FlowFixMe[incompatible-use] found when upgrading Flow - getRelativeTime() - currentReactComponentMeasure.timestamp; - currentReactComponentMeasure = null; + if (currentReactComponentMeasure) { + if (currentTimelineData) { + currentTimelineData.componentMeasures.push( + currentReactComponentMeasure, + ); } + + // $FlowFixMe[incompatible-use] found when upgrading Flow + currentReactComponentMeasure.duration = + // $FlowFixMe[incompatible-use] found when upgrading Flow + getRelativeTime() - currentReactComponentMeasure.timestamp; + currentReactComponentMeasure = null; } if (supportsUserTimingV3) { @@ -466,43 +467,43 @@ export function createProfilingHooks({ } function markComponentPassiveEffectMountStarted(fiber: Fiber): void { - if (isProfiling || supportsUserTimingV3) { - const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; + if (!isProfiling) { + return; + } - if (isProfiling) { - // TODO (timeline) Record and cache component stack - if (isProfiling) { - currentReactComponentMeasure = { - componentName, - duration: 0, - timestamp: getRelativeTime(), - type: 'passive-effect-mount', - warning: null, - }; - } - } + const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; - if (supportsUserTimingV3) { - markAndClear(`--component-passive-effect-mount-start-${componentName}`); - } + // TODO (timeline) Record and cache component stack + currentReactComponentMeasure = { + componentName, + duration: 0, + timestamp: getRelativeTime(), + type: 'passive-effect-mount', + warning: null, + }; + + if (supportsUserTimingV3) { + markAndClear(`--component-passive-effect-mount-start-${componentName}`); } } function markComponentPassiveEffectMountStopped(): void { - if (isProfiling) { - if (currentReactComponentMeasure) { - if (currentTimelineData) { - currentTimelineData.componentMeasures.push( - currentReactComponentMeasure, - ); - } + if (!isProfiling) { + return; + } - // $FlowFixMe[incompatible-use] found when upgrading Flow - currentReactComponentMeasure.duration = - // $FlowFixMe[incompatible-use] found when upgrading Flow - getRelativeTime() - currentReactComponentMeasure.timestamp; - currentReactComponentMeasure = null; + if (currentReactComponentMeasure) { + if (currentTimelineData) { + currentTimelineData.componentMeasures.push( + currentReactComponentMeasure, + ); } + + // $FlowFixMe[incompatible-use] found when upgrading Flow + currentReactComponentMeasure.duration = + // $FlowFixMe[incompatible-use] found when upgrading Flow + getRelativeTime() - currentReactComponentMeasure.timestamp; + currentReactComponentMeasure = null; } if (supportsUserTimingV3) { @@ -511,45 +512,43 @@ export function createProfilingHooks({ } function markComponentPassiveEffectUnmountStarted(fiber: Fiber): void { - if (isProfiling || supportsUserTimingV3) { - const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; + if (!isProfiling) { + return; + } - if (isProfiling) { - // TODO (timeline) Record and cache component stack - if (isProfiling) { - currentReactComponentMeasure = { - componentName, - duration: 0, - timestamp: getRelativeTime(), - type: 'passive-effect-unmount', - warning: null, - }; - } - } + const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; - if (supportsUserTimingV3) { - markAndClear( - `--component-passive-effect-unmount-start-${componentName}`, - ); - } + // TODO (timeline) Record and cache component stack + currentReactComponentMeasure = { + componentName, + duration: 0, + timestamp: getRelativeTime(), + type: 'passive-effect-unmount', + warning: null, + }; + + if (supportsUserTimingV3) { + markAndClear(`--component-passive-effect-unmount-start-${componentName}`); } } function markComponentPassiveEffectUnmountStopped(): void { - if (isProfiling) { - if (currentReactComponentMeasure) { - if (currentTimelineData) { - currentTimelineData.componentMeasures.push( - currentReactComponentMeasure, - ); - } + if (!isProfiling) { + return; + } - // $FlowFixMe[incompatible-use] found when upgrading Flow - currentReactComponentMeasure.duration = - // $FlowFixMe[incompatible-use] found when upgrading Flow - getRelativeTime() - currentReactComponentMeasure.timestamp; - currentReactComponentMeasure = null; + if (currentReactComponentMeasure) { + if (currentTimelineData) { + currentTimelineData.componentMeasures.push( + currentReactComponentMeasure, + ); } + + // $FlowFixMe[incompatible-use] found when upgrading Flow + currentReactComponentMeasure.duration = + // $FlowFixMe[incompatible-use] found when upgrading Flow + getRelativeTime() - currentReactComponentMeasure.timestamp; + currentReactComponentMeasure = null; } if (supportsUserTimingV3) { @@ -562,37 +561,37 @@ export function createProfilingHooks({ thrownValue: mixed, lanes: Lanes, ): void { - if (isProfiling || supportsUserTimingV3) { - const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; - const phase = fiber.alternate === null ? 'mount' : 'update'; - - let message = ''; - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - typeof thrownValue.message === 'string' - ) { - message = thrownValue.message; - } else if (typeof thrownValue === 'string') { - message = thrownValue; - } + if (!isProfiling) { + return; + } - if (isProfiling) { - // TODO (timeline) Record and cache component stack - if (currentTimelineData) { - currentTimelineData.thrownErrors.push({ - componentName, - message, - phase, - timestamp: getRelativeTime(), - type: 'thrown-error', - }); - } - } + const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; + const phase = fiber.alternate === null ? 'mount' : 'update'; - if (supportsUserTimingV3) { - markAndClear(`--error-${componentName}-${phase}-${message}`); - } + let message = ''; + if ( + thrownValue !== null && + typeof thrownValue === 'object' && + typeof thrownValue.message === 'string' + ) { + message = thrownValue.message; + } else if (typeof thrownValue === 'string') { + message = thrownValue; + } + + // TODO (timeline) Record and cache component stack + if (currentTimelineData) { + currentTimelineData.thrownErrors.push({ + componentName, + message, + phase, + timestamp: getRelativeTime(), + type: 'thrown-error', + }); + } + + if (supportsUserTimingV3) { + markAndClear(`--error-${componentName}-${phase}-${message}`); } } @@ -613,44 +612,44 @@ export function createProfilingHooks({ wakeable: Wakeable, lanes: Lanes, ): void { - if (isProfiling || supportsUserTimingV3) { - const eventType = wakeableIDs.has(wakeable) ? 'resuspend' : 'suspend'; - const id = getWakeableID(wakeable); - const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; - const phase = fiber.alternate === null ? 'mount' : 'update'; - - // Following the non-standard fn.displayName convention, - // frameworks like Relay may also annotate Promises with a displayName, - // describing what operation/data the thrown Promise is related to. - // When this is available we should pass it along to the Timeline. - const displayName = (wakeable: any).displayName || ''; - - let suspenseEvent: SuspenseEvent | null = null; - if (isProfiling) { - // TODO (timeline) Record and cache component stack - suspenseEvent = { - componentName, - depth: 0, - duration: 0, - id: `${id}`, - phase, - promiseName: displayName, - resolution: 'unresolved', - timestamp: getRelativeTime(), - type: 'suspense', - warning: null, - }; - - if (currentTimelineData) { - currentTimelineData.suspenseEvents.push(suspenseEvent); - } - } + if (!isProfiling) { + return; + } - if (supportsUserTimingV3) { - markAndClear( - `--suspense-${eventType}-${id}-${componentName}-${phase}-${lanes}-${displayName}`, - ); - } + const eventType = wakeableIDs.has(wakeable) ? 'resuspend' : 'suspend'; + const id = getWakeableID(wakeable); + const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; + const phase = fiber.alternate === null ? 'mount' : 'update'; + + // Following the non-standard fn.displayName convention, + // frameworks like Relay may also annotate Promises with a displayName, + // describing what operation/data the thrown Promise is related to. + // When this is available we should pass it along to the Timeline. + const displayName = (wakeable: any).displayName || ''; + + let suspenseEvent: SuspenseEvent | null = null; + // TODO (timeline) Record and cache component stack + suspenseEvent = { + componentName, + depth: 0, + duration: 0, + id: `${id}`, + phase, + promiseName: displayName, + resolution: 'unresolved', + timestamp: getRelativeTime(), + type: 'suspense', + warning: null, + }; + + if (currentTimelineData) { + currentTimelineData.suspenseEvents.push(suspenseEvent); + } + + if (supportsUserTimingV3) { + markAndClear( + `--suspense-${eventType}-${id}-${componentName}-${phase}-${lanes}-${displayName}`, + ); wakeable.then( () => { @@ -680,100 +679,109 @@ export function createProfilingHooks({ } function markLayoutEffectsStarted(lanes: Lanes): void { - if (isProfiling) { - recordReactMeasureStarted('layout-effects', lanes); + if (!isProfiling) { + return; } + recordReactMeasureStarted('layout-effects', lanes); if (supportsUserTimingV3) { markAndClear(`--layout-effects-start-${lanes}`); } } function markLayoutEffectsStopped(): void { - if (isProfiling) { - recordReactMeasureCompleted('layout-effects'); + if (!isProfiling) { + return; } + recordReactMeasureCompleted('layout-effects'); if (supportsUserTimingV3) { markAndClear('--layout-effects-stop'); } } function markPassiveEffectsStarted(lanes: Lanes): void { - if (isProfiling) { - recordReactMeasureStarted('passive-effects', lanes); + if (!isProfiling) { + return; } + recordReactMeasureStarted('passive-effects', lanes); if (supportsUserTimingV3) { markAndClear(`--passive-effects-start-${lanes}`); } } function markPassiveEffectsStopped(): void { - if (isProfiling) { - recordReactMeasureCompleted('passive-effects'); + if (!isProfiling) { + return; } + recordReactMeasureCompleted('passive-effects'); if (supportsUserTimingV3) { markAndClear('--passive-effects-stop'); } } function markRenderStarted(lanes: Lanes): void { - if (isProfiling) { - if (nextRenderShouldStartNewBatch) { - nextRenderShouldStartNewBatch = false; - currentBatchUID++; - } + if (!isProfiling) { + return; + } - // If this is a new batch of work, wrap an "idle" measure around it. - // Log it before the "render" measure to preserve the stack ordering. - if ( - currentReactMeasuresStack.length === 0 || - currentReactMeasuresStack[currentReactMeasuresStack.length - 1].type !== - 'render-idle' - ) { - recordReactMeasureStarted('render-idle', lanes); - } + if (nextRenderShouldStartNewBatch) { + nextRenderShouldStartNewBatch = false; + currentBatchUID++; + } - recordReactMeasureStarted('render', lanes); + // If this is a new batch of work, wrap an "idle" measure around it. + // Log it before the "render" measure to preserve the stack ordering. + if ( + currentReactMeasuresStack.length === 0 || + currentReactMeasuresStack[currentReactMeasuresStack.length - 1].type !== + 'render-idle' + ) { + recordReactMeasureStarted('render-idle', lanes); } + recordReactMeasureStarted('render', lanes); if (supportsUserTimingV3) { markAndClear(`--render-start-${lanes}`); } } function markRenderYielded(): void { - if (isProfiling) { - recordReactMeasureCompleted('render'); + if (!isProfiling) { + return; } + recordReactMeasureCompleted('render'); if (supportsUserTimingV3) { markAndClear('--render-yield'); } } function markRenderStopped(): void { - if (isProfiling) { - recordReactMeasureCompleted('render'); + if (!isProfiling) { + return; } + recordReactMeasureCompleted('render'); if (supportsUserTimingV3) { markAndClear('--render-stop'); } } function markRenderScheduled(lane: Lane): void { - if (isProfiling) { - if (currentTimelineData) { - currentTimelineData.schedulingEvents.push({ - lanes: laneToLanesArray(lane), - timestamp: getRelativeTime(), - type: 'schedule-render', - warning: null, - }); - } + if (!isProfiling) { + return; + } + + if (currentTimelineData) { + currentTimelineData.schedulingEvents.push({ + lanes: laneToLanesArray(lane), + timestamp: getRelativeTime(), + type: 'schedule-render', + warning: null, + }); } if (supportsUserTimingV3) { @@ -782,25 +790,25 @@ export function createProfilingHooks({ } function markForceUpdateScheduled(fiber: Fiber, lane: Lane): void { - if (isProfiling || supportsUserTimingV3) { - const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; + if (!isProfiling) { + return; + } - if (isProfiling) { - // TODO (timeline) Record and cache component stack - if (currentTimelineData) { - currentTimelineData.schedulingEvents.push({ - componentName, - lanes: laneToLanesArray(lane), - timestamp: getRelativeTime(), - type: 'schedule-force-update', - warning: null, - }); - } - } + const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; - if (supportsUserTimingV3) { - markAndClear(`--schedule-forced-update-${lane}-${componentName}`); - } + // TODO (timeline) Record and cache component stack + if (currentTimelineData) { + currentTimelineData.schedulingEvents.push({ + componentName, + lanes: laneToLanesArray(lane), + timestamp: getRelativeTime(), + type: 'schedule-force-update', + warning: null, + }); + } + + if (supportsUserTimingV3) { + markAndClear(`--schedule-forced-update-${lane}-${componentName}`); } } @@ -815,30 +823,30 @@ export function createProfilingHooks({ } function markStateUpdateScheduled(fiber: Fiber, lane: Lane): void { - if (isProfiling || supportsUserTimingV3) { - const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; + if (!isProfiling) { + return; + } - if (isProfiling) { - // TODO (timeline) Record and cache component stack - if (currentTimelineData) { - const event: ReactScheduleStateUpdateEvent = { - componentName, - // Store the parent fibers so we can post process - // them after we finish profiling - lanes: laneToLanesArray(lane), - timestamp: getRelativeTime(), - type: 'schedule-state-update', - warning: null, - }; - currentFiberStacks.set(event, getParentFibers(fiber)); - // $FlowFixMe[incompatible-use] found when upgrading Flow - currentTimelineData.schedulingEvents.push(event); - } - } + const componentName = getDisplayNameForFiber(fiber) || 'Unknown'; - if (supportsUserTimingV3) { - markAndClear(`--schedule-state-update-${lane}-${componentName}`); - } + // TODO (timeline) Record and cache component stack + if (currentTimelineData) { + const event: ReactScheduleStateUpdateEvent = { + componentName, + // Store the parent fibers so we can post process + // them after we finish profiling + lanes: laneToLanesArray(lane), + timestamp: getRelativeTime(), + type: 'schedule-state-update', + warning: null, + }; + currentFiberStacks.set(event, getParentFibers(fiber)); + // $FlowFixMe[incompatible-use] found when upgrading Flow + currentTimelineData.schedulingEvents.push(event); + } + + if (supportsUserTimingV3) { + markAndClear(`--schedule-state-update-${lane}-${componentName}`); } } diff --git a/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js b/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js index e03d948a45d3a..fdd7bce2f8d9e 100644 --- a/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js +++ b/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js @@ -13,8 +13,12 @@ export function formatOwnerStack(error: Error): string { const prevPrepareStackTrace = Error.prepareStackTrace; // $FlowFixMe[incompatible-type] It does accept undefined. Error.prepareStackTrace = undefined; - let stack = error.stack; + const stack = error.stack; Error.prepareStackTrace = prevPrepareStackTrace; + return formatOwnerStackString(stack); +} + +export function formatOwnerStackString(stack: string): string { if (stack.startsWith('Error: react-stack-top-frame\n')) { // V8's default formatting prefixes with the error message which we // don't want/need. @@ -25,7 +29,10 @@ export function formatOwnerStack(error: Error): string { // Pop the JSX frame. stack = stack.slice(idx + 1); } - idx = stack.indexOf('react-stack-bottom-frame'); + idx = stack.indexOf('react_stack_bottom_frame'); + if (idx === -1) { + idx = stack.indexOf('react-stack-bottom-frame'); + } if (idx !== -1) { idx = stack.lastIndexOf('\n', idx); } diff --git a/packages/react-devtools-shared/src/backend/utils/index.js b/packages/react-devtools-shared/src/backend/utils/index.js index 977683ef9a208..b7e449869190e 100644 --- a/packages/react-devtools-shared/src/backend/utils/index.js +++ b/packages/react-devtools-shared/src/backend/utils/index.js @@ -18,6 +18,8 @@ import type {DehydratedData} from 'react-devtools-shared/src/frontend/types'; export {default as formatWithStyles} from './formatWithStyles'; export {default as formatConsoleArguments} from './formatConsoleArguments'; +import {formatOwnerStackString} from '../shared/DevToolsOwnerStack'; + // TODO: update this to the first React version that has a corresponding DevTools backend const FIRST_DEVTOOLS_BACKEND_LOCKSTEP_VER = '999.9.9'; export function hasAssignedBackend(version?: string): boolean { @@ -345,6 +347,89 @@ export function parseSourceFromComponentStack( return parseSourceFromFirefoxStack(componentStack); } +let collectedLocation: Source | null = null; + +function collectStackTrace( + error: Error, + structuredStackTrace: CallSite[], +): string { + let result: null | Source = null; + // Collect structured stack traces from the callsites. + // We mirror how V8 serializes stack frames and how we later parse them. + for (let i = 0; i < structuredStackTrace.length; i++) { + const callSite = structuredStackTrace[i]; + const name = callSite.getFunctionName(); + if ( + name != null && + (name.includes('react_stack_bottom_frame') || + name.includes('react-stack-bottom-frame')) + ) { + // We pick the last frame that matches before the bottom frame since + // that will be immediately inside the component as opposed to some helper. + // If we don't find a bottom frame then we bail to string parsing. + collectedLocation = result; + // Skip everything after the bottom frame since it'll be internals. + break; + } else { + const sourceURL = callSite.getScriptNameOrSourceURL(); + const line = + // $FlowFixMe[prop-missing] + typeof callSite.getEnclosingLineNumber === 'function' + ? (callSite: any).getEnclosingLineNumber() + : callSite.getLineNumber(); + const col = + // $FlowFixMe[prop-missing] + typeof callSite.getEnclosingColumnNumber === 'function' + ? (callSite: any).getEnclosingColumnNumber() + : callSite.getColumnNumber(); + if (!sourceURL || !line || !col) { + // Skip eval etc. without source url. They don't have location. + continue; + } + result = { + sourceURL, + line: line, + column: col, + }; + } + } + // At the same time we generate a string stack trace just in case someone + // else reads it. + const name = error.name || 'Error'; + const message = error.message || ''; + let stack = name + ': ' + message; + for (let i = 0; i < structuredStackTrace.length; i++) { + stack += '\n at ' + structuredStackTrace[i].toString(); + } + return stack; +} + +export function parseSourceFromOwnerStack(error: Error): Source | null { + // First attempt to collected the structured data using prepareStackTrace. + collectedLocation = null; + const previousPrepare = Error.prepareStackTrace; + Error.prepareStackTrace = collectStackTrace; + let stack; + try { + stack = error.stack; + } catch (e) { + // $FlowFixMe[incompatible-type] It does accept undefined. + Error.prepareStackTrace = undefined; + stack = error.stack; + } finally { + Error.prepareStackTrace = previousPrepare; + } + if (collectedLocation !== null) { + return collectedLocation; + } + if (stack == null) { + return null; + } + // Fallback to parsing the string form. + const componentStack = formatOwnerStackString(stack); + return parseSourceFromComponentStack(componentStack); +} + // 0.123456789 => 0.123 // Expects high-resolution timestamp in milliseconds, like from performance.now() // Mainly used for optimizing the size of serialized profiling payload diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/types.js b/packages/react-devtools-shared/src/devtools/ContextMenu/types.js index c2f296db10fe5..9fda9be199941 100644 --- a/packages/react-devtools-shared/src/devtools/ContextMenu/types.js +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/types.js @@ -25,7 +25,7 @@ export type ContextMenuHandle = { hide(): void, }; -/*:: -export type ContextMenuComponent = component(ref: React$RefSetter); -*/ +export type ContextMenuComponent = component( + ref: React$RefSetter, +); export type ContextMenuRef = {current: ContextMenuHandle | null}; diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 3895217053df1..97c3adc88f032 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -195,6 +195,10 @@ export default class Store extends EventEmitter<{ // Only used in browser extension for synchronization with built-in Elements panel. _lastSelectedHostInstanceElementId: Element['id'] | null = null; + // Maximum recorded node depth during the lifetime of this Store. + // Can only increase: not guaranteed to return maximal value for currently recorded elements. + _maximumRecordedDepth = 0; + constructor(bridge: FrontendBridge, config?: Config) { super(); @@ -698,6 +702,50 @@ export default class Store extends EventEmitter<{ return index; } + isDescendantOf(parentId: number, descendantId: number): boolean { + if (descendantId === 0) { + return false; + } + + const descendant = this.getElementByID(descendantId); + if (descendant === null) { + return false; + } + + if (descendant.parentID === parentId) { + return true; + } + + const parent = this.getElementByID(parentId); + if (!parent || parent.depth >= descendant.depth) { + return false; + } + + return this.isDescendantOf(parentId, descendant.parentID); + } + + /** + * Returns index of the lowest descendant element, if available. + * May not be the deepest element, the lowest is used in a sense of bottom-most from UI Tree representation perspective. + */ + getIndexOfLowestDescendantElement(element: Element): number | null { + let current: null | Element = element; + while (current !== null) { + if (current.isCollapsed || current.children.length === 0) { + if (current === element) { + return null; + } + + return this.getIndexOfElementID(current.id); + } else { + const lastChildID = current.children[current.children.length - 1]; + current = this.getElementByID(lastChildID); + } + } + + return null; + } + getOwnersListForElement(ownerID: number): Array { const list: Array = []; const element = this._idToElement.get(ownerID); @@ -1089,9 +1137,15 @@ export default class Store extends EventEmitter<{ compiledWithForget, } = parseElementDisplayNameFromBackend(displayName, type); + const elementDepth = parentElement.depth + 1; + this._maximumRecordedDepth = Math.max( + this._maximumRecordedDepth, + elementDepth, + ); + const element: Element = { children: [], - depth: parentElement.depth + 1, + depth: elementDepth, displayName: displayNameWithoutHOCs, hocDisplayNames, id, @@ -1536,6 +1590,14 @@ export default class Store extends EventEmitter<{ } }; + /** + * Maximum recorded node depth during the lifetime of this Store. + * Can only increase: not guaranteed to return maximal value for currently recorded elements. + */ + getMaximumRecordedDepth(): number { + return this._maximumRecordedDepth; + } + updateHookSettings: (settings: $ReadOnly) => void = settings => { this._hookSettings = settings; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Components.js b/packages/react-devtools-shared/src/devtools/views/Components/Components.js index b7ce607685051..1d99317ba0909 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Components.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Components.js @@ -176,7 +176,7 @@ function Components(_: {}) { const LOCAL_STORAGE_KEY = 'React::DevTools::createResizeReducer'; const VERTICAL_MODE_MAX_WIDTH = 600; -const MINIMUM_SIZE = 50; +const MINIMUM_SIZE = 100; function initResizeState(): ResizeState { let horizontalPercentage = 0.65; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.css b/packages/react-devtools-shared/src/devtools/views/Components/Element.css index b11e321e2e6d5..c25eddbdbb42c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.css @@ -1,7 +1,9 @@ .Element, +.HoveredElement, .InactiveSelectedElement, -.SelectedElement, -.HoveredElement { +.HighlightedElement, +.InactiveHighlightedElement, +.SelectedElement { color: var(--color-component-name); } .HoveredElement { @@ -10,8 +12,15 @@ .InactiveSelectedElement { background-color: var(--color-background-inactive); } +.HighlightedElement { + background-color: var(--color-selected-tree-highlight-active); +} +.InactiveHighlightedElement { + background-color: var(--color-selected-tree-highlight-inactive); +} .Wrapper { + position: relative; padding: 0 0.25rem; white-space: pre; height: var(--line-height-data); diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.js b/packages/react-devtools-shared/src/devtools/views/Components/Element.js index 71e0ebfbe9cbe..c3ddf1da07518 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.js @@ -45,10 +45,6 @@ export default function Element({data, index, style}: Props): React.Node { const [isHovered, setIsHovered] = useState(false); - const {isNavigatingWithKeyboard, onElementMouseEnter, treeFocused} = data; - const id = element === null ? null : element.id; - const isSelected = inspectedElementID === id; - const errorsAndWarningsSubscription = useMemo( () => ({ getCurrentValue: () => @@ -68,6 +64,15 @@ export default function Element({data, index, style}: Props): React.Node { }>(errorsAndWarningsSubscription); const changeOwnerAction = useChangeOwnerAction(); + + // Handle elements that are removed from the tree while an async render is in progress. + if (element == null) { + console.warn(` Could not find element at index ${index}`); + + // This return needs to happen after hooks, since hooks can't be conditional. + return null; + } + const handleDoubleClick = () => { if (id !== null) { changeOwnerAction(id); @@ -107,15 +112,8 @@ export default function Element({data, index, style}: Props): React.Node { event.preventDefault(); }; - // Handle elements that are removed from the tree while an async render is in progress. - if (element == null) { - console.warn(` Could not find element at index ${index}`); - - // This return needs to happen after hooks, since hooks can't be conditional. - return null; - } - const { + id, depth, displayName, hocDisplayNames, @@ -123,6 +121,19 @@ export default function Element({data, index, style}: Props): React.Node { key, compiledWithForget, } = element; + const { + isNavigatingWithKeyboard, + onElementMouseEnter, + treeFocused, + calculateElementOffset, + } = data; + + const isSelected = inspectedElementID === id; + const isDescendantOfSelected = + inspectedElementID !== null && + !isSelected && + store.isDescendantOf(inspectedElementID, id); + const elementOffset = calculateElementOffset(depth); // Only show strict mode non-compliance badges for top level elements. // Showing an inline badge for every element in the tree would be noisy. @@ -135,6 +146,10 @@ export default function Element({data, index, style}: Props): React.Node { : styles.InactiveSelectedElement; } else if (isHovered && !isNavigatingWithKeyboard) { className = styles.HoveredElement; + } else if (isDescendantOfSelected) { + className = treeFocused + ? styles.HighlightedElement + : styles.InactiveHighlightedElement; } return ( @@ -144,17 +159,13 @@ export default function Element({data, index, style}: Props): React.Node { onMouseLeave={handleMouseLeave} onMouseDown={handleClick} onDoubleClick={handleDoubleClick} - style={style} - data-testname="ComponentTreeListItem" - data-depth={depth}> + style={{ + ...style, + paddingLeft: elementOffset, + }} + data-testname="ComponentTreeListItem"> {/* This wrapper is used by Tree for measurement purposes. */} -
+
{ownerID === null && ( )} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.css b/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.css deleted file mode 100644 index 19b64a8ef4702..0000000000000 --- a/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.css +++ /dev/null @@ -1,16 +0,0 @@ -.Active, -.Inactive { - position: absolute; - left: 0; - width: 100%; - z-index: 0; - pointer-events: none; -} - -.Active { - background-color: var(--color-selected-tree-highlight-active); -} - -.Inactive { - background-color: var(--color-selected-tree-highlight-inactive); -} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.js b/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.js deleted file mode 100644 index 16035a13d65f9..0000000000000 --- a/packages/react-devtools-shared/src/devtools/views/Components/SelectedTreeHighlight.js +++ /dev/null @@ -1,110 +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 type {Element} from 'react-devtools-shared/src/frontend/types'; - -import * as React from 'react'; -import {useContext, useMemo} from 'react'; -import {TreeStateContext} from './TreeContext'; -import {SettingsContext} from '../Settings/SettingsContext'; -import TreeFocusedContext from './TreeFocusedContext'; -import {StoreContext} from '../context'; -import {useSubscription} from '../hooks'; - -import styles from './SelectedTreeHighlight.css'; - -type Data = { - startIndex: number, - stopIndex: number, -}; - -export default function SelectedTreeHighlight(_: {}): React.Node { - const {lineHeight} = useContext(SettingsContext); - const store = useContext(StoreContext); - const treeFocused = useContext(TreeFocusedContext); - const {ownerID, inspectedElementID} = useContext(TreeStateContext); - - const subscription = useMemo( - () => ({ - getCurrentValue: () => { - if ( - inspectedElementID === null || - store.isInsideCollapsedSubTree(inspectedElementID) - ) { - return null; - } - - const element = store.getElementByID(inspectedElementID); - if ( - element === null || - element.isCollapsed || - element.children.length === 0 - ) { - return null; - } - - const startIndex = store.getIndexOfElementID(element.children[0]); - if (startIndex === null) { - return null; - } - - let stopIndex = null; - let current: null | Element = element; - while (current !== null) { - if (current.isCollapsed || current.children.length === 0) { - // We've found the last/deepest descendant. - stopIndex = store.getIndexOfElementID(current.id); - current = null; - } else { - const lastChildID = current.children[current.children.length - 1]; - current = store.getElementByID(lastChildID); - } - } - - if (stopIndex === null) { - return null; - } - - return { - startIndex, - stopIndex, - }; - }, - subscribe: (callback: Function) => { - store.addListener('mutated', callback); - return () => { - store.removeListener('mutated', callback); - }; - }, - }), - [inspectedElementID, store], - ); - const data = useSubscription(subscription); - - if (ownerID !== null) { - return null; - } - - if (data === null) { - return null; - } - - const {startIndex, stopIndex} = data; - - return ( -
- ); -} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.css b/packages/react-devtools-shared/src/devtools/views/Components/Tree.css index bf18f1d2e6019..a65cda45aac9f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.css @@ -5,17 +5,16 @@ display: flex; flex-direction: column; border-top: 1px solid var(--color-border); - - /* Default size will be adjusted by Tree after scrolling */ - --indentation-size: 12px; } -.List { - overflow-x: hidden !important; +.InnerElementType { + position: relative; } -.InnerElementType { - overflow-x: hidden; +.VerticalDelimiter { + position: absolute; + width: 0.025rem; + background: #b0b0b0; } .SearchInput { @@ -97,4 +96,4 @@ .Link { color: var(--color-button-active); -} \ No newline at end of file +} 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..4e2d0f3551fee 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js @@ -29,7 +29,6 @@ import InspectHostNodesToggle from './InspectHostNodesToggle'; import OwnersStack from './OwnersStack'; import ComponentSearchInput from './ComponentSearchInput'; import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle'; -import SelectedTreeHighlight from './SelectedTreeHighlight'; import TreeFocusedContext from './TreeFocusedContext'; import {useHighlightHostInstance, useSubscription} from '../hooks'; import {clearErrorsAndWarnings as clearErrorsAndWarningsAPI} from 'react-devtools-shared/src/backendAPI'; @@ -40,13 +39,18 @@ import {logEvent} from 'react-devtools-shared/src/Logger'; import {useExtensionComponentsPanelVisibility} from 'react-devtools-shared/src/frontend/hooks/useExtensionComponentsPanelVisibility'; import {useChangeOwnerAction} from './OwnersListContext'; -// Never indent more than this number of pixels (even if we have the room). -const DEFAULT_INDENTATION_SIZE = 12; +// Indent for each node at level N, compared to node at level N - 1. +const INDENTATION_SIZE = 10; + +function calculateElementOffset(elementDepth: number): number { + return elementDepth * INDENTATION_SIZE; +} export type ItemData = { isNavigatingWithKeyboard: boolean, onElementMouseEnter: (id: number) => void, treeFocused: boolean, + calculateElementOffset: (depth: number) => number, }; function calculateInitialScrollOffset( @@ -90,16 +94,56 @@ export default function Tree(): React.Node { const treeRef = useRef(null); const focusTargetRef = useRef(null); const listRef = useRef(null); + const listDOMElementRef = useRef(null); useEffect(() => { - if (!componentsPanelVisible) { + if (!componentsPanelVisible || inspectedElementIndex == null) { + return; + } + + const listDOMElement = listDOMElementRef.current; + if (listDOMElement == null) { return; } - if (listRef.current != null && inspectedElementIndex !== null) { - listRef.current.scrollToItem(inspectedElementIndex, 'smart'); + const viewportHeight = listDOMElement.clientHeight; + const viewportLeft = listDOMElement.scrollLeft; + const viewportRight = viewportLeft + listDOMElement.clientWidth; + const viewportTop = listDOMElement.scrollTop; + const viewportBottom = viewportTop + viewportHeight; + + const element = store.getElementAtIndex(inspectedElementIndex); + if (element == null) { + return; + } + const elementLeft = calculateElementOffset(element.depth); + // Because of virtualization, this element might not be rendered yet; we can't look up its width. + // Assuming that it may take up to the half of the vieport. + const elementRight = elementLeft + listDOMElement.clientWidth / 2; + const elementTop = inspectedElementIndex * lineHeight; + const elementBottom = elementTop + lineHeight; + + const isElementFullyVisible = + elementTop >= viewportTop && + elementBottom <= viewportBottom && + elementLeft >= viewportLeft && + elementRight <= viewportRight; + + if (!isElementFullyVisible) { + const verticalDelta = + Math.min(0, elementTop - viewportTop) + + Math.max(0, elementBottom - viewportBottom); + const horizontalDelta = + Math.min(0, elementLeft - viewportLeft) + + Math.max(0, elementRight - viewportRight); + + listDOMElement.scrollBy({ + top: verticalDelta, + left: horizontalDelta, + behavior: treeFocused && ownerID == null ? 'smooth' : 'instant', + }); } - }, [inspectedElementIndex, componentsPanelVisible]); + }, [inspectedElementIndex, componentsPanelVisible, lineHeight]); // Picking an element in the inspector should put focus into the tree. // If possible, navigation works right after picking a node. @@ -291,8 +335,14 @@ export default function Tree(): React.Node { isNavigatingWithKeyboard, onElementMouseEnter: handleElementMouseEnter, treeFocused, + calculateElementOffset, }), - [isNavigatingWithKeyboard, handleElementMouseEnter, treeFocused], + [ + isNavigatingWithKeyboard, + handleElementMouseEnter, + treeFocused, + calculateElementOffset, + ], ); const itemKey = useCallback( @@ -422,6 +472,8 @@ export default function Tree(): React.Node { itemKey={itemKey} itemSize={lineHeight} ref={listRef} + outerRef={listDOMElementRef} + overscanCount={10} width={width}> {Element} @@ -434,153 +486,57 @@ export default function Tree(): React.Node { ); } -// Indentation size can be adjusted but child width is fixed. -// We need to adjust indentations so the widest child can fit without overflowing. -// Sometimes the widest child is also the deepest in the tree: -// ┏----------------------┓ -// ┆ ┆ -// ┆ •••• ┆ -// ┆ •••••••• ┆ -// ┗----------------------┛ -// -// But this is not always the case. -// Even with the above example, a change in indentation may change the overall widest child: -// ┏----------------------┓ -// ┆ ┆ -// ┆ •• ┆ -// ┆ •••• ┆ -// ┗----------------------┛ -// -// In extreme cases this difference can be important: -// ┏----------------------┓ -// ┆ ┆ -// ┆ •• ┆ -// ┆ •••• ┆ -// ┆ •••••• ┆ -// ┆ •••••••• ┆ -// ┗----------------------┛ -// -// In the above example, the current indentation is fine, -// but if we naively assumed that the widest element is also the deepest element, -// we would end up compressing the indentation unnecessarily: -// ┏----------------------┓ -// ┆ ┆ -// ┆ • ┆ -// ┆ •• ┆ -// ┆ ••• ┆ -// ┆ •••• ┆ -// ┗----------------------┛ -// -// The way we deal with this is to compute the max indentation size that can fit each child, -// given the child's fixed width and depth within the tree. -// Then we take the smallest of these indentation sizes... -function updateIndentationSizeVar( - innerDiv: HTMLDivElement, - cachedChildWidths: WeakMap, - indentationSizeRef: {current: number}, - prevListWidthRef: {current: number}, -): void { - const list = ((innerDiv.parentElement: any): HTMLDivElement); - const listWidth = list.clientWidth; - - // Skip measurements when the Components panel is hidden. - if (listWidth === 0) { - return; - } - - // Reset the max indentation size if the width of the tree has increased. - if (listWidth > prevListWidthRef.current) { - indentationSizeRef.current = DEFAULT_INDENTATION_SIZE; - } - prevListWidthRef.current = listWidth; - - let maxIndentationSize: number = indentationSizeRef.current; - - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const child of innerDiv.children) { - const depth = parseInt(child.getAttribute('data-depth'), 10) || 0; - - let childWidth: number = 0; - - const cachedChildWidth = cachedChildWidths.get(child); - if (cachedChildWidth != null) { - childWidth = cachedChildWidth; - } else { - const {firstElementChild} = child; - - // Skip over e.g. the guideline element - if (firstElementChild != null) { - childWidth = firstElementChild.clientWidth; - cachedChildWidths.set(child, childWidth); - } - } - - const remainingWidth = Math.max(0, listWidth - childWidth); +// $FlowFixMe[missing-local-annot] +function InnerElementType({children, style}) { + const store = useContext(StoreContext); - maxIndentationSize = Math.min(maxIndentationSize, remainingWidth / depth); - } + const {height} = style; + const maxDepth = store.getMaximumRecordedDepth(); + // Maximum possible indentation plus some arbitrary offset for the node content. + const width = calculateElementOffset(maxDepth) + 500; - indentationSizeRef.current = maxIndentationSize; + return ( +
+ {children} - list.style.setProperty('--indentation-size', `${maxIndentationSize}px`); + +
+ ); } -// $FlowFixMe[missing-local-annot] -function InnerElementType({children, style}) { - const {ownerID} = useContext(TreeStateContext); +function VerticalDelimiter() { + const store = useContext(StoreContext); + const {ownerID, inspectedElementIndex} = useContext(TreeStateContext); + const {lineHeight} = useContext(SettingsContext); - const cachedChildWidths = useMemo>( - () => new WeakMap(), - [], - ); + if (ownerID != null || inspectedElementIndex == null) { + return null; + } - // This ref tracks the current indentation size. - // We decrease indentation to fit wider/deeper trees. - // We intentionally do not increase it again afterward, to avoid the perception of content "jumping" - // e.g. clicking to toggle/collapse a row might otherwise jump horizontally beneath your cursor, - // e.g. scrolling a wide row off screen could cause narrower rows to jump to the right some. - // - // There are two exceptions for this: - // 1. The first is when the width of the tree increases. - // 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 prevListWidthRef = useRef(0); - const prevOwnerIDRef = useRef(ownerID); - const divRef = useRef(null); - - // We shouldn't retain this width across different conceptual trees though, - // 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; + const element = store.getElementAtIndex(inspectedElementIndex); + if (element == null) { + return null; + } + const indexOfLowestDescendant = + store.getIndexOfLowestDescendantElement(element); + if (indexOfLowestDescendant == null) { + return null; } - // When we render new content, measure to see if we need to shrink indentation to fit it. - useEffect(() => { - if (divRef.current !== null) { - updateIndentationSizeVar( - divRef.current, - cachedChildWidths, - indentationSizeRef, - prevListWidthRef, - ); - } - }); + const delimiterLeft = calculateElementOffset(element.depth) + 12; + const delimiterTop = (inspectedElementIndex + 1) * lineHeight; + const delimiterHeight = + (indexOfLowestDescendant + 1) * lineHeight - delimiterTop; - // This style override enables the background color to fill the full visible width, - // when combined with the CSS tweaks in Element. - // A lot of options were considered; this seemed the one that requires the least code. - // See https://github.com/bvaughn/react-devtools-experimental/issues/9 return (
- - {children} -
+ className={styles.VerticalDelimiter} + style={{ + left: delimiterLeft, + top: delimiterTop, + height: delimiterHeight, + }} + /> ); } 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 ( -
- +
+
+ +
- +
+ +