diff --git a/.circleci/config.yml b/.circleci/config.yml index fc1fffdaa9..1aad95b12a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -507,6 +507,10 @@ workflows: - "-r=www-modern --env=production --variant=false" - "-r=www-modern --env=development --variant=true" - "-r=www-modern --env=production --variant=true" + - "-r=xplat --env=development --variant=false" + - "-r=xplat --env=development --variant=true" + - "-r=xplat --env=production --variant=false" + - "-r=xplat --env=production --variant=true" # TODO: Test more persistent configurations? - '-r=stable --env=development --persistent' @@ -552,6 +556,12 @@ workflows: # - "-r=www-modern --env=development --variant=true" # - "-r=www-modern --env=production --variant=true" + # TODO: Update test config to support xplat build tests + # - "-r=xplat --env=development --variant=false" + # - "-r=xplat --env=development --variant=true" + # - "-r=xplat --env=production --variant=false" + # - "-r=xplat --env=production --variant=true" + # TODO: Test more persistent configurations? - download_base_build_for_sizebot: filters: diff --git a/.eslintrc.js b/.eslintrc.js index cf5b585870..ec20e2196e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -486,6 +486,7 @@ module.exports = { $ReadOnlyArray: 'readonly', $ArrayBufferView: 'readonly', $Shape: 'readonly', + CallSite: 'readonly', ConsoleTask: 'readonly', // TOOD: Figure out what the official name of this will be. ReturnType: 'readonly', AnimationFrameID: 'readonly', diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9f8129db9e..2b1404175e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,4 @@ +blank_issues_enabled: false contact_links: - name: 📃 Documentation Issue url: https://github.com/reactjs/react.dev/issues/new/choose diff --git a/.github/workflows/commit_artifacts.yml b/.github/workflows/commit_artifacts.yml index 3b09f99803..aec96b0fe3 100644 --- a/.github/workflows/commit_artifacts.yml +++ b/.github/workflows/commit_artifacts.yml @@ -10,7 +10,36 @@ jobs: outputs: www_branch_count: ${{ steps.check_branches.outputs.www_branch_count }} fbsource_branch_count: ${{ steps.check_branches.outputs.fbsource_branch_count }} + last_version_classic: ${{ steps.get_last_version_www.outputs.last_version_classic }} + last_version_modern: ${{ steps.get_last_version_www.outputs.last_version_modern }} + last_version_rn: ${{ steps.get_last_version_rn.outputs.last_version_rn }} + current_version_classic: ${{ steps.get_current_version.outputs.current_version_classic }} + current_version_modern: ${{ steps.get_current_version.outputs.current_version_modern }} + current_version_rn: ${{ steps.get_current_version.outputs.current_version_rn }} steps: + - uses: actions/checkout@v4 + with: + ref: builds/facebook-www + - name: "Get last version string for www" + id: get_last_version_www + run: | + # Empty checks only needed for backwards compatibility,can remove later. + VERSION_CLASSIC=$( [ -f ./compiled/facebook-www/VERSION_CLASSIC ] && cat ./compiled/facebook-www/VERSION_CLASSIC || echo '' ) + VERSION_MODERN=$( [ -f ./compiled/facebook-www/VERSION_MODERN ] && cat ./compiled/facebook-www/VERSION_MODERN || echo '' ) + echo "Last classic version is $VERSION_CLASSIC" + echo "Last modern version is $VERSION_MODERN" + echo "last_version_classic=$VERSION_CLASSIC" >> "$GITHUB_OUTPUT" + echo "last_version_modern=$VERSION_MODERN" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + ref: builds/facebook-fbsource + - name: "Get last version string for rn" + id: get_last_version_rn + run: | + # Empty checks only needed for backwards compatibility,can remove later. + VERSION_NATIVE_FB=$( [ -f ./compiled-rn/VERSION_NATIVE_FB ] && cat ./compiled-rn/VERSION_NATIVE_FB || echo '' ) + echo "Last rn version is $VERSION_NATIVE_FB" + echo "last_version_rn=$VERSION_NATIVE_FB" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v4 - name: "Check branches" id: check_branches @@ -147,7 +176,7 @@ jobs: mkdir -p ${BASE_FOLDER}/react-native-github/Libraries/Renderer/ mkdir -p ${BASE_FOLDER}/RKJSModules/vendor/react/{scheduler,react,react-is,react-test-renderer}/ - # Move React Native renderer + # Move React Native renderer mv build/react-native/implementations/ $BASE_FOLDER/react-native-github/Libraries/Renderer/ mv build/react-native/shims/ $BASE_FOLDER/react-native-github/Libraries/Renderer/ mv build/facebook-react-native/scheduler/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/scheduler/ @@ -160,11 +189,27 @@ jobs: rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js rm $RENDERER_FOLDER/ReactNativeRenderer-{dev,prod,profiling}.js - ls -R ./compiled - - name: Add REVISION file + # Move React Native version file + mv build/facebook-react-native/VERSION_NATIVE_FB ./compiled-rn/VERSION_NATIVE_FB + + ls -R ./compiled-rn + - name: Add REVISION files run: | echo ${{ github.sha }} >> ./compiled/facebook-www/REVISION + cp ./compiled/facebook-www/REVISION ./compiled/facebook-www/REVISION_TRANSFORMS echo ${{ github.sha }} >> ./compiled-rn/facebook-fbsource/xplat/js/react-native-github/Libraries/Renderer/REVISION + - name: "Get current version string" + id: get_current_version + run: | + VERSION_CLASSIC=$(cat ./compiled/facebook-www/VERSION_CLASSIC) + VERSION_MODERN=$(cat ./compiled/facebook-www/VERSION_MODERN) + VERSION_NATIVE_FB=$(cat ./compiled-rn/VERSION_NATIVE_FB) + echo "Current classic version is $VERSION_CLASSIC" + echo "Current modern version is $VERSION_MODERN" + echo "Current rn version is $VERSION_NATIVE_FB" + echo "current_version_classic=$VERSION_CLASSIC" >> "$GITHUB_OUTPUT" + echo "current_version_modern=$VERSION_MODERN" >> "$GITHUB_OUTPUT" + echo "current_version_rn=$VERSION_NATIVE_FB" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v3 with: name: compiled @@ -188,15 +233,58 @@ jobs: with: name: compiled path: compiled/ - - run: git status -u + - name: Revert version changes + if: needs.download_artifacts.outputs.last_version_classic != '' && needs.download_artifacts.outputs.last_version_modern != '' + env: + CURRENT_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.current_version_classic }} + CURRENT_VERSION_MODERN: ${{ needs.download_artifacts.outputs.current_version_modern }} + LAST_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.last_version_classic }} + LAST_VERSION_MODERN: ${{ needs.download_artifacts.outputs.last_version_modern }} + run: | + echo "Reverting $CURRENT_VERSION_CLASSIC to $LAST_VERSION_CLASSIC" + grep -rl "$CURRENT_VERSION_CLASSIC" ./compiled || echo "No files found with $CURRENT_VERSION_CLASSIC" + grep -rl "$CURRENT_VERSION_CLASSIC" ./compiled | xargs -r sed -i -e "s/$CURRENT_VERSION_CLASSIC/$LAST_VERSION_CLASSIC/g" + grep -rl "$CURRENT_VERSION_CLASSIC" ./compiled || echo "Classic version reverted" + echo "====================" + echo "Reverting $CURRENT_VERSION_MODERN to $LAST_VERSION_MODERN" + grep -rl "$CURRENT_VERSION_MODERN" ./compiled || echo "No files found with $CURRENT_VERSION_MODERN" + grep -rl "$CURRENT_VERSION_MODERN" ./compiled | xargs -r sed -i -e "s/$CURRENT_VERSION_MODERN/$LAST_VERSION_MODERN/g" + grep -rl "$CURRENT_VERSION_MODERN" ./compiled || echo "Modern version reverted" - name: Check if only the REVISION file has changed id: check_should_commit run: | - if git status --porcelain | grep -qv '/REVISION$'; then + echo "Full git status" + git status + echo "====================" + if git status --porcelain | grep -qv '/REVISION'; then + echo "Changes detected" echo "should_commit=true" >> "$GITHUB_OUTPUT" else + echo "No Changes detected" echo "should_commit=false" >> "$GITHUB_OUTPUT" fi + - name: Re-apply version changes + if: steps.check_should_commit.outputs.should_commit == 'true' && needs.download_artifacts.outputs.last_version_classic != '' && needs.download_artifacts.outputs.last_version_modern != '' + env: + CURRENT_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.current_version_classic }} + CURRENT_VERSION_MODERN: ${{ needs.download_artifacts.outputs.current_version_modern }} + LAST_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.last_version_classic }} + LAST_VERSION_MODERN: ${{ needs.download_artifacts.outputs.last_version_modern }} + run: | + echo "Re-applying $LAST_VERSION_CLASSIC to $CURRENT_VERSION_CLASSIC" + grep -rl "$LAST_VERSION_CLASSIC" ./compiled || echo "No files found with $LAST_VERSION_CLASSIC" + grep -rl "$LAST_VERSION_CLASSIC" ./compiled | xargs -r sed -i -e "s/$LAST_VERSION_CLASSIC/$CURRENT_VERSION_CLASSIC/g" + grep -rl "$LAST_VERSION_CLASSIC" ./compiled || echo "Classic version re-applied" + echo "====================" + echo "Re-applying $LAST_VERSION_MODERN to $CURRENT_VERSION_MODERN" + grep -rl "$LAST_VERSION_MODERN" ./compiled || echo "No files found with $LAST_VERSION_MODERN" + grep -rl "$LAST_VERSION_MODERN" ./compiled | xargs -r sed -i -e "s/$LAST_VERSION_MODERN/$CURRENT_VERSION_MODERN/g" + grep -rl "$LAST_VERSION_MODERN" ./compiled || echo "Classic version re-applied" + - name: Will commit these changes + if: steps.check_should_commit.outputs.should_commit == 'true' + run: | + echo ":" + git status -u - name: Commit changes to branch if: steps.check_should_commit.outputs.should_commit == 'true' uses: stefanzweifel/git-auto-commit-action@v4 @@ -219,20 +307,52 @@ jobs: with: ref: builds/facebook-fbsource - name: Ensure clean directory - run: rm -rf compiled + run: rm -rf compiled-rn - uses: actions/download-artifact@v3 with: name: compiled-rn path: compiled-rn/ - - run: git status -u + - name: Revert version changes + if: needs.download_artifacts.outputs.last_version_rn != '' + env: + CURRENT_VERSION: ${{ needs.download_artifacts.outputs.current_version_rn }} + LAST_VERSION: ${{ needs.download_artifacts.outputs.last_version_rn }} + run: | + echo "Reverting $CURRENT_VERSION to $LAST_VERSION" + grep -rl "$CURRENT_VERSION" ./compiled-rn || echo "No files found with $CURRENT_VERSION" + grep -rl "$CURRENT_VERSION" ./compiled-rn | xargs -r sed -i -e "s/$CURRENT_VERSION/$LAST_VERSION/g" + grep -rl "$CURRENT_VERSION" ./compiled-rn || echo "Version reverted" - name: Check if only the REVISION file has changed id: check_should_commit run: | - if git status --porcelain | grep -qv '/REVISION$'; then + echo "Full git status" + git status + echo "====================" + echo "Checking for changes" + # Check if there are changes in the files other than REVISION or @generated headers + # We also filter out the file name lines with "---" and "+++". + if git diff -- . ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|@generated SignedSource)" | grep "^[+-]" > /dev/null; then + echo "Changes detected" echo "should_commit=true" >> "$GITHUB_OUTPUT" else + echo "No Changes detected" echo "should_commit=false" >> "$GITHUB_OUTPUT" fi + - name: Re-apply version changes + if: steps.check_should_commit.outputs.should_commit == 'true' && needs.download_artifacts.outputs.last_version_rn != '' + env: + CURRENT_VERSION: ${{ needs.download_artifacts.outputs.current_version_rn }} + LAST_VERSION: ${{ needs.download_artifacts.outputs.last_version_rn }} + run: | + echo "Re-applying $LAST_VERSION to $CURRENT_VERSION" + grep -rl "$LAST_VERSION" ./compiled-rn || echo "No files found with $LAST_VERSION" + grep -rl "$LAST_VERSION" ./compiled-rn | xargs -r sed -i -e "s/$LAST_VERSION/$CURRENT_VERSION/g" + grep -rl "$LAST_VERSION" ./compiled-rn || echo "Version re-applied" + - name: Will commit these changes + if: steps.check_should_commit.outputs.should_commit == 'true' + run: | + echo ":" + git status -u - name: Commit changes to branch if: steps.check_should_commit.outputs.should_commit == 'true' uses: stefanzweifel/git-auto-commit-action@v4 diff --git a/ReactVersions.js b/ReactVersions.js index 14e3ba57c4..8fb7586ec8 100644 --- a/ReactVersions.js +++ b/ReactVersions.js @@ -28,6 +28,10 @@ const ReactVersion = '19.0.0'; // npm dist tags used during publish, refer to .circleci/config.yml. const canaryChannelLabel = 'rc'; +// If the canaryChannelLabel is "rc", the build pipeline will use this to build +// an RC version of the packages. +const rcNumber = 0; + const stablePackages = { 'eslint-plugin-react-hooks': '5.1.0', 'jest-react': '0.16.0', @@ -53,6 +57,7 @@ const experimentalPackages = []; module.exports = { ReactVersion, canaryChannelLabel, + rcNumber, stablePackages, experimentalPackages, }; diff --git a/compiler/packages/babel-plugin-react-compiler/package.json b/compiler/packages/babel-plugin-react-compiler/package.json index b53d374e28..dd41b7084e 100644 --- a/compiler/packages/babel-plugin-react-compiler/package.json +++ b/compiler/packages/babel-plugin-react-compiler/package.json @@ -1,6 +1,6 @@ { "name": "babel-plugin-react-compiler", - "version": "0.0.0-experimental-487cb0e-20240529", + "version": "0.0.0-experimental-938cd9a-20240601", "description": "Babel plugin for React Compiler.", "main": "dist/index.js", "license": "MIT", diff --git a/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts b/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts index 88731a8496..64a5816048 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Babel/BabelPlugin.ts @@ -30,10 +30,16 @@ export default function BabelPluginReactCompiler( */ Program(prog, pass): void { let opts = parsePluginOptions(pass.opts); - if (pipelineUsesReanimatedPlugin(pass.file.opts.plugins)) { + const isDev = + (typeof __DEV__ !== "undefined" && __DEV__ === true) || + process.env["NODE_ENV"] === "development"; + if ( + opts.enableReanimatedCheck === true && + pipelineUsesReanimatedPlugin(pass.file.opts.plugins) + ) { opts = injectReanimatedFlag(opts); } - if (process.env["NODE_ENV"] === "development") { + if (isDev) { opts = { ...opts, environment: { 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 db305ea5c8..262e9b1001 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts @@ -111,6 +111,12 @@ export type PluginOptions = { ignoreUseNoForget: boolean; sources?: Array | ((filename: string) => boolean) | null; + + /** + * The compiler has customized support for react-native-reanimated, intended as a temporary workaround. + * Set this flag (on by default) to automatically check for this library and activate the support. + */ + enableReanimatedCheck: boolean; }; const CompilationModeSchema = z.enum([ @@ -188,6 +194,7 @@ export const defaultOptions: PluginOptions = { sources: (filename) => { return filename.indexOf("node_modules") === -1; }, + enableReanimatedCheck: true, } as const; export function parsePluginOptions(obj: unknown): PluginOptions { 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 1fa755499e..6d231919a6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -91,6 +91,7 @@ import { validatePreservedManualMemoization, validateUseMemo, } from "../Validation"; +import pruneInitializationDependencies from "../ReactiveScopes/PruneInitializationDependencies"; export type CompilerPipelineValue = | { kind: "ast"; name: string; value: CodegenFunction } @@ -147,8 +148,14 @@ function* runWithEnvironment( validateContextVariableLValues(hir); validateUseMemo(hir); - dropManualMemoization(hir); - yield log({ kind: "hir", name: "DropManualMemoization", value: hir }); + if ( + !env.config.enablePreserveExistingManualUseMemo && + !env.config.disableMemoizationForDebugging && + !env.config.enableChangeDetectionForDebugging + ) { + dropManualMemoization(hir); + yield log({ kind: "hir", name: "DropManualMemoization", value: hir }); + } inlineImmediatelyInvokedFunctionExpressions(hir); yield log({ @@ -373,6 +380,15 @@ function* runWithEnvironment( value: reactiveFunction, }); + if (env.config.enableChangeDetectionForDebugging != null) { + pruneInitializationDependencies(reactiveFunction); + yield log({ + kind: "reactive", + name: "PruneInitializationDependencies", + value: reactiveFunction, + }); + } + propagateEarlyReturns(reactiveFunction); yield log({ kind: "reactive", 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 dc74077b63..3d6612afd4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts @@ -421,6 +421,13 @@ export function compileProgram( ); externalFunctions.push(enableEmitHookGuards); } + + if (options.environment?.enableChangeDetectionForDebugging != null) { + const enableChangeDetectionForDebugging = tryParseExternalFunction( + options.environment.enableChangeDetectionForDebugging + ); + externalFunctions.push(enableChangeDetectionForDebugging); + } } catch (err) { handleError(err, pass, null); return; 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 59e2f0c89f..91f2fb8c7c 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts @@ -124,7 +124,7 @@ export function lower( ) { const place: Place = { kind: "Identifier", - identifier: builder.makeTemporary(), + identifier: builder.makeTemporary(param.node.loc ?? GeneratedSource), effect: Effect.Unknown, reactive: false, loc: param.node.loc ?? GeneratedSource, @@ -141,7 +141,7 @@ export function lower( } else if (param.isRestElement()) { const place: Place = { kind: "Identifier", - identifier: builder.makeTemporary(), + identifier: builder.makeTemporary(param.node.loc ?? GeneratedSource), effect: Effect.Unknown, reactive: false, loc: param.node.loc ?? GeneratedSource, @@ -1256,7 +1256,9 @@ function lowerStatement( if (hasNode(handlerBindingPath)) { const place: Place = { kind: "Identifier", - identifier: builder.makeTemporary(), + identifier: builder.makeTemporary( + handlerBindingPath.node.loc ?? GeneratedSource + ), effect: Effect.Unknown, reactive: false, loc: handlerBindingPath.node.loc ?? GeneratedSource, @@ -3301,7 +3303,7 @@ function lowerIdentifier( function buildTemporaryPlace(builder: HIRBuilder, loc: SourceLocation): Place { const place: Place = { kind: "Identifier", - identifier: builder.makeTemporary(), + identifier: builder.makeTemporary(loc), effect: Effect.Unknown, reactive: false, loc, 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 f950068f15..2a94eec79b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -165,6 +165,13 @@ const EnvironmentConfigSchema = z.object({ */ validatePreserveExistingMemoizationGuarantees: z.boolean().default(true), + /** + * When this is true, rather than pruning existing manual memoization but ensuring or validating + * that the memoized values remain memoized, the compiler will simply not prune existing calls to + * useMemo/useCallback. + */ + enablePreserveExistingManualUseMemo: z.boolean().default(false), + // 🌲 enableForest: z.boolean().default(false), @@ -343,6 +350,23 @@ const EnvironmentConfigSchema = z.object({ */ enableTreatFunctionDepsAsConditional: z.boolean().default(false), + /** + * When true, always act as though the dependencies of a memoized value + * have changed. This makes the compiler not actually perform any optimizations, + * but is useful for debugging. Implicitly also sets + * @enablePreserveExistingManualUseMemo, because otherwise memoization in the + * original source will be disabled as well. + */ + disableMemoizationForDebugging: z.boolean().default(false), + + /** + * When true, rather using memoized values, the compiler will always re-compute + * values, and then use a heuristic to compare the memoized value to the newly + * computed one. This detects cases where rules of react violations may cause the + * compiled code to behave differently than the original. + */ + enableChangeDetectionForDebugging: ExternalFunctionSchema.nullish(), + /** * The react native re-animated library uses custom Babel transforms that * requires the calls to library API remain unmodified. @@ -462,6 +486,18 @@ export class Environment { this.#shapes = new Map(DEFAULT_SHAPES); this.#globals = new Map(DEFAULT_GLOBALS); + if ( + config.disableMemoizationForDebugging && + config.enableChangeDetectionForDebugging != null + ) { + CompilerError.throwInvalidConfig({ + reason: `Invalid environment config: the 'disableMemoizationForDebugging' and 'enableChangeDetectionForDebugging' options cannot be used together`, + description: null, + loc: null, + suggestions: null, + }); + } + for (const [hookName, hook] of this.config.customHooks) { CompilerError.invariant(!this.#globals.has(hookName), { reason: `[Globals] Found existing definition in global registry for custom hook ${hookName}`, 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 931d315d30..041d2fbf00 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts @@ -13,6 +13,7 @@ import { BuiltInUseInsertionEffectHookId, BuiltInUseLayoutEffectHookId, BuiltInUseOperatorId, + BuiltInUseReducerId, BuiltInUseRefId, BuiltInUseStateId, ShapeRegistry, @@ -265,6 +266,18 @@ const REACT_APIS: Array<[string, BuiltInType]> = [ returnValueReason: ValueReason.State, }), ], + [ + "useReducer", + addHook(DEFAULT_SHAPES, { + positionalParams: [], + restParam: Effect.Freeze, + returnType: { kind: "Object", shapeId: BuiltInUseReducerId }, + calleeEffect: Effect.Read, + hookKind: "useReducer", + returnValueKind: ValueKind.Frozen, + returnValueReason: ValueReason.ReducerState, + }), + ], [ "useRef", addHook(DEFAULT_SHAPES, { 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 da900c275c..afa0799b40 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts @@ -1144,6 +1144,7 @@ export type Identifier = { */ scope: ReactiveScope | null; type: Type; + loc: SourceLocation; }; export type IdentifierName = ValidatedIdentifier | PromotedIdentifier; @@ -1253,6 +1254,11 @@ export enum ValueReason { */ State = "state", + /** + * A value returned from `useReducer` + */ + ReducerState = "reducer-state", + /** * Props of a component or arguments of a hook. */ @@ -1376,6 +1382,8 @@ export type ReactiveScope = { * no longer exist due to being pruned. */ merged: Set; + + loc: SourceLocation; }; export type ReactiveScopeDependencies = Set; @@ -1490,6 +1498,14 @@ export function isSetStateType(id: Identifier): boolean { return id.type.kind === "Function" && id.type.shapeId === "BuiltInSetState"; } +export function isUseReducerType(id: Identifier): boolean { + return id.type.kind === "Function" && id.type.shapeId === "BuiltInUseReducer"; +} + +export function isDispatcherType(id: Identifier): boolean { + return id.type.kind === "Function" && id.type.shapeId === "BuiltInDispatch"; +} + export function isUseEffectHookType(id: Identifier): boolean { return ( id.type.kind === "Function" && id.type.shapeId === "BuiltInUseEffectHook" 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 970e4ba51d..0342d57ea3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts @@ -21,6 +21,7 @@ import { IdentifierId, Instruction, Place, + SourceLocation, Terminal, VariableBinding, makeBlockId, @@ -174,7 +175,7 @@ export default class HIRBuilder { return handler ?? null; } - makeTemporary(): Identifier { + makeTemporary(loc: SourceLocation): Identifier { const id = this.nextIdentifierId; return { id, @@ -182,6 +183,7 @@ export default class HIRBuilder { mutableRange: { start: makeInstructionId(0), end: makeInstructionId(0) }, scope: null, type: makeType(), + loc, }; } @@ -320,6 +322,7 @@ export default class HIRBuilder { }, scope: null, type: makeType(), + loc: node.loc ?? GeneratedSource, }; this.#bindings.set(name, { node, identifier }); return identifier; @@ -877,7 +880,10 @@ export function removeUnnecessaryTryCatch(fn: HIR): void { } } -export function createTemporaryPlace(env: Environment): Place { +export function createTemporaryPlace( + env: Environment, + loc: SourceLocation +): Place { return { kind: "Identifier", identifier: { @@ -886,6 +892,7 @@ export function createTemporaryPlace(env: Environment): Place { name: null, scope: null, type: makeType(), + loc, }, reactive: false, effect: Effect.Unknown, 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 fd04bf43c2..8997ad086f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -118,6 +118,7 @@ function addShape( export type HookKind = | "useContext" | "useState" + | "useReducer" | "useRef" | "useEffect" | "useLayoutEffect" @@ -200,6 +201,8 @@ export const BuiltInUseEffectHookId = "BuiltInUseEffectHook"; export const BuiltInUseLayoutEffectHookId = "BuiltInUseLayoutEffectHook"; export const BuiltInUseInsertionEffectHookId = "BuiltInUseInsertionEffectHook"; export const BuiltInUseOperatorId = "BuiltInUseOperator"; +export const BuiltInUseReducerId = "BuiltInUseReducer"; +export const BuiltInDispatchId = "BuiltInDispatch"; // ShapeRegistry with default definitions for built-ins. export const BUILTIN_SHAPES: ShapeRegistry = new Map(); @@ -387,6 +390,25 @@ addObject(BUILTIN_SHAPES, BuiltInUseStateId, [ ], ]); +addObject(BUILTIN_SHAPES, BuiltInUseReducerId, [ + ["0", { kind: "Poly" }], + [ + "1", + addFunction( + BUILTIN_SHAPES, + [], + { + positionalParams: [], + restParam: Effect.Freeze, + returnType: PRIMITIVE_TYPE, + calleeEffect: Effect.Read, + returnValueKind: ValueKind.Primitive, + }, + BuiltInDispatchId + ), + ], +]); + addObject(BUILTIN_SHAPES, BuiltInUseRefId, [ ["current", { kind: "Object", shapeId: BuiltInRefValueId }], ]); 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 5aaf7989fe..932cb4cc80 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/DropManualMemoization.ts @@ -178,7 +178,7 @@ function makeManualMemoizationMarkers( return [ { id: makeInstructionId(0), - lvalue: createTemporaryPlace(env), + lvalue: createTemporaryPlace(env, fnExpr.loc), value: { kind: "StartMemoize", manualMemoId, @@ -193,7 +193,7 @@ function makeManualMemoizationMarkers( }, { id: makeInstructionId(0), - lvalue: createTemporaryPlace(env), + lvalue: createTemporaryPlace(env, fnExpr.loc), value: { kind: "FinishMemoize", manualMemoId, 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 ad2f666ac1..e6a7bb49ce 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReactivePlaces.ts @@ -15,6 +15,7 @@ import { Place, computePostDominatorTree, getHookKind, + isDispatcherType, isSetStateType, isUseOperator, } from "../HIR"; @@ -219,7 +220,10 @@ export function inferReactivePlaces(fn: HIRFunction): void { if (hasReactiveInput) { for (const lvalue of eachInstructionLValue(instruction)) { - if (isSetStateType(lvalue.identifier)) { + if ( + isSetStateType(lvalue.identifier) || + isDispatcherType(lvalue.identifier) + ) { continue; } reactiveIdentifiers.markReactive(lvalue); 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 520684c026..387dafb6e5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts @@ -2117,6 +2117,8 @@ function getWriteErrorReason(abstractValue: AbstractValue): string { return "Mutating component props or hook arguments is not allowed. Consider using a local variable instead"; } else if (abstractValue.reason.has(ValueReason.State)) { 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 { return "This mutates a variable that React considers immutable"; } 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 5da6fcd4fe..c64ed19d18 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InlineImmediatelyInvokedFunctionExpressions.ts @@ -236,6 +236,7 @@ function rewriteBlock( name: null, scope: null, type: makeType(), + loc: terminal.loc, }, kind: "Identifier", reactive: false, @@ -277,6 +278,7 @@ function declareTemporary( name: null, scope: null, type: makeType(), + loc: result.loc, }, kind: "Identifier", reactive: false, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts b/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts index e2116c9d94..fd19369c24 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts @@ -369,6 +369,58 @@ function evaluateInstruction( } break; } + case "|": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs | rhs, loc: value.loc }; + } + break; + } + case "&": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs & rhs, loc: value.loc }; + } + break; + } + case "^": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs ^ rhs, loc: value.loc }; + } + break; + } + case "<<": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs << rhs, loc: value.loc }; + } + break; + } + case ">>": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs >> rhs, loc: value.loc }; + } + break; + } + case ">>>": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { + kind: "Primitive", + value: lhs >>> rhs, + loc: value.loc, + }; + } + break; + } + case "%": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs % rhs, loc: value.loc }; + } + break; + } + case "**": { + if (typeof lhs === "number" && typeof rhs === "number") { + result = { kind: "Primitive", value: lhs ** rhs, loc: value.loc }; + } + break; + } case "<": { if (typeof lhs === "number" && typeof rhs === "number") { result = { kind: "Primitive", value: lhs < rhs, loc: value.loc }; 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 075fb98792..f43cae0831 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -461,6 +461,11 @@ function codegenReactiveScope( ): void { const cacheStoreStatements: Array = []; const cacheLoadStatements: Array = []; + const cacheLoads: Array<{ + name: t.Identifier; + index: number; + value: t.Expression; + }> = []; const changeExpressions: Array = []; const changeExpressionComments: Array = []; const outputComments: Array = []; @@ -488,6 +493,10 @@ function codegenReactiveScope( } else { changeExpressions.push(comparison); } + /* + * Adding directly to cacheStoreStatements rather than cacheLoads, because there + * is no corresponding cacheLoadStatement for dependencies + */ cacheStoreStatements.push( t.expressionStatement( t.assignmentExpression( @@ -523,32 +532,7 @@ function codegenReactiveScope( t.variableDeclaration("let", [t.variableDeclarator(name)]) ); } - cacheStoreStatements.push( - t.expressionStatement( - t.assignmentExpression( - "=", - t.memberExpression( - t.identifier(cx.synthesizeName("$")), - t.numericLiteral(index), - true - ), - wrapCacheDep(cx, name) - ) - ) - ); - cacheLoadStatements.push( - t.expressionStatement( - t.assignmentExpression( - "=", - name, - t.memberExpression( - t.identifier(cx.synthesizeName("$")), - t.numericLiteral(index), - true - ) - ) - ) - ); + cacheLoads.push({ name, index, value: wrapCacheDep(cx, name) }); cx.declare(identifier); } for (const reassignment of scope.reassignments) { @@ -558,34 +542,9 @@ function codegenReactiveScope( } const name = convertIdentifier(reassignment); outputComments.push(name.name); - - cacheStoreStatements.push( - t.expressionStatement( - t.assignmentExpression( - "=", - t.memberExpression( - t.identifier(cx.synthesizeName("$")), - t.numericLiteral(index), - true - ), - wrapCacheDep(cx, name) - ) - ) - ); - cacheLoadStatements.push( - t.expressionStatement( - t.assignmentExpression( - "=", - name, - t.memberExpression( - t.identifier(cx.synthesizeName("$")), - t.numericLiteral(index), - true - ) - ) - ) - ); + cacheLoads.push({ name, index, value: wrapCacheDep(cx, name) }); } + let testCondition = (changeExpressions as Array).reduce( (acc: t.Expression | null, ident: t.Expression) => { if (acc == null) { @@ -616,15 +575,139 @@ function codegenReactiveScope( ); } + if (cx.env.config.disableMemoizationForDebugging) { + CompilerError.invariant( + cx.env.config.enableChangeDetectionForDebugging == null, + { + reason: `Expected to not have both change detection enabled and memoization disabled`, + description: `Incompatible config options`, + loc: null, + } + ); + testCondition = t.logicalExpression( + "||", + testCondition, + t.booleanLiteral(true) + ); + } let computationBlock = codegenBlock(cx, block); - computationBlock.body.push(...cacheStoreStatements); - const memoBlock = t.blockStatement(cacheLoadStatements); - const memoStatement = t.ifStatement( - testCondition, - computationBlock, - memoBlock - ); + let memoStatement; + if ( + cx.env.config.enableChangeDetectionForDebugging != null && + changeExpressions.length > 0 + ) { + const loc = + typeof scope.loc === "symbol" + ? "unknown location" + : `(${scope.loc.start.line}:${scope.loc.end.line})`; + const detectionFunction = + cx.env.config.enableChangeDetectionForDebugging.importSpecifierName; + const cacheLoadOldValueStatements: Array = []; + const changeDetectionStatements: Array = []; + const idempotenceDetectionStatements: Array = []; + + for (const { name, index, value } of cacheLoads) { + const loadName = cx.synthesizeName(`old$${name.name}`); + const slot = t.memberExpression( + t.identifier(cx.synthesizeName("$")), + t.numericLiteral(index), + true + ); + cacheStoreStatements.push( + t.expressionStatement(t.assignmentExpression("=", slot, value)) + ); + cacheLoadOldValueStatements.push( + t.variableDeclaration("let", [ + t.variableDeclarator(t.identifier(loadName), slot), + ]) + ); + changeDetectionStatements.push( + t.expressionStatement( + t.callExpression(t.identifier(detectionFunction), [ + t.identifier(loadName), + name, + t.stringLiteral(name.name), + t.stringLiteral(cx.fnName), + t.stringLiteral("cached"), + t.stringLiteral(loc), + ]) + ) + ); + idempotenceDetectionStatements.push( + t.expressionStatement( + t.callExpression(t.identifier(detectionFunction), [ + slot, + name, + t.stringLiteral(name.name), + t.stringLiteral(cx.fnName), + t.stringLiteral("recomputed"), + t.stringLiteral(loc), + ]) + ) + ); + idempotenceDetectionStatements.push( + t.expressionStatement(t.assignmentExpression("=", name, slot)) + ); + } + const condition = cx.synthesizeName("condition"); + memoStatement = t.blockStatement([ + ...computationBlock.body, + t.variableDeclaration("let", [ + t.variableDeclarator(t.identifier(condition), testCondition), + ]), + t.ifStatement( + t.unaryExpression("!", t.identifier(condition)), + t.blockStatement([ + ...cacheLoadOldValueStatements, + ...changeDetectionStatements, + ]) + ), + ...cacheStoreStatements, + t.ifStatement( + t.identifier(condition), + t.blockStatement([ + ...computationBlock.body, + ...idempotenceDetectionStatements, + ]) + ), + ]); + } else { + for (const { name, index, value } of cacheLoads) { + cacheStoreStatements.push( + t.expressionStatement( + t.assignmentExpression( + "=", + t.memberExpression( + t.identifier(cx.synthesizeName("$")), + t.numericLiteral(index), + true + ), + value + ) + ) + ); + cacheLoadStatements.push( + t.expressionStatement( + t.assignmentExpression( + "=", + name, + t.memberExpression( + t.identifier(cx.synthesizeName("$")), + t.numericLiteral(index), + true + ) + ) + ) + ); + } + computationBlock.body.push(...cacheStoreStatements); + memoStatement = t.ifStatement( + testCondition, + computationBlock, + t.blockStatement(cacheLoadStatements) + ); + } if (cx.env.config.enableMemoizationComments) { if (changeExpressionComments.length) { @@ -665,9 +748,9 @@ function codegenReactiveScope( true ); } - if (memoBlock.body.length > 0) { + if (cacheLoadStatements.length > 0) { t.addComment( - memoBlock.body[0]!, + cacheLoadStatements[0]!, "leading", ` Inputs did not change, use cached value`, true diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts index a8142c8720..833b784f0d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { CompilerError } from ".."; +import { CompilerError, SourceLocation } from ".."; import { Environment } from "../HIR"; import { GeneratedSource, @@ -110,6 +110,7 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void { reassignments: new Set(), earlyReturnValue: null, merged: new Set(), + loc: identifier.loc, }; scopes.set(groupIdentifier, scope); } else { @@ -119,6 +120,7 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void { scope.range.end = makeInstructionId( Math.max(scope.range.end, identifier.mutableRange.end) ); + scope.loc = mergeLocation(scope.loc, identifier.loc); } identifier.scope = scope; identifier.mutableRange = scope.range; @@ -159,6 +161,25 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void { } } +function mergeLocation(l: SourceLocation, r: SourceLocation): SourceLocation { + if (l === GeneratedSource) { + return r; + } else if (r === GeneratedSource) { + return l; + } else { + return { + start: { + line: Math.min(l.start.line, r.start.line), + column: Math.min(l.start.column, r.start.column), + }, + end: { + line: Math.max(l.end.line, r.end.line), + column: Math.max(l.end.column, r.end.column), + }, + }; + } +} + // Is the operand mutable at this given instruction export function isMutable({ id }: Instruction, place: Place): boolean { const range = place.identifier.mutableRange; diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateEarlyReturns.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateEarlyReturns.ts index ee25a123fb..ef2c217e25 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateEarlyReturns.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PropagateEarlyReturns.ts @@ -153,10 +153,10 @@ class Transform extends ReactiveFunctionTransform { const instructions = scopeBlock.instructions; const loc = earlyReturnValue.loc; - const sentinelTemp = createTemporaryPlace(this.env); - const symbolTemp = createTemporaryPlace(this.env); - const forTemp = createTemporaryPlace(this.env); - const argTemp = createTemporaryPlace(this.env); + const sentinelTemp = createTemporaryPlace(this.env, loc); + const symbolTemp = createTemporaryPlace(this.env, loc); + const forTemp = createTemporaryPlace(this.env, loc); + const argTemp = createTemporaryPlace(this.env, loc); scopeBlock.instructions = [ { kind: "instruction", @@ -274,7 +274,7 @@ class Transform extends ReactiveFunctionTransform { if (state.earlyReturnValue !== null) { earlyReturnValue = state.earlyReturnValue; } else { - const identifier = createTemporaryPlace(this.env).identifier; + const identifier = createTemporaryPlace(this.env, loc).identifier; promoteTemporary(identifier); earlyReturnValue = { label: this.env.nextBlockId, diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts new file mode 100644 index 0000000000..b9939addcf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneInitializationDependencies.ts @@ -0,0 +1,290 @@ +/** + * 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 } from "../CompilerError"; +import { + Environment, + Identifier, + IdentifierId, + InstructionId, + Place, + ReactiveBlock, + ReactiveFunction, + ReactiveInstruction, + ReactiveScopeBlock, + ReactiveTerminalStatement, + getHookKind, + isUseRefType, + isUseStateType, +} from "../HIR"; +import { eachCallArgument, eachInstructionLValue } from "../HIR/visitors"; +import DisjointSet from "../Utils/DisjointSet"; +import { assertExhaustive } from "../Utils/utils"; +import { ReactiveFunctionVisitor, visitReactiveFunction } from "./visitors"; + +/** + * This pass is built based on the observation by @jbrown215 that arguments + * to useState and useRef are only used the first time a component is rendered. + * Any subsequent times, the arguments will be evaluated but ignored. In this pass, + * we use this fact to improve the output of the compiler by not recomputing values that + * are only used as arguments (or inputs to arguments to) useState and useRef. + * + * This pass isn't yet stress-tested so it's not enabled by default. It's only enabled + * to support certain debug modes that detect non-idempotent code, since non-idempotent + * code can "safely" be used if its only passed to useState and useRef. We plan to rewrite + * this pass in HIR and enable it as an optimization in the future. + * + * Algorithm: + * We take two passes over the reactive function AST. In the first pass, we gather + * aliases and build relationships between property accesses--the key thing we need + * to do here is to find that, e.g., $0.x and $1 refer to the same value if + * $1 = PropertyLoad $0.x. + * + * In the second pass, we traverse the AST in reverse order and track how each place + * is used. If a place is read from in any Terminal, we mark the place as "Update", meaning + * it is used whenever the component is updated/re-rendered. If a place is read from in + * a useState or useRef hook call, we mark it as "Create", since it is only used when the + * component is created. In other instructions, we propagate the inferred place for the + * instructions lvalues onto any other instructions that are read. + * + * Whenever we finish this reverse pass over a reactive block, we can look at the blocks + * dependencies and see whether the dependencies are used in an "Update" context or only + * in a "Create" context. If a dependency is create-only, then we can remove that dependency + * from the block. + */ + +type CreateUpdate = "Create" | "Update" | "Unknown"; + +type KindMap = Map; + +class Visitor extends ReactiveFunctionVisitor { + map: KindMap = new Map(); + aliases: DisjointSet; + paths: Map>; + env: Environment; + + constructor( + env: Environment, + aliases: DisjointSet, + paths: Map> + ) { + super(); + this.aliases = aliases; + this.paths = paths; + this.env = env; + } + + join(values: Array): CreateUpdate { + function join2(l: CreateUpdate, r: CreateUpdate): CreateUpdate { + if (l === "Update" || r === "Update") { + return "Update"; + } else if (l === "Create" || r === "Create") { + return "Create"; + } else if (l === "Unknown" || r === "Unknown") { + return "Unknown"; + } + assertExhaustive(r, `Unhandled variable kind ${r}`); + } + return values.reduce(join2, "Unknown"); + } + + isCreateOnlyHook(id: Identifier): boolean { + return isUseStateType(id) || isUseRefType(id); + } + + override visitPlace( + _: InstructionId, + place: Place, + state: CreateUpdate + ): void { + this.map.set( + place.identifier.id, + this.join([state, this.map.get(place.identifier.id) ?? "Unknown"]) + ); + } + + override visitBlock(block: ReactiveBlock, state: CreateUpdate): void { + super.visitBlock([...block].reverse(), state); + } + + override visitInstruction(instruction: ReactiveInstruction): void { + const state = this.join( + [...eachInstructionLValue(instruction)].map( + (operand) => this.map.get(operand.identifier.id) ?? "Unknown" + ) + ); + + const visitCallOrMethodNonArgs = (): void => { + switch (instruction.value.kind) { + case "CallExpression": { + this.visitPlace(instruction.id, instruction.value.callee, state); + break; + } + case "MethodCall": { + this.visitPlace(instruction.id, instruction.value.property, state); + this.visitPlace(instruction.id, instruction.value.receiver, state); + break; + } + } + }; + + const isHook = (): boolean => { + let callee = null; + switch (instruction.value.kind) { + case "CallExpression": { + callee = instruction.value.callee.identifier; + break; + } + case "MethodCall": { + callee = instruction.value.property.identifier; + break; + } + } + return callee != null && getHookKind(this.env, callee) != null; + }; + + switch (instruction.value.kind) { + case "CallExpression": + case "MethodCall": { + if ( + instruction.lvalue && + this.isCreateOnlyHook(instruction.lvalue.identifier) + ) { + [...eachCallArgument(instruction.value.args)].forEach((operand) => + this.visitPlace(instruction.id, operand, "Create") + ); + visitCallOrMethodNonArgs(); + } else { + this.traverseInstruction(instruction, isHook() ? "Update" : state); + } + break; + } + default: { + this.traverseInstruction(instruction, state); + } + } + } + + override visitScope(scope: ReactiveScopeBlock): void { + const state = this.join( + [ + ...scope.scope.declarations.keys(), + ...[...scope.scope.reassignments.values()].map((ident) => ident.id), + ].map((id) => this.map.get(id) ?? "Unknown") + ); + super.visitScope(scope, state); + [...scope.scope.dependencies].forEach((ident) => { + let target: undefined | IdentifierId = + this.aliases.find(ident.identifier.id) ?? ident.identifier.id; + ident.path.forEach((key) => { + target &&= this.paths.get(target)?.get(key); + }); + if (target && this.map.get(target) === "Create") { + scope.scope.dependencies.delete(ident); + } + }); + } + + override visitTerminal( + stmt: ReactiveTerminalStatement, + state: CreateUpdate + ): void { + CompilerError.invariant(state !== "Create", { + reason: "Visiting a terminal statement with state 'Create'", + loc: stmt.terminal.loc, + }); + super.visitTerminal(stmt, state); + } + + override visitReactiveFunctionValue( + _id: InstructionId, + _dependencies: Array, + fn: ReactiveFunction, + state: CreateUpdate + ): void { + visitReactiveFunction(fn, this, state); + } +} + +export default function pruneInitializationDependencies( + fn: ReactiveFunction +): void { + const [aliases, paths] = getAliases(fn); + visitReactiveFunction(fn, new Visitor(fn.env, aliases, paths), "Update"); +} + +function update( + map: Map>, + key: IdentifierId, + path: string, + value: IdentifierId +): void { + const inner = map.get(key) ?? new Map(); + inner.set(path, value); + map.set(key, inner); +} + +class AliasVisitor extends ReactiveFunctionVisitor { + scopeIdentifiers: DisjointSet = new DisjointSet(); + scopePaths: Map> = new Map(); + + override visitInstruction(instr: ReactiveInstruction): void { + if ( + instr.value.kind === "StoreLocal" || + instr.value.kind === "StoreContext" + ) { + this.scopeIdentifiers.union([ + instr.value.lvalue.place.identifier.id, + instr.value.value.identifier.id, + ]); + } else if ( + instr.value.kind === "LoadLocal" || + instr.value.kind === "LoadContext" + ) { + instr.lvalue && + this.scopeIdentifiers.union([ + instr.lvalue.identifier.id, + instr.value.place.identifier.id, + ]); + } else if (instr.value.kind === "PropertyLoad") { + instr.lvalue && + update( + this.scopePaths, + instr.value.object.identifier.id, + instr.value.property, + instr.lvalue.identifier.id + ); + } else if (instr.value.kind === "PropertyStore") { + update( + this.scopePaths, + instr.value.object.identifier.id, + instr.value.property, + instr.value.value.identifier.id + ); + } + } +} + +function getAliases( + fn: ReactiveFunction +): [DisjointSet, Map>] { + const visitor = new AliasVisitor(); + visitReactiveFunction(fn, visitor, null); + let disjoint = visitor.scopeIdentifiers; + let scopePaths = new Map>(); + for (const [key, value] of visitor.scopePaths) { + for (const [path, id] of value) { + update( + scopePaths, + disjoint.find(key) ?? key, + path, + disjoint.find(id) ?? id + ); + } + } + return [disjoint, scopePaths]; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonReactiveDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonReactiveDependencies.ts index 0c82cefc59..aef5d50ee3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonReactiveDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/PruneNonReactiveDependencies.ts @@ -10,6 +10,7 @@ import { ReactiveFunction, ReactiveInstruction, ReactiveScopeBlock, + isDispatcherType, isSetStateType, } from "../HIR"; import { eachPatternOperand } from "../HIR/visitors"; @@ -56,7 +57,10 @@ class Visitor extends ReactiveFunctionVisitor { case "Destructure": { if (state.has(value.value.identifier.id)) { for (const lvalue of eachPatternOperand(value.lvalue.pattern)) { - if (isSetStateType(lvalue.identifier)) { + if ( + isSetStateType(lvalue.identifier) || + isDispatcherType(lvalue.identifier) + ) { continue; } state.add(lvalue.identifier.id); diff --git a/compiler/packages/babel-plugin-react-compiler/src/SSA/EnterSSA.ts b/compiler/packages/babel-plugin-react-compiler/src/SSA/EnterSSA.ts index e39b54aaca..8f5b78cc77 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/SSA/EnterSSA.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/SSA/EnterSSA.ts @@ -86,6 +86,7 @@ class SSABuilder { }, scope: null, // reset along w the mutable range type: makeType(), + loc: oldId.loc, }; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/assignment-variations.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/assignment-variations.expect.md index 746fed4056..880601fbc6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/assignment-variations.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/assignment-variations.expect.md @@ -22,10 +22,7 @@ export const FIXTURE_ENTRYPOINT = { ```javascript function f() { - let x; - - x = 3 >>> 1; - return x; + return 1; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md new file mode 100644 index 0000000000..099faadced --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.expect.md @@ -0,0 +1,48 @@ + +## Input + +```javascript +// @enableChangeDetectionForDebugging +function Component(props) { + let x = null; + if (props.cond) { + x = []; + x.push(props.value); + } + return x; +} + +``` + +## Code + +```javascript +import { $structuralCheck } from "react-compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableChangeDetectionForDebugging +function Component(props) { + const $ = _c(2); + let x = null; + if (props.cond) { + { + x = []; + x.push(props.value); + let condition = $[0] !== props.value; + if (!condition) { + let old$x = $[1]; + $structuralCheck(old$x, x, "x", "Component", "cached", "(3:6)"); + } + $[0] = props.value; + $[1] = x; + if (condition) { + x = []; + x.push(props.value); + $structuralCheck($[1], x, "x", "Component", "recomputed", "(3:6)"); + x = $[1]; + } + } + } + return x; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.js new file mode 100644 index 0000000000..8ccc3d30f0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/change-detect-reassign.js @@ -0,0 +1,9 @@ +// @enableChangeDetectionForDebugging +function Component(props) { + let x = null; + if (props.cond) { + x = []; + x.push(props.value); + } + return x; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.expect.md new file mode 100644 index 0000000000..99c5d71e78 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.expect.md @@ -0,0 +1,78 @@ + +## Input + +```javascript +import { Stringify } from "shared-runtime"; + +function foo() { + return ( + > 0, + 123 >>> 0, + 123.45 | 1, + 123.45 & 1, + 123.45 ^ 1, + 123 << 1, + 123 >> 1, + 123 >>> 1, + 3 ** 2, + 3 ** 2.5, + 3.5 ** 2, + 2 ** (3 ** 0.5), + 4 % 2, + 4 % 2.5, + 4 % 3, + 4.5 % 2, + ]} + /> + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: foo, + params: [], + isComponent: false, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { Stringify } from "shared-runtime"; + +function foo() { + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = ( + + ); + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: foo, + params: [], + isComponent: false, +}; + +``` + +### Eval output +(kind: ok)
{"value":[123,0,123,123,123,123,123,1,122,246,61,61,9,15.588457268119896,12.25,3.3219970854839125,0,1.5,1,0.5]}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.js new file mode 100644 index 0000000000..967d48c209 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-bit-ops.js @@ -0,0 +1,36 @@ +import { Stringify } from "shared-runtime"; + +function foo() { + return ( + > 0, + 123 >>> 0, + 123.45 | 1, + 123.45 & 1, + 123.45 ^ 1, + 123 << 1, + 123 >> 1, + 123 >>> 1, + 3 ** 2, + 3 ** 2.5, + 3.5 ** 2, + 2 ** (3 ** 0.5), + 4 % 2, + 4 % 2.5, + 4 % 3, + 4.5 % 2, + ]} + /> + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: foo, + params: [], + isComponent: false, +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.expect.md new file mode 100644 index 0000000000..22bdff08d8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.expect.md @@ -0,0 +1,28 @@ + +## Input + +```javascript +import { useReducer } from "react"; + +function Foo() { + let [state, setState] = useReducer({ foo: 1 }); + state.foo = 1; + return state; +} + +``` + + +## Error + +``` + 3 | function Foo() { + 4 | let [state, setState] = useReducer({ foo: 1 }); +> 5 | state.foo = 1; + | ^^^^^ InvalidReact: Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead (5:5) + 6 | return state; + 7 | } + 8 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.js new file mode 100644 index 0000000000..42a04fc8da --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.modify-useReducer-state.js @@ -0,0 +1,7 @@ +import { useReducer } from "react"; + +function Foo() { + let [state, setState] = useReducer({ foo: 1 }); + state.foo = 1; + return state; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.expect.md new file mode 100644 index 0000000000..73d664f593 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.expect.md @@ -0,0 +1,17 @@ + +## Input + +```javascript +// @disableMemoizationForDebugging @enableChangeDetectionForDebugging +function Component(props) {} + +``` + + +## Error + +``` +InvalidConfig: Invalid environment config: the 'disableMemoizationForDebugging' and 'enableChangeDetectionForDebugging' options cannot be used together +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.js new file mode 100644 index 0000000000..ce93cd29f1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.js @@ -0,0 +1,2 @@ +// @disableMemoizationForDebugging @enableChangeDetectionForDebugging +function Component(props) {} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.expect.md new file mode 100644 index 0000000000..eac8607628 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @disableMemoizationForDebugging +import { useMemo } from "react"; + +function Component({ a }) { + let x = useMemo(() => [a], []); + return
{x}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @disableMemoizationForDebugging +import { useMemo } from "react"; + +function Component(t0) { + const $ = _c(5); + const { a } = t0; + let t1; + if ($[0] !== a || true) { + t1 = () => [a]; + $[0] = a; + $[1] = t1; + } else { + t1 = $[1]; + } + let t2; + if ($[2] === Symbol.for("react.memo_cache_sentinel") || true) { + t2 = []; + $[2] = t2; + } else { + t2 = $[2]; + } + const x = useMemo(t1, t2); + let t3; + if ($[3] !== x || true) { + t3 =
{x}
; + $[3] = x; + $[4] = t3; + } else { + t3 = $[4]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; + +``` + +### Eval output +(kind: ok)
42
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.js new file mode 100644 index 0000000000..b68649d928 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved-nomemo.js @@ -0,0 +1,13 @@ +// @disableMemoizationForDebugging +import { useMemo } from "react"; + +function Component({ a }) { + let x = useMemo(() => [a], []); + return
{x}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.expect.md new file mode 100644 index 0000000000..6c813c27a6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @enablePreserveExistingManualUseMemo +import { useMemo } from "react"; + +function Component({ a }) { + let x = useMemo(() => [a], []); + return
{x}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @enablePreserveExistingManualUseMemo +import { useMemo } from "react"; + +function Component(t0) { + const $ = _c(5); + const { a } = t0; + let t1; + if ($[0] !== a) { + t1 = () => [a]; + $[0] = a; + $[1] = t1; + } else { + t1 = $[1]; + } + let t2; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t2 = []; + $[2] = t2; + } else { + t2 = $[2]; + } + const x = useMemo(t1, t2); + let t3; + if ($[3] !== x) { + t3 =
{x}
; + $[3] = x; + $[4] = t3; + } else { + t3 = $[4]; + } + return t3; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; + +``` + +### Eval output +(kind: ok)
42
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.js new file mode 100644 index 0000000000..a5731f2f09 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useMemo-simple-preserved.js @@ -0,0 +1,13 @@ +// @enablePreserveExistingManualUseMemo +import { useMemo } from "react"; + +function Component({ a }) { + let x = useMemo(() => [a], []); + return
{x}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: 42 }], + isComponent: true, +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.expect.md new file mode 100644 index 0000000000..32c0836647 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.expect.md @@ -0,0 +1,57 @@ + +## Input + +```javascript +import { useReducer } from "react"; + +function f() { + const [state, dispatch] = useReducer(); + + const onClick = () => { + dispatch(); + }; + + return
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: f, + params: [], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useReducer } from "react"; + +function f() { + const $ = _c(1); + const [state, dispatch] = useReducer(); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const onClick = () => { + dispatch(); + }; + + t0 =
; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: f, + params: [], + isComponent: true, +}; + +``` + +### Eval output +(kind: ok)
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.js new file mode 100644 index 0000000000..c1dec4e5a7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useReducer-returned-dispatcher-is-non-reactive.js @@ -0,0 +1,17 @@ +import { useReducer } from "react"; + +function f() { + const [state, dispatch] = useReducer(); + + const onClick = () => { + dispatch(); + }; + + return
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: f, + params: [], + isComponent: true, +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md new file mode 100644 index 0000000000..63203246d6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.expect.md @@ -0,0 +1,92 @@ + +## Input + +```javascript +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function useOther(x) { + return x; +} + +function Component(props) { + const w = f(props.x); + const z = useOther(w); + const [x, _] = useState(z); + return
{x}
; +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { $structuralCheck } from "react-compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function useOther(x) { + return x; +} + +function Component(props) { + const $ = _c(4); + let t0; + { + t0 = f(props.x); + let condition = $[0] !== props.x; + if (!condition) { + let old$t0 = $[1]; + $structuralCheck(old$t0, t0, "t0", "Component", "cached", "(8:8)"); + } + $[0] = props.x; + $[1] = t0; + if (condition) { + t0 = f(props.x); + $structuralCheck($[1], t0, "t0", "Component", "recomputed", "(8:8)"); + t0 = $[1]; + } + } + const w = t0; + const z = useOther(w); + const [x] = useState(z); + let t1; + { + t1 =
{x}
; + let condition = $[2] !== x; + if (!condition) { + let old$t1 = $[3]; + $structuralCheck(old$t1, t1, "t1", "Component", "cached", "(11:11)"); + } + $[2] = x; + $[3] = t1; + if (condition) { + t1 =
{x}
; + $structuralCheck($[3], t1, "t1", "Component", "recomputed", "(11:11)"); + t1 = $[3]; + } + } + return t1; +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.js new file mode 100644 index 0000000000..4f57f785d9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-and-other-hook-unpruned-dependency.js @@ -0,0 +1,22 @@ +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function useOther(x) { + return x; +} + +function Component(props) { + const w = f(props.x); + const z = useOther(w); + const [x, _] = useState(z); + return
{x}
; +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md new file mode 100644 index 0000000000..4ae84cfdf2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md @@ -0,0 +1,52 @@ + +## Input + +```javascript +// @enableChangeDetectionForDebugging +import { useState } from "react"; + +function Component(props) { + const [x, _] = useState(f(props.x)); + return
{x}
; +} + +``` + +## Code + +```javascript +import { $structuralCheck } from "react-compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; // @enableChangeDetectionForDebugging +import { useState } from "react"; + +function Component(props) { + const $ = _c(3); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = f(props.x); + $[0] = t0; + } else { + t0 = $[0]; + } + const [x] = useState(t0); + let t1; + { + t1 =
{x}
; + let condition = $[1] !== x; + if (!condition) { + let old$t1 = $[2]; + $structuralCheck(old$t1, t1, "t1", "Component", "cached", "(6:6)"); + } + $[1] = x; + $[2] = t1; + if (condition) { + t1 =
{x}
; + $structuralCheck($[2], t1, "t1", "Component", "recomputed", "(6:6)"); + t1 = $[2]; + } + } + return t1; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.js new file mode 100644 index 0000000000..46a9c23fe9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.js @@ -0,0 +1,7 @@ +// @enableChangeDetectionForDebugging +import { useState } from "react"; + +function Component(props) { + const [x, _] = useState(f(props.x)); + return
{x}
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md new file mode 100644 index 0000000000..8ca0d23ba8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.expect.md @@ -0,0 +1,98 @@ + +## Input + +```javascript +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function Component(props) { + const w = f(props.x); + const [x, _] = useState(w); + return ( +
+ {x} + {w} +
+ ); +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; + +``` + +## Code + +```javascript +import { $structuralCheck } from "react-compiler-runtime"; +import { c as _c } from "react/compiler-runtime"; +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function Component(props) { + const $ = _c(5); + let t0; + { + t0 = f(props.x); + let condition = $[0] !== props.x; + if (!condition) { + let old$t0 = $[1]; + $structuralCheck(old$t0, t0, "t0", "Component", "cached", "(4:4)"); + } + $[0] = props.x; + $[1] = t0; + if (condition) { + t0 = f(props.x); + $structuralCheck($[1], t0, "t0", "Component", "recomputed", "(4:4)"); + t0 = $[1]; + } + } + const w = t0; + const [x] = useState(w); + let t1; + { + t1 = ( +
+ {x} + {w} +
+ ); + let condition = $[2] !== x || $[3] !== w; + if (!condition) { + let old$t1 = $[4]; + $structuralCheck(old$t1, t1, "t1", "Component", "cached", "(7:10)"); + } + $[2] = x; + $[3] = w; + $[4] = t1; + if (condition) { + t1 = ( +
+ {x} + {w} +
+ ); + $structuralCheck($[4], t1, "t1", "Component", "recomputed", "(7:10)"); + t1 = $[4]; + } + } + return t1; +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.js new file mode 100644 index 0000000000..c63c16aebc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-unpruned-dependency.js @@ -0,0 +1,22 @@ +import { useState } from "react"; // @enableChangeDetectionForDebugging + +function Component(props) { + const w = f(props.x); + const [x, _] = useState(w); + return ( +
+ {x} + {w} +
+ ); +} + +function f(x) { + return x; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ x: 42 }], + isComponent: true, +}; diff --git a/compiler/packages/eslint-plugin-react-compiler/package.json b/compiler/packages/eslint-plugin-react-compiler/package.json index 25bfc0dcb1..8f82ce3d44 100644 --- a/compiler/packages/eslint-plugin-react-compiler/package.json +++ b/compiler/packages/eslint-plugin-react-compiler/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-compiler", - "version": "0.0.0-experimental-a97cca1-20240529", + "version": "0.0.0-experimental-51a85ea-20240601", "description": "ESLint plugin to display errors found by the React compiler.", "main": "dist/index.js", "scripts": { diff --git a/compiler/packages/eslint-plugin-react-compiler/src/rules/ReactCompilerRule.ts b/compiler/packages/eslint-plugin-react-compiler/src/rules/ReactCompilerRule.ts index fd33ae0339..7c46cf0b88 100644 --- a/compiler/packages/eslint-plugin-react-compiler/src/rules/ReactCompilerRule.ts +++ b/compiler/packages/eslint-plugin-react-compiler/src/rules/ReactCompilerRule.ts @@ -124,12 +124,14 @@ const rule: Rule.RuleModule = { }); } catch {} } else { - babelAST = HermesParser.parse(sourceCode, { - babel: true, - enableExperimentalComponentSyntax: true, - sourceFilename: filename, - sourceType: "module", - }); + try { + babelAST = HermesParser.parse(sourceCode, { + babel: true, + enableExperimentalComponentSyntax: true, + sourceFilename: filename, + sourceType: "module", + }); + } catch {} } if (babelAST != null) { diff --git a/compiler/packages/react-compiler-healthcheck/package.json b/compiler/packages/react-compiler-healthcheck/package.json index 8839e62efe..240cd706e3 100644 --- a/compiler/packages/react-compiler-healthcheck/package.json +++ b/compiler/packages/react-compiler-healthcheck/package.json @@ -1,6 +1,6 @@ { "name": "react-compiler-healthcheck", - "version": "0.0.0-experimental-31393f7-20240529", + "version": "0.0.0-experimental-7054a14-20240601", "description": "Health check script to test violations of the rules of react.", "bin": { "react-compiler-healthcheck": "dist/index.js" diff --git a/compiler/packages/react-compiler-healthcheck/src/config.ts b/compiler/packages/react-compiler-healthcheck/src/config.ts index eb1d7a8068..b3349d5198 100644 --- a/compiler/packages/react-compiler-healthcheck/src/config.ts +++ b/compiler/packages/react-compiler-healthcheck/src/config.ts @@ -1,3 +1,3 @@ export const config = { - knownIncompatibleLibraries: ["mobx"], + knownIncompatibleLibraries: ["mobx", "@risingstack/react-easy-state"], }; diff --git a/compiler/packages/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index 743b7633aa..6975c19411 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -9,7 +9,7 @@ import * as React from "react"; -const { useRef, useEffect } = React; +const { useRef, useEffect, isValidElement } = React; const ReactSecretInternals = //@ts-ignore React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ?? @@ -251,3 +251,172 @@ export function useRenderCounter(name: string): void { }; }); } + +const seenErrors = new Set(); + +export function $structuralCheck( + oldValue: any, + newValue: any, + variableName: string, + fnName: string, + kind: string, + loc: string +): void { + function error(l: string, r: string, path: string, depth: number) { + const str = `${fnName}:${loc} [${kind}] ${variableName}${path} changed from ${l} to ${r} at depth ${depth}`; + if (seenErrors.has(str)) { + return; + } + seenErrors.add(str); + console.error(str); + } + const depthLimit = 2; + function recur(oldValue: any, newValue: any, path: string, depth: number) { + if (depth > depthLimit) { + return; + } else if (oldValue === newValue) { + return; + } else if (typeof oldValue !== typeof newValue) { + error(`type ${typeof oldValue}`, `type ${typeof newValue}`, path, depth); + } else if (typeof oldValue === "object") { + const oldArray = Array.isArray(oldValue); + const newArray = Array.isArray(newValue); + if (oldValue === null && newValue !== null) { + error("null", `type ${typeof newValue}`, path, depth); + } else if (newValue === null) { + error(`type ${typeof oldValue}`, "null", path, depth); + } else if (oldValue instanceof Map) { + if (!(newValue instanceof Map)) { + error(`Map instance`, `other value`, path, depth); + } else if (oldValue.size !== newValue.size) { + error( + `Map instance with size ${oldValue.size}`, + `Map instance with size ${newValue.size}`, + path, + depth + ); + } else { + for (const [k, v] of oldValue) { + if (!newValue.has(k)) { + error( + `Map instance with key ${k}`, + `Map instance without key ${k}`, + path, + depth + ); + } else { + recur(v, newValue.get(k), `${path}.get(${k})`, depth + 1); + } + } + } + } else if (newValue instanceof Map) { + error("other value", `Map instance`, path, depth); + } else if (oldValue instanceof Set) { + if (!(newValue instanceof Set)) { + error(`Set instance`, `other value`, path, depth); + } else if (oldValue.size !== newValue.size) { + error( + `Set instance with size ${oldValue.size}`, + `Set instance with size ${newValue.size}`, + path, + depth + ); + } else { + for (const v of newValue) { + if (!oldValue.has(v)) { + error( + `Set instance without element ${v}`, + `Set instance with element ${v}`, + path, + depth + ); + } + } + } + } else if (newValue instanceof Set) { + error("other value", `Set instance`, path, depth); + } else if (oldArray || newArray) { + if (oldArray !== newArray) { + error( + `type ${oldArray ? "array" : "object"}`, + `type ${newArray ? "array" : "object"}`, + path, + depth + ); + } else if (oldValue.length !== newValue.length) { + error( + `array with length ${oldValue.length}`, + `array with length ${newValue.length}`, + path, + depth + ); + } else { + for (let ii = 0; ii < oldValue.length; ii++) { + recur(oldValue[ii], newValue[ii], `${path}[${ii}]`, depth + 1); + } + } + } else if (isValidElement(oldValue) || isValidElement(newValue)) { + if (isValidElement(oldValue) !== isValidElement(newValue)) { + error( + `type ${isValidElement(oldValue) ? "React element" : "object"}`, + `type ${isValidElement(newValue) ? "React element" : "object"}`, + path, + depth + ); + } else if (oldValue.type !== newValue.type) { + error( + `React element of type ${oldValue.type}`, + `React element of type ${newValue.type}`, + path, + depth + ); + } else { + recur( + oldValue.props, + newValue.props, + `[props of ${path}]`, + depth + 1 + ); + } + } else { + for (const key in newValue) { + if (!(key in oldValue)) { + error( + `object without key ${key}`, + `object with key ${key}`, + path, + depth + ); + } + } + for (const key in oldValue) { + if (!(key in newValue)) { + error( + `object with key ${key}`, + `object without key ${key}`, + path, + depth + ); + } else { + recur(oldValue[key], newValue[key], `${path}.${key}`, depth + 1); + } + } + } + } else if (typeof oldValue === "function") { + // Bail on functions for now + return; + } else if (isNaN(oldValue) || isNaN(newValue)) { + if (isNaN(oldValue) !== isNaN(newValue)) { + error( + `${isNaN(oldValue) ? "NaN" : "non-NaN value"}`, + `${isNaN(newValue) ? "NaN" : "non-NaN value"}`, + path, + depth + ); + } + } else if (oldValue !== newValue) { + error(oldValue, newValue, path, depth); + } + } + recur(oldValue, newValue, "", 0); +} diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index 14ed51b2cc..0bfa03c397 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -495,6 +495,10 @@ const skipFilter = new Set([ "flag-enable-emit-hook-guards", "fast-refresh-refresh-on-const-changes-dev", + "useState-pruned-dependency-change-detect", + "useState-unpruned-dependency", + "useState-and-other-hook-unpruned-dependency", + "change-detect-reassign", ]); export default skipFilter; diff --git a/compiler/packages/snap/src/compiler.ts b/compiler/packages/snap/src/compiler.ts index ab2cf5cef8..8d6671d0c2 100644 --- a/compiler/packages/snap/src/compiler.ts +++ b/compiler/packages/snap/src/compiler.ts @@ -43,6 +43,7 @@ function makePluginOptions( let hookPattern: string | null = null; // TODO(@mofeiZ) rewrite snap fixtures to @validatePreserveExistingMemo:false let validatePreserveExistingMemoizationGuarantees = false; + let enableChangeDetectionForDebugging = null; if (firstLine.indexOf("@compilationMode(annotation)") !== -1) { assert( @@ -120,6 +121,12 @@ function makePluginOptions( validatePreserveExistingMemoizationGuarantees = true; } + if (firstLine.includes("@enableChangeDetectionForDebugging")) { + enableChangeDetectionForDebugging = { + source: "react-compiler-runtime", + importSpecifierName: "$structuralCheck", + }; + } const hookPatternMatch = /@hookPattern:"([^"]+)"/.exec(firstLine); if ( hookPatternMatch && @@ -173,6 +180,7 @@ function makePluginOptions( enableSharedRuntime__testonly: true, hookPattern, validatePreserveExistingMemoizationGuarantees, + enableChangeDetectionForDebugging, }, compilationMode, logger: null, @@ -183,6 +191,7 @@ function makePluginOptions( eslintSuppressionRules, flowSuppressions, ignoreUseNoForget, + enableReanimatedCheck: false, }; } diff --git a/compiler/packages/snap/src/runner-watch.ts b/compiler/packages/snap/src/runner-watch.ts index 6a229f5155..414d99084c 100644 --- a/compiler/packages/snap/src/runner-watch.ts +++ b/compiler/packages/snap/src/runner-watch.ts @@ -153,8 +153,8 @@ function subscribeFilterFile( } else if ( events.findIndex((event) => event.path.includes(FILTER_FILENAME)) !== -1 ) { - state.filter = await readTestFilter(); if (state.mode.filter) { + state.filter = await readTestFilter(); state.mode.action = RunnerAction.Test; onChange(state); } @@ -189,7 +189,7 @@ function subscribeKeyEvents( state: RunnerState, onChange: (state: RunnerState) => void ) { - process.stdin.on("keypress", (str, key) => { + process.stdin.on("keypress", async (str, key) => { if (key.name === "u") { // u => update fixtures state.mode.action = RunnerAction.Update; @@ -197,6 +197,7 @@ function subscribeKeyEvents( process.exit(0); } else if (key.name === "f") { state.mode.filter = !state.mode.filter; + state.filter = state.mode.filter ? await readTestFilter() : null; state.mode.action = RunnerAction.Test; } else { // any other key re-runs tests @@ -218,7 +219,7 @@ export async function makeWatchRunner( action: RunnerAction.Test, filter: filterMode, }, - filter: await readTestFilter(), + filter: filterMode ? await readTestFilter() : null, }; subscribeTsc(state, onChange); diff --git a/fixtures/flight/config/webpack.config.js b/fixtures/flight/config/webpack.config.js index de6eb9916b..665cd37216 100644 --- a/fixtures/flight/config/webpack.config.js +++ b/fixtures/flight/config/webpack.config.js @@ -199,7 +199,7 @@ module.exports = function (webpackEnv) { ? shouldUseSourceMap ? 'source-map' : false - : isEnvDevelopment && 'cheap-module-source-map', + : isEnvDevelopment && 'source-map', // These are the "entry points" to our application. // This means they will be the "root" imports that are included in JS bundle. entry: isEnvProduction diff --git a/fixtures/flight/loader/region.js b/fixtures/flight/loader/region.js index fc2b3ced7e..c81538bc71 100644 --- a/fixtures/flight/loader/region.js +++ b/fixtures/flight/loader/region.js @@ -16,6 +16,7 @@ const babelOptions = { '@babel/plugin-syntax-import-meta', '@babel/plugin-transform-react-jsx', ], + sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false, }; async function babelLoad(url, context, defaultLoad) { diff --git a/fixtures/flight/package.json b/fixtures/flight/package.json index a2d61155ac..cb0f77c8ea 100644 --- a/fixtures/flight/package.json +++ b/fixtures/flight/package.json @@ -71,7 +71,7 @@ "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/", "dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"", "dev:global": "NODE_ENV=development BUILD_PATH=dist node --experimental-loader ./loader/global.js server/global", - "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/region.js --conditions=react-server server/region", + "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --enable-source-maps --experimental-loader ./loader/region.js --conditions=react-server server/region", "start": "node scripts/build.js && concurrently \"npm run start:region\" \"npm run start:global\"", "start:global": "NODE_ENV=production node --experimental-loader ./loader/global.js server/global", "start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region", diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js index 779270e16f..e4ae3a6291 100644 --- a/fixtures/flight/server/global.js +++ b/fixtures/flight/server/global.js @@ -214,6 +214,43 @@ app.all('/', async function (req, res, next) { if (process.env.NODE_ENV === 'development') { app.use(express.static('public')); + + app.get('/source-maps', async function (req, res, next) { + // Proxy the request to the regional server. + const proxiedHeaders = { + 'X-Forwarded-Host': req.hostname, + 'X-Forwarded-For': req.ips, + 'X-Forwarded-Port': 3000, + 'X-Forwarded-Proto': req.protocol, + }; + + const promiseForData = request( + { + host: '127.0.0.1', + port: 3001, + method: req.method, + path: req.originalUrl, + headers: proxiedHeaders, + }, + req + ); + + try { + const rscResponse = await promiseForData; + res.set('Content-type', 'application/json'); + rscResponse.on('data', data => { + res.write(data); + res.flush(); + }); + rscResponse.on('end', data => { + res.end(); + }); + } catch (e) { + console.error(`Failed to proxy request: ${e.stack}`); + res.statusCode = 500; + res.end(); + } + }); } else { // In production we host the static build output. app.use(express.static('build')); diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index 1064e87e7d..4313f48502 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -24,6 +24,7 @@ babelRegister({ ], presets: ['@babel/preset-react'], plugins: ['@babel/transform-modules-commonjs'], + sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false, }); if (typeof fetch === 'undefined') { @@ -38,6 +39,8 @@ const app = express(); const compress = require('compression'); const {Readable} = require('node:stream'); +const nodeModule = require('node:module'); + app.use(compress()); // Application @@ -176,6 +179,71 @@ app.get('/todos', function (req, res) { ]); }); +if (process.env.NODE_ENV === 'development') { + const rootDir = path.resolve(__dirname, '../'); + + app.get('/source-maps', async function (req, res, next) { + try { + res.set('Content-type', 'application/json'); + let requestedFilePath = req.query.name; + + let isCompiledOutput = false; + if (requestedFilePath.startsWith('file://')) { + // We assume that if it was prefixed with file:// it's referring to the compiled output + // and if it's a direct file path we assume it's source mapped back to original format. + isCompiledOutput = true; + requestedFilePath = requestedFilePath.slice(7); + } + + const relativePath = path.relative(rootDir, requestedFilePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + // This is outside the root directory of the app. Forbid it to be served. + res.status = 403; + res.write('{}'); + res.end(); + return; + } + + const sourceMap = nodeModule.findSourceMap(requestedFilePath); + let map; + // There are two ways to return a source map depending on what we observe in error.stack. + // A real app will have a similar choice to make for which strategy to pick. + if (!sourceMap || !isCompiledOutput) { + // If a file doesn't have a source map, such as this file, then we generate a blank + // source map that just contains the original content and segments pointing to the + // original lines. + // Similarly + const sourceContent = await readFile(requestedFilePath, 'utf8'); + const lines = sourceContent.split('\n').length; + map = { + version: 3, + sources: [requestedFilePath], + sourcesContent: [sourceContent], + // Note: This approach to mapping each line only lets you jump to each line + // not jump to a column within a line. To do that, you need a proper source map + // generated for each parsed segment or add a segment for each column. + mappings: 'AAAA' + ';AACA'.repeat(lines - 1), + sourceRoot: '', + }; + } else { + // We always set prepareStackTrace before reading the stack so that we get the stack + // without source maps applied. Therefore we have to use the original source map. + // If something read .stack before we did, we might observe the line/column after + // source mapping back to the original file. We use the isCompiledOutput check above + // in that case. + map = sourceMap.payload; + } + res.write(JSON.stringify(map)); + res.end(); + } catch (x) { + res.status = 500; + res.write('{}'); + res.end(); + console.error(x); + } + }); +} + app.listen(3001, () => { console.log('Regional Flight Server listening on port 3001...'); }); diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index c888a8a53b..f5b3e7406b 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -39,6 +39,9 @@ async function hydrateApp() { }), { callServer, + findSourceMapURL(fileName) { + return '/source-maps?name=' + encodeURIComponent(fileName); + }, } ); diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 16b8e928aa..a66c59dc21 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -67,8 +67,11 @@ import { REACT_ELEMENT_TYPE, REACT_POSTPONE_TYPE, ASYNC_ITERATOR, + REACT_FRAGMENT_TYPE, } from 'shared/ReactSymbols'; +import getComponentNameFromType from 'shared/getComponentNameFromType'; + export type {CallServerCallback, EncodeFormActionCallback}; interface FlightStreamController { @@ -236,6 +239,8 @@ Chunk.prototype.then = function ( } }; +export type FindSourceMapURLCallback = (fileName: string) => null | string; + export type Response = { _bundlerConfig: SSRModuleMap, _moduleLoading: ModuleLoading, @@ -251,6 +256,8 @@ export type Response = { _rowLength: number, // remaining bytes in the row. 0 indicates that we're looking for a newline. _buffer: Array, // chunks received so far as part of this row _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from + _debugRootTask?: null | ConsoleTask, // DEV-only + _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only }; function readChunk(chunk: SomeChunk): T { @@ -573,7 +580,45 @@ function nullRefGetter() { } } +function getServerComponentTaskName(componentInfo: ReactComponentInfo): string { + return '<' + (componentInfo.name || '...') + '>'; +} + +function getTaskName(type: mixed): string { + if (type === REACT_FRAGMENT_TYPE) { + return '<>'; + } + if (typeof type === 'function') { + // This is a function so it must have been a Client Reference that resolved to + // a function. We use "use client" to indicate that this is the boundary into + // the client. There should only be one for any given owner chain. + return '"use client"'; + } + if ( + typeof type === 'object' && + type !== null && + type.$$typeof === REACT_LAZY_TYPE + ) { + if (type._init === readChunk) { + // This is a lazy node created by Flight. It is probably a client reference. + // We use the "use client" string to indicate that this is the boundary into + // the client. There will only be one for any given owner chain. + return '"use client"'; + } + // We don't want to eagerly initialize the initializer in DEV mode so we can't + // call it to extract the type so we don't know the type of this component. + return '<...>'; + } + try { + const name = getComponentNameFromType(type); + return name ? '<' + name + '>' : '<...>'; + } catch (x) { + return '<...>'; + } +} + function createElement( + response: Response, type: mixed, key: mixed, props: mixed, @@ -647,11 +692,34 @@ function createElement( writable: true, value: stack, }); + + let task: null | ConsoleTask = null; + if (supportsCreateTask && stack !== null) { + const createTaskFn = (console: any).createTask.bind( + console, + getTaskName(type), + ); + const callStack = buildFakeCallStack(response, stack, 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); + } + } Object.defineProperty(element, '_debugTask', { configurable: false, enumerable: false, writable: true, - value: null, + value: task, }); } // TODO: We should be freezing the element but currently, we might write into @@ -1049,6 +1117,7 @@ function parseModelTuple( // TODO: Consider having React just directly accept these arrays as elements. // Or even change the ReactElement type to be an array. return createElement( + response, tuple[1], tuple[2], tuple[3], @@ -1074,6 +1143,7 @@ export function createResponse( encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, temporaryReferences: void | TemporaryReferenceSet, + findSourceMapURL: void | FindSourceMapURLCallback, ): Response { const chunks: Map> = new Map(); const response: Response = { @@ -1092,6 +1162,17 @@ export function createResponse( _buffer: [], _tempRefs: temporaryReferences, }; + if (supportsCreateTask) { + // Any stacks that appear on the server need to be rooted somehow on the client + // so we create a root Task for this response which will be the root owner for any + // elements created by the server. We use the "use server" string to indicate that + // this is where we enter the server from the client. + // TODO: Make this string configurable. + response._debugRootTask = (console: any).createTask('"use server"'); + } + if (__DEV__) { + response._debugFindSourceMapURL = findSourceMapURL; + } // Don't inline this call because it causes closure to outline the call above. response._fromJSON = createFromJSONCallback(response); return response; @@ -1582,6 +1663,151 @@ function resolveHint( dispatchHint(code, hintModel); } +// eslint-disable-next-line react-internal/no-production-logging +const supportsCreateTask = + __DEV__ && enableOwnerStacks && !!(console: any).createTask; + +const taskCache: null | WeakMap< + ReactComponentInfo | ReactAsyncInfo, + ConsoleTask, +> = supportsCreateTask ? new WeakMap() : null; + +type FakeFunction = (() => T) => T; +const fakeFunctionCache: Map> = __DEV__ + ? new Map() + : (null: any); + +function createFakeFunction( + name: string, + filename: string, + sourceMap: null | string, + line: number, + col: number, +): FakeFunction { + // This creates a fake copy of a Server Module. It represents a module that has already + // executed on the server but we re-execute a blank copy for its stack frames on the client. + + const comment = + '/* This module was rendered by a Server Component. Turn on Source Maps to see the server source. */'; + + // We generate code where the call is at the line and column of the server executed code. + // This allows us to use the original source map as the source map of this fake file to + // point to the original source. + let code; + if (line <= 1) { + code = '_=>' + ' '.repeat(col < 4 ? 0 : col - 4) + '_()\n' + comment + '\n'; + } else { + code = + comment + + '\n'.repeat(line - 2) + + '_=>\n' + + ' '.repeat(col < 1 ? 0 : col - 1) + + '_()\n'; + } + + if (sourceMap) { + code += '//# sourceMappingURL=' + sourceMap; + } else if (filename) { + code += '//# sourceURL=' + filename; + } + + let fn: FakeFunction; + try { + // eslint-disable-next-line no-eval + fn = (0, eval)(code); + } catch (x) { + // If eval fails, such as if in an environment that doesn't support it, + // we fallback to creating a function here. It'll still have the right + // name but it'll lose line/column number and file name. + fn = function (_) { + return _(); + }; + } + // $FlowFixMe[cannot-write] + Object.defineProperty(fn, 'name', {value: name || '(anonymous)'}); + // $FlowFixMe[prop-missing] + fn.displayName = name; + return fn; +} + +// This matches either of these V8 formats. +// at name (filename:0:0) +// at filename:0:0 +// at async filename:0:0 +const frameRegExp = + /^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|(?:async )?([^\)]+):(\d+):(\d+))$/; + +function buildFakeCallStack( + response: Response, + stack: string, + innerCall: () => T, +): () => T { + const frames = stack.split('\n'); + let callStack = innerCall; + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + let fn = fakeFunctionCache.get(frame); + if (fn === undefined) { + const parsed = frameRegExp.exec(frame); + if (!parsed) { + // We assume the server returns a V8 compatible stack trace. + continue; + } + const name = parsed[1] || ''; + const filename = parsed[2] || parsed[5] || ''; + const line = +(parsed[3] || parsed[6]); + const col = +(parsed[4] || parsed[7]); + const sourceMap = response._debugFindSourceMapURL + ? response._debugFindSourceMapURL(filename) + : null; + fn = createFakeFunction(name, filename, sourceMap, line, col); + // TODO: This cache should technically live on the response since the _debugFindSourceMapURL + // function is an input and can vary by response. + fakeFunctionCache.set(frame, fn); + } + callStack = fn.bind(null, callStack); + } + return callStack; +} + +function initializeFakeTask( + response: Response, + debugInfo: ReactComponentInfo | ReactAsyncInfo, +): null | ConsoleTask { + if (taskCache === null || typeof debugInfo.stack !== 'string') { + return null; + } + const componentInfo: ReactComponentInfo = (debugInfo: any); // Refined + const stack: string = debugInfo.stack; + const cachedEntry = taskCache.get((componentInfo: any)); + if (cachedEntry !== undefined) { + return cachedEntry; + } + + const ownerTask = + componentInfo.owner == null + ? null + : initializeFakeTask(response, componentInfo.owner); + + // eslint-disable-next-line react-internal/no-production-logging + const createTaskFn = (console: any).createTask.bind( + console, + getServerComponentTaskName(componentInfo), + ); + const callStack = buildFakeCallStack(response, stack, createTaskFn); + + if (ownerTask === null) { + const rootTask = response._debugRootTask; + if (rootTask != null) { + return rootTask.run(callStack); + } else { + return callStack(); + } + } else { + return ownerTask.run(callStack); + } +} + function resolveDebugInfo( response: Response, id: number, @@ -1594,6 +1820,10 @@ 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. + initializeFakeTask(response, debugInfo); const chunk = getChunk(response, id); const chunkDebugInfo: ReactDebugInfo = chunk._debugInfo || (chunk._debugInfo = []); @@ -1615,12 +1845,34 @@ function resolveConsoleEntry( const payload: [string, string, null | ReactComponentInfo, string, mixed] = parseModel(response, value); const methodName = payload[0]; - // TODO: Restore the fake stack before logging. - // const stackTrace = payload[1]; - // const owner = payload[2]; + const stackTrace = payload[1]; + const owner = payload[2]; const env = payload[3]; const args = payload.slice(4); - printToConsole(methodName, args, env); + if (!enableOwnerStacks) { + // Printing with stack isn't really limited to owner stacks but + // we gate it behind the same flag for now while iterating. + printToConsole(methodName, args, env); + return; + } + const callStack = buildFakeCallStack( + response, + stackTrace, + printToConsole.bind(null, methodName, args, env), + ); + if (owner != null) { + const task = initializeFakeTask(response, owner); + if (task !== null) { + task.run(callStack); + return; + } + } + const rootTask = response._debugRootTask; + if (rootTask != null) { + rootTask.run(callStack); + return; + } + callStack(); } function mergeBuffer( diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 145dae4ddb..09ba351235 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -868,7 +868,12 @@ function findCommonAncestorIndex(rootStack: any, hookStack: any) { } function isReactWrapper(functionName: any, wrapperName: string) { - return parseHookName(functionName) === wrapperName; + const hookName = parseHookName(functionName); + if (wrapperName === 'HostTransitionStatus') { + return hookName === wrapperName || hookName === 'FormStatus'; + } + + return hookName === wrapperName; } function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) { @@ -878,21 +883,24 @@ function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) { return -1; } for (let i = 0; i < primitiveStack.length && i < hookStack.length; i++) { + // Note: there is no guarantee that we will find the top-most primitive frame in the stack + // For React Native (uses Hermes), these source fields will be identical and skipped if (primitiveStack[i].source !== hookStack[i].source) { - // If the next frame is a method from the dispatcher, we - // assume that the next frame after that is the actual public API call. - // This prohibits nesting dispatcher calls in hooks. + // If the next two frames are functions called `useX` then we assume that they're part of the + // wrappers that the React package or other packages adds around the dispatcher. if ( i < hookStack.length - 1 && isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName) ) { i++; - // Guard against the dispatcher call being inlined. - // At this point we wouldn't be able to recover the actual React Hook name. - if (i < hookStack.length - 1) { - i++; - } } + if ( + i < hookStack.length - 1 && + isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName) + ) { + i++; + } + return i; } } @@ -1040,7 +1048,7 @@ function buildTree( const levelChild: HooksNode = { id, isStateEditable, - name: name, + name, value: hook.value, subHooks: [], debugInfo: debugInfo, diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index 6829c27895..e4e4ada1c3 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -279,7 +279,6 @@ function initialize(socket: WebSocket) { // $FlowFixMe[incompatible-call] found when upgrading Flow store = new Store(bridge, { checkBridgeProtocolCompatibility: true, - supportsNativeInspection: true, supportsTraceUpdates: true, }); diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index 224e4cd4b4..e1db3d5055 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -97,6 +97,7 @@ function createBridgeAndStore() { // At this time, the timeline can only parse Chrome performance profiles. supportsTimeline: __IS_CHROME__, supportsTraceUpdates: true, + supportsNativeInspection: true, }); if (!isProfiling) { diff --git a/packages/react-devtools-fusebox/src/frontend.js b/packages/react-devtools-fusebox/src/frontend.js index ca236031dd..976b8693d3 100644 --- a/packages/react-devtools-fusebox/src/frontend.js +++ b/packages/react-devtools-fusebox/src/frontend.js @@ -37,7 +37,6 @@ export function createStore(bridge: FrontendBridge, config?: Config): Store { return new Store(bridge, { checkBridgeProtocolCompatibility: true, supportsTraceUpdates: true, - supportsNativeInspection: true, ...config, }); } diff --git a/packages/react-devtools-inline/src/frontend.js b/packages/react-devtools-inline/src/frontend.js index d0e0fbfccc..9031f6ffc7 100644 --- a/packages/react-devtools-inline/src/frontend.js +++ b/packages/react-devtools-inline/src/frontend.js @@ -23,7 +23,6 @@ export function createStore(bridge: FrontendBridge, config?: Config): Store { checkBridgeProtocolCompatibility: true, supportsTraceUpdates: true, supportsTimeline: true, - supportsNativeInspection: true, ...config, }); } diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 565d670678..c6ce366df0 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -1915,8 +1915,12 @@ describe('Store', () => { }); }); - // @reactVersion >= 18.0 - it('from react get counted', () => { + // In React 19, JSX warnings were moved into the renderer - https://github.com/facebook/react/pull/29088 + // When the error is emitted, the source fiber of this error is not yet mounted + // So DevTools can't connect the error and the fiber + // TODO(hoxyq): update RDT to keep track of such fibers + // @reactVersion >= 19.0 + it('from react get counted [React >= 19]', () => { function Example() { return []; } @@ -1938,6 +1942,31 @@ describe('Store', () => { `); }); + // @reactVersion >= 18.0 + // @reactVersion < 19.0 + it('from react get counted [React 18.x]', () => { + function Example() { + return []; + } + function Child() { + return null; + } + + withErrorsOrWarningsIgnored( + ['Warning: Each child in a list should have a unique "key" prop'], + () => { + act(() => render()); + }, + ); + + expect(store).toMatchInlineSnapshot(` + ✕ 1, ⚠ 0 + [root] + ▾ ✕ + + `); + }); + // @reactVersion >= 18.0 it('can be cleared for the whole app', () => { function Example() { diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 3eb589b903..408151dcdb 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -172,7 +172,7 @@ export default class Store extends EventEmitter<{ _rootIDToRendererID: Map = new Map(); // These options may be initially set by a configuration option when constructing the Store. - _supportsNativeInspection: boolean = true; + _supportsNativeInspection: boolean = false; _supportsReloadAndProfile: boolean = false; _supportsTimeline: boolean = false; _supportsTraceUpdates: boolean = false; @@ -216,7 +216,9 @@ export default class Store extends EventEmitter<{ supportsTimeline, supportsTraceUpdates, } = config; - this._supportsNativeInspection = supportsNativeInspection !== false; + if (supportsNativeInspection) { + this._supportsNativeInspection = true; + } if (supportsReloadAndProfile) { this._supportsReloadAndProfile = true; } diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json index a5f2c1fadc..cc89dfcf67 100644 --- a/packages/react-devtools/package.json +++ b/packages/react-devtools/package.json @@ -25,7 +25,7 @@ "dependencies": { "cross-spawn": "^5.0.1", "electron": "^23.1.2", - "ip": "^1.1.4", + "internal-ip": "^6.2.0", "minimist": "^1.2.3", "react-devtools-core": "5.2.0", "update-notifier": "^2.1.0" diff --git a/packages/react-devtools/preload.js b/packages/react-devtools/preload.js index d9d2dbd3cd..634cffc635 100644 --- a/packages/react-devtools/preload.js +++ b/packages/react-devtools/preload.js @@ -1,11 +1,11 @@ const {clipboard, shell, contextBridge} = require('electron'); const fs = require('fs'); -const {address} = require('ip'); +const internalIP = require('internal-ip'); // Expose protected methods so that render process does not need unsafe node integration contextBridge.exposeInMainWorld('api', { electron: {clipboard, shell}, - ip: {address}, + ip: {address: internalIP.v4.sync}, getDevTools() { let devtools; try { diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 2fcf5bf9a5..2cff98e0e8 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2358,6 +2358,7 @@ export function getResource( type: string, currentProps: any, pendingProps: any, + currentResource: null | Resource, ): null | Resource { const resourceRoot = getCurrentResourceRoot(); if (!resourceRoot) { @@ -2430,9 +2431,44 @@ export function getResource( ); } } + if (currentProps && currentResource === null) { + // This node was previously an Instance type and is becoming a Resource type + // For now we error because we don't support flavor changes + let diff = ''; + if (__DEV__) { + diff = ` + + - ${describeLinkForResourceErrorDEV(currentProps)} + + ${describeLinkForResourceErrorDEV(pendingProps)}`; + } + throw new Error( + 'Expected not to update to be updated to a stylesheet with precedence.' + + ' Check the `rel`, `href`, and `precedence` props of this component.' + + ' Alternatively, check whether two different components render in the same slot or share the same key.' + + diff, + ); + } return resource; + } else { + if (currentProps && currentResource !== null) { + // This node was previously a Resource type and is becoming an Instance type + // For now we error because we don't support flavor changes + let diff = ''; + if (__DEV__) { + diff = ` + + - ${describeLinkForResourceErrorDEV(currentProps)} + + ${describeLinkForResourceErrorDEV(pendingProps)}`; + } + throw new Error( + 'Expected stylesheet with precedence to not be updated to a different kind of .' + + ' Check the `rel`, `href`, and `precedence` props of this component.' + + ' Alternatively, check whether two different components render in the same slot or share the same key.' + + diff, + ); + } + return null; } - return null; } case 'script': { const async = pendingProps.async; @@ -2473,6 +2509,49 @@ export function getResource( } } +function describeLinkForResourceErrorDEV(props: any) { + if (__DEV__) { + let describedProps = 0; + + let description = ' describedProps) { + description += ' ...'; + } + description += ' />'; + return description; + } + return ''; +} + function styleTagPropsFromRawProps( rawProps: StyleTagQualifyingProps, ): StyleTagProps { diff --git a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js index 5fa0c88d13..4b940731b9 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js @@ -20,6 +20,14 @@ export function scheduleWork(callback: () => void) { callback(); } +export function scheduleMicrotask(callback: () => void) { + // While this defies the method name the legacy builds have special + // overrides that make work scheduling sync. At the moment scheduleMicrotask + // isn't used by any legacy APIs so this is somewhat academic but if they + // did in the future we'd probably want to have this be in sync with scheduleWork + callback(); +} + export function flushBuffered(destination: Destination) {} export function beginWriting(destination: Destination) {} diff --git a/packages/react-dom/src/__tests__/ReactClassComponentPropResolutionFizz-test.js b/packages/react-dom/src/__tests__/ReactClassComponentPropResolutionFizz-test.js index 653797ec44..67e7fff249 100644 --- a/packages/react-dom/src/__tests__/ReactClassComponentPropResolutionFizz-test.js +++ b/packages/react-dom/src/__tests__/ReactClassComponentPropResolutionFizz-test.js @@ -10,6 +10,7 @@ 'use strict'; import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; // Polyfills for test environment global.ReadableStream = @@ -21,12 +22,16 @@ let ReactDOMServer; let Scheduler; let assertLog; let container; +let act; describe('ReactClassComponentPropResolutionFizz', () => { beforeEach(() => { jest.resetModules(); - React = require('react'); Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + + React = require('react'); ReactDOMServer = require('react-dom/server.browser'); assertLog = require('internal-test-utils').assertLog; container = document.createElement('div'); @@ -37,6 +42,17 @@ describe('ReactClassComponentPropResolutionFizz', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + async function readIntoContainer(stream) { const reader = stream.getReader(); let result = ''; @@ -57,7 +73,7 @@ describe('ReactClassComponentPropResolutionFizz', () => { return text; } - test('resolves ref and default props before calling lifecycle methods', async () => { + it('resolves ref and default props before calling lifecycle methods', async () => { function getPropKeys(props) { return Object.keys(props).join(', '); } @@ -80,11 +96,13 @@ describe('ReactClassComponentPropResolutionFizz', () => { }; // `ref` should never appear as a prop. `default` always should. + const ref = React.createRef(); - const stream = await ReactDOMServer.renderToReadableStream( - , + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), ); await readIntoContainer(stream); + assertLog([ 'constructor: text, default', 'componentWillMount: text, default', diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js index fbfb00df87..04e60648fb 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js @@ -13,6 +13,7 @@ import { insertNodesAndExecuteScripts, getVisibleChildren, } from '../test-utils/FizzTestUtils'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; // Polyfills for test environment global.ReadableStream = @@ -33,13 +34,14 @@ let Suspense; describe('ReactDOMFizzForm', () => { beforeEach(() => { jest.resetModules(); - React = require('react'); Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + React = require('react'); ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); useDeferredValue = React.useDeferredValue; Suspense = React.Suspense; - act = require('internal-test-utils').act; assertLog = require('internal-test-utils').assertLog; waitForPaint = require('internal-test-utils').waitForPaint; container = document.createElement('div'); @@ -50,6 +52,17 @@ describe('ReactDOMFizzForm', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + async function readIntoContainer(stream) { const reader = stream.getReader(); let result = ''; @@ -76,7 +89,9 @@ describe('ReactDOMFizzForm', () => { return useDeferredValue('Final', 'Initial'); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toEqual('Initial'); @@ -107,7 +122,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toEqual('Loading...'); @@ -153,8 +170,9 @@ describe('ReactDOMFizzForm', () => { const cRef = React.createRef(); - // The server renders using the "initial" value for B. - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); assertLog(['A', 'B [Initial]', 'C']); expect(getVisibleChildren(container)).toEqual( diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js index f578748e92..b83abb5693 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js @@ -10,6 +10,7 @@ 'use strict'; import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; // Polyfills for test environment global.ReadableStream = @@ -24,10 +25,13 @@ let ReactDOMClient; let useFormStatus; let useOptimistic; let useActionState; +let Scheduler; describe('ReactDOMFizzForm', () => { beforeEach(() => { jest.resetModules(); + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); React = require('react'); ReactDOMServer = require('react-dom/server.browser'); ReactDOMClient = require('react-dom/client'); @@ -48,6 +52,14 @@ describe('ReactDOMFizzForm', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + }); + return maybePromise; + } + function submit(submitter) { const form = submitter.form || submitter; if (!submitter.form) { @@ -96,7 +108,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); await act(async () => { ReactDOMClient.hydrateRoot(container, ); @@ -143,7 +157,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); await act(async () => { ReactDOMClient.hydrateRoot(container, ); @@ -175,7 +191,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); await expect(async () => { await act(async () => { @@ -197,7 +215,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); // This should ideally warn because only the client provides a function that doesn't line up. await act(async () => { @@ -231,7 +251,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); let root; await act(async () => { @@ -278,7 +300,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); let root; await act(async () => { @@ -334,7 +358,9 @@ describe('ReactDOMFizzForm', () => { // Specifying the extra form fields are a DEV error, but we expect it // to eventually still be patched up after an update. await expect(async () => { - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); }).toErrorDev([ 'Cannot specify a encType or method for a form that specifies a function as the action.', @@ -379,7 +405,9 @@ describe('ReactDOMFizzForm', () => { return 'Pending: ' + pending; } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toBe('Pending: false'); @@ -400,7 +428,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); // Dispatch an event before hydration @@ -441,7 +471,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); submit(container.getElementsByTagName('input')[1]); @@ -463,7 +495,9 @@ describe('ReactDOMFizzForm', () => { return optimisticState; } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toBe('hi'); @@ -484,7 +518,9 @@ describe('ReactDOMFizzForm', () => { return state; } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); expect(container.textContent).toBe('0'); @@ -521,7 +557,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); const form = container.firstChild; @@ -581,7 +619,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); const input = container.getElementsByTagName('input')[1]; @@ -651,7 +691,9 @@ describe('ReactDOMFizzForm', () => { ); } - const stream = await ReactDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); await readIntoContainer(stream); const barField = container.querySelector('[name=bar]'); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js index f6ac8739f0..cfeade2ff6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -17,15 +19,33 @@ global.TextEncoder = require('util').TextEncoder; let React; let ReactDOMFizzServer; let Suspense; +let Scheduler; +let act; describe('ReactDOMFizzServerBrowser', () => { beforeEach(() => { jest.resetModules(); + + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + React = require('react'); ReactDOMFizzServer = require('react-dom/server.browser'); Suspense = React.Suspense; }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + const theError = new Error('This is an error'); function Throw() { throw theError; @@ -48,18 +68,20 @@ describe('ReactDOMFizzServerBrowser', () => { } it('should call renderToReadableStream', async () => { - const stream = await ReactDOMFizzServer.renderToReadableStream( -
hello world
, + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(
hello world
), ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot(`"
hello world
"`); }); it('should emit DOCTYPE at the root of the document', async () => { - const stream = await ReactDOMFizzServer.renderToReadableStream( - - hello world - , + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( + + hello world + , + ), ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( @@ -68,13 +90,12 @@ describe('ReactDOMFizzServerBrowser', () => { }); it('should emit bootstrap script src at the end', async () => { - const stream = await ReactDOMFizzServer.renderToReadableStream( -
hello world
, - { + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(
hello world
, { bootstrapScriptContent: 'INIT();', bootstrapScripts: ['init.js'], bootstrapModules: ['init.mjs'], - }, + }), ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( @@ -93,23 +114,22 @@ describe('ReactDOMFizzServerBrowser', () => { return 'Done'; } let isComplete = false; - const stream = await ReactDOMFizzServer.renderToReadableStream( -
- - - -
, + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ + + +
, + ), ); stream.allReady.then(() => (isComplete = true)); - await jest.runAllTimers(); expect(isComplete).toBe(false); // Resolve the loading. hasLoaded = true; - await resolve(); - - await jest.runAllTimers(); + await serverAct(() => resolve()); expect(isComplete).toBe(true); @@ -123,15 +143,17 @@ describe('ReactDOMFizzServerBrowser', () => { const reportedErrors = []; let caughtError = null; try { - await ReactDOMFizzServer.renderToReadableStream( -
- -
, - { - onError(x) { - reportedErrors.push(x); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); } catch (error) { caughtError = error; @@ -144,17 +166,19 @@ describe('ReactDOMFizzServerBrowser', () => { const reportedErrors = []; let caughtError = null; try { - await ReactDOMFizzServer.renderToReadableStream( -
- }> - - -
, - { - onError(x) { - reportedErrors.push(x); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ }> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); } catch (error) { caughtError = error; @@ -165,17 +189,19 @@ describe('ReactDOMFizzServerBrowser', () => { it('should not error the stream when an error is thrown inside suspense boundary', async () => { const reportedErrors = []; - const stream = await ReactDOMFizzServer.renderToReadableStream( -
- Loading
}> - - -
, - { - onError(x) { - reportedErrors.push(x); + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ Loading
}> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); const result = await readResult(stream); @@ -186,18 +212,20 @@ describe('ReactDOMFizzServerBrowser', () => { it('should be able to complete by aborting even if the promise never resolves', async () => { const errors = []; const controller = new AbortController(); - const stream = await ReactDOMFizzServer.renderToReadableStream( -
- Loading
}> - - -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); controller.abort(); @@ -211,20 +239,20 @@ describe('ReactDOMFizzServerBrowser', () => { it('should reject if aborting before the shell is complete', async () => { const errors = []; const controller = new AbortController(); - const promise = ReactDOMFizzServer.renderToReadableStream( -
- -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const promise = serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); - await jest.runAllTimers(); - const theReason = new Error('aborted for reasons'); controller.abort(theReason); @@ -249,16 +277,18 @@ describe('ReactDOMFizzServerBrowser', () => { ); } - const streamPromise = ReactDOMFizzServer.renderToReadableStream( -
- -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const streamPromise = serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); let caughtError = null; @@ -277,18 +307,20 @@ describe('ReactDOMFizzServerBrowser', () => { const theReason = new Error('aborted for reasons'); controller.abort(theReason); - const promise = ReactDOMFizzServer.renderToReadableStream( -
- Loading
}> - - - , - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const promise = serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); // Technically we could still continue rendering the shell but currently the @@ -317,17 +349,19 @@ describe('ReactDOMFizzServerBrowser', () => { return 'Done'; } const errors = []; - const stream = await ReactDOMFizzServer.renderToReadableStream( -
- Loading
}> - - - , - { - onError(x) { - errors.push(x.message); + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( +
+ Loading
}> + + + , + { + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); stream.allReady.then(() => (isComplete = true)); @@ -344,9 +378,7 @@ describe('ReactDOMFizzServerBrowser', () => { ]); hasLoaded = true; - resolve(); - - await jest.runAllTimers(); + await serverAct(() => resolve()); expect(rendered).toBe(false); expect(isComplete).toBe(true); @@ -366,14 +398,16 @@ describe('ReactDOMFizzServerBrowser', () => { // as such for now. I don't think it needs to be maintained if in the future // the view sizes change or become dynamic becasue of the use of byobRequest let stream; - stream = await ReactDOMFizzServer.renderToReadableStream( - <> -
- {''} -
-
{str492}
-
{str492}
- , + stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( + <> +
+ {''} +
+
{str492}
+
{str492}
+ , + ), ); let result; @@ -385,10 +419,12 @@ describe('ReactDOMFizzServerBrowser', () => { // this size 2049 was chosen to be a couple base 2 orders larger than the current view // size. if the size changes in the future hopefully this will still exercise // a chunk that is too large for the view size. - stream = await ReactDOMFizzServer.renderToReadableStream( - <> -
{str2049}
- , + stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( + <> +
{str2049}
+ , + ), ); result = await readResult(stream); @@ -419,13 +455,15 @@ describe('ReactDOMFizzServerBrowser', () => { const errors = []; const controller = new AbortController(); - await ReactDOMFizzServer.renderToReadableStream(, { - signal: controller.signal, - onError(x) { - errors.push(x); - return 'a digest'; - }, - }); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(, { + signal: controller.signal, + onError(x) { + errors.push(x); + return 'a digest'; + }, + }), + ); controller.abort('foobar'); @@ -456,13 +494,15 @@ describe('ReactDOMFizzServerBrowser', () => { const errors = []; const controller = new AbortController(); - await ReactDOMFizzServer.renderToReadableStream(, { - signal: controller.signal, - onError(x) { - errors.push(x.message); - return 'a digest'; - }, - }); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + return 'a digest'; + }, + }), + ); controller.abort(new Error('uh oh')); @@ -471,13 +511,15 @@ describe('ReactDOMFizzServerBrowser', () => { // https://github.com/facebook/react/pull/25534/files - fix transposed escape functions it('should encode title properly', async () => { - const stream = await ReactDOMFizzServer.renderToReadableStream( - - - foo - - bar - , + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream( + + + foo + + bar + , + ), ); const result = await readResult(stream); @@ -488,14 +530,13 @@ describe('ReactDOMFizzServerBrowser', () => { it('should support nonce attribute for bootstrap scripts', async () => { const nonce = 'R4nd0m'; - const stream = await ReactDOMFizzServer.renderToReadableStream( -
hello world
, - { + const stream = await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(
hello world
, { nonce, bootstrapScriptContent: 'INIT();', bootstrapScripts: ['init.js'], bootstrapModules: ['init.mjs'], - }, + }), ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( @@ -523,14 +564,16 @@ describe('ReactDOMFizzServerBrowser', () => { let caughtError = null; try { - await ReactDOMFizzServer.renderToReadableStream(, { - onError(error) { - errors.push(error.message); - }, - onPostpone(reason) { - postponed.push(reason); - }, - }); + await serverAct(() => + ReactDOMFizzServer.renderToReadableStream(, { + onError(error) { + errors.push(error.message); + }, + onPostpone(reason) { + postponed.push(reason); + }, + }), + ); } catch (error) { caughtError = error; } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 043c5fc42a..7a3db48b01 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + import { getVisibleChildren, insertNodesAndExecuteScripts, @@ -26,10 +28,17 @@ let ReactDOMFizzServer; let ReactDOMFizzStatic; let Suspense; let container; +let Scheduler; +let act; describe('ReactDOMFizzStaticBrowser', () => { beforeEach(() => { jest.resetModules(); + + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + React = require('react'); ReactDOM = require('react-dom'); ReactDOMFizzServer = require('react-dom/server.browser'); @@ -45,6 +54,17 @@ describe('ReactDOMFizzStaticBrowser', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + const theError = new Error('This is an error'); function Throw() { throw theError; @@ -113,17 +133,21 @@ describe('ReactDOMFizzStaticBrowser', () => { // @gate experimental it('should call prerender', async () => { - const result = await ReactDOMFizzStatic.prerender(
hello world
); + const result = await serverAct(() => + ReactDOMFizzStatic.prerender(
hello world
), + ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot(`"
hello world
"`); }); // @gate experimental it('should emit DOCTYPE at the root of the document', async () => { - const result = await ReactDOMFizzStatic.prerender( - - hello world - , + const result = await serverAct(() => + ReactDOMFizzStatic.prerender( + + hello world + , + ), ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( @@ -133,11 +157,13 @@ describe('ReactDOMFizzStaticBrowser', () => { // @gate experimental it('should emit bootstrap script src at the end', async () => { - const result = await ReactDOMFizzStatic.prerender(
hello world
, { - bootstrapScriptContent: 'INIT();', - bootstrapScripts: ['init.js'], - bootstrapModules: ['init.mjs'], - }); + const result = await serverAct(() => + ReactDOMFizzStatic.prerender(
hello world
, { + bootstrapScriptContent: 'INIT();', + bootstrapScripts: ['init.js'], + bootstrapModules: ['init.mjs'], + }), + ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( `"
hello world
"`, @@ -155,12 +181,14 @@ describe('ReactDOMFizzStaticBrowser', () => { } return 'Done'; } - const resultPromise = ReactDOMFizzStatic.prerender( -
- - - -
, + const resultPromise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ + + +
, + ), ); await jest.runAllTimers(); @@ -171,9 +199,7 @@ describe('ReactDOMFizzStaticBrowser', () => { const result = await resultPromise; const prelude = await readContent(result.prelude); - expect(prelude).toMatchInlineSnapshot( - `"
Done
"`, - ); + expect(prelude).toMatchInlineSnapshot(`"
Done
"`); }); // @gate experimental @@ -181,15 +207,17 @@ describe('ReactDOMFizzStaticBrowser', () => { const reportedErrors = []; let caughtError = null; try { - await ReactDOMFizzStatic.prerender( -
- -
, - { - onError(x) { - reportedErrors.push(x); + await serverAct(() => + ReactDOMFizzStatic.prerender( +
+ +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); } catch (error) { caughtError = error; @@ -203,17 +231,19 @@ describe('ReactDOMFizzStaticBrowser', () => { const reportedErrors = []; let caughtError = null; try { - await ReactDOMFizzStatic.prerender( -
- }> - - -
, - { - onError(x) { - reportedErrors.push(x); + await serverAct(() => + ReactDOMFizzStatic.prerender( +
+ }> + + +
, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); } catch (error) { caughtError = error; @@ -225,17 +255,19 @@ describe('ReactDOMFizzStaticBrowser', () => { // @gate experimental it('should not error the stream when an error is thrown inside suspense boundary', async () => { const reportedErrors = []; - const result = await ReactDOMFizzStatic.prerender( -
- Loading
}> - - - , - { - onError(x) { - reportedErrors.push(x); + const result = await serverAct(() => + ReactDOMFizzStatic.prerender( +
+ Loading
}> + + + , + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); const prelude = await readContent(result.prelude); @@ -247,21 +279,22 @@ describe('ReactDOMFizzStaticBrowser', () => { it('should be able to complete by aborting even if the promise never resolves', async () => { const errors = []; const controller = new AbortController(); - const resultPromise = ReactDOMFizzStatic.prerender( -
- Loading
}> - - - , - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + let resultPromise; + await serverAct(() => { + resultPromise = ReactDOMFizzStatic.prerender( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, - ); - - await jest.runAllTimers(); + ); + }); controller.abort(); @@ -277,16 +310,18 @@ describe('ReactDOMFizzStaticBrowser', () => { it('should reject if aborting before the shell is complete', async () => { const errors = []; const controller = new AbortController(); - const promise = ReactDOMFizzStatic.prerender( -
- -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const promise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); await jest.runAllTimers(); @@ -316,16 +351,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const streamPromise = ReactDOMFizzStatic.prerender( -
- -
, - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const streamPromise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); let caughtError = null; @@ -345,18 +382,20 @@ describe('ReactDOMFizzStaticBrowser', () => { const theReason = new Error('aborted for reasons'); controller.abort(theReason); - const promise = ReactDOMFizzStatic.prerender( -
- Loading
}> - - - , - { - signal: controller.signal, - onError(x) { - errors.push(x.message); + const promise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); // Technically we could still continue rendering the shell but currently the @@ -396,12 +435,15 @@ describe('ReactDOMFizzStaticBrowser', () => { const errors = []; const controller = new AbortController(); - const resultPromise = ReactDOMFizzStatic.prerender(, { - signal: controller.signal, - onError(x) { - errors.push(x); - return 'a digest'; - }, + let resultPromise; + await serverAct(() => { + resultPromise = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x); + return 'a digest'; + }, + }); }); controller.abort('foobar'); @@ -436,12 +478,15 @@ describe('ReactDOMFizzStaticBrowser', () => { const errors = []; const controller = new AbortController(); - const resultPromise = ReactDOMFizzStatic.prerender(, { - signal: controller.signal, - onError(x) { - errors.push(x.message); - return 'a digest'; - }, + let resultPromise; + await serverAct(() => { + resultPromise = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + return 'a digest'; + }, + }); }); controller.abort(new Error('uh oh')); @@ -471,14 +516,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -513,14 +562,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -552,14 +605,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -600,14 +657,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -641,14 +702,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -682,14 +747,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); const html = await readContent(concat(prerendered.prelude, content)); @@ -748,9 +817,11 @@ describe('ReactDOMFizzStaticBrowser', () => { {virtual: true}, ); - const prerendered = await ReactDOMFizzStatic.prerender(, { - bootstrapScripts: ['init.js'], - }); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(, { + bootstrapScripts: ['init.js'], + }), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); @@ -779,9 +850,11 @@ describe('ReactDOMFizzStaticBrowser', () => { ]); prerendering = false; - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(content); @@ -860,14 +933,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -911,14 +988,18 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(prerendered.prelude); @@ -957,7 +1038,9 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); // TODO: This should actually be null because we should've been able to fully // resolve the render on the server eventually, even though the fallback postponed. // So we should not need to resume. @@ -967,9 +1050,11 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(getVisibleChildren(container)).toEqual(
Outer
); - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(resumed); @@ -1020,7 +1105,9 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); @@ -1033,14 +1120,16 @@ describe('ReactDOMFizzStaticBrowser', () => { prerendering = false; const errors = []; - const resumed = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - onError(x) { - errors.push(x.message); + const resumed = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); expect(errors).toEqual([ @@ -1085,7 +1174,9 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); @@ -1098,15 +1189,17 @@ describe('ReactDOMFizzStaticBrowser', () => { const errors = []; - const resumedPromise = ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), - { - signal: controller.signal, - onError(x) { - errors.push(x); + const resumedPromise = serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + signal: controller.signal, + onError(x) { + errors.push(x); + }, }, - }, + ), ); controller.abort('abort'); @@ -1160,16 +1253,20 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); prerendering = false; - const resumedPromise = ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const resumedPromise = serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await jest.runAllTimers(); @@ -1204,16 +1301,20 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; expect(await readContent(prerendered.prelude)).toBe(''); - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); expect(await readContent(content)).toBe( @@ -1246,16 +1347,20 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; expect(await readContent(prerendered.prelude)).toBe(''); - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); expect(await readContent(content)).toBe( @@ -1293,16 +1398,20 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; expect(await readContent(prerendered.prelude)).toBe(''); - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); expect(await readContent(content)).toBe( @@ -1356,9 +1465,11 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(, { - onHeaders, - }); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(, { + onHeaders, + }), + ); expect(prerendered.postponed).not.toBe(null); prerendering = false; @@ -1375,9 +1486,11 @@ describe('ReactDOMFizzStaticBrowser', () => { }), ); - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); const decoder = new TextDecoder(); @@ -1391,7 +1504,7 @@ describe('ReactDOMFizzStaticBrowser', () => { await 1; hasLoaded = true; - resolve(); + await serverAct(resolve); while (true) { ({value, done} = await reader.read()); @@ -1425,10 +1538,12 @@ describe('ReactDOMFizzStaticBrowser', () => { throw new Error('bad onHeaders'); } - const prerendered = await ReactDOMFizzStatic.prerender(
hello
, { - onHeaders, - onError, - }); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(
hello
, { + onHeaders, + onError, + }), + ); expect(prerendered.postponed).toBe(null); expect(errors).toEqual(['bad onHeaders']); @@ -1469,9 +1584,11 @@ describe('ReactDOMFizzStaticBrowser', () => { {virtual: true}, ); - const prerendered = await ReactDOMFizzStatic.prerender(, { - bootstrapScripts: ['init.js'], - }); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(, { + bootstrapScripts: ['init.js'], + }), + ); const postponedSerializedState = JSON.stringify(prerendered.postponed); @@ -1497,9 +1614,8 @@ describe('ReactDOMFizzStaticBrowser', () => { prerendering = false; - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(postponedSerializedState), + const content = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedSerializedState)), ); await readIntoContainer(content); @@ -1542,7 +1658,9 @@ describe('ReactDOMFizzStaticBrowser', () => { ); } - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); const postponedState = JSON.stringify(prerendered.postponed); await readIntoContainer(prerendered.prelude); @@ -1550,9 +1668,8 @@ describe('ReactDOMFizzStaticBrowser', () => { isPrerendering = false; - const dynamic = await ReactDOMFizzServer.resume( - , - JSON.parse(postponedState), + const dynamic = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedState)), ); await readIntoContainer(dynamic); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js index 9a825bf1e3..baa65c806c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + import { getVisibleChildren, insertNodesAndExecuteScripts, @@ -25,10 +27,16 @@ let ReactDOMFizzServer; let ReactDOMFizzStatic; let Suspense; let container; +let Scheduler; +let act; describe('ReactDOMFizzStaticFloat', () => { beforeEach(() => { jest.resetModules(); + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; + React = require('react'); ReactDOM = require('react-dom'); ReactDOMFizzServer = require('react-dom/server.browser'); @@ -44,6 +52,17 @@ describe('ReactDOMFizzStaticFloat', () => { document.body.removeChild(container); }); + async function serverAct(callback) { + let maybePromise; + await act(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + async function readIntoContainer(stream) { const reader = stream.getReader(); let result = ''; @@ -135,7 +154,9 @@ describe('ReactDOMFizzStaticFloat', () => { virtual: true, }); - const prerendered = await ReactDOMFizzStatic.prerender(); + const prerendered = await serverAct(() => + ReactDOMFizzStatic.prerender(), + ); expect(prerendered.postponed).not.toBe(null); await readIntoContainer(prerendered.prelude); @@ -171,28 +192,28 @@ describe('ReactDOMFizzStaticFloat', () => { ]); prerendering = false; - const content = await ReactDOMFizzServer.resume( - , - JSON.parse(JSON.stringify(prerendered.postponed)), + const content = await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + ), ); await readIntoContainer(content); - // Dispatch load event to injected stylesheet - const linkCreds = document.querySelector( - 'link[rel="stylesheet"][href="style creds"]', - ); - const linkAnon = document.querySelector( - 'link[rel="stylesheet"][href="style anon"]', - ); - const event = document.createEvent('Events'); - event.initEvent('load', true, true); - linkCreds.dispatchEvent(event); - linkAnon.dispatchEvent(event); - - // Wait for the instruction microtasks to flush. - await 0; - await 0; + await act(() => { + // Dispatch load event to injected stylesheet + const linkCreds = document.querySelector( + 'link[rel="stylesheet"][href="style creds"]', + ); + const linkAnon = document.querySelector( + 'link[rel="stylesheet"][href="style anon"]', + ); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + linkCreds.dispatchEvent(event); + linkAnon.dispatchEvent(event); + }); expect(getVisibleChildren(document)).toEqual( diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 9fa20e5d11..fbae2805ba 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -1020,15 +1020,15 @@ describe('ReactDOMForm', () => { assertLog(['0']); expect(container.textContent).toBe('0'); - await act(() => dispatch('increment')); + await act(() => startTransition(() => dispatch('increment'))); assertLog(['Async action started [1]', 'Pending 0']); expect(container.textContent).toBe('Pending 0'); // Dispatch a few more actions. None of these will start until the previous // one finishes. - await act(() => dispatch('increment')); - await act(() => dispatch('decrement')); - await act(() => dispatch('increment')); + await act(() => startTransition(() => dispatch('increment'))); + await act(() => startTransition(() => dispatch('decrement'))); + await act(() => startTransition(() => dispatch('increment'))); assertLog([]); // Each action starts as soon as the previous one finishes. @@ -1067,7 +1067,7 @@ describe('ReactDOMForm', () => { // Perform an action. This will increase the state by 1, as defined by the // stepSize prop. - await act(() => increment()); + await act(() => startTransition(() => increment())); assertLog(['Pending 0', '1']); // Now increase the stepSize prop to 10. Subsequent steps will increase @@ -1076,7 +1076,7 @@ describe('ReactDOMForm', () => { assertLog(['1']); // Increment again. The state should increase by 10. - await act(() => increment()); + await act(() => startTransition(() => increment())); assertLog(['Pending 1', '11']); }); @@ -1113,11 +1113,11 @@ describe('ReactDOMForm', () => { await act(() => root.render()); assertLog(['A']); - await act(() => action('B')); + await act(() => startTransition(() => action('B'))); // The first dispatch will update the pending state. assertLog(['Pending A']); - await act(() => action('C')); - await act(() => action('D')); + await act(() => startTransition(() => action('C'))); + await act(() => startTransition(() => action('D'))); assertLog([]); await act(() => resolveText('B')); @@ -1151,10 +1151,10 @@ describe('ReactDOMForm', () => { // Dispatch two actions. The first one is async, so it forces the second // one into an async queue. - await act(() => action('First action')); + await act(() => startTransition(() => action('First action'))); assertLog(['Initial (pending)']); // This action won't run until the first one finishes. - await act(() => action('Second action')); + await act(() => startTransition(() => action('Second action'))); // While the first action is still pending, update a prop. This causes the // inline action implementation to change, but it should not affect the @@ -1169,7 +1169,9 @@ describe('ReactDOMForm', () => { // Confirm that if we dispatch yet another action, it uses the updated // action implementation. - await expect(act(() => action('Third action'))).rejects.toThrow('Oops!'); + await expect( + act(() => startTransition(() => action('Third action'))), + ).rejects.toThrow('Oops!'); }, ); @@ -1192,7 +1194,7 @@ describe('ReactDOMForm', () => { // Perform an action. This will increase the state by 1, as defined by the // stepSize prop. - await act(() => increment()); + await act(() => startTransition(() => increment())); assertLog(['Pending 0', '1']); // Now increase the stepSize prop to 10. Subsequent steps will increase @@ -1201,7 +1203,7 @@ describe('ReactDOMForm', () => { assertLog(['1']); // Increment again. The state should increase by 10. - await act(() => increment()); + await act(() => startTransition(() => increment())); assertLog(['Pending 1', '11']); }); @@ -1219,12 +1221,12 @@ describe('ReactDOMForm', () => { await act(() => root.render()); assertLog(['A']); - await act(() => action(getText('B'))); + await act(() => startTransition(() => action(getText('B')))); // The first dispatch will update the pending state. assertLog(['Pending A']); - await act(() => action('C')); - await act(() => action(getText('D'))); - await act(() => action('E')); + await act(() => startTransition(() => action('C'))); + await act(() => startTransition(() => action(getText('D')))); + await act(() => startTransition(() => action('E'))); assertLog([]); await act(() => resolveText('B')); @@ -1235,14 +1237,12 @@ describe('ReactDOMForm', () => { // @gate enableAsyncActions test('useActionState: error handling (sync action)', async () => { - let resetErrorBoundary; class ErrorBoundary extends React.Component { state = {error: null}; static getDerivedStateFromError(error) { return {error}; } render() { - resetErrorBoundary = () => this.setState({error: null}); if (this.state.error !== null) { return ; } @@ -1273,7 +1273,7 @@ describe('ReactDOMForm', () => { ); assertLog(['A']); - await act(() => action('Oops!')); + await act(() => startTransition(() => action('Oops!'))); assertLog([ // Action begins, error has not thrown yet. 'Pending A', @@ -1282,31 +1282,16 @@ describe('ReactDOMForm', () => { 'Caught an error: Oops!', ]); expect(container.textContent).toBe('Caught an error: Oops!'); - - // Reset the error boundary - await act(() => resetErrorBoundary()); - assertLog(['A']); - - // Trigger an error again, but this time, perform another action that - // overrides the first one and fixes the error - await act(() => { - action('Oops!'); - action('B'); - }); - assertLog(['Pending A', 'B']); - expect(container.textContent).toBe('B'); }); // @gate enableAsyncActions test('useActionState: error handling (async action)', async () => { - let resetErrorBoundary; class ErrorBoundary extends React.Component { state = {error: null}; static getDerivedStateFromError(error) { return {error}; } render() { - resetErrorBoundary = () => this.setState({error: null}); if (this.state.error !== null) { return ; } @@ -1338,27 +1323,71 @@ describe('ReactDOMForm', () => { ); assertLog(['A']); - await act(() => action('Oops!')); + await act(() => startTransition(() => action('Oops!'))); // The first dispatch will update the pending state. assertLog(['Pending A']); await act(() => resolveText('Oops!')); assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']); expect(container.textContent).toBe('Caught an error: Oops!'); + }); - // Reset the error boundary - await act(() => resetErrorBoundary()); + test('useActionState: when an action errors, subsequent actions are canceled', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error !== null) { + return ; + } + return this.props.children; + } + } + + let action; + function App() { + const [state, dispatch, isPending] = useActionState(async (s, a) => { + Scheduler.log('Start action: ' + a); + const text = await getText(a); + if (text.endsWith('!')) { + throw new Error(text); + } + return text; + }, 'A'); + action = dispatch; + const pending = isPending ? 'Pending ' : ''; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => + root.render( + + + , + ), + ); assertLog(['A']); - // Trigger an error again, but this time, perform another action that - // overrides the first one and fixes the error - await act(() => { - action('Oops!'); - action('B'); - }); - assertLog(['Pending A']); - await act(() => resolveText('B')); - assertLog(['B']); - expect(container.textContent).toBe('B'); + await act(() => startTransition(() => action('Oops!'))); + assertLog(['Start action: Oops!', 'Pending A']); + + // Queue up another action after the one will error. + await act(() => startTransition(() => action('Should never run'))); + assertLog([]); + + // The first dispatch will update the pending state. + await act(() => resolveText('Oops!')); + assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']); + expect(container.textContent).toBe('Caught an error: Oops!'); + + // Attempt to dispatch another action. This should not run either. + await act(() => + startTransition(() => action('This also should never run')), + ); + assertLog([]); + expect(container.textContent).toBe('Caught an error: Oops!'); }); // @gate enableAsyncActions @@ -1399,7 +1428,7 @@ describe('ReactDOMForm', () => { assertLog(['0']); expect(container.textContent).toBe('0'); - await act(() => dispatch('increment')); + await act(() => startTransition(() => dispatch('increment'))); assertLog(['Async action started [1]', 'Pending 0']); expect(container.textContent).toBe('Pending 0'); @@ -1408,6 +1437,77 @@ describe('ReactDOMForm', () => { expect(container.textContent).toBe('1'); }); + test('useActionState does not wrap action in a transition unless dispatch is in a transition', async () => { + let dispatch; + function App() { + const [state, _dispatch] = useActionState(() => { + return state + 1; + }, 0); + dispatch = _dispatch; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => + root.render( + }> + + , + ), + ); + assertLog(['Suspend! [Count: 0]', 'Loading...']); + await act(() => resolveText('Count: 0')); + assertLog(['Count: 0']); + + // Dispatch outside of a transition. This will trigger a loading state. + await act(() => dispatch()); + assertLog(['Suspend! [Count: 1]', 'Loading...']); + expect(container.textContent).toBe('Loading...'); + + await act(() => resolveText('Count: 1')); + assertLog(['Count: 1']); + expect(container.textContent).toBe('Count: 1'); + + // Now dispatch inside of a transition. This one does not trigger a + // loading state. + await act(() => startTransition(() => dispatch())); + assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']); + expect(container.textContent).toBe('Count: 1'); + + await act(() => resolveText('Count: 2')); + assertLog(['Count: 2']); + expect(container.textContent).toBe('Count: 2'); + }); + + test('useActionState warns if async action is dispatched outside of a transition', async () => { + let dispatch; + function App() { + const [state, _dispatch] = useActionState(async () => { + return state + 1; + }, 0); + dispatch = _dispatch; + return ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + assertLog(['Suspend! [Count: 0]']); + await act(() => resolveText('Count: 0')); + assertLog(['Count: 0']); + + // Dispatch outside of a transition. + await act(() => dispatch()); + assertConsoleErrorDev([ + [ + 'An async function was passed to useActionState, but it was ' + + 'dispatched outside of an action context', + {withoutStack: true}, + ], + ]); + assertLog(['Suspend! [Count: 1]']); + expect(container.textContent).toBe('Count: 0'); + }); + test('uncontrolled form inputs are reset after the action completes', async () => { const formRef = React.createRef(); const inputRef = React.createRef(); diff --git a/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js b/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js new file mode 100644 index 0000000000..a0d3981a5d --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js @@ -0,0 +1,123 @@ +/** + * 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 ./scripts/jest/ReactDOMServerIntegrationEnvironment + */ + +'use strict'; + +let JSDOM; +let React; +let ReactDOMClient; +let container; +let waitForAll; + +describe('ReactDOM HostSingleton', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + global.window = jsdom.window; + global.document = jsdom.window.document; + container = global.document.getElementById('container'); + + React = require('react'); + ReactDOMClient = require('react-dom/client'); + + const InternalTestUtils = require('internal-test-utils'); + waitForAll = InternalTestUtils.waitForAll; + }); + + it('errors when a hoistable component becomes a Resource', async () => { + const errors = []; + function onError(e) { + errors.push(e.message); + } + const root = ReactDOMClient.createRoot(container, { + onUncaughtError: onError, + }); + + root.render( +
+ +
, + ); + await waitForAll([]); + + root.render( +
+ +
, + ); + await waitForAll([]); + if (__DEV__) { + expect(errors).toEqual([ + `Expected not to update to be updated to a stylesheet with precedence. Check the \`rel\`, \`href\`, and \`precedence\` props of this component. Alternatively, check whether two different components render in the same slot or share the same key. + + - + + `, + ]); + } else { + expect(errors).toEqual([ + 'Expected not to update to be updated to a stylesheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.', + ]); + } + }); + + it('errors when a hoistable Resource becomes an instance', async () => { + const errors = []; + function onError(e) { + errors.push(e.message); + } + const root = ReactDOMClient.createRoot(container, { + onUncaughtError: onError, + }); + + root.render( +
+ +
, + ); + await waitForAll([]); + const event = new window.Event('load'); + const preloads = document.querySelectorAll('link[rel="preload"]'); + for (let i = 0; i < preloads.length; i++) { + const node = preloads[i]; + node.dispatchEvent(event); + } + const stylesheets = document.querySelectorAll('link[rel="preload"]'); + for (let i = 0; i < stylesheets.length; i++) { + const node = stylesheets[i]; + node.dispatchEvent(event); + } + + root.render( +
+ +
, + ); + await waitForAll([]); + if (__DEV__) { + expect(errors).toEqual([ + `Expected stylesheet with precedence to not be updated to a different kind of . Check the \`rel\`, \`href\`, and \`precedence\` props of this component. Alternatively, check whether two different components render in the same slot or share the same key. + + - + + `, + ]); + } else { + expect(errors).toEqual([ + 'Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.', + ]); + } + }); +}); diff --git a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js index 0143ab2843..817c01f187 100644 --- a/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js +++ b/packages/react-native-renderer/src/ReactNativeAttributePayloadFabric.js @@ -14,7 +14,10 @@ import { } from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; import isArray from 'shared/isArray'; -import {enableAddPropertiesFastPath} from 'shared/ReactFeatureFlags'; +import { + enableAddPropertiesFastPath, + enableShallowPropDiffing, +} from 'shared/ReactFeatureFlags'; import type {AttributeConfiguration} from './ReactNativeTypes'; @@ -342,7 +345,7 @@ function diffProperties( // Pattern match on: attributeConfig if (typeof attributeConfig !== 'object') { // case: !Object is the default case - if (defaultDiffer(prevProp, nextProp)) { + if (enableShallowPropDiffing || defaultDiffer(prevProp, nextProp)) { // a normal leaf has changed (updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[ propKey @@ -354,6 +357,7 @@ function diffProperties( ) { // case: CustomAttributeConfiguration const shouldUpdate = + enableShallowPropDiffing || prevProp === undefined || (typeof attributeConfig.diff === 'function' ? attributeConfig.diff(prevProp, nextProp) @@ -449,17 +453,24 @@ function fastAddProperties( props: Object, validAttributes: AttributeConfiguration, ): null | Object { - let attributeConfig; - let prop; + // Flatten nested style props. + if (isArray(props)) { + for (let i = 0; i < props.length; i++) { + payload = fastAddProperties(payload, props[i], validAttributes); + } + return payload; + } for (const propKey in props) { - prop = props[propKey]; + const prop = props[propKey]; if (prop === undefined) { continue; } - attributeConfig = ((validAttributes[propKey]: any): AttributeConfiguration); + const attributeConfig = ((validAttributes[ + propKey + ]: any): AttributeConfiguration); if (attributeConfig == null) { continue; @@ -477,7 +488,7 @@ function fastAddProperties( // An atomic prop with custom processing. newValue = attributeConfig.process(prop); } else if (typeof attributeConfig.diff === 'function') { - // An atomic prop with custom diffing. We don't do diffing here. + // An atomic prop with custom diffing. We don't need to do diffing when adding props. newValue = prop; } @@ -489,17 +500,6 @@ function fastAddProperties( continue; } - // Not-atomic prop that needs to be flattened. Likely it's the 'style' prop. - - // It can be an array. - if (isArray(prop)) { - for (let i = 0; i < prop.length; i++) { - payload = fastAddProperties(payload, prop[i], attributeConfig); - } - continue; - } - - // Or it can be an object. payload = fastAddProperties(payload, prop, attributeConfig); } @@ -514,11 +514,7 @@ function addProperties( props: Object, validAttributes: AttributeConfiguration, ): null | Object { - if (enableAddPropertiesFastPath) { - return fastAddProperties(updatePayload, props, validAttributes); - } else { - return diffProperties(updatePayload, emptyObject, props, validAttributes); - } + return diffProperties(updatePayload, emptyObject, props, validAttributes); } /** @@ -538,11 +534,11 @@ export function create( props: Object, validAttributes: AttributeConfiguration, ): null | Object { - return addProperties( - null, // updatePayload - props, - validAttributes, - ); + if (enableAddPropertiesFastPath) { + return fastAddProperties(null, props, validAttributes); + } else { + return addProperties(null, props, validAttributes); + } } export function diff( diff --git a/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js new file mode 100644 index 0000000000..68cf318c6f --- /dev/null +++ b/packages/react-native-renderer/src/__tests__/ReactNativeAttributePayloadFabric-test.internal.js @@ -0,0 +1,449 @@ +/** + * 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. + * + * @jest-environment node + */ +'use strict'; + +const {diff, create} = require('../ReactNativeAttributePayloadFabric'); + +describe('ReactNativeAttributePayloadFabric.create', () => { + it('should work with simple example', () => { + expect(create({b: 2, c: 3}, {a: true, b: true})).toEqual({ + b: 2, + }); + }); + + it('should work with complex example', () => { + const validAttributes = { + style: { + position: true, + zIndex: true, + flexGrow: true, + flexShrink: true, + flexDirection: true, + overflow: true, + backgroundColor: true, + }, + }; + + expect( + create( + { + style: [ + { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'row', + overflow: 'scroll', + }, + [ + {position: 'relative', zIndex: 2}, + {flexGrow: 0}, + {backgroundColor: 'red'}, + ], + ], + }, + validAttributes, + ), + ).toEqual({ + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + overflow: 'scroll', + position: 'relative', + zIndex: 2, + backgroundColor: 'red', + }); + }); + + it('should ignore fields that are set to undefined', () => { + expect(create({}, {a: true})).toEqual(null); + expect(create({a: undefined}, {a: true})).toEqual(null); + expect(create({a: undefined, b: undefined}, {a: true, b: true})).toEqual( + null, + ); + expect( + create({a: undefined, b: undefined, c: 1}, {a: true, b: true}), + ).toEqual(null); + expect( + create({a: undefined, b: undefined, c: 1}, {a: true, b: true, c: true}), + ).toEqual({c: 1}); + expect( + create({a: 1, b: undefined, c: 2}, {a: true, b: true, c: true}), + ).toEqual({a: 1, c: 2}); + }); + + it('should ignore invalid fields', () => { + expect(create({b: 2}, {})).toEqual(null); + }); + + it('should not use the diff attribute', () => { + const diffA = jest.fn(); + expect(create({a: [2]}, {a: {diff: diffA}})).toEqual({a: [2]}); + expect(diffA).not.toBeCalled(); + }); + + it('should use the process attribute', () => { + const processA = jest.fn(a => a + 1); + expect(create({a: 2}, {a: {process: processA}})).toEqual({a: 3}); + expect(processA).toBeCalledWith(2); + }); + + it('should work with undefined styles', () => { + expect(create({style: undefined}, {style: {b: true}})).toEqual(null); + expect(create({style: {a: '#ffffff', b: 1}}, {style: {b: true}})).toEqual({ + b: 1, + }); + }); + + it('should flatten nested styles and predefined styles', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + expect( + create({someStyle: [{foo: 1}, {bar: 2}]}, validStyleAttribute), + ).toEqual({foo: 1, bar: 2}); + expect(create({}, validStyleAttribute)).toEqual(null); + const barStyle = { + bar: 3, + }; + expect( + create( + {someStyle: [[{foo: 1}, {foo: 2}], barStyle]}, + validStyleAttribute, + ), + ).toEqual({foo: 2, bar: 3}); + }); + + it('should not flatten nested props if attribute config is a primitive or only has diff/process', () => { + expect(create({a: {foo: 1, bar: 2}}, {a: true})).toEqual({ + a: {foo: 1, bar: 2}, + }); + expect(create({a: [{foo: 1}, {bar: 2}]}, {a: true})).toEqual({ + a: [{foo: 1}, {bar: 2}], + }); + expect(create({a: {foo: 1, bar: 2}}, {a: {diff: a => a}})).toEqual({ + a: {foo: 1, bar: 2}, + }); + expect( + create({a: [{foo: 1}, {bar: 2}]}, {a: {diff: a => a, process: a => a}}), + ).toEqual({a: [{foo: 1}, {bar: 2}]}); + }); + + it('handles attributes defined multiple times', () => { + const validAttributes = {foo: true, style: {foo: true}}; + expect(create({foo: 4, style: {foo: 2}}, validAttributes)).toEqual({ + foo: 2, + }); + expect(create({style: {foo: 2}}, validAttributes)).toEqual({ + foo: 2, + }); + expect(create({style: {foo: 2}, foo: 4}, validAttributes)).toEqual({ + foo: 4, + }); + expect(create({foo: 4, style: {foo: null}}, validAttributes)).toEqual({ + foo: null, // this should ideally be null. + }); + expect( + create({foo: 4, style: [{foo: null}, {foo: 5}]}, validAttributes), + ).toEqual({ + foo: 5, + }); + }); + + // Function properties are just markers to native that events should be sent. + it('should convert functions to booleans', () => { + expect( + create( + { + a: function () { + return 9; + }, + b: function () { + return 3; + }, + }, + {a: true, b: true}, + ), + ).toEqual({a: true, b: true}); + }); +}); + +describe('ReactNativeAttributePayloadFabric.diff', () => { + it('should work with simple example', () => { + expect(diff({a: 1, c: 3}, {b: 2, c: 3}, {a: true, b: true})).toEqual({ + a: null, + b: 2, + }); + }); + + it('should skip fields that are equal', () => { + expect( + diff( + {a: 1, b: 'two', c: true, d: false, e: undefined, f: 0}, + {a: 1, b: 'two', c: true, d: false, e: undefined, f: 0}, + {a: true, b: true, c: true, d: true, e: true, f: true}, + ), + ).toEqual(null); + }); + + it('should remove fields', () => { + expect(diff({a: 1}, {}, {a: true})).toEqual({a: null}); + }); + + it('should remove fields that are set to undefined', () => { + expect(diff({a: 1}, {a: undefined}, {a: true})).toEqual({a: null}); + }); + + it('should ignore invalid fields', () => { + expect(diff({a: 1}, {b: 2}, {})).toEqual(null); + }); + + // @gate !enableShallowPropDiffing + it('should use the diff attribute', () => { + const diffA = jest.fn((a, b) => true); + const diffB = jest.fn((a, b) => false); + expect( + diff( + {a: [1], b: [3]}, + {a: [2], b: [4]}, + {a: {diff: diffA}, b: {diff: diffB}}, + ), + ).toEqual({a: [2]}); + expect(diffA).toBeCalledWith([1], [2]); + expect(diffB).toBeCalledWith([3], [4]); + }); + + it('should not use the diff attribute on addition/removal', () => { + const diffA = jest.fn(); + const diffB = jest.fn(); + expect( + diff({a: [1]}, {b: [2]}, {a: {diff: diffA}, b: {diff: diffB}}), + ).toEqual({a: null, b: [2]}); + expect(diffA).not.toBeCalled(); + expect(diffB).not.toBeCalled(); + }); + + // @gate !enableShallowPropDiffing + it('should do deep diffs of Objects by default', () => { + expect( + diff( + {a: [1], b: {k: [3, 4]}, c: {k: [4, 4]}}, + {a: [2], b: {k: [3, 4]}, c: {k: [4, 5]}}, + {a: true, b: true, c: true}, + ), + ).toEqual({a: [2], c: {k: [4, 5]}}); + }); + + it('should work with undefined styles', () => { + expect( + diff( + {style: {a: '#ffffff', b: 1}}, + {style: undefined}, + {style: {b: true}}, + ), + ).toEqual({b: null}); + expect( + diff( + {style: undefined}, + {style: {a: '#ffffff', b: 1}}, + {style: {b: true}}, + ), + ).toEqual({b: 1}); + expect( + diff({style: undefined}, {style: undefined}, {style: {b: true}}), + ).toEqual(null); + }); + + it('should work with empty styles', () => { + expect(diff({a: 1, c: 3}, {}, {a: true, b: true})).toEqual({a: null}); + expect(diff({}, {a: 1, c: 3}, {a: true, b: true})).toEqual({a: 1}); + expect(diff({}, {}, {a: true, b: true})).toEqual(null); + }); + + it('should flatten nested styles and predefined styles', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + + expect( + diff({}, {someStyle: [{foo: 1}, {bar: 2}]}, validStyleAttribute), + ).toEqual({foo: 1, bar: 2}); + + expect( + diff({someStyle: [{foo: 1}, {bar: 2}]}, {}, validStyleAttribute), + ).toEqual({foo: null, bar: null}); + + const barStyle = { + bar: 3, + }; + + expect( + diff( + {}, + {someStyle: [[{foo: 1}, {foo: 2}], barStyle]}, + validStyleAttribute, + ), + ).toEqual({foo: 2, bar: 3}); + }); + + it('should reset a value to a previous if it is removed', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + + expect( + diff( + {someStyle: [{foo: 1}, {foo: 3}]}, + {someStyle: [{foo: 1}, {bar: 2}]}, + validStyleAttribute, + ), + ).toEqual({foo: 1, bar: 2}); + }); + + it('should not clear removed props if they are still in another slot', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + + expect( + diff( + {someStyle: [{}, {foo: 3, bar: 2}]}, + {someStyle: [{foo: 3}, {bar: 2}]}, + validStyleAttribute, + ), + ).toEqual({foo: 3}); // this should ideally be null. heuristic tradeoff. + + expect( + diff( + {someStyle: [{}, {foo: 3, bar: 2}]}, + {someStyle: [{foo: 1, bar: 1}, {bar: 2}]}, + validStyleAttribute, + ), + ).toEqual({bar: 2, foo: 1}); + }); + + it('should clear a prop if a later style is explicit null/undefined', () => { + const validStyleAttribute = {someStyle: {foo: true, bar: true}}; + expect( + diff( + {someStyle: [{}, {foo: 3, bar: 2}]}, + {someStyle: [{foo: 1}, {bar: 2, foo: null}]}, + validStyleAttribute, + ), + ).toEqual({foo: null}); + + expect( + diff( + {someStyle: [{foo: 3}, {foo: null, bar: 2}]}, + {someStyle: [{foo: null}, {bar: 2}]}, + validStyleAttribute, + ), + ).toEqual({foo: null}); + + expect( + diff( + {someStyle: [{foo: 1}, {foo: null}]}, + {someStyle: [{foo: 2}, {foo: null}]}, + validStyleAttribute, + ), + ).toEqual({foo: null}); // this should ideally be null. heuristic. + + // Test the same case with object equality because an early bailout doesn't + // work in this case. + const fooObj = {foo: 3}; + expect( + diff( + {someStyle: [{foo: 1}, fooObj]}, + {someStyle: [{foo: 2}, fooObj]}, + validStyleAttribute, + ), + ).toEqual({foo: 3}); // this should ideally be null. heuristic. + + expect( + diff( + {someStyle: [{foo: 1}, {foo: 3}]}, + {someStyle: [{foo: 2}, {foo: undefined}]}, + validStyleAttribute, + ), + ).toEqual({foo: null}); // this should ideally be null. heuristic. + }); + + it('handles attributes defined multiple times', () => { + const validAttributes = {foo: true, style: {foo: true}}; + expect(diff({}, {foo: 4, style: {foo: 2}}, validAttributes)).toEqual({ + foo: 2, + }); + expect(diff({foo: 4}, {style: {foo: 2}}, validAttributes)).toEqual({ + foo: 2, + }); + expect(diff({style: {foo: 2}}, {foo: 4}, validAttributes)).toEqual({ + foo: 4, + }); + }); + + // Function properties are just markers to native that events should be sent. + it('should convert functions to booleans', () => { + // Note that if the property changes from one function to another, we don't + // need to send an update. + expect( + diff( + { + a: function () { + return 1; + }, + b: function () { + return 2; + }, + c: 3, + }, + { + b: function () { + return 9; + }, + c: function () { + return 3; + }, + }, + {a: true, b: true, c: true}, + ), + ).toEqual({a: null, c: true}); + }); + + it('should skip changed functions', () => { + expect( + diff( + { + a: function () { + return 1; + }, + }, + { + a: function () { + return 9; + }, + }, + {a: true}, + ), + ).toEqual(null); + }); + + // @gate !enableShallowPropDiffing + it('should skip deeply-nested changed functions', () => { + expect( + diff( + { + wrapper: { + a: function () { + return 1; + }, + }, + }, + { + wrapper: { + a: function () { + return 9; + }, + }, + }, + {wrapper: true}, + ), + ).toEqual(null); + }); +}); diff --git a/packages/react-native-renderer/src/__tests__/ResponderEventPlugin-test.internal.js b/packages/react-native-renderer/src/__tests__/ResponderEventPlugin-test.internal.js index ccd84d08a0..afa9bda411 100644 --- a/packages/react-native-renderer/src/__tests__/ResponderEventPlugin-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ResponderEventPlugin-test.internal.js @@ -1377,113 +1377,118 @@ describe('ResponderEventPlugin', () => { expect(ResponderEventPlugin._getResponder()).toBe(null); }); - it('should determine the first common ancestor correctly', async () => { - // This test was moved here from the ReactTreeTraversal test since only the - // ResponderEventPlugin uses `getLowestCommonAncestor` - const React = require('react'); - const ReactDOMClient = require('react-dom/client'); - const act = require('internal-test-utils').act; - const getLowestCommonAncestor = - require('react-native-renderer/src/legacy-events/ResponderEventPlugin').getLowestCommonAncestor; - // This works by accident and will likely break in the future. - const ReactDOMComponentTree = require('react-dom-bindings/src/client/ReactDOMComponentTree'); + it( + 'should determine the first common ancestor correctly', + async () => { + // This test was moved here from the ReactTreeTraversal test since only the + // ResponderEventPlugin uses `getLowestCommonAncestor` + const React = require('react'); + const ReactDOMClient = require('react-dom/client'); + const act = require('internal-test-utils').act; + const getLowestCommonAncestor = + require('react-native-renderer/src/legacy-events/ResponderEventPlugin').getLowestCommonAncestor; + // This works by accident and will likely break in the future. + const ReactDOMComponentTree = require('react-dom-bindings/src/client/ReactDOMComponentTree'); - class ChildComponent extends React.Component { - divRef = React.createRef(); - div1Ref = React.createRef(); - div2Ref = React.createRef(); + class ChildComponent extends React.Component { + divRef = React.createRef(); + div1Ref = React.createRef(); + div2Ref = React.createRef(); - render() { - return ( -
-
-
-
- ); - } - } - - class ParentComponent extends React.Component { - pRef = React.createRef(); - p_P1Ref = React.createRef(); - p_P1_C1Ref = React.createRef(); - p_P1_C2Ref = React.createRef(); - p_OneOffRef = React.createRef(); - - render() { - return ( -
-
- - + render() { + return ( +
+
+
-
-
+ ); + } + } + + class ParentComponent extends React.Component { + pRef = React.createRef(); + p_P1Ref = React.createRef(); + p_P1_C1Ref = React.createRef(); + p_P1_C2Ref = React.createRef(); + p_OneOffRef = React.createRef(); + + render() { + return ( +
+
+ + +
+
+
+ ); + } + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + let parent; + await act(() => { + root.render( (parent = current)} />); + }); + + const ancestors = [ + // Common ancestor with self is self. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1_C1Ref.current.div1Ref.current, + com: parent.p_P1_C1Ref.current.div1Ref.current, + }, + // Common ancestor with self is self - even if topmost DOM. + { + one: parent.pRef.current, + two: parent.pRef.current, + com: parent.pRef.current, + }, + // Siblings + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1_C1Ref.current.div2Ref.current, + com: parent.p_P1_C1Ref.current.divRef.current, + }, + // Common ancestor with parent is the parent. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1_C1Ref.current.divRef.current, + com: parent.p_P1_C1Ref.current.divRef.current, + }, + // Common ancestor with grandparent is the grandparent. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1Ref.current, + com: parent.p_P1Ref.current, + }, + // Grandparent across subcomponent boundaries. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_P1_C2Ref.current.div1Ref.current, + com: parent.p_P1Ref.current, + }, + // Something deep with something one-off. + { + one: parent.p_P1_C1Ref.current.div1Ref.current, + two: parent.p_OneOffRef.current, + com: parent.pRef.current, + }, + ]; + let i; + for (i = 0; i < ancestors.length; i++) { + const plan = ancestors[i]; + const firstCommon = getLowestCommonAncestor( + ReactDOMComponentTree.getInstanceFromNode(plan.one), + ReactDOMComponentTree.getInstanceFromNode(plan.two), + ); + expect(firstCommon).toBe( + ReactDOMComponentTree.getInstanceFromNode(plan.com), ); } - } - - const container = document.createElement('div'); - const root = ReactDOMClient.createRoot(container); - let parent; - await act(() => { - root.render( (parent = current)} />); - }); - - const ancestors = [ - // Common ancestor with self is self. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1_C1Ref.current.div1Ref.current, - com: parent.p_P1_C1Ref.current.div1Ref.current, - }, - // Common ancestor with self is self - even if topmost DOM. - { - one: parent.pRef.current, - two: parent.pRef.current, - com: parent.pRef.current, - }, - // Siblings - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1_C1Ref.current.div2Ref.current, - com: parent.p_P1_C1Ref.current.divRef.current, - }, - // Common ancestor with parent is the parent. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1_C1Ref.current.divRef.current, - com: parent.p_P1_C1Ref.current.divRef.current, - }, - // Common ancestor with grandparent is the grandparent. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1Ref.current, - com: parent.p_P1Ref.current, - }, - // Grandparent across subcomponent boundaries. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_P1_C2Ref.current.div1Ref.current, - com: parent.p_P1Ref.current, - }, - // Something deep with something one-off. - { - one: parent.p_P1_C1Ref.current.div1Ref.current, - two: parent.p_OneOffRef.current, - com: parent.pRef.current, - }, - ]; - let i; - for (i = 0; i < ancestors.length; i++) { - const plan = ancestors[i]; - const firstCommon = getLowestCommonAncestor( - ReactDOMComponentTree.getInstanceFromNode(plan.one), - ReactDOMComponentTree.getInstanceFromNode(plan.two), - ); - expect(firstCommon).toBe( - ReactDOMComponentTree.getInstanceFromNode(plan.com), - ); - } - }); + }, + // TODO: this is a long running test, we should speed it up. + 60 * 1000, + ); }); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 983ae748e0..cf6f24404c 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -25,6 +25,9 @@ type Destination = Array; const textEncoder = new TextEncoder(); const ReactNoopFlightServer = ReactFlightServer({ + scheduleMicrotask(callback: () => void) { + callback(); + }, scheduleWork(callback: () => void) { callback(); }, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 7d739d3178..4e2832e4f2 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -74,6 +74,9 @@ function write(destination: Destination, buffer: Uint8Array): void { } const ReactNoopServer = ReactFizzServer({ + scheduleMicrotask(callback: () => void) { + callback(); + }, scheduleWork(callback: () => void) { callback(); }, diff --git a/packages/react-reconciler/src/ReactCurrentFiber.js b/packages/react-reconciler/src/ReactCurrentFiber.js index cf0c2543a5..fd2b4e7d80 100644 --- a/packages/react-reconciler/src/ReactCurrentFiber.js +++ b/packages/react-reconciler/src/ReactCurrentFiber.js @@ -44,7 +44,7 @@ export function getCurrentParentStackInDev(): string { return ''; } -function getCurrentFiberStackInDev(): string { +function getCurrentFiberStackInDev(stack: Error): string { if (__DEV__) { if (current === null) { return ''; @@ -54,7 +54,7 @@ function getCurrentFiberStackInDev(): string { // TODO: The above comment is not actually true. We might be // in a commit phase or preemptive set state callback. if (enableOwnerStacks) { - return getOwnerStackByFiberInDev(current); + return getOwnerStackByFiberInDev(current, stack); } return getStackByFiberInDevAndProd(current); } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 6eeb7ab377..793a3fa942 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -1689,22 +1689,36 @@ function updateHostHoistable( renderLanes: Lanes, ) { markRef(current, workInProgress); - const currentProps = current === null ? null : current.memoizedProps; - const resource = (workInProgress.memoizedState = getResource( - workInProgress.type, - currentProps, - workInProgress.pendingProps, - )); + if (current === null) { - if (!getIsHydrating() && resource === null) { - // This is not a Resource Hoistable and we aren't hydrating so we construct the instance. - workInProgress.stateNode = createHoistableInstance( - workInProgress.type, - workInProgress.pendingProps, - getRootHostContainer(), - workInProgress, - ); + const resource = getResource( + workInProgress.type, + null, + workInProgress.pendingProps, + null, + ); + if (resource) { + workInProgress.memoizedState = resource; + } else { + if (!getIsHydrating()) { + // This is not a Resource Hoistable and we aren't hydrating so we construct the instance. + workInProgress.stateNode = createHoistableInstance( + workInProgress.type, + workInProgress.pendingProps, + getRootHostContainer(), + workInProgress, + ); + } } + } else { + // Get Resource may or may not return a resource. either way we stash the result + // on memoized state. + workInProgress.memoizedState = getResource( + workInProgress.type, + current.memoizedProps, + workInProgress.pendingProps, + current.memoizedState, + ); } // Resources never have reconciler managed children. It is possible for diff --git a/packages/react-reconciler/src/ReactFiberCallUserSpace.js b/packages/react-reconciler/src/ReactFiberCallUserSpace.js index dfc88b64be..e85d0431b9 100644 --- a/packages/react-reconciler/src/ReactFiberCallUserSpace.js +++ b/packages/react-reconciler/src/ReactFiberCallUserSpace.js @@ -9,7 +9,7 @@ import type {LazyComponent} from 'react/src/ReactLazy'; -import {setIsRendering} from './ReactCurrentFiber'; +import {isRendering, setIsRendering} from './ReactCurrentFiber'; // These indirections exists so we can exclude its stack frame in DEV (and anything below it). // TODO: Consider marking the whole bundle instead of these boundaries. @@ -20,10 +20,14 @@ export function callComponentInDEV( props: Props, secondArg: Arg, ): R { + const wasRendering = isRendering; setIsRendering(true); - const result = Component(props, secondArg); - setIsRendering(false); - return result; + try { + const result = Component(props, secondArg); + return result; + } finally { + setIsRendering(wasRendering); + } } interface ClassInstance { @@ -32,10 +36,14 @@ interface ClassInstance { /** @noinline */ export function callRenderInDEV(instance: ClassInstance): R { + const wasRendering = isRendering; setIsRendering(true); - const result = instance.render(); - setIsRendering(false); - return result; + try { + const result = instance.render(); + return result; + } finally { + setIsRendering(wasRendering); + } } /** @noinline */ diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index a060d0f00a..4a671940ba 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -1052,7 +1052,6 @@ function completeWork( return null; } else { // This is a Hoistable Instance - // This must come at the very end of the complete phase. bubbleProperties(workInProgress); preloadInstanceAndSuspendIfNeeded( @@ -1064,21 +1063,18 @@ function completeWork( return null; } } else { - // We are updating. - const currentResource = current.memoizedState; - if (nextResource !== currentResource) { - // We are transitioning to, from, or between Hoistable Resources - // and require an update - markUpdate(workInProgress); - } - if (nextResource !== null) { - // This is a Hoistable Resource - // This must come at the very end of the complete phase. - - bubbleProperties(workInProgress); - if (nextResource === currentResource) { - workInProgress.flags &= ~MaySuspendCommit; - } else { + // This is an update. + if (nextResource) { + // This is a Resource + if (nextResource !== current.memoizedState) { + // we have a new Resource. we need to update + markUpdate(workInProgress); + // This must come at the very end of the complete phase. + bubbleProperties(workInProgress); + // This must come at the very end of the complete phase, because it might + // throw to suspend, and if the resource immediately loads, the work loop + // will resume rendering as if the work-in-progress completed. So it must + // fully complete. preloadResourceAndSuspendIfNeeded( workInProgress, nextResource, @@ -1086,10 +1082,15 @@ function completeWork( newProps, renderLanes, ); + return null; + } else { + // This must come at the very end of the complete phase. + bubbleProperties(workInProgress); + workInProgress.flags &= ~MaySuspendCommit; + return null; } - return null; } else { - // This is a Hoistable Instance + // This is an Instance // We may have props to update on the Hoistable instance. if (supportsMutation) { const oldProps = current.memoizedProps; @@ -1107,7 +1108,6 @@ function completeWork( renderLanes, ); } - // This must come at the very end of the complete phase. bubbleProperties(workInProgress); preloadInstanceAndSuspendIfNeeded( diff --git a/packages/react-reconciler/src/ReactFiberComponentStack.js b/packages/react-reconciler/src/ReactFiberComponentStack.js index e5e25f6746..8a69ba9ddf 100644 --- a/packages/react-reconciler/src/ReactFiberComponentStack.js +++ b/packages/react-reconciler/src/ReactFiberComponentStack.js @@ -90,13 +90,27 @@ function describeFunctionComponentFrameWithoutLineNumber(fn: Function): string { return name ? describeBuiltInComponentFrame(name) : ''; } -export function getOwnerStackByFiberInDev(workInProgress: Fiber): string { +export function getOwnerStackByFiberInDev( + workInProgress: Fiber, + topStack: null | Error, +): string { if (!enableOwnerStacks || !__DEV__) { return ''; } try { let info = ''; + if (topStack) { + // Prefix with a filtered version of the currently executing + // stack. This information will be available in the native + // stack regardless but it's hidden since we're reprinting + // the stack on top of it. + const formattedTopStack = formatOwnerStack(topStack); + if (formattedTopStack !== '') { + info += '\n' + formattedTopStack; + } + } + if (workInProgress.tag === HostText) { // Text nodes never have an owner/stack because they're not created through JSX. // We use the parent since text nodes are always created through a host parent. @@ -125,14 +139,16 @@ export function getOwnerStackByFiberInDev(workInProgress: Fiber): string { case FunctionComponent: case SimpleMemoComponent: case ClassComponent: - if (!workInProgress._debugOwner) { + if (!workInProgress._debugOwner && info === '') { + // Only if we have no other data about the callsite do we add + // the component name as the single stack frame. info += describeFunctionComponentFrameWithoutLineNumber( workInProgress.type, ); } break; case ForwardRef: - if (!workInProgress._debugOwner) { + if (!workInProgress._debugOwner && info === '') { info += describeFunctionComponentFrameWithoutLineNumber( workInProgress.type.render, ); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index f69b8f1a2f..f19f40a175 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1083,20 +1083,49 @@ function useThenable(thenable: Thenable): T { thenableState = createThenableState(); } const result = trackUsedThenable(thenableState, thenable, index); - if ( - currentlyRenderingFiber.alternate === null && - (workInProgressHook === null - ? currentlyRenderingFiber.memoizedState === null - : workInProgressHook.next === null) - ) { - // Initial render, and either this is the first time the component is - // called, or there were no Hooks called after this use() the previous - // time (perhaps because it threw). Subsequent Hook calls should use the - // mount dispatcher. + + // When something suspends with `use`, we replay the component with the + // "re-render" dispatcher instead of the "mount" or "update" dispatcher. + // + // But if there are additional hooks that occur after the `use` invocation + // that suspended, they wouldn't have been processed during the previous + // attempt. So after we invoke `use` again, we may need to switch from the + // "re-render" dispatcher back to the "mount" or "update" dispatcher. That's + // what the following logic accounts for. + // + // TODO: Theoretically this logic only needs to go into the rerender + // dispatcher. Could optimize, but probably not be worth it. + + // This is the same logic as in updateWorkInProgressHook. + const workInProgressFiber = currentlyRenderingFiber; + const nextWorkInProgressHook = + workInProgressHook === null + ? // We're at the beginning of the list, so read from the first hook from + // the fiber. + workInProgressFiber.memoizedState + : workInProgressHook.next; + + if (nextWorkInProgressHook !== null) { + // There are still hooks remaining from the previous attempt. + } else { + // There are no remaining hooks from the previous attempt. We're no longer + // in "re-render" mode. Switch to the normal mount or update dispatcher. + // + // This is the same as the logic in renderWithHooks, except we don't bother + // to track the hook types debug information in this case (sufficient to + // only do that when nothing suspends). + const currentFiber = workInProgressFiber.alternate; if (__DEV__) { - ReactSharedInternals.H = HooksDispatcherOnMountInDEV; + if (currentFiber !== null && currentFiber.memoizedState !== null) { + ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV; + } else { + ReactSharedInternals.H = HooksDispatcherOnMountInDEV; + } } else { - ReactSharedInternals.H = HooksDispatcherOnMount; + ReactSharedInternals.H = + currentFiber === null || currentFiber.memoizedState === null + ? HooksDispatcherOnMount + : HooksDispatcherOnUpdate; } } return result; @@ -1965,7 +1994,9 @@ type ActionStateQueue = { dispatch: Dispatch

, // This is the most recent action function that was rendered. It's updated // during the commit phase. - action: (Awaited, P) => S, + // If it's null, it means the action queue errored and subsequent actions + // should not run. + action: ((Awaited, P) => S) | null, // This is a circular linked list of pending action payloads. It incudes the // action that is currently running. pending: ActionStateQueueNode | null, @@ -1977,65 +2008,93 @@ type ActionStateQueueNode = { action: (Awaited, P) => S, // This is never null because it's part of a circular linked list. next: ActionStateQueueNode, + + // Whether or not the action was dispatched as part of a transition. We use + // this to restore the transition context when the queued action is run. Once + // we're able to track parallel async actions, this should be updated to + // represent the specific transition instance the action is associated with. + isTransition: boolean, + + // Implements the Thenable interface. We use it to suspend until the action + // finishes. + then: (listener: () => void) => void, + status: 'pending' | 'rejected' | 'fulfilled', + value: any, + reason: any, + listeners: Array<() => void>, }; function dispatchActionState( fiber: Fiber, actionQueue: ActionStateQueue, setPendingState: boolean => void, - setState: Dispatch>, + setState: Dispatch>, payload: P, ): void { if (isRenderPhaseUpdate(fiber)) { throw new Error('Cannot update form state while rendering.'); } + + const currentAction = actionQueue.action; + if (currentAction === null) { + // An earlier action errored. Subsequent actions should not run. + return; + } + + const actionNode: ActionStateQueueNode = { + payload, + action: currentAction, + next: (null: any), // circular + + isTransition: true, + + status: 'pending', + value: null, + reason: null, + listeners: [], + then(listener) { + // We know the only thing that subscribes to these promises is `use` so + // this implementation is simpler than a generic thenable. E.g. we don't + // bother to check if the thenable is still pending because `use` already + // does that. + actionNode.listeners.push(listener); + }, + }; + + // Check if we're inside a transition. If so, we'll need to restore the + // transition context when the action is run. + const prevTransition = ReactSharedInternals.T; + if (prevTransition !== null) { + // Optimistically update the pending state, similar to useTransition. + // This will be reverted automatically when all actions are finished. + setPendingState(true); + // `actionNode` is a thenable that resolves to the return value of + // the action. + setState(actionNode); + } else { + // This is not a transition. + actionNode.isTransition = false; + setState(actionNode); + } + const last = actionQueue.pending; if (last === null) { // There are no pending actions; this is the first one. We can run // it immediately. - const newLast: ActionStateQueueNode = { - payload, - action: actionQueue.action, - next: (null: any), // circular - }; - newLast.next = actionQueue.pending = newLast; - - runActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - newLast, - ); + actionNode.next = actionQueue.pending = actionNode; + runActionStateAction(actionQueue, actionNode); } else { // There's already an action running. Add to the queue. const first = last.next; - const newLast: ActionStateQueueNode = { - payload, - action: actionQueue.action, - next: first, - }; - actionQueue.pending = last.next = newLast; + actionNode.next = first; + actionQueue.pending = last.next = actionNode; } } function runActionStateAction( actionQueue: ActionStateQueue, - setPendingState: boolean => void, - setState: Dispatch>, node: ActionStateQueueNode, ) { - // This is a fork of startTransition - const prevTransition = ReactSharedInternals.T; - const currentTransition: BatchConfigTransition = {}; - ReactSharedInternals.T = currentTransition; - if (__DEV__) { - ReactSharedInternals.T._updatedFibers = new Set(); - } - - // Optimistically update the pending state, similar to useTransition. - // This will be reverted automatically when all actions are finished. - setPendingState(true); - // `node.action` represents the action function at the time it was dispatched. // If this action was queued, it might be stale, i.e. it's not necessarily the // most current implementation of the action, stored on `actionQueue`. This is @@ -2045,93 +2104,106 @@ function runActionStateAction( const action = node.action; const payload = node.payload; const prevState = actionQueue.state; - try { - const returnValue = action(prevState, payload); - const onStartTransitionFinish = ReactSharedInternals.S; - if (onStartTransitionFinish !== null) { - onStartTransitionFinish(currentTransition, returnValue); - } - if ( - returnValue !== null && - typeof returnValue === 'object' && - // $FlowFixMe[method-unbinding] - typeof returnValue.then === 'function' - ) { - const thenable = ((returnValue: any): Thenable>); - // Attach a listener to read the return state of the action. As soon as - // this resolves, we can run the next action in the sequence. - thenable.then( - (nextState: Awaited) => { - actionQueue.state = nextState; - finishRunningActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - ); - }, - () => - finishRunningActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - ), - ); - - setState((thenable: any)); - } else { - setState((returnValue: any)); - - const nextState = ((returnValue: any): Awaited); - actionQueue.state = nextState; - finishRunningActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - ); - } - } catch (error) { - // This is a trick to get the `useActionState` hook to rethrow the error. - // When it unwraps the thenable with the `use` algorithm, the error - // will be thrown. - const rejectedThenable: S = ({ - then() {}, - status: 'rejected', - reason: error, - // $FlowFixMe: Not sure why this doesn't work - }: RejectedThenable>); - setState(rejectedThenable); - finishRunningActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - ); - } finally { - ReactSharedInternals.T = prevTransition; + if (node.isTransition) { + // The original dispatch was part of a transition. We restore its + // transition context here. + // This is a fork of startTransition + const prevTransition = ReactSharedInternals.T; + const currentTransition: BatchConfigTransition = {}; + ReactSharedInternals.T = currentTransition; if (__DEV__) { - if (prevTransition === null && currentTransition._updatedFibers) { - const updatedFibersCount = currentTransition._updatedFibers.size; - currentTransition._updatedFibers.clear(); - if (updatedFibersCount > 10) { - console.warn( - 'Detected a large number of updates inside startTransition. ' + - 'If this is due to a subscription please re-write it to use React provided hooks. ' + - 'Otherwise concurrent mode guarantees are off the table.', - ); + ReactSharedInternals.T._updatedFibers = new Set(); + } + try { + const returnValue = action(prevState, payload); + const onStartTransitionFinish = ReactSharedInternals.S; + if (onStartTransitionFinish !== null) { + onStartTransitionFinish(currentTransition, returnValue); + } + handleActionReturnValue(actionQueue, node, returnValue); + } catch (error) { + onActionError(actionQueue, node, error); + } finally { + ReactSharedInternals.T = prevTransition; + + if (__DEV__) { + if (prevTransition === null && currentTransition._updatedFibers) { + const updatedFibersCount = currentTransition._updatedFibers.size; + currentTransition._updatedFibers.clear(); + if (updatedFibersCount > 10) { + console.warn( + 'Detected a large number of updates inside startTransition. ' + + 'If this is due to a subscription please re-write it to use React provided hooks. ' + + 'Otherwise concurrent mode guarantees are off the table.', + ); + } } } } + } else { + // The original dispatch was not part of a transition. + try { + const returnValue = action(prevState, payload); + handleActionReturnValue(actionQueue, node, returnValue); + } catch (error) { + onActionError(actionQueue, node, error); + } } } -function finishRunningActionStateAction( +function handleActionReturnValue( actionQueue: ActionStateQueue, - setPendingState: Dispatch>, - setState: Dispatch>, + node: ActionStateQueueNode, + returnValue: mixed, ) { - // The action finished running. Pop it from the queue and run the next pending - // action, if there are any. + if ( + returnValue !== null && + typeof returnValue === 'object' && + // $FlowFixMe[method-unbinding] + typeof returnValue.then === 'function' + ) { + const thenable = ((returnValue: any): Thenable>); + // Attach a listener to read the return state of the action. As soon as + // this resolves, we can run the next action in the sequence. + thenable.then( + (nextState: Awaited) => { + onActionSuccess(actionQueue, node, nextState); + }, + (error: mixed) => onActionError(actionQueue, node, error), + ); + + if (__DEV__) { + if (!node.isTransition) { + console.error( + 'An async function was passed to useActionState, but it was ' + + 'dispatched outside of an action context. This is likely not ' + + 'what you intended. Either pass the dispatch function to an ' + + '`action` prop, or dispatch manually inside `startTransition`', + ); + } + } + } else { + const nextState = ((returnValue: any): Awaited); + onActionSuccess(actionQueue, node, nextState); + } +} + +function onActionSuccess( + actionQueue: ActionStateQueue, + actionNode: ActionStateQueueNode, + nextState: Awaited, +) { + // The action finished running. + actionNode.status = 'fulfilled'; + actionNode.value = nextState; + notifyActionListeners(actionNode); + + actionQueue.state = nextState; + + // Pop the action from the queue and run the next pending action, if there + // are any. const last = actionQueue.pending; if (last !== null) { const first = last.next; @@ -2144,16 +2216,44 @@ function finishRunningActionStateAction( last.next = next; // Run the next action. - runActionStateAction( - actionQueue, - (setPendingState: any), - (setState: any), - next, - ); + runActionStateAction(actionQueue, next); } } } +function onActionError( + actionQueue: ActionStateQueue, + actionNode: ActionStateQueueNode, + error: mixed, +) { + // Mark all the following actions as rejected. + const last = actionQueue.pending; + actionQueue.pending = null; + if (last !== null) { + const first = last.next; + do { + actionNode.status = 'rejected'; + actionNode.reason = error; + notifyActionListeners(actionNode); + actionNode = actionNode.next; + } while (actionNode !== first); + } + + // Prevent subsequent actions from being dispatched. + actionQueue.action = null; +} + +function notifyActionListeners(actionNode: ActionStateQueueNode) { + // Notify React that the action has finished. + const listeners = actionNode.listeners; + for (let i = 0; i < listeners.length; i++) { + // This is always a React internal listener, so we don't need to worry + // about it throwing. + const listener = listeners[i]; + listener(); + } +} + function actionStateReducer(oldState: S, newState: S): S { return newState; } diff --git a/packages/react-reconciler/src/ReactFiberOwnerStack.js b/packages/react-reconciler/src/ReactFiberOwnerStack.js index b8510ebf6b..fe9e4f1cfd 100644 --- a/packages/react-reconciler/src/ReactFiberOwnerStack.js +++ b/packages/react-reconciler/src/ReactFiberOwnerStack.js @@ -103,6 +103,11 @@ function filterDebugStack(error: Error): string { if (lastFrameIdx !== -1) { // Cut off everything after our "callComponent" slot since it'll be Fiber internals. frames.length = lastFrameIdx; + } else { + // We didn't find any internal callsite out to user space. + // This means that this was called outside an owner or the owner is fully internal. + // To keep things light we exclude the entire trace in this case. + return ''; } return frames.filter(isNotExternal).join('\n'); } diff --git a/packages/react-reconciler/src/__tests__/Activity-test.js b/packages/react-reconciler/src/__tests__/Activity-test.js index d37513e01e..65546609cc 100644 --- a/packages/react-reconciler/src/__tests__/Activity-test.js +++ b/packages/react-reconciler/src/__tests__/Activity-test.js @@ -118,7 +118,7 @@ describe('Activity', () => { ); }); - // @gate www && !disableLegacyMode + // @gate enableLegacyHidden && !disableLegacyMode it('does not defer in legacy mode', async () => { let setState; function Foo() { @@ -163,7 +163,7 @@ describe('Activity', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('does defer in concurrent mode', async () => { let setState; function Foo() { diff --git a/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js b/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js index a2b4de2e0c..473ae55381 100644 --- a/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js @@ -140,7 +140,7 @@ describe('Activity Suspense', () => { ); }); - // @gate www + // @gate enableLegacyHidden test('LegacyHidden does not handle suspense', async () => { const root = ReactNoop.createRoot(); @@ -174,7 +174,7 @@ describe('Activity Suspense', () => { ); }); - // @gate experimental || www + // @gate enableActivity test("suspending inside currently hidden tree that's switching to visible", async () => { const root = ReactNoop.createRoot(); @@ -319,7 +319,7 @@ describe('Activity Suspense', () => { ); }); - // @gate experimental || www + // @gate enableActivity test('update that suspends inside hidden tree', async () => { let setText; function Child() { @@ -352,7 +352,7 @@ describe('Activity Suspense', () => { }); }); - // @gate experimental || www + // @gate enableActivity test('updates at multiple priorities that suspend inside hidden tree', async () => { let setText; let setStep; diff --git a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js index 20e102ed6f..a58bbeaf45 100644 --- a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js +++ b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js @@ -550,7 +550,7 @@ describe('ReactLazyContextPropagation', () => { expect(root).toMatchRenderedOutput('BB'); }); - // @gate www + // @gate enableLegacyCache && enableLegacyHidden test('context is propagated through offscreen trees', async () => { const LegacyHidden = React.unstable_LegacyHidden; @@ -596,7 +596,7 @@ describe('ReactLazyContextPropagation', () => { expect(root).toMatchRenderedOutput('BB'); }); - // @gate www + // @gate enableLegacyCache && enableLegacyHidden test('multiple contexts are propagated across through offscreen trees', async () => { // Same as previous test, but with multiple context providers const LegacyHidden = React.unstable_LegacyHidden; @@ -822,7 +822,7 @@ describe('ReactLazyContextPropagation', () => { expect(root).toMatchRenderedOutput('BB'); }); - // @gate www + // @gate enableLegacyCache && enableLegacyHidden test('nested bailouts through offscreen trees', async () => { // Lazy context propagation will stop propagating when it hits the first // match. If we bail out again inside that tree, we must resume propagating. diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index 8cc5c15a9e..fd1879dabf 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -1618,8 +1618,7 @@ describe('ReactHooks', () => { ' Previous render Next render\n' + ' ------------------------------------------------------\n' + `1. ${formatHookNamesToMatchErrorMessage(hookNameA, hookNameB)}\n` + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n' + - ' in App (at **)', + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', ]); // further warnings for this component are silenced @@ -1671,8 +1670,7 @@ describe('ReactHooks', () => { ' ------------------------------------------------------\n' + `1. ${formatHookNamesToMatchErrorMessage(hookNameA, hookNameA)}\n` + `2. undefined use${hookNameB}\n` + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n' + - ' in App (at **)', + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', ]); }); }); @@ -1758,8 +1756,7 @@ describe('ReactHooks', () => { 'ImperativeHandle', 'Memo', )}\n` + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n' + - ' in App (at **)', + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', ]); // further warnings for this component are silenced diff --git a/packages/react-reconciler/src/__tests__/ReactIncremental-test.js b/packages/react-reconciler/src/__tests__/ReactIncremental-test.js index 4beb0a12da..f0fe5d5afb 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncremental-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIncremental-test.js @@ -239,7 +239,7 @@ describe('ReactIncremental', () => { expect(inst.state).toEqual({text: 'bar', text2: 'baz'}); }); - // @gate www + // @gate enableLegacyHidden it('can deprioritize unfinished work and resume it later', async () => { function Bar(props) { Scheduler.log('Bar'); @@ -279,7 +279,7 @@ describe('ReactIncremental', () => { await waitForAll(['Middle', 'Middle']); }); - // @gate www + // @gate enableLegacyHidden it('can deprioritize a tree from without dropping work', async () => { function Bar(props) { Scheduler.log('Bar'); @@ -1864,8 +1864,7 @@ describe('ReactIncremental', () => { ]); }); - // @gate www - // @gate !disableLegacyContext + // @gate enableLegacyHidden && !disableLegacyContext it('provides context when reusing work', async () => { class Intl extends React.Component { static childContextTypes = { diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js index b0ac81016e..2f8b26801e 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js @@ -289,7 +289,7 @@ describe('ReactIncrementalErrorHandling', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('does not include offscreen work when retrying after an error', async () => { function App(props) { if (props.isBroken) { diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalSideEffects-test.js b/packages/react-reconciler/src/__tests__/ReactIncrementalSideEffects-test.js index 6226e2bc22..8b1de82b26 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalSideEffects-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalSideEffects-test.js @@ -481,7 +481,7 @@ describe('ReactIncrementalSideEffects', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('preserves a previously rendered node when deprioritized', async () => { function Middle(props) { Scheduler.log('Middle'); @@ -530,7 +530,7 @@ describe('ReactIncrementalSideEffects', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('can reuse side-effects after being preempted', async () => { function Bar(props) { Scheduler.log('Bar'); @@ -610,7 +610,7 @@ describe('ReactIncrementalSideEffects', () => { ); }); - // @gate www + // @gate enableLegacyHidden it('can reuse side-effects after being preempted, if shouldComponentUpdate is false', async () => { class Bar extends React.Component { shouldComponentUpdate(nextProps) { @@ -733,7 +733,7 @@ describe('ReactIncrementalSideEffects', () => { expect(ReactNoop.getChildrenAsJSX()).toEqual(); }); - // @gate www + // @gate enableLegacyHidden it('updates a child even though the old props is empty', async () => { function Foo(props) { return ( @@ -984,7 +984,7 @@ describe('ReactIncrementalSideEffects', () => { expect(ops).toEqual(['Bar', 'Baz', 'Bar', 'Bar']); }); - // @gate www + // @gate enableLegacyHidden it('deprioritizes setStates that happens within a deprioritized tree', async () => { const barInstances = []; diff --git a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js index 3ec58b2f70..73f9aa9cc5 100644 --- a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js @@ -228,18 +228,10 @@ describe('ReactLazy', () => { expect(error.message).toMatch('Element type is invalid'); assertLog(['Loading...']); - assertConsoleErrorDev( - [ - 'Expected the result of a dynamic import() call', - 'Expected the result of a dynamic import() call', - ], - gate(flags => flags.enableOwnerStacks) - ? { - // There's no owner - withoutStack: true, - } - : undefined, - ); + assertConsoleErrorDev([ + 'Expected the result of a dynamic import() call', + 'Expected the result of a dynamic import() call', + ]); expect(root).not.toMatchRenderedOutput('Hi'); }); diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js index f5f043f8b7..59d88a9ffa 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js @@ -699,7 +699,7 @@ describe('ReactNewContext', () => { ); }); - // @gate www + // @gate enableLegacyHidden it("context consumer doesn't bail out inside hidden subtree", async () => { const Context = React.createContext('dark'); const Consumer = getConsumer(Context); diff --git a/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.js b/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.js index a29280b36c..8646c0ff46 100644 --- a/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.js @@ -131,7 +131,7 @@ describe('ReactSchedulerIntegration', () => { await waitForAll(['D', 'E']); }); - // @gate www + // @gate enableLegacyHidden it('idle updates are not blocked by offscreen work', async () => { function Text({text}) { Scheduler.log(text); diff --git a/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js b/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js index fd2982b811..e3960ec678 100644 --- a/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js @@ -41,7 +41,7 @@ describe('ReactScope', () => { container = null; }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryAllNodes() works as intended', async () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_Scope; @@ -86,7 +86,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryAllNodes() provides the correct host instance', async () => { const testScopeQuery = (type, props) => type === 'div'; const TestScope = React.unstable_Scope; @@ -143,7 +143,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryFirstNode() works as intended', async () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_Scope; @@ -188,7 +188,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); - // @gate www + // @gate enableScopeAPI it('containsNode() works as intended', async () => { const TestScope = React.unstable_Scope; const scopeRef = React.createRef(); @@ -248,7 +248,7 @@ describe('ReactScope', () => { expect(scopeRef.current.containsNode(emRef.current)).toBe(false); }); - // @gate www + // @gate enableScopeAPI it('scopes support server-side rendering and hydration', async () => { const TestScope = React.unstable_Scope; const scopeRef = React.createRef(); @@ -281,7 +281,7 @@ describe('ReactScope', () => { expect(nodes).toEqual([divRef.current, spanRef.current, aRef.current]); }); - // @gate www + // @gate enableScopeAPI it('getChildContextValues() works as intended', async () => { const TestContext = React.createContext(); const TestScope = React.unstable_Scope; @@ -320,7 +320,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); - // @gate www + // @gate enableScopeAPI it('correctly works with suspended boundaries that are hydrated', async () => { let suspend = false; let resolve; @@ -392,7 +392,7 @@ describe('ReactScope', () => { ReactTestRenderer = require('react-test-renderer'); }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryAllNodes() works as intended', async () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_Scope; @@ -434,7 +434,7 @@ describe('ReactScope', () => { expect(nodes).toEqual([aRef.current, divRef.current, spanRef.current]); }); - // @gate www + // @gate enableScopeAPI it('DO_NOT_USE_queryFirstNode() works as intended', async () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_Scope; @@ -477,7 +477,7 @@ describe('ReactScope', () => { expect(node).toEqual(aRef.current); }); - // @gate www + // @gate enableScopeAPI it('containsNode() works as intended', async () => { const TestScope = React.unstable_Scope; const scopeRef = React.createRef(); diff --git a/packages/react-reconciler/src/__tests__/ReactSubtreeFlagsWarning-test.js b/packages/react-reconciler/src/__tests__/ReactSubtreeFlagsWarning-test.js index 24c4266b50..6c58d1b6d1 100644 --- a/packages/react-reconciler/src/__tests__/ReactSubtreeFlagsWarning-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSubtreeFlagsWarning-test.js @@ -130,7 +130,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { const resolveText = resolveMostRecentTextCache; - // @gate www && !disableLegacyMode + // @gate enableLegacyCache && !disableLegacyMode it('regression: false positive for legacy suspense', async () => { const Child = ({text}) => { // If text hasn't resolved, this will throw and exit before the passive diff --git a/packages/react-reconciler/src/__tests__/ReactUse-test.js b/packages/react-reconciler/src/__tests__/ReactUse-test.js index dede68854c..451912cd45 100644 --- a/packages/react-reconciler/src/__tests__/ReactUse-test.js +++ b/packages/react-reconciler/src/__tests__/ReactUse-test.js @@ -16,6 +16,7 @@ let act; let use; let useDebugValue; let useState; +let useTransition; let useMemo; let useEffect; let Suspense; @@ -38,6 +39,7 @@ describe('ReactUse', () => { use = React.use; useDebugValue = React.useDebugValue; useState = React.useState; + useTransition = React.useTransition; useMemo = React.useMemo; useEffect = React.useEffect; Suspense = React.Suspense; @@ -1915,4 +1917,80 @@ describe('ReactUse', () => { assertLog(['Hi', 'World']); expect(root).toMatchRenderedOutput(

Hi World
); }); + + it( + 'regression: does not get stuck in pending state after `use` suspends ' + + '(when `use` comes before all hooks)', + async () => { + // This is a regression test. The root cause was an issue where we failed to + // switch from the "re-render" dispatcher back to the "update" dispatcher + // after a `use` suspends and triggers a replay. + let update; + function App({promise}) { + const value = use(promise); + + const [isPending, startLocalTransition] = useTransition(); + update = () => { + startLocalTransition(() => { + root.render(); + }); + }; + + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['Initial']); + expect(root).toMatchRenderedOutput('Initial'); + + await act(() => update()); + assertLog(['Async text requested [Updated]', 'Initial (pending...)']); + + await act(() => resolveTextRequests('Updated')); + assertLog(['Updated']); + expect(root).toMatchRenderedOutput('Updated'); + }, + ); + + it( + 'regression: does not get stuck in pending state after `use` suspends ' + + '(when `use` in in the middle of hook list)', + async () => { + // Same as previous test but `use` comes in between two hooks. + let update; + function App({promise}) { + // This hook is only here to test that `use` resumes correctly after + // suspended even if it comes in between other hooks. + useState(false); + + const value = use(promise); + + const [isPending, startLocalTransition] = useTransition(); + update = () => { + startLocalTransition(() => { + root.render(); + }); + }; + + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['Initial']); + expect(root).toMatchRenderedOutput('Initial'); + + await act(() => update()); + assertLog(['Async text requested [Updated]', 'Initial (pending...)']); + + await act(() => resolveTextRequests('Updated')); + assertLog(['Updated']); + expect(root).toMatchRenderedOutput('Updated'); + }, + ); }); diff --git a/packages/react-refresh/src/__tests__/ReactFresh-test.js b/packages/react-refresh/src/__tests__/ReactFresh-test.js index 13ded58419..3415a5d5bb 100644 --- a/packages/react-refresh/src/__tests__/ReactFresh-test.js +++ b/packages/react-refresh/src/__tests__/ReactFresh-test.js @@ -2441,7 +2441,7 @@ describe('ReactFresh', () => { } }); - // @gate www && __DEV__ + // @gate enableLegacyHidden && __DEV__ it('can hot reload offscreen components', async () => { const AppV1 = prepare(() => { function Hello() { diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js index dbc4430ec1..56d98e6517 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js @@ -9,7 +9,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -38,6 +41,7 @@ export type Options = { moduleBaseURL?: string, callServer?: CallServerCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: void | Options) { @@ -50,6 +54,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js index 97a9ec0a08..7bcc12d94b 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type { + Response, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {Readable} from 'stream'; @@ -46,6 +49,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, }; function createFromNodeStream( @@ -61,6 +65,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js index 2f5a554b5a..1aac84fde6 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js @@ -9,7 +9,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -37,6 +40,7 @@ type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: void | Options) { @@ -49,6 +53,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js index 57ed079c5a..c6336f7e42 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -67,6 +70,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: Options) { @@ -79,6 +83,9 @@ function createResponseFromOptions(options: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js index b34958424c..d0fb59c51e 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type { + Response, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type { SSRModuleMap, @@ -56,6 +59,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, }; function createFromNodeStream( @@ -70,6 +74,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js index f74143b220..eef2e82454 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOM-test.js @@ -9,16 +9,14 @@ 'use strict'; +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setImmediate = cb => cb(); - let act; let use; let clientExports; @@ -29,6 +27,8 @@ let ReactDOMClient; let ReactServerDOMServer; let ReactServerDOMClient; let Suspense; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOM', () => { beforeEach(() => { @@ -37,6 +37,10 @@ describe('ReactFlightDOM', () => { // condition jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchSetImmediate(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react-server-dom-turbopack/server', () => require('react-server-dom-turbopack/server.node.unbundled'), @@ -61,6 +65,17 @@ describe('ReactFlightDOM', () => { ReactServerDOMClient = require('react-server-dom-turbopack/client'); }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function getTestStream() { const writable = new Stream.PassThrough(); const readable = new ReadableStream({ @@ -100,9 +115,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - turbopackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, turbopackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -149,9 +163,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - turbopackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, turbopackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -191,9 +204,11 @@ describe('ReactFlightDOM', () => { const AsyncModuleRef2 = await clientExports(AsyncModule2); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - turbopackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + turbopackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js index d797946a3f..a47cca7068 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMBrowser-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -18,11 +20,17 @@ global.TextDecoder = require('util').TextDecoder; let React; let ReactServerDOMServer; let ReactServerDOMClient; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMBrowser', () => { beforeEach(() => { jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchMessageChannel(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-turbopack/server', () => @@ -38,6 +46,17 @@ describe('ReactFlightDOMBrowser', () => { ReactServerDOMClient = require('react-server-dom-turbopack/client'); }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + it('should resolve HTML using W3C streams', async () => { function Text({children}) { return {children}; @@ -58,7 +77,9 @@ describe('ReactFlightDOMBrowser', () => { return model; } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); const model = await response; expect(model).toEqual({ diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js index e06ee0a32f..1276d4d0be 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMNode-test.js @@ -9,9 +9,7 @@ 'use strict'; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setImmediate = cb => cb(); +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; let clientExports; let turbopackMap; @@ -23,11 +21,17 @@ let ReactServerDOMServer; let ReactServerDOMClient; let Stream; let use; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMNode', () => { beforeEach(() => { jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchSetImmediate(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-turbopack/server', () => @@ -55,6 +59,17 @@ describe('ReactFlightDOMNode', () => { use = React.use; }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function readResult(stream) { return new Promise((resolve, reject) => { let buffer = ''; @@ -102,9 +117,8 @@ describe('ReactFlightDOMNode', () => { return ; } - const stream = ReactServerDOMServer.renderToPipeableStream( - , - turbopackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, turbopackMap), ); const readable = new Stream.PassThrough(); @@ -121,8 +135,8 @@ describe('ReactFlightDOMNode', () => { return use(response); } - const ssrStream = await ReactDOMServer.renderToPipeableStream( - , + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream(), ); const result = await readResult(ssrStream); expect(result).toEqual( diff --git a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReply-test.js b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReply-test.js index e47352cfe9..cf328ab2e8 100644 --- a/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReply-test.js +++ b/packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMReply-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -19,10 +21,15 @@ global.TextDecoder = require('util').TextDecoder; let turbopackServerMap; let ReactServerDOMServer; let ReactServerDOMClient; +let ReactServerScheduler; describe('ReactFlightDOMReply', () => { beforeEach(() => { jest.resetModules(); + + ReactServerScheduler = require('scheduler'); + patchMessageChannel(ReactServerScheduler); + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-turbopack/server', () => diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js index 2f5a554b5a..1aac84fde6 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js @@ -9,7 +9,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -37,6 +40,7 @@ type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: void | Options) { @@ -49,6 +53,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js index 57ed079c5a..c6336f7e42 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -67,6 +70,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: Options) { @@ -79,6 +83,9 @@ function createResponseFromOptions(options: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js index b34958424c..d0fb59c51e 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type { + Response, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type { SSRModuleMap, @@ -56,6 +59,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, }; function createFromNodeStream( @@ -70,6 +74,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 5315b990d8..3bf8e02e0f 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -9,16 +9,14 @@ 'use strict'; +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setImmediate = cb => cb(); - let act; let use; let clientExports; @@ -36,6 +34,9 @@ let ReactDOMStaticServer; let Suspense; let ErrorBoundary; let JSDOM; +let ReactServerScheduler; +let reactServerAct; +let assertConsoleErrorDev; describe('ReactFlightDOM', () => { beforeEach(() => { @@ -46,6 +47,10 @@ describe('ReactFlightDOM', () => { JSDOM = require('jsdom').JSDOM; + ReactServerScheduler = require('scheduler'); + patchSetImmediate(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); FlightReact = require('react'); @@ -66,6 +71,8 @@ describe('ReactFlightDOM', () => { __unmockReact(); jest.resetModules(); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; Stream = require('stream'); React = require('react'); use = React.use; @@ -92,6 +99,49 @@ describe('ReactFlightDOM', () => { }; }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + + async function readInto( + container: Document | HTMLElement, + stream: ReadableStream, + ) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let content = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + content += decoder.decode(); + break; + } + content += decoder.decode(value, {stream: true}); + } + if (container.nodeType === 9 /* DOCUMENT */) { + const doc = new JSDOM(content).window.document; + container.documentElement.innerHTML = doc.documentElement.innerHTML; + while (container.documentElement.attributes.length > 0) { + container.documentElement.removeAttribute( + container.documentElement.attributes[0].name, + ); + } + const attrs = doc.documentElement.attributes; + for (let i = 0; i < attrs.length; i++) { + container.documentElement.setAttribute(attrs[i].name, attrs[i].value); + } + } else { + container.innerHTML = content; + } + } + function getTestStream() { const writable = new Stream.PassThrough(); const readable = new ReadableStream({ @@ -181,9 +231,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -230,9 +279,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -266,9 +314,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -300,9 +347,8 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -349,9 +395,11 @@ describe('ReactFlightDOM', () => { ); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -386,9 +434,11 @@ describe('ReactFlightDOM', () => { const {Component} = clientExports(Module); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -424,9 +474,11 @@ describe('ReactFlightDOM', () => { const {split: Component} = clientExports(Module); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -464,9 +516,11 @@ describe('ReactFlightDOM', () => { const AsyncModuleRef2 = await clientExports(AsyncModule2); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -502,9 +556,11 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -539,9 +595,8 @@ describe('ReactFlightDOM', () => { const ThenRef = clientExports(thenExports).then; const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -719,15 +774,13 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - model, - webpackMap, - { + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(model, webpackMap, { onError(x) { reportedErrors.push(x); return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; }, - }, + }), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -744,14 +797,18 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

(loading)

'); // This isn't enough to show anything. - await act(() => { - resolveFriends(); + await serverAct(async () => { + await act(() => { + resolveFriends(); + }); }); expect(container.innerHTML).toBe('

(loading)

'); // We can now show the details. Sidebar and posts are still loading. - await act(() => { - resolveName(); + await serverAct(async () => { + await act(() => { + resolveName(); + }); }); // Advance time enough to trigger a nested fallback. await act(() => { @@ -768,9 +825,11 @@ describe('ReactFlightDOM', () => { const theError = new Error('Game over'); // Let's *fail* loading games. - await act(async () => { - await rejectGames(theError); - await 'the inner async function'; + await serverAct(async () => { + await act(async () => { + await rejectGames(theError); + await 'the inner async function'; + }); }); const expectedGamesValue = __DEV__ ? '

Game over + a dev digest

' @@ -786,9 +845,11 @@ describe('ReactFlightDOM', () => { reportedErrors = []; // We can now show the sidebar. - await act(async () => { - await resolvePhotos(); - await 'the inner async function'; + await serverAct(async () => { + await act(async () => { + await resolvePhotos(); + await 'the inner async function'; + }); }); expect(container.innerHTML).toBe( '
:name::avatar:
' + @@ -798,9 +859,11 @@ describe('ReactFlightDOM', () => { ); // Show everything. - await act(async () => { - await resolvePosts(); - await 'the inner async function'; + await serverAct(async () => { + await act(async () => { + await resolvePosts(); + await 'the inner async function'; + }); }); expect(container.innerHTML).toBe( '
:name::avatar:
' + @@ -867,14 +930,16 @@ describe('ReactFlightDOM', () => { const [Photos, resolvePhotosData] = makeDelayedText(); const suspendedChunk = createSuspendedChunk(

loading

); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - suspendedChunk.row, - webpackMap, - { - onError(error) { - reportedErrors.push(error); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + suspendedChunk.row, + webpackMap, + { + onError(error) { + reportedErrors.push(error); + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -900,16 +965,20 @@ describe('ReactFlightDOM', () => { ); - await act(async () => { - suspendedChunk.resolve({value, done: false, next: donePromise.promise}); - donePromise.resolve({value, done: true}); + await serverAct(async () => { + await act(async () => { + suspendedChunk.resolve({value, done: false, next: donePromise.promise}); + donePromise.resolve({value, done: true}); + }); }); expect(container.innerHTML).toBe('

loading posts and photos

'); - await act(async () => { - await resolvePostsData('posts'); - await resolvePhotosData('photos'); + await serverAct(async () => { + await act(async () => { + await resolvePostsData('posts'); + await resolvePhotosData('photos'); + }); }); expect(container.innerHTML).toBe('
posts
photos
'); @@ -945,9 +1014,11 @@ describe('ReactFlightDOM', () => { const root = ReactDOMClient.createRoot(container); const stream1 = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(stream1.writable); const response1 = ReactServerDOMClient.createFromReadableStream( @@ -973,9 +1044,11 @@ describe('ReactFlightDOM', () => { inputB.value = 'goodbye'; const stream2 = getTestStream(); - const {pipe: pipe2} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe: pipe2} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe2(stream2.writable); const response2 = ReactServerDOMClient.createFromReadableStream( @@ -1005,18 +1078,20 @@ describe('ReactFlightDOM', () => { const reportedErrors = []; const {writable, readable} = getTestStream(); - const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x); - const message = typeof x === 'string' ? x : x.message; - return __DEV__ ? 'a dev digest' : `digest("${message}")`; + const {pipe, abort} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x); + const message = typeof x === 'string' ? x : x.message; + return __DEV__ ? 'a dev digest' : `digest("${message}")`; + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1067,16 +1142,18 @@ describe('ReactFlightDOM', () => { const ClientReference = clientModuleError(new Error('module init error')); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1117,16 +1194,18 @@ describe('ReactFlightDOM', () => { ); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1176,17 +1255,19 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x.message); - return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x.message); + return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; + }, }, - }, + ), ); pipe(writable); @@ -1255,9 +1336,11 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1311,15 +1394,17 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, - { - onError(x) { - reportedErrors.push(x); - return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + { + onError(x) { + reportedErrors.push(x); + return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; + }, }, - }, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1368,9 +1453,11 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); @@ -1463,9 +1550,8 @@ describe('ReactFlightDOM', () => { const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); pipe(writable); @@ -1485,11 +1571,10 @@ describe('ReactFlightDOM', () => { function onError(error, errorInfo) { errors.push(error, errorInfo); } - const result = await ReactDOMStaticServer.prerenderToNodeStream( - , - { + const result = await serverAct(() => + ReactDOMStaticServer.prerenderToNodeStream(, { onError, - }, + }), ); const prelude = await new Promise((resolve, reject) => { @@ -1554,9 +1639,11 @@ describe('ReactFlightDOM', () => { // module graphs and we are contriving the sequencing to work in a way where // the right HostDispatcher is in scope during the Flight Server Float calls and the // Flight Client hint dispatches - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(flightWritable); @@ -1577,24 +1664,12 @@ describe('ReactFlightDOM', () => { ); } - await act(async () => { + await serverAct(async () => { ReactDOMFizzServer.renderToPipeableStream().pipe(fizzWritable); }); - const decoder = new TextDecoder(); - const reader = fizzReadable.getReader(); - let content = ''; - while (true) { - const {done, value} = await reader.read(); - if (done) { - content += decoder.decode(); - break; - } - content += decoder.decode(value, {stream: true}); - } - - const doc = new JSDOM(content).window.document; - expect(getMeaningfulChildren(doc)).toEqual( + await readInto(document, fizzReadable); + expect(getMeaningfulChildren(document)).toEqual( @@ -1680,11 +1755,11 @@ describe('ReactFlightDOM', () => { // pausing to let Flight runtime tick. This is a test only artifact of the fact that // we aren't operating separate module graphs for flight and fiber. In a real app // each would have their own dispatcher and there would be no cross dispatching. - await 1; + await serverAct(() => {}); const {writable: fizzWritable1, readable: fizzReadable1} = getTestStream(); const {writable: fizzWritable2, readable: fizzReadable2} = getTestStream(); - await act(async () => { + await serverAct(async () => { ReactDOMFizzServer.renderToPipeableStream( , ).pipe(fizzWritable1); @@ -1751,10 +1826,12 @@ describe('ReactFlightDOM', () => { const {writable, readable} = getTestStream(); - ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, - ).pipe(writable); + await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ).pipe(writable), + ); const hintRows = []; async function collectHints(stream) { @@ -1798,16 +1875,18 @@ describe('ReactFlightDOM', () => { class InvalidValue {} const {writable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( -
- -
, - webpackMap, - { - onError(x) { - reportedErrors.push(x); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( +
+ +
, + webpackMap, + { + onError(x) { + reportedErrors.push(x); + }, }, - }, + ), ); pipe(writable); @@ -1839,9 +1918,11 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ), ); pipe(writable); const response = ReactServerDOMClient.createFromReadableStream(readable); @@ -1854,4 +1935,540 @@ describe('ReactFlightDOM', () => { }); expect(container.innerHTML).toBe('Hello World'); }); + + it('can abort synchronously during render', async () => { + function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}> + + +
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + function ComponentThatAborts() { + abortRef.current(); + return

hello world

; + } + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('can abort during render in an async tick', async () => { + async function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}> + + +
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + async function ComponentThatAborts() { + await 1; + abortRef.current(); + return

hello world

; + } + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('can abort during render in a lazy initializer for a component', async () => { + function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}> + +
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + const LazyAbort = React.lazy(() => { + abortRef.current(); + return { + then(cb) { + cb({default: 'div'}); + }, + }; + }); + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('can abort during render in a lazy initializer for an element', async () => { + function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}>{lazyAbort}
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + const lazyAbort = React.lazy(() => { + abortRef.current(); + return { + then(cb) { + cb({default: 'hello world'}); + }, + }; + }); + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('can abort during a synchronous thenable resolution', async () => { + function Sibling() { + return

sibling

; + } + + function App() { + return ( +
+ loading 1...

}>{thenable}
+ loading 2...

}> + +
+
+ loading 3...

}> +
+ +
+
+
+
+ ); + } + + const abortRef = {current: null}; + const thenable = { + then(cb) { + abortRef.current(); + cb(thenable.value); + }, + }; + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
+

loading 3...

+
+
, + ); + }); + + it('wont serialize thenables that were not already settled by the time an abort happens', async () => { + function App() { + return ( +
+ loading 1...

}> + +
+ loading 2...

}>{thenable1}
+
+ loading 3...

}>{thenable2}
+
+
+ ); + } + + const abortRef = {current: null}; + const thenable1 = { + then(cb) { + cb('hello world'); + }, + }; + + const thenable2 = { + then(cb) { + cb('hello world'); + }, + status: 'fulfilled', + value: 'hello world', + }; + + function ComponentThatAborts() { + abortRef.current(); + return thenable1; + } + + const {writable: flightWritable, readable: flightReadable} = + getTestStream(); + + await serverAct(() => { + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream( + , + webpackMap, + ); + abortRef.current = abort; + pipe(flightWritable); + }); + + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + ]); + + const response = + ReactServerDOMClient.createFromReadableStream(flightReadable); + + const {writable: fizzWritable, readable: fizzReadable} = getTestStream(); + + function ClientApp() { + return use(response); + } + + const shellErrors = []; + await serverAct(async () => { + ReactDOMFizzServer.renderToPipeableStream( + React.createElement(ClientApp), + { + onShellError(error) { + shellErrors.push(error.message); + }, + }, + ).pipe(fizzWritable); + }); + assertConsoleErrorDev([ + 'The render was aborted by the server without a reason.', + 'The render was aborted by the server without a reason.', + ]); + + expect(shellErrors).toEqual([]); + + const container = document.createElement('div'); + await readInto(container, fizzReadable); + expect(getMeaningfulChildren(container)).toEqual( +
+

loading 1...

+

loading 2...

+
hello world
+
, + ); + }); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index ed9de3ceb2..1c0d3180eb 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -15,6 +15,10 @@ global.ReadableStream = global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; +const { + patchMessageChannel, +} = require('../../../../scripts/jest/patchMessageChannel'); + let clientExports; let serverExports; let webpackMap; @@ -30,11 +34,18 @@ let Suspense; let use; let ReactServer; let ReactServerDOM; +let Scheduler; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMBrowser', () => { beforeEach(() => { jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchMessageChannel(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); @@ -54,6 +65,9 @@ describe('ReactFlightDOMBrowser', () => { __unmockReact(); jest.resetModules(); + Scheduler = require('scheduler'); + patchMessageChannel(Scheduler); + act = require('internal-test-utils').act; React = require('react'); ReactDOM = require('react-dom'); @@ -64,6 +78,17 @@ describe('ReactFlightDOMBrowser', () => { use = React.use; }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function makeDelayedText(Model) { let error, _resolve, _reject; let promise = new Promise((resolve, reject) => { @@ -152,7 +177,9 @@ describe('ReactFlightDOMBrowser', () => { return model; } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); const model = await response; expect(model).toEqual({ @@ -185,7 +212,9 @@ describe('ReactFlightDOMBrowser', () => { return model; } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); const model = await response; expect(model).toEqual({ @@ -221,9 +250,8 @@ describe('ReactFlightDOMBrowser', () => { return Hello, World!; } - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(, webpackMap), ); function ClientRoot({response}) { @@ -270,9 +298,11 @@ describe('ReactFlightDOMBrowser', () => { const shared = [1, 2, 3]; const value = [shared, shared]; - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); function ClientRoot({response}) { @@ -319,9 +349,11 @@ describe('ReactFlightDOMBrowser', () => { const shared = [1, 2, 3]; const value = [shared, shared]; - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); function ClientRoot({response}) { @@ -457,15 +489,13 @@ describe('ReactFlightDOMBrowser', () => { return use(response).rootContent; } - const stream = ReactServerDOMServer.renderToReadableStream( - model, - webpackMap, - { + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(model, webpackMap, { onError(x) { reportedErrors.push(x); return __DEV__ ? `a dev digest` : `digest("${x.message}")`; }, - }, + }), ); const response = ReactServerDOMClient.createFromReadableStream(stream); @@ -481,14 +511,18 @@ describe('ReactFlightDOMBrowser', () => { expect(container.innerHTML).toBe('

(loading)

'); // This isn't enough to show anything. - await act(() => { - resolveFriends(); + await serverAct(async () => { + await act(() => { + resolveFriends(); + }); }); expect(container.innerHTML).toBe('

(loading)

'); // We can now show the details. Sidebar and posts are still loading. - await act(() => { - resolveName(); + await serverAct(async () => { + await act(() => { + resolveName(); + }); }); // Advance time enough to trigger a nested fallback. jest.advanceTimersByTime(500); @@ -503,8 +537,10 @@ describe('ReactFlightDOMBrowser', () => { const theError = new Error('Game over'); // Let's *fail* loading games. - await act(() => { - rejectGames(theError); + await serverAct(async () => { + await act(() => { + rejectGames(theError); + }); }); const gamesExpectedValue = __DEV__ @@ -522,8 +558,10 @@ describe('ReactFlightDOMBrowser', () => { reportedErrors = []; // We can now show the sidebar. - await act(() => { - resolvePhotos(); + await serverAct(async () => { + await act(() => { + resolvePhotos(); + }); }); expect(container.innerHTML).toBe( '
:name::avatar:
' + @@ -533,8 +571,10 @@ describe('ReactFlightDOMBrowser', () => { ); // Show everything. - await act(() => { - resolvePosts(); + await serverAct(async () => { + await act(() => { + resolvePosts(); + }); }); expect(container.innerHTML).toBe( '
:name::avatar:
' + @@ -596,9 +636,8 @@ describe('ReactFlightDOMBrowser', () => { rootContent: , }; - const stream = ReactServerDOMServer.renderToReadableStream( - model, - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(model, webpackMap), ); const reader = stream.getReader(); @@ -621,7 +660,7 @@ describe('ReactFlightDOMBrowser', () => { // Advance time enough to trigger a nested fallback. jest.advanceTimersByTime(500); - await act(() => {}); + await serverAct(() => {}); expect(flightResponse).toContain('(loading everything)'); expect(flightResponse).toContain('(loading sidebar)'); @@ -629,25 +668,25 @@ describe('ReactFlightDOMBrowser', () => { expect(flightResponse).not.toContain(':friends:'); expect(flightResponse).not.toContain(':name:'); - await act(() => { + await serverAct(() => { resolveFriends(); }); expect(flightResponse).toContain(':friends:'); - await act(() => { + await serverAct(() => { resolveName(); }); expect(flightResponse).toContain(':name:'); - await act(() => { + await serverAct(() => { resolvePhotos(); }); expect(flightResponse).toContain(':photos:'); - await act(() => { + await serverAct(() => { resolvePosts(); }); @@ -695,19 +734,21 @@ describe('ReactFlightDOMBrowser', () => { } const controller = new AbortController(); - const stream = ReactServerDOMServer.renderToReadableStream( -
- -
, - webpackMap, - { - signal: controller.signal, - onError(x) { - const message = typeof x === 'string' ? x : x.message; - reportedErrors.push(x); - return __DEV__ ? 'a dev digest' : `digest("${message}")`; + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( +
+ +
, + webpackMap, + { + signal: controller.signal, + onError(x) { + const message = typeof x === 'string' ? x : x.message; + reportedErrors.push(x); + return __DEV__ ? 'a dev digest' : `digest("${message}")`; + }, }, - }, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream); @@ -751,17 +792,20 @@ describe('ReactFlightDOMBrowser', () => { const root = ReactDOMClient.createRoot(container); await expect(async () => { - const stream = ReactServerDOMServer.renderToReadableStream( - <> - {Array(6).fill(
no key
)}
- - {Array(6).fill(
no key
)} -
- , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + <> + {Array(6).fill(
no key
)}
+ + {Array(6).fill(
no key
)} +
+ , + webpackMap, + ), ); const result = await ReactServerDOMClient.createFromReadableStream(stream); + await act(() => { root.render(result); }); @@ -777,7 +821,9 @@ describe('ReactFlightDOMBrowser', () => { ); } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { @@ -816,7 +862,9 @@ describe('ReactFlightDOMBrowser', () => { ); } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { @@ -853,15 +901,13 @@ describe('ReactFlightDOMBrowser', () => { } const reportedErrors = []; - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, - { + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(, webpackMap, { onError(x) { reportedErrors.push(x); return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; }, - }, + }), ); const response = ReactServerDOMClient.createFromReadableStream(stream); @@ -912,7 +958,9 @@ describe('ReactFlightDOMBrowser', () => { return ReactServer.use(thenable); } - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { @@ -947,7 +995,9 @@ describe('ReactFlightDOMBrowser', () => { // Because the thenable resolves synchronously, we should be able to finish // rendering synchronously, with no fallback. - const stream = ReactServerDOMServer.renderToReadableStream(); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(), + ); const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { @@ -988,9 +1038,11 @@ describe('ReactFlightDOMBrowser', () => { const boundFn = ServerModuleA.greet.bind(null, ServerModuleB.upper); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1035,9 +1087,11 @@ describe('ReactFlightDOMBrowser', () => { }); const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1100,9 +1154,11 @@ describe('ReactFlightDOMBrowser', () => { const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1140,9 +1196,11 @@ describe('ReactFlightDOMBrowser', () => { }); const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1178,26 +1236,29 @@ describe('ReactFlightDOMBrowser', () => { } async function send(text) { - return Promise.reject(new Error(`Error for ${text}`)); + throw new Error(`Error for ${text}`); } const ServerModule = serverExports({send}); const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); - const response = ReactServerDOMClient.createFromReadableStream(stream, { async callServer(actionId, args) { const body = await ReactServerDOMClient.encodeReply(args); + const result = callServer(actionId, body); + // Flight doesn't attach error handlers early enough. we suppress the warning + // by putting a dummy catch on the result here + result.catch(() => {}); return ReactServerDOMClient.createFromReadableStream( - ReactServerDOMServer.renderToReadableStream( - callServer(actionId, body), - null, - {onError: error => 'test-error-digest'}, - ), + ReactServerDOMServer.renderToReadableStream(result, null, { + onError: error => 'test-error-digest', + }), ); }, }); @@ -1212,17 +1273,17 @@ describe('ReactFlightDOMBrowser', () => { root.render(); }); + let thrownError; + + try { + await serverAct(() => actionProxy('test')); + } catch (error) { + thrownError = error; + } + if (__DEV__) { - await expect(actionProxy('test')).rejects.toThrow('Error for test'); + expect(thrownError).toEqual(new Error('Error for test')); } else { - let thrownError; - - try { - await actionProxy('test'); - } catch (error) { - thrownError = error; - } - expect(thrownError).toEqual( new Error( 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.', @@ -1253,9 +1314,14 @@ describe('ReactFlightDOMBrowser', () => { }); const ClientRef = clientExports(Client); - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream, { @@ -1298,9 +1364,11 @@ describe('ReactFlightDOMBrowser', () => { ); // Send the action to the client - const stream = ReactServerDOMServer.renderToReadableStream( - {action: serverModule.action}, - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + {action: serverModule.action}, + webpackMap, + ), ); const response = await ReactServerDOMClient.createFromReadableStream(stream); @@ -1340,9 +1408,11 @@ describe('ReactFlightDOMBrowser', () => { return ; } - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); let response = null; @@ -1406,9 +1476,11 @@ describe('ReactFlightDOMBrowser', () => { return ; } - const stream = ReactServerDOMServer.renderToReadableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ), ); let response = null; @@ -1427,15 +1499,11 @@ describe('ReactFlightDOMBrowser', () => { ); } - // pausing to let Flight runtime tick. This is a test only artifact of the fact that - // we aren't operating separate module graphs for flight and fiber. In a real app - // each would have their own dispatcher and there would be no cross dispatching. - await 1; - - let fizzStream; + let fizzPromise; await act(async () => { - fizzStream = await ReactDOMFizzServer.renderToReadableStream(); + fizzPromise = ReactDOMFizzServer.renderToReadableStream(); }); + const fizzStream = await fizzPromise; const decoder = new TextDecoder(); const reader = fizzStream.getReader(); @@ -1464,16 +1532,18 @@ describe('ReactFlightDOMBrowser', () => { let postponed = null; - const stream = ReactServerDOMServer.renderToReadableStream( - - - , - null, - { - onPostpone(reason) { - postponed = reason; + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + + + , + null, + { + onPostpone(reason) { + postponed = reason; + }, }, - }, + ), ); const response = ReactServerDOMClient.createFromReadableStream(stream); @@ -1512,18 +1582,20 @@ describe('ReactFlightDOMBrowser', () => { return 'Done'; } const errors = []; - const stream = await ReactServerDOMServer.renderToReadableStream( -
- Loading
}> - - -
, - null, - { - onError(x) { - errors.push(x.message); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( +
+ Loading
}> + + +
, + null, + { + onError(x) { + errors.push(x.message); + }, }, - }, + ), ); expect(rendered).toBe(false); @@ -1559,20 +1631,22 @@ describe('ReactFlightDOMBrowser', () => { let error = null; const controller = new AbortController(); - const stream = ReactServerDOMServer.renderToReadableStream( - - - , - null, - { - onError(x) { - error = x; + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + + + , + null, + { + onError(x) { + error = x; + }, + onPostpone(reason) { + postponed = reason; + }, + signal: controller.signal, }, - onPostpone(reason) { - postponed = reason; - }, - signal: controller.signal, - }, + ), ); try { @@ -1589,7 +1663,7 @@ describe('ReactFlightDOMBrowser', () => { const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); - await act(async () => { + await act(() => { root.render(
Shell: @@ -1643,27 +1717,33 @@ describe('ReactFlightDOMBrowser', () => { controller2 = c; }, }); - const rscStream = ReactServerDOMServer.renderToReadableStream( - { - s1, - s2, - }, - {}, - { - onError(x) { - errors.push(x); - return x; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + { + s1, + s2, }, - }, + {}, + { + onError(x) { + errors.push(x); + return x; + }, + }, + ), ); const result = await ReactServerDOMClient.createFromReadableStream( passThrough(rscStream), ); + const reader1 = result.s1.getReader(); const reader2 = result.s2.getReader(); - controller1.enqueue({hello: 'world'}); - controller2.enqueue({hi: 'there'}); + await serverAct(() => { + controller1.enqueue({hello: 'world'}); + controller2.enqueue({hi: 'there'}); + }); + expect(await reader1.read()).toEqual({ value: {hello: 'world'}, done: false, @@ -1673,10 +1753,11 @@ describe('ReactFlightDOMBrowser', () => { done: false, }); - controller1.enqueue('text1'); - controller2.enqueue('text2'); - controller1.close(); - controller2.error('rejected'); + await serverAct(async () => { + controller1.enqueue('text1'); + controller2.enqueue('text2'); + controller1.close(); + }); expect(await reader1.read()).toEqual({ value: 'text1', @@ -1690,6 +1771,9 @@ describe('ReactFlightDOMBrowser', () => { value: 'text2', done: false, }); + await serverAct(async () => { + controller2.error('rejected'); + }); let error = null; try { await reader2.read(); @@ -1713,14 +1797,16 @@ describe('ReactFlightDOMBrowser', () => { }, }); let loggedReason; - const rscStream = ReactServerDOMServer.renderToReadableStream( - s, - {}, - { - onError(reason) { - loggedReason = reason; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + s, + {}, + { + onError(reason) { + loggedReason = reason; + }, }, - }, + ), ); const reader = rscStream.getReader(); controller.enqueue('hi'); @@ -1745,21 +1831,25 @@ describe('ReactFlightDOMBrowser', () => { cancelReason = r; }, }); - const rscStream = ReactServerDOMServer.renderToReadableStream( - s, - {}, - { - signal: abortController.signal, - onError(x) { - errors.push(x); - return x.message; + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + s, + {}, + { + signal: abortController.signal, + onError(x) { + errors.push(x); + return x.message; + }, }, - }, + ), ); const result = await ReactServerDOMClient.createFromReadableStream( passThrough(rscStream), ); const reader = result.getReader(); + controller.enqueue('hi'); await 0; @@ -1808,18 +1898,20 @@ describe('ReactFlightDOMBrowser', () => { throw 'F'; })(); - const rscStream = ReactServerDOMServer.renderToReadableStream( - { - multiShotIterable, - singleShotIterator, - }, - {}, - { - onError(x) { - errors.push(x); - return x; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + { + multiShotIterable, + singleShotIterator, }, - }, + {}, + { + onError(x) { + errors.push(x); + return x; + }, + }, + ), ); const result = await ReactServerDOMClient.createFromReadableStream( passThrough(rscStream), @@ -1840,7 +1932,9 @@ describe('ReactFlightDOMBrowser', () => { done: false, }); - await resolve(); + await serverAct(() => { + resolve(); + }); expect(await iterator1.next()).toEqual({ value: {hi: 'B'}, @@ -1914,16 +2008,21 @@ describe('ReactFlightDOMBrowser', () => { yield 'c'; })(); let loggedReason; - const rscStream = ReactServerDOMServer.renderToReadableStream( - iterator, - {}, - { - onError(reason) { - loggedReason = reason; + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + iterator, + {}, + { + onError(reason) { + loggedReason = reason; + }, }, - }, + ), ); + const reader = rscStream.getReader(); + const reason = new Error('aborted'); reader.cancel(reason); await resolve(); @@ -1949,16 +2048,18 @@ describe('ReactFlightDOMBrowser', () => { } yield 'c'; })(); - const rscStream = ReactServerDOMServer.renderToReadableStream( - iterator, - {}, - { - signal: abortController.signal, - onError(x) { - errors.push(x); - return x.message; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + iterator, + {}, + { + signal: abortController.signal, + onError(x) { + errors.push(x); + return x.message; + }, }, - }, + ), ); const result = await ReactServerDOMClient.createFromReadableStream( passThrough(rscStream), @@ -1967,7 +2068,9 @@ describe('ReactFlightDOMBrowser', () => { const reason = new Error('aborted'); abortController.abort(reason); - await resolve(); + await serverAct(() => { + resolve(); + }); // We should be able to read the part we already emitted before the abort expect(await result.next()).toEqual({ diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index df1850896d..6f6a825e5e 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -9,13 +9,11 @@ 'use strict'; +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; + global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; -// Don't wait before processing work on the server. -// TODO: we can replace this with FlightServer.act(). -global.setImmediate = cb => cb(); - let clientExports; let webpackMap; let webpackModules; @@ -26,11 +24,17 @@ let ReactServerDOMServer; let ReactServerDOMClient; let Stream; let use; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMNode', () => { beforeEach(() => { jest.resetModules(); + ReactServerScheduler = require('scheduler'); + patchSetImmediate(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-webpack/server', () => @@ -58,6 +62,17 @@ describe('ReactFlightDOMNode', () => { use = React.use; }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + function readResult(stream) { return new Promise((resolve, reject) => { let buffer = ''; @@ -110,9 +125,8 @@ describe('ReactFlightDOMNode', () => { return ; } - const stream = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); const readable = new Stream.PassThrough(); let response; @@ -128,8 +142,8 @@ describe('ReactFlightDOMNode', () => { return use(response); } - const ssrStream = await ReactDOMServer.renderToPipeableStream( - , + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream(), ); const result = await readResult(ssrStream); expect(result).toEqual( @@ -140,9 +154,11 @@ describe('ReactFlightDOMNode', () => { it('should encode long string in a compact format', async () => { const testString = '"\n\t'.repeat(500) + '🙃'; - const stream = ReactServerDOMServer.renderToPipeableStream({ - text: testString, - }); + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream({ + text: testString, + }), + ); const readable = new Stream.PassThrough(); @@ -187,7 +203,9 @@ describe('ReactFlightDOMNode', () => { new BigUint64Array(buffer, 0), new DataView(buffer, 3), ]; - const stream = ReactServerDOMServer.renderToPipeableStream(buffers); + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(buffers), + ); const readable = new Stream.PassThrough(); const promise = ReactServerDOMClient.createFromNodeStream(readable, { moduleMap: {}, @@ -232,9 +250,8 @@ describe('ReactFlightDOMNode', () => { return ; } - const stream = ReactServerDOMServer.renderToPipeableStream( - , - webpackMap, + const stream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); const readable = new Stream.PassThrough(); let response; @@ -253,8 +270,8 @@ describe('ReactFlightDOMNode', () => { return use(response); } - const ssrStream = await ReactDOMServer.renderToPipeableStream( - , + const ssrStream = await serverAct(() => + ReactDOMServer.renderToPipeableStream(), ); const result = await readResult(ssrStream); expect(result).toEqual( @@ -275,14 +292,16 @@ describe('ReactFlightDOMNode', () => { }, }); - const rscStream = ReactServerDOMServer.renderToPipeableStream( - s, - {}, - { - onError(error) { - return error.message; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + s, + {}, + { + onError(error) { + return error.message; + }, }, - }, + ), ); const writable = new Stream.PassThrough(); @@ -317,15 +336,17 @@ describe('ReactFlightDOMNode', () => { cancelReason = r; }, }); - const rscStream = ReactServerDOMServer.renderToPipeableStream( - s, - {}, - { - onError(x) { - errors.push(x); - return x.message; + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + s, + {}, + { + onError(x) { + errors.push(x); + return x.message; + }, }, - }, + ), ); const readable = new Stream.PassThrough(); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js index bd92c88493..30aa539e5a 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + // Polyfills for test environment global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; @@ -20,10 +22,17 @@ let webpackServerMap; let React; let ReactServerDOMServer; let ReactServerDOMClient; +let ReactServerScheduler; +let reactServerAct; describe('ReactFlightDOMReply', () => { beforeEach(() => { jest.resetModules(); + + ReactServerScheduler = require('scheduler'); + patchMessageChannel(ReactServerScheduler); + reactServerAct = require('internal-test-utils').act; + // Simulate the condition resolution jest.mock('react', () => require('react/react.react-server')); jest.mock('react-server-dom-webpack/server', () => @@ -39,6 +48,17 @@ describe('ReactFlightDOMReply', () => { ReactServerDOMClient = require('react-server-dom-webpack/client'); }); + async function serverAct(callback) { + let maybePromise; + await reactServerAct(() => { + maybePromise = callback(); + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } + }); + return maybePromise; + } + // This method should exist on File but is not implemented in JSDOM async function arrayBuffer(file) { return new Promise((resolve, reject) => { @@ -369,12 +389,10 @@ describe('ReactFlightDOMReply', () => { webpackServerMap, {temporaryReferences: temporaryReferencesServer}, ); - const stream = ReactServerDOMServer.renderToReadableStream( - serverPayload, - null, - { + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(serverPayload, null, { temporaryReferences: temporaryReferencesServer, - }, + }), ); const response = await ReactServerDOMClient.createFromReadableStream( stream, @@ -408,13 +426,15 @@ describe('ReactFlightDOMReply', () => { webpackServerMap, {temporaryReferences: temporaryReferencesServer}, ); - const stream = ReactServerDOMServer.renderToReadableStream( - { - root: serverPayload, - obj: serverPayload.obj, - }, - null, - {temporaryReferences: temporaryReferencesServer}, + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + { + root: serverPayload, + obj: serverPayload.obj, + }, + null, + {temporaryReferences: temporaryReferencesServer}, + ), ); const response = await ReactServerDOMClient.createFromReadableStream( stream, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index d8903597a8..11b558c592 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -26,6 +26,7 @@ import {enableFlightReadableStream} from 'shared/ReactFeatureFlags'; import { scheduleWork, + scheduleMicrotask, flushBuffered, beginWriting, writeChunkAndReturn, @@ -137,10 +138,41 @@ function isNotExternal(stackFrame: string): boolean { return !externalRegExp.test(stackFrame); } +function prepareStackTrace( + error: Error, + structuredStackTrace: CallSite[], +): string { + 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; +} + +function getStack(error: Error): string { + // We override Error.prepareStackTrace with our own version that normalizes + // the stack to V8 formatting even if the server uses other formatting. + // It also ensures that source maps are NOT applied to this since that can + // be slow we're better off doing that lazily from the client instead of + // eagerly on the server. If the stack has already been read, then we might + // not get a normalized stack and it might still have been source mapped. + // So the client still needs to be resilient to this. + const previousPrepare = Error.prepareStackTrace; + Error.prepareStackTrace = prepareStackTrace; + try { + // eslint-disable-next-line react-internal/safe-string-coercion + return String(error.stack); + } finally { + Error.prepareStackTrace = previousPrepare; + } +} + function initCallComponentFrame(): string { // Extract the stack frame of the callComponentInDEV function. const error = callComponentInDEV(Error, 'react-stack-top-frame', {}); - const stack = error.stack; + const stack = getStack(error); const startIdx = stack.startsWith('Error: react-stack-top-frame\n') ? 29 : 0; const endIdx = stack.indexOf('\n', startIdx); if (endIdx === -1) { @@ -155,7 +187,7 @@ function initCallIteratorFrame(): string { (callIteratorInDEV: any)({next: null}); return ''; } catch (error) { - const stack = error.stack; + const stack = getStack(error); const startIdx = stack.startsWith('TypeError: ') ? stack.indexOf('\n') + 1 : 0; @@ -174,7 +206,7 @@ function initCallLazyInitFrame(): string { _init: Error, _payload: 'react-stack-top-frame', }); - const stack = error.stack; + const stack = getStack(error); const startIdx = stack.startsWith('Error: react-stack-top-frame\n') ? 29 : 0; const endIdx = stack.indexOf('\n', startIdx); if (endIdx === -1) { @@ -188,7 +220,7 @@ function filterDebugStack(error: Error): string { // to save bandwidth even in DEV. We'll also replay these stacks on the client so by // stripping them early we avoid that overhead. Otherwise we'd normally just rely on // the DevTools or framework's ignore lists to filter them out. - let stack = error.stack; + let stack = getStack(error); if (stack.startsWith('Error: react-stack-top-frame\n')) { // V8's default formatting prefixes with the error message which we // don't want/need. @@ -241,14 +273,7 @@ function patchConsole(consoleInst: typeof console, methodName: string) { // Extract the stack. Not all console logs print the full stack but they have at // least the line it was called from. We could optimize transfer by keeping just // one stack frame but keeping it simple for now and include all frames. - let stack = filterDebugStack(new Error('react-stack-top-frame')); - const firstLine = stack.indexOf('\n'); - if (firstLine === -1) { - stack = ''; - } else { - // Skip the console wrapper itself. - stack = stack.slice(firstLine + 1); - } + const stack = filterDebugStack(new Error('react-stack-top-frame')); request.pendingChunks++; // We don't currently use this id for anything but we emit it so that we can later // refer to previous logs in debug info to associate them with a component. @@ -356,10 +381,11 @@ const PENDING = 0; const COMPLETED = 1; const ABORTED = 3; const ERRORED = 4; +const RENDERING = 5; type Task = { id: number, - status: 0 | 1 | 3 | 4, + status: 0 | 1 | 3 | 4 | 5, model: ReactClientValue, ping: () => void, toJSON: (key: string, value: ReactClientValue) => ReactJSONValue, @@ -371,7 +397,7 @@ type Task = { interface Reference {} export type Request = { - status: 0 | 1 | 2, + status: 0 | 1 | 2 | 3, flushScheduled: boolean, fatalError: mixed, destination: null | Destination, @@ -402,6 +428,8 @@ export type Request = { didWarnForKey: null | WeakSet, }; +const AbortSigil = {}; + const { TaintRegistryObjects, TaintRegistryValues, @@ -441,8 +469,9 @@ function defaultPostponeHandler(reason: string) { } const OPEN = 0; -const CLOSING = 1; -const CLOSED = 2; +const ABORTING = 1; +const CLOSING = 2; +const CLOSED = 3; export function createRequest( model: ReactClientValue, @@ -531,7 +560,6 @@ function serializeThenable( task.implicitSlot, request.abortableTasks, ); - if (__DEV__) { // If this came from Flight, forward any debug info into this new row. const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo; @@ -565,6 +593,15 @@ function serializeThenable( return newTask.id; } default: { + if (request.status === ABORTING) { + // We can no longer accept any resolved values + newTask.status = ABORTED; + const errorId: number = (request.fatalError: any); + const model = stringify(serializeByValueID(errorId)); + emitModelChunk(request, newTask.id, model); + request.abortableTasks.delete(newTask); + return newTask.id; + } if (typeof thenable.status === 'string') { // Only instrument the thenable if the status if not defined. If // it's defined, but an unknown value, assume it's been instrumented by @@ -1021,6 +1058,14 @@ function renderFunctionComponent( const secondArg = undefined; result = Component(props, secondArg); } + + if (request.status === ABORTING) { + // If we aborted during rendering we should interrupt the render but + // we don't need to provide an error because the renderer will encode + // the abort error as the reason. + throw AbortSigil; + } + if ( typeof result === 'object' && result !== null && @@ -1222,12 +1267,25 @@ function renderFragment( if (task.keyPath !== null) { // We have a Server Component that specifies a key but we're now splitting // the tree using a fragment. - const fragment = [ - REACT_ELEMENT_TYPE, - REACT_FRAGMENT_TYPE, - task.keyPath, - {children}, - ]; + const fragment = __DEV__ + ? enableOwnerStacks + ? [ + REACT_ELEMENT_TYPE, + REACT_FRAGMENT_TYPE, + task.keyPath, + {children}, + null, + null, + 0, + ] + : [ + REACT_ELEMENT_TYPE, + REACT_FRAGMENT_TYPE, + task.keyPath, + {children}, + null, + ] + : [REACT_ELEMENT_TYPE, REACT_FRAGMENT_TYPE, task.keyPath, {children}]; if (!task.implicitSlot) { // If this was keyed inside a set. I.e. the outer Server Component was keyed // then we need to handle reorders of the whole set. To do this we need to wrap @@ -1281,12 +1339,25 @@ function renderAsyncFragment( if (task.keyPath !== null) { // We have a Server Component that specifies a key but we're now splitting // the tree using a fragment. - const fragment = [ - REACT_ELEMENT_TYPE, - REACT_FRAGMENT_TYPE, - task.keyPath, - {children}, - ]; + const fragment = __DEV__ + ? enableOwnerStacks + ? [ + REACT_ELEMENT_TYPE, + REACT_FRAGMENT_TYPE, + task.keyPath, + {children}, + null, + null, + 0, + ] + : [ + REACT_ELEMENT_TYPE, + REACT_FRAGMENT_TYPE, + task.keyPath, + {children}, + null, + ] + : [REACT_ELEMENT_TYPE, REACT_FRAGMENT_TYPE, task.keyPath, {children}]; if (!task.implicitSlot) { // If this was keyed inside a set. I.e. the outer Server Component was keyed // then we need to handle reorders of the whole set. To do this we need to wrap @@ -1472,6 +1543,12 @@ function renderElement( const init = type._init; wrappedType = init(payload); } + if (request.status === ABORTING) { + // lazy initializers are user code and could abort during render + // we don't wan to return any value resolved from the lazy initializer + // if it aborts so we interrupt rendering here + throw AbortSigil; + } return renderElement( request, task, @@ -1521,7 +1598,7 @@ function pingTask(request: Request, task: Task): void { pingedTasks.push(task); if (pingedTasks.length === 1) { request.flushScheduled = request.destination !== null; - scheduleWork(() => performWork(request)); + scheduleMicrotask(() => performWork(request)); } } @@ -1891,6 +1968,15 @@ function renderModel( try { return renderModelDestructive(request, task, parent, key, value); } catch (thrownValue) { + // If the suspended/errored value was an element or lazy it can be reduced + // to a lazy reference, so that it doesn't error the parent. + const model = task.model; + const wasReactNode = + typeof model === 'object' && + model !== null && + ((model: any).$$typeof === REACT_ELEMENT_TYPE || + (model: any).$$typeof === REACT_LAZY_TYPE); + const x = thrownValue === SuspenseException ? // This is a special type of exception used for Suspense. For historical @@ -1900,17 +1986,18 @@ function renderModel( // later, once we deprecate the old API in favor of `use`. getSuspendedThenable() : thrownValue; - // If the suspended/errored value was an element or lazy it can be reduced - // to a lazy reference, so that it doesn't error the parent. - const model = task.model; - const wasReactNode = - typeof model === 'object' && - model !== null && - ((model: any).$$typeof === REACT_ELEMENT_TYPE || - (model: any).$$typeof === REACT_LAZY_TYPE); + if (typeof x === 'object' && x !== null) { // $FlowFixMe[method-unbinding] if (typeof x.then === 'function') { + if (request.status === ABORTING) { + task.status = ABORTED; + const errorId: number = (request.fatalError: any); + if (wasReactNode) { + return serializeLazyID(errorId); + } + return serializeByValueID(errorId); + } // Something suspended, we'll need to create a new task and resolve it later. const newTask = createTask( request, @@ -1953,6 +2040,15 @@ function renderModel( } } + if (thrownValue === AbortSigil) { + task.status = ABORTED; + const errorId: number = (request.fatalError: any); + if (wasReactNode) { + return serializeLazyID(errorId); + } + return serializeByValueID(errorId); + } + // Restore the context. We assume that this will be restored by the inner // functions in case nothing throws so we don't use "finally" here. task.keyPath = prevKeyPath; @@ -2096,6 +2192,12 @@ function renderModelDestructive( const init = lazy._init; resolvedModel = init(payload); } + if (request.status === ABORTING) { + // lazy initializers are user code and could abort during render + // we don't wan to return any value resolved from the lazy initializer + // if it aborts so we interrupt rendering here + throw AbortSigil; + } if (__DEV__) { const debugInfo: ?ReactDebugInfo = lazy._debugInfo; if (debugInfo) { @@ -2582,8 +2684,7 @@ function emitPostponeChunk( try { // eslint-disable-next-line react-internal/safe-string-coercion reason = String(postponeInstance.message); - // eslint-disable-next-line react-internal/safe-string-coercion - stack = String(postponeInstance.stack); + stack = getStack(postponeInstance); } catch (x) {} row = serializeRowHeader('P', id) + stringify({reason, stack}) + '\n'; } else { @@ -2608,8 +2709,7 @@ function emitErrorChunk( if (error instanceof Error) { // eslint-disable-next-line react-internal/safe-string-coercion message = String(error.message); - // eslint-disable-next-line react-internal/safe-string-coercion - stack = String(error.stack); + stack = getStack(error); } else if (typeof error === 'object' && error !== null) { message = describeObjectForErrorMessage(error); } else { @@ -3213,6 +3313,7 @@ function retryTask(request: Request, task: Task): void { } const prevDebugID = debugID; + task.status = RENDERING; try { // Track the root so we know that we have to emit this object even though it @@ -3279,10 +3380,19 @@ function retryTask(request: Request, task: Task): void { if (typeof x === 'object' && x !== null) { // $FlowFixMe[method-unbinding] if (typeof x.then === 'function') { + if (request.status === ABORTING) { + request.abortableTasks.delete(task); + task.status = ABORTED; + const errorId: number = (request.fatalError: any); + const model = stringify(serializeByValueID(errorId)); + emitModelChunk(request, task.id, model); + return; + } // Something suspended again, let's pick it back up later. + task.status = PENDING; + task.thenableState = getThenableStateAfterSuspending(); const ping = task.ping; x.then(ping, ping); - task.thenableState = getThenableStateAfterSuspending(); return; } else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) { request.abortableTasks.delete(task); @@ -3293,6 +3403,16 @@ function retryTask(request: Request, task: Task): void { return; } } + + if (x === AbortSigil) { + request.abortableTasks.delete(task); + task.status = ABORTED; + const errorId: number = (request.fatalError: any); + const model = stringify(serializeByValueID(errorId)); + emitModelChunk(request, task.id, model); + return; + } + request.abortableTasks.delete(task); task.status = ERRORED; const digest = logRecoverableError(request, x); @@ -3350,6 +3470,10 @@ function performWork(request: Request): void { } function abortTask(task: Task, request: Request, errorId: number): void { + if (task.status === RENDERING) { + // This task will be aborted by the render + return; + } task.status = ABORTED; // Instead of emitting an error per task.id, we emit a model that only // has a single value referencing the error. @@ -3435,6 +3559,7 @@ function flushCompletedChunks( if (enableTaint) { cleanupTaintQueue(request); } + request.status = CLOSED; close(destination); request.destination = null; } @@ -3458,9 +3583,14 @@ function enqueueFlush(request: Request): void { // happen when we start flowing again request.destination !== null ) { - const destination = request.destination; request.flushScheduled = true; - scheduleWork(() => flushCompletedChunks(request, destination)); + scheduleWork(() => { + request.flushScheduled = false; + const destination = request.destination; + if (destination) { + flushCompletedChunks(request, destination); + } + }); } } @@ -3493,12 +3623,14 @@ export function stopFlowing(request: Request): void { // This is called to early terminate a request. It creates an error at all pending tasks. export function abort(request: Request, reason: mixed): void { try { + request.status = ABORTING; const abortableTasks = request.abortableTasks; // We have tasks to abort. We'll emit one error row and then emit a reference // to that row from every row that's still remaining. if (abortableTasks.size > 0) { request.pendingChunks++; const errorId = request.nextChunkId++; + request.fatalError = errorId; if ( enablePostpone && typeof reason === 'object' && @@ -3514,6 +3646,10 @@ export function abort(request: Request, reason: mixed): void { ? new Error( 'The render was aborted by the server without a reason.', ) + : typeof reason === 'object' && + reason !== null && + typeof reason.then === 'function' + ? new Error('The render was aborted by the server with a promise.') : reason; const digest = logRecoverableError(request, error); emitErrorChunk(request, errorId, digest, error); @@ -3540,6 +3676,10 @@ export function abort(request: Request, reason: mixed): void { ? new Error( 'The render was aborted by the server without a reason.', ) + : typeof reason === 'object' && + reason !== null && + typeof reason.then === 'function' + ? new Error('The render was aborted by the server with a promise.') : reason; } abortListeners.forEach(callback => callback(error)); diff --git a/packages/react-server/src/ReactServerStreamConfigBrowser.js b/packages/react-server/src/ReactServerStreamConfigBrowser.js index f937130384..2e68ca7117 100644 --- a/packages/react-server/src/ReactServerStreamConfigBrowser.js +++ b/packages/react-server/src/ReactServerStreamConfigBrowser.js @@ -13,10 +13,35 @@ export type PrecomputedChunk = Uint8Array; export opaque type Chunk = Uint8Array; export type BinaryChunk = Uint8Array; +const channel = new MessageChannel(); +const taskQueue = []; +channel.port1.onmessage = () => { + const task = taskQueue.shift(); + if (task) { + task(); + } +}; + export function scheduleWork(callback: () => void) { - callback(); + taskQueue.push(callback); + channel.port2.postMessage(null); } +function handleErrorInNextTick(error: any) { + setTimeout(() => { + throw error; + }); +} + +const LocalPromise = Promise; + +export const scheduleMicrotask: (callback: () => void) => void = + typeof queueMicrotask === 'function' + ? queueMicrotask + : callback => { + LocalPromise.resolve(null).then(callback).catch(handleErrorInNextTick); + }; + export function flushBuffered(destination: Destination) { // WHATWG Streams do not yet have a way to flush the underlying // transform streams. https://github.com/whatwg/streams/issues/960 diff --git a/packages/react-server/src/ReactServerStreamConfigBun.js b/packages/react-server/src/ReactServerStreamConfigBun.js index 4686e0e970..81f86a50b7 100644 --- a/packages/react-server/src/ReactServerStreamConfigBun.js +++ b/packages/react-server/src/ReactServerStreamConfigBun.js @@ -22,9 +22,11 @@ export opaque type Chunk = string; export type BinaryChunk = $ArrayBufferView; export function scheduleWork(callback: () => void) { - callback(); + setTimeout(callback, 0); } +export const scheduleMicrotask = queueMicrotask; + export function flushBuffered(destination: Destination) { // Bun direct streams provide a flush function. // If we don't have any more data to send right now. diff --git a/packages/react-server/src/ReactServerStreamConfigEdge.js b/packages/react-server/src/ReactServerStreamConfigEdge.js index e77dc28284..22f165ded9 100644 --- a/packages/react-server/src/ReactServerStreamConfigEdge.js +++ b/packages/react-server/src/ReactServerStreamConfigEdge.js @@ -13,6 +13,21 @@ export type PrecomputedChunk = Uint8Array; export opaque type Chunk = Uint8Array; export type BinaryChunk = Uint8Array; +function handleErrorInNextTick(error: any) { + setTimeout(() => { + throw error; + }); +} + +const LocalPromise = Promise; + +export const scheduleMicrotask: (callback: () => void) => void = + typeof queueMicrotask === 'function' + ? queueMicrotask + : callback => { + LocalPromise.resolve(null).then(callback).catch(handleErrorInNextTick); + }; + export function scheduleWork(callback: () => void) { setTimeout(callback, 0); } diff --git a/packages/react-server/src/ReactServerStreamConfigNode.js b/packages/react-server/src/ReactServerStreamConfigNode.js index cbd366ab54..773c998610 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -26,6 +26,8 @@ export function scheduleWork(callback: () => void) { setImmediate(callback); } +export const scheduleMicrotask = queueMicrotask; + export function flushBuffered(destination: Destination) { // If we don't have any more data to send right now. // Flush whatever is in the buffer to the wire. diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js index 22cd6551c0..a9799cb7ba 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js @@ -31,6 +31,7 @@ export opaque type Chunk = mixed; // eslint-disable-line no-undef export opaque type BinaryChunk = mixed; // eslint-disable-line no-undef export const scheduleWork = $$$config.scheduleWork; +export const scheduleMicrotask = $$$config.scheduleMicrotask; export const beginWriting = $$$config.beginWriting; export const writeChunk = $$$config.writeChunk; export const writeChunkAndReturn = $$$config.writeChunkAndReturn; diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js index 03cc3e1b82..2d705e2a1c 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb-experimental.js @@ -42,10 +42,26 @@ export interface Destination { onError(error: mixed): void; } -export function scheduleWork(callback: () => void) { - callback(); +function handleErrorInNextTick(error: any) { + setTimeout(() => { + throw error; + }); } +const LocalPromise = Promise; + +/** + * Since this environment doesn't have a way to schedule tasks from JS we schedule + * using a microtask instead. This isn't necessarily ideal since we would like to give + * other IO a chance to run before performing work typically but it's the best we can + * do in this environment + */ +export function scheduleWork(callback: () => void) { + LocalPromise.resolve().then(callback).catch(handleErrorInNextTick); +} + +export const scheduleMicrotask: (callback: () => void) => void = scheduleWork; + export function beginWriting(destination: Destination) { destination.beginWriting(); } diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb.js index e15f680867..12ed6ba598 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-fb.js @@ -9,6 +9,10 @@ export * from '../ReactServerStreamConfigFB'; +export function scheduleMicrotask(callback: () => void) { + // We don't schedule work in this model, and instead expect performWork to always be called repeatedly. +} + export function scheduleWork(callback: () => void) { // We don't schedule work in this model, and instead expect performWork to always be called repeatedly. } diff --git a/packages/react/src/ReactChildren.js b/packages/react/src/ReactChildren.js index 391c0985ef..7296a452c0 100644 --- a/packages/react/src/ReactChildren.js +++ b/packages/react/src/ReactChildren.js @@ -229,11 +229,21 @@ function mapIntoArray( childKey, ); if (__DEV__) { - if (nameSoFar !== '' && mappedChild.key == null) { - // We need to validate that this child should have had a key before assigning it one. - if (!newChild._store.validated) { - // We mark this child as having failed validation but we let the actual renderer - // print the warning later. + // If `child` was an element without a `key`, we need to validate if + // it should have had a `key`, before assigning one to `mappedChild`. + // $FlowFixMe[incompatible-type] Flow incorrectly thinks React.Portal doesn't have a key + if ( + nameSoFar !== '' && + child != null && + isValidElement(child) && + child.key == null + ) { + // We check truthiness of `child._store.validated` instead of being + // inequal to `1` to provide a bit of backward compatibility for any + // libraries (like `fbt`) which may be hacking this property. + if (child._store && !child._store.validated) { + // Mark this child as having failed validation, but let the actual + // renderer print the warning later. newChild._store.validated = 2; } } diff --git a/packages/react/src/ReactForwardRef.js b/packages/react/src/ReactForwardRef.js index ad9f5a9090..8978763ba1 100644 --- a/packages/react/src/ReactForwardRef.js +++ b/packages/react/src/ReactForwardRef.js @@ -71,6 +71,7 @@ export function forwardRef( Object.defineProperty(render, 'name', { value: name, }); + render.displayName = name; } }, }); diff --git a/packages/react/src/ReactMemo.js b/packages/react/src/ReactMemo.js index 2948a28193..0149712b05 100644 --- a/packages/react/src/ReactMemo.js +++ b/packages/react/src/ReactMemo.js @@ -51,6 +51,7 @@ export function memo( Object.defineProperty(type, 'name', { value: name, }); + type.displayName = name; } }, }); diff --git a/packages/react/src/ReactServer.js b/packages/react/src/ReactServer.js index a8b4fca0d7..d6702023e4 100644 --- a/packages/react/src/ReactServer.js +++ b/packages/react/src/ReactServer.js @@ -22,19 +22,11 @@ import { isValidElement, } from './jsx/ReactJSXElement'; import {createRef} from './ReactCreateRef'; -import { - use, - useId, - useCallback, - useDebugValue, - useMemo, - useActionState, -} from './ReactHooks'; +import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks'; import {forwardRef} from './ReactForwardRef'; import {lazy} from './ReactLazy'; import {memo} from './ReactMemo'; import {cache} from './ReactCacheServer'; -import {startTransition} from './ReactStartTransition'; import version from 'shared/ReactVersion'; const Children = { @@ -60,11 +52,9 @@ export { lazy, memo, cache, - startTransition, useId, useCallback, useDebugValue, useMemo, - useActionState, version, }; diff --git a/packages/react/src/ReactSharedInternalsClient.js b/packages/react/src/ReactSharedInternalsClient.js index 452bd933da..6a54c73be3 100644 --- a/packages/react/src/ReactSharedInternalsClient.js +++ b/packages/react/src/ReactSharedInternalsClient.js @@ -35,7 +35,7 @@ export type SharedStateClient = { thrownErrors: Array, // ReactDebugCurrentFrame - getCurrentStack: null | (() => string), + getCurrentStack: null | ((stack: Error) => string), }; export type RendererTask = boolean => RendererTask | null; @@ -54,7 +54,9 @@ if (__DEV__) { ReactSharedInternals.didUsePromise = false; ReactSharedInternals.thrownErrors = []; // Stack implementation injected by the current renderer. - ReactSharedInternals.getCurrentStack = (null: null | (() => string)); + ReactSharedInternals.getCurrentStack = (null: + | null + | ((stack: Error) => string)); } export default ReactSharedInternals; diff --git a/packages/react/src/ReactSharedInternalsServer.js b/packages/react/src/ReactSharedInternalsServer.js index d670fa18fe..749ce5c3ad 100644 --- a/packages/react/src/ReactSharedInternalsServer.js +++ b/packages/react/src/ReactSharedInternalsServer.js @@ -38,7 +38,7 @@ export type SharedStateServer = { // DEV-only // ReactDebugCurrentFrame - getCurrentStack: null | (() => string), + getCurrentStack: null | ((stack: Error) => string), }; export type RendererTask = boolean => RendererTask | null; @@ -58,7 +58,9 @@ if (enableTaint) { if (__DEV__) { // Stack implementation injected by the current renderer. - ReactSharedInternals.getCurrentStack = (null: null | (() => string)); + ReactSharedInternals.getCurrentStack = (null: + | null + | ((stack: Error) => string)); } export default ReactSharedInternals; diff --git a/packages/react/src/__tests__/ReactChildren-test.js b/packages/react/src/__tests__/ReactChildren-test.js index 08560a4f1e..c4e92c44cc 100644 --- a/packages/react/src/__tests__/ReactChildren-test.js +++ b/packages/react/src/__tests__/ReactChildren-test.js @@ -868,6 +868,143 @@ describe('ReactChildren', () => { ]); }); + it('warns for mapped list children without keys', async () => { + function ComponentRenderingMappedChildren({children}) { + return ( +
+ {React.Children.map(children, child => ( +
+ ))} +
+ ); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + + {[
]} + , + ); + }); + }).toErrorDev([ + 'Warning: Each child in a list should have a unique "key" prop.', + ]); + }); + + it('does not warn for mapped static children without keys', async () => { + function ComponentRenderingMappedChildren({children}) { + return ( +
+ {React.Children.map(children, child => ( +
+ ))} +
+ ); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + +
+
+ , + ); + }); + }).toErrorDev([]); + }); + + it('warns for cloned list children without keys', async () => { + function ComponentRenderingClonedChildren({children}) { + return ( +
+ {React.Children.map(children, child => React.cloneElement(child))} +
+ ); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + + {[
]} + , + ); + }); + }).toErrorDev([ + 'Warning: Each child in a list should have a unique "key" prop.', + ]); + }); + + it('does not warn for cloned static children without keys', async () => { + function ComponentRenderingClonedChildren({children}) { + return ( +
+ {React.Children.map(children, child => React.cloneElement(child))} +
+ ); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + +
+
+ , + ); + }); + }).toErrorDev([]); + }); + + it('warns for flattened list children without keys', async () => { + function ComponentRenderingFlattenedChildren({children}) { + return
{React.Children.toArray(children)}
; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + + {[
]} + , + ); + }); + }).toErrorDev([ + 'Warning: Each child in a list should have a unique "key" prop.', + ]); + }); + + it('does not warn for flattened static children without keys', async () => { + function ComponentRenderingFlattenedChildren({children}) { + return
{React.Children.toArray(children)}
; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render( + +
+
+ , + ); + }); + }).toErrorDev([]); + }); + it('should escape keys', () => { const zero =
; const one =
; diff --git a/packages/react/src/__tests__/ReactMismatchedVersions-test.js b/packages/react/src/__tests__/ReactMismatchedVersions-test.js index cee86e5087..602b71476d 100644 --- a/packages/react/src/__tests__/ReactMismatchedVersions-test.js +++ b/packages/react/src/__tests__/ReactMismatchedVersions-test.js @@ -9,6 +9,8 @@ 'use strict'; +import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel'; + describe('ReactMismatchedVersions-test', () => { // Polyfills for test environment global.ReadableStream = @@ -20,6 +22,9 @@ describe('ReactMismatchedVersions-test', () => { beforeEach(() => { jest.resetModules(); + + patchMessageChannel(); + jest.mock('react', () => { const actualReact = jest.requireActual('react'); return { diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index 201ef39036..367992dfd3 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -170,6 +170,17 @@ describe(`onRender`, () => { 'read current time', 'read current time', ]); + } else if (gate(flags => !flags.allowConcurrentByDefault)) { + assertLog([ + 'read current time', + 'read current time', + 'read current time', + 'read current time', + 'read current time', + 'read current time', + 'read current time', + // TODO: why is there one less in this case? + ]); } else { assertLog([ 'read current time', diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index a5a9880b05..0f8b9f397d 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -953,7 +953,7 @@ export function createElement(type, config, children) { } export function cloneAndReplaceKey(oldElement, newKey) { - return ReactElement( + const clonedElement = ReactElement( oldElement.type, newKey, // When enableRefAsProp is on, this argument is ignored. This check only @@ -966,6 +966,11 @@ export function cloneAndReplaceKey(oldElement, newKey) { __DEV__ && enableOwnerStacks ? oldElement._debugStack : undefined, __DEV__ && enableOwnerStacks ? oldElement._debugTask : undefined, ); + if (__DEV__) { + // The cloned element should inherit the original element's key validation. + clonedElement._store.validated = oldElement._store.validated; + } + return clonedElement; } /** diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index adec53c109..8b2d0800cb 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -125,6 +125,8 @@ export const enableAddPropertiesFastPath = false; export const enableOwnerStacks = __EXPERIMENTAL__; +export const enableShallowPropDiffing = false; + /** * Enables an expiration time for retry lanes to avoid starvation. */ diff --git a/packages/shared/consoleWithStackDev.js b/packages/shared/consoleWithStackDev.js index bdcf754802..4638ede81c 100644 --- a/packages/shared/consoleWithStackDev.js +++ b/packages/shared/consoleWithStackDev.js @@ -24,7 +24,7 @@ export function setSuppressWarning(newSuppressWarning) { export function warn(format, ...args) { if (__DEV__) { if (!suppressWarning) { - printWarning('warn', format, args); + printWarning('warn', format, args, new Error('react-stack-top-frame')); } } } @@ -32,7 +32,7 @@ export function warn(format, ...args) { export function error(format, ...args) { if (__DEV__) { if (!suppressWarning) { - printWarning('error', format, args); + printWarning('error', format, args, new Error('react-stack-top-frame')); } } } @@ -40,7 +40,7 @@ export function error(format, ...args) { // eslint-disable-next-line react-internal/no-production-logging const supportsCreateTask = __DEV__ && enableOwnerStacks && !!console.createTask; -function printWarning(level, format, args) { +function printWarning(level, format, args, currentStack) { // When changing this logic, you might want to also // update consoleWithStackDev.www.js as well. if (__DEV__) { @@ -51,7 +51,7 @@ function printWarning(level, format, args) { // We only add the current stack to the console when createTask is not supported. // Since createTask requires DevTools to be open to work, this means that stacks // can be lost while DevTools isn't open but we can't detect this. - const stack = ReactSharedInternals.getCurrentStack(); + const stack = ReactSharedInternals.getCurrentStack(currentStack); if (stack !== '') { format += '%s'; args = args.concat([stack]); diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index bb8b523e6d..ecdb375569 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -24,4 +24,5 @@ export const enableAddPropertiesFastPath = __VARIANT__; export const enableDeferRootSchedulingToMicrotask = __VARIANT__; export const enableFastJSX = __VARIANT__; export const enableInfiniteRenderLoopDetection = __VARIANT__; +export const enableShallowPropDiffing = __VARIANT__; export const passChildrenWhenCloningPersistedNodes = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index f5387abb03..c306b2a6a2 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -26,6 +26,7 @@ export const { enableDeferRootSchedulingToMicrotask, enableFastJSX, enableInfiniteRenderLoopDetection, + enableShallowPropDiffing, passChildrenWhenCloningPersistedNodes, } = dynamicFlags; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index f6820d3bf5..63fe1885c0 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -102,7 +102,7 @@ export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const passChildrenWhenCloningPersistedNodes = false; export const enableAsyncIterableChildren = false; export const enableAddPropertiesFastPath = false; - +export const enableShallowPropDiffing = false; export const renameElementSymbol = true; export const enableOwnerStacks = __EXPERIMENTAL__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 24d94adaf8..e40351ae1f 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -79,6 +79,7 @@ export const enableInfiniteRenderLoopDetection = false; export const enableAddPropertiesFastPath = false; export const renameElementSymbol = true; +export const enableShallowPropDiffing = false; // TODO: This must be in sync with the main ReactFeatureFlags file because // the Test Renderer's value must be the same as the one used by the diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 731aa42147..fda4ec73af 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -92,6 +92,7 @@ export const enableAddPropertiesFastPath = false; export const renameElementSymbol = false; export const enableOwnerStacks = false; +export const enableShallowPropDiffing = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 9f5aa656c8..8bb8df8736 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -79,7 +79,7 @@ export const disableClientCache = true; export const enableServerComponentLogs = true; export const enableInfiniteRenderLoopDetection = false; -export const enableRefAsProp = false; +export const enableRefAsProp = true; export const disableStringRefs = false; export const enableFastJSX = false; @@ -92,6 +92,7 @@ export const enableAddPropertiesFastPath = false; export const renameElementSymbol = false; export const enableOwnerStacks = false; +export const enableShallowPropDiffing = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index e57cf043ee..7ad7c293f2 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -23,7 +23,6 @@ export const alwaysThrottleRetries = true; export const enableDO_NOT_USE_disableStrictPassiveEffect = __VARIANT__; export const enableUseDeferredValueInitialArg = __VARIANT__; export const enableRenderableContext = __VARIANT__; -export const enableRefAsProp = __VARIANT__; export const enableFastJSX = __VARIANT__; export const enableRetryLaneExpiration = __VARIANT__; export const favorSafetyOverHydrationPerf = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index de8fdc2c0a..25064d60e9 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -31,7 +31,6 @@ export const { transitionLaneExpirationMs, enableInfiniteRenderLoopDetection, enableRenderableContext, - enableRefAsProp, favorSafetyOverHydrationPerf, disableDefaultPropsExceptForClasses, enableNoCloningMemoCache, @@ -94,6 +93,8 @@ export const enableLegacyHidden = true; export const enableComponentStackLocations = true; +export const enableRefAsProp = true; + export const disableTextareaChildren = __EXPERIMENTAL__; export const allowConcurrentByDefault = true; @@ -121,6 +122,7 @@ export const disableStringRefs = false; export const disableLegacyMode = __EXPERIMENTAL__; export const enableOwnerStacks = false; +export const enableShallowPropDiffing = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/consoleWithStackDev.www.js b/packages/shared/forks/consoleWithStackDev.www.js index c4311efe09..5f04f36359 100644 --- a/packages/shared/forks/consoleWithStackDev.www.js +++ b/packages/shared/forks/consoleWithStackDev.www.js @@ -18,7 +18,7 @@ export function setSuppressWarning(newSuppressWarning) { export function warn(format, ...args) { if (__DEV__) { if (!suppressWarning) { - printWarning('warn', format, args); + printWarning('warn', format, args, new Error('react-stack-top-frame')); } } } @@ -26,19 +26,19 @@ export function warn(format, ...args) { export function error(format, ...args) { if (__DEV__) { if (!suppressWarning) { - printWarning('error', format, args); + printWarning('error', format, args, new Error('react-stack-top-frame')); } } } -function printWarning(level, format, args) { +function printWarning(level, format, args, currentStack) { if (__DEV__) { const React = require('react'); const ReactSharedInternals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; // Defensive in case this is fired before React is initialized. if (ReactSharedInternals != null && ReactSharedInternals.getCurrentStack) { - const stack = ReactSharedInternals.getCurrentStack(); + const stack = ReactSharedInternals.getCurrentStack(currentStack); if (stack !== '') { format += '%s'; args.push(stack); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 9bb82658ac..ef4ae75a6d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -512,5 +512,8 @@ "524": "Values cannot be passed to next() of AsyncIterables passed to Client Components.", "525": "A React Element from an older version of React was rendered. This is not supported. It can happen if:\n- Multiple copies of the \"react\" package is used.\n- A library pre-bundled an old copy of \"react\" or \"react/jsx-runtime\".\n- A compiler tries to \"inline\" JSX instead of using the runtime.", "526": "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server.", - "527": "Incompatible React versions: The \"react\" and \"react-dom\" packages must have the exact same version. Instead got:\n - react: %s\n - react-dom: %s\nLearn more: https://react.dev/warnings/version-mismatch" + "527": "Incompatible React versions: The \"react\" and \"react-dom\" packages must have the exact same version. Instead got:\n - react: %s\n - react-dom: %s\nLearn more: https://react.dev/warnings/version-mismatch", + "528": "Expected not to update to be updated to a stylesheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", + "529": "Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", + "530": "The render was aborted by the server with a promise." } diff --git a/scripts/jest/TestFlags.js b/scripts/jest/TestFlags.js index 1a95333b1d..0434529ab8 100644 --- a/scripts/jest/TestFlags.js +++ b/scripts/jest/TestFlags.js @@ -60,6 +60,7 @@ function getTestFlags() { const schedulerFeatureFlags = require('scheduler/src/SchedulerFeatureFlags'); const www = global.__WWW__ === true; + const xplat = global.__XPLAT__ === true; const releaseChannel = www ? __EXPERIMENTAL__ ? 'modern' @@ -79,8 +80,8 @@ function getTestFlags() { www, // These aren't flags, just a useful aliases for tests. - enableActivity: releaseChannel === 'experimental' || www, - enableSuspenseList: releaseChannel === 'experimental' || www, + enableActivity: releaseChannel === 'experimental' || www || xplat, + enableSuspenseList: releaseChannel === 'experimental' || www || xplat, enableLegacyHidden: www, // This flag is used to determine whether we should run Fizz tests using diff --git a/scripts/jest/config.source-xplat.js b/scripts/jest/config.source-xplat.js new file mode 100644 index 0000000000..760a584cc1 --- /dev/null +++ b/scripts/jest/config.source-xplat.js @@ -0,0 +1,30 @@ +'use strict'; + +const baseConfig = require('./config.base'); + +module.exports = Object.assign({}, baseConfig, { + modulePathIgnorePatterns: [ + ...baseConfig.modulePathIgnorePatterns, + 'packages/react-devtools-extensions', + 'packages/react-devtools-shared', + 'ReactIncrementalPerf', + 'ReactIncrementalUpdatesMinimalism', + 'ReactIncrementalTriangle', + 'ReactIncrementalReflection', + 'forwardRef', + ], + // RN configs should not run react-dom tests. + // There are many other tests that use react-dom + // and for those we will use the www entrypoint, + // but those tests should be migrated to Noop renderer. + testPathIgnorePatterns: [ + 'node_modules', + 'packages/react-dom', + 'packages/react-server-dom-webpack', + ], + setupFiles: [ + ...baseConfig.setupFiles, + require.resolve('./setupTests.xplat.js'), + require.resolve('./setupHostConfigs.js'), + ], +}); diff --git a/scripts/jest/jest-cli.js b/scripts/jest/jest-cli.js index 22098a1905..9c3be220fb 100644 --- a/scripts/jest/jest-cli.js +++ b/scripts/jest/jest-cli.js @@ -9,6 +9,7 @@ const semver = require('semver'); const ossConfig = './scripts/jest/config.source.js'; const wwwConfig = './scripts/jest/config.source-www.js'; +const xplatConfig = './scripts/jest/config.source-xplat.js'; const devToolsConfig = './scripts/jest/config.build-devtools.js'; // TODO: These configs are separate but should be rolled into the configs above @@ -46,7 +47,7 @@ const argv = yargs requiresArg: true, type: 'string', default: 'experimental', - choices: ['experimental', 'stable', 'www-classic', 'www-modern'], + choices: ['experimental', 'stable', 'www-classic', 'www-modern', 'xplat'], }, env: { alias: 'e', @@ -124,6 +125,10 @@ function isWWWConfig() { ); } +function isXplatConfig() { + return argv.releaseChannel === 'xplat' && argv.project !== 'devtools'; +} + function isOSSConfig() { return ( argv.releaseChannel === 'stable' || argv.releaseChannel === 'experimental' @@ -189,7 +194,7 @@ function validateOptions() { } } - if (isWWWConfig()) { + if (isWWWConfig() || isXplatConfig()) { if (argv.variant === undefined) { // Turn internal experiments on by default argv.variant = true; @@ -224,6 +229,13 @@ function validateOptions() { success = false; } + if (argv.build && isXplatConfig()) { + logError( + 'Build targets are only not supported for xplat release channels. Update these options to continue.' + ); + success = false; + } + if (argv.env && argv.env !== 'production' && argv.prod) { logError( 'Build type does not match --prod. Update these options to continue.' @@ -277,6 +289,8 @@ function getCommandArgs() { args.push(persistentConfig); } else if (isWWWConfig()) { args.push(wwwConfig); + } else if (isXplatConfig()) { + args.push(xplatConfig); } else if (isOSSConfig()) { args.push(ossConfig); } else { diff --git a/scripts/jest/patchMessageChannel.js b/scripts/jest/patchMessageChannel.js new file mode 100644 index 0000000000..bbcc6690c5 --- /dev/null +++ b/scripts/jest/patchMessageChannel.js @@ -0,0 +1,30 @@ +'use strict'; + +export function patchMessageChannel(Scheduler) { + global.MessageChannel = class { + constructor() { + const port1 = { + onmesssage: () => {}, + }; + + this.port1 = port1; + + this.port2 = { + postMessage(msg) { + if (Scheduler) { + Scheduler.unstable_scheduleCallback( + Scheduler.unstable_NormalPriority, + () => { + port1.onmessage(msg); + } + ); + } else { + throw new Error( + 'MessageChannel patch was used without providing a Scheduler implementation. This is useful for tests that require this class to exist but are not actually utilizing the MessageChannel class. However it appears some test is trying to use this class so you should pass a Scheduler implemenation to the patch method' + ); + } + }, + }; + } + }; +} diff --git a/scripts/jest/patchSetImmediate.js b/scripts/jest/patchSetImmediate.js new file mode 100644 index 0000000000..831314c664 --- /dev/null +++ b/scripts/jest/patchSetImmediate.js @@ -0,0 +1,13 @@ +'use strict'; + +export function patchSetImmediate(Scheduler) { + if (!Scheduler) { + throw new Error( + 'setImmediate patch was used without providing a Scheduler implementation. If you are patching setImmediate you must provide a Scheduler.' + ); + } + + global.setImmediate = cb => { + Scheduler.unstable_scheduleCallback(Scheduler.unstable_NormalPriority, cb); + }; +} diff --git a/scripts/jest/setupEnvironment.js b/scripts/jest/setupEnvironment.js index 3b9f004bc2..44acb04f18 100644 --- a/scripts/jest/setupEnvironment.js +++ b/scripts/jest/setupEnvironment.js @@ -21,19 +21,6 @@ global.__EXPERIMENTAL__ = global.__VARIANT__ = !!process.env.VARIANT; if (typeof window !== 'undefined') { - global.requestIdleCallback = function (callback) { - return setTimeout(() => { - callback({ - timeRemaining() { - return Infinity; - }, - }); - }); - }; - - global.cancelIdleCallback = function (callbackID) { - clearTimeout(callbackID); - }; } else { global.AbortController = require('abortcontroller-polyfill/dist/cjs-ponyfill').AbortController; diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index 0339f14469..fcd1ed2130 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -77,7 +77,7 @@ function mockReact() { jest.mock('react', () => { const resolvedEntryPoint = resolveEntryFork( require.resolve('react'), - global.__WWW__ + global.__WWW__ || global.__XPLAT__ ); return jest.requireActual(resolvedEntryPoint); }); @@ -100,7 +100,7 @@ jest.mock('react/react.react-server', () => { }); const resolvedEntryPoint = resolveEntryFork( require.resolve('react/src/ReactServer'), - global.__WWW__ + global.__WWW__ || global.__XPLAT__ ); return jest.requireActual(resolvedEntryPoint); }); @@ -198,7 +198,7 @@ inlinedHostConfigs.forEach(rendererInfo => { mockAllConfigs(rendererInfo); const resolvedEntryPoint = resolveEntryFork( require.resolve(entryPoint), - global.__WWW__ + global.__WWW__ || global.__XPLAT__ ); return jest.requireActual(resolvedEntryPoint); }); diff --git a/scripts/jest/setupTests.xplat.js b/scripts/jest/setupTests.xplat.js new file mode 100644 index 0000000000..859a506598 --- /dev/null +++ b/scripts/jest/setupTests.xplat.js @@ -0,0 +1,33 @@ +'use strict'; + +jest.mock('shared/ReactFeatureFlags', () => { + jest.mock( + 'ReactNativeInternalFeatureFlags', + () => + jest.requireActual('shared/forks/ReactFeatureFlags.native-fb-dynamic.js'), + {virtual: true} + ); + const actual = jest.requireActual( + 'shared/forks/ReactFeatureFlags.native-fb.js' + ); + + // Lots of tests use these, but we don't want to expose it to RN. + // Ideally, tests for xplat wouldn't use react-dom, but many of our tests do. + // Since the xplat tests run with the www entry points, some of these flags + // need to be set to the www value for the entrypoint, otherwise gating would + // fail due to the tests passing. Ideally, the www entry points for these APIs + // would be gated, and then these would fail correctly. + actual.enableLegacyCache = true; + actual.enableLegacyHidden = true; + actual.enableScopeAPI = true; + actual.enableTaint = false; + + return actual; +}); + +jest.mock('react-noop-renderer', () => + jest.requireActual('react-noop-renderer/persistent') +); + +global.__PERSISTENT__ = true; +global.__XPLAT__ = true; diff --git a/scripts/release/shared-commands/download-build-artifacts.js b/scripts/release/shared-commands/download-build-artifacts.js index 2952bc9771..2539abd6a6 100644 --- a/scripts/release/shared-commands/download-build-artifacts.js +++ b/scripts/release/shared-commands/download-build-artifacts.js @@ -50,6 +50,8 @@ const run = async ({build, cwd, releaseChannel}) => { sourceDir = 'oss-stable'; } else if (releaseChannel === 'experimental') { sourceDir = 'oss-experimental'; + } else if (releaseChannel === 'rc') { + sourceDir = 'oss-stable-rc'; } else if (releaseChannel === 'latest') { sourceDir = 'oss-stable-semver'; } else { diff --git a/scripts/release/shared-commands/parse-params.js b/scripts/release/shared-commands/parse-params.js index 1866cbb8aa..6e3b783709 100644 --- a/scripts/release/shared-commands/parse-params.js +++ b/scripts/release/shared-commands/parse-params.js @@ -50,10 +50,11 @@ module.exports = async () => { if ( channel !== 'experimental' && channel !== 'stable' && + channel !== 'rc' && channel !== 'latest' ) { console.error( - theme.error`Invalid release channel (-r) "${channel}". Must be "stable", "experimental", or "latest".` + theme.error`Invalid release channel (-r) "${channel}". Must be "stable", "experimental", "rc", or "latest".` ); process.exit(1); } diff --git a/scripts/rollup/build-all-release-channels.js b/scripts/rollup/build-all-release-channels.js index 098177baf9..5e8cd27cf5 100644 --- a/scripts/rollup/build-all-release-channels.js +++ b/scripts/rollup/build-all-release-channels.js @@ -2,7 +2,6 @@ /* eslint-disable no-for-of-loops/no-for-of-loops */ -const crypto = require('node:crypto'); const fs = require('fs'); const fse = require('fs-extra'); const {spawnSync} = require('child_process'); @@ -14,6 +13,7 @@ const { stablePackages, experimentalPackages, canaryChannelLabel, + rcNumber, } = require('../../ReactVersions'); // Runs the build script for both stable and experimental release channels, @@ -41,7 +41,10 @@ if (dateString.startsWith("'")) { // Build the artifacts using a placeholder React version. We'll then do a string // replace to swap it with the correct version per release channel. -const PLACEHOLDER_REACT_VERSION = ReactVersion + '-PLACEHOLDER'; +// +// The placeholder version is the same format that the "next" channel uses +const PLACEHOLDER_REACT_VERSION = + ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString; // TODO: We should inject the React version using a build-time parameter // instead of overwriting the source files. @@ -116,6 +119,13 @@ function processStable(buildDir) { // Identical to `oss-stable` but with real, semver versions. This is what // will get published to @latest. shell.cp('-r', buildDir + '/node_modules', buildDir + '/oss-stable-semver'); + if (canaryChannelLabel === 'rc') { + // During the RC phase, we also generate an RC build that pins to exact + // versions but does not include a SHA, e.g. `19.0.0-rc.0`. This is purely + // for signaling purposes — aside from the version, it's no different from + // the corresponding canary. + shell.cp('-r', buildDir + '/node_modules', buildDir + '/oss-stable-rc'); + } const defaultVersionIfNotFound = '0.0.0' + '-' + sha + '-' + dateString; const versionsMap = new Map(); @@ -139,6 +149,41 @@ function processStable(buildDir) { ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString ); + if (canaryChannelLabel === 'rc') { + const rcVersionsMap = new Map(); + for (const moduleName in stablePackages) { + const version = stablePackages[moduleName]; + rcVersionsMap.set(moduleName, version + `-rc.${rcNumber}`); + } + updatePackageVersions( + buildDir + '/oss-stable-rc', + rcVersionsMap, + defaultVersionIfNotFound, + // For RCs, we pin to exact versions, like we do for canaries. + true + ); + updatePlaceholderReactVersionInCompiledArtifacts( + buildDir + '/oss-stable-rc', + ReactVersion + ); + } + + const rnVersionString = + ReactVersion + '-native-fb-' + sha + '-' + dateString; + if (fs.existsSync(buildDir + '/facebook-react-native')) { + updatePlaceholderReactVersionInCompiledArtifacts( + buildDir + '/facebook-react-native', + rnVersionString + ); + } + + if (fs.existsSync(buildDir + '/react-native')) { + updatePlaceholderReactVersionInCompiledArtifactsFb( + buildDir + '/react-native', + rnVersionString + ); + } + // Now do the semver ones const semverVersionsMap = new Map(); for (const moduleName in stablePackages) { @@ -149,6 +194,7 @@ function processStable(buildDir) { buildDir + '/oss-stable-semver', semverVersionsMap, defaultVersionIfNotFound, + // Use ^ only for non-prerelease versions false ); updatePlaceholderReactVersionInCompiledArtifacts( @@ -158,37 +204,23 @@ function processStable(buildDir) { } if (fs.existsSync(buildDir + '/facebook-www')) { - for (const fileName of fs.readdirSync(buildDir + '/facebook-www').sort()) { + for (const fileName of fs.readdirSync(buildDir + '/facebook-www')) { const filePath = buildDir + '/facebook-www/' + fileName; const stats = fs.statSync(filePath); if (!stats.isDirectory()) { fs.renameSync(filePath, filePath.replace('.js', '.classic.js')); } } + const versionString = + ReactVersion + '-www-classic-' + sha + '-' + dateString; updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', - ReactVersion + '-www-classic-%FILEHASH%' + versionString ); + // Also save a file with the version number + fs.writeFileSync(buildDir + '/facebook-www/VERSION_CLASSIC', versionString); } - [ - buildDir + '/react-native/implementations/', - buildDir + '/facebook-react-native/', - ].forEach(reactNativeBuildDir => { - if (fs.existsSync(reactNativeBuildDir)) { - updatePlaceholderReactVersionInCompiledArtifacts( - reactNativeBuildDir, - ReactVersion + '-' + canaryChannelLabel + '-%FILEHASH%' - ); - } - }); - - // Update remaining placeholders with canary channel version - updatePlaceholderReactVersionInCompiledArtifacts( - buildDir, - ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString - ); - if (fs.existsSync(buildDir + '/sizes')) { fs.renameSync(buildDir + '/sizes', buildDir + '/sizes-stable'); } @@ -222,36 +254,44 @@ function processExperimental(buildDir, version) { } if (fs.existsSync(buildDir + '/facebook-www')) { - for (const fileName of fs.readdirSync(buildDir + '/facebook-www').sort()) { + for (const fileName of fs.readdirSync(buildDir + '/facebook-www')) { const filePath = buildDir + '/facebook-www/' + fileName; const stats = fs.statSync(filePath); if (!stats.isDirectory()) { fs.renameSync(filePath, filePath.replace('.js', '.modern.js')); } } + const versionString = + ReactVersion + '-www-modern-' + sha + '-' + dateString; updatePlaceholderReactVersionInCompiledArtifacts( buildDir + '/facebook-www', - ReactVersion + '-www-modern-%FILEHASH%' + versionString + ); + + // Also save a file with the version number + fs.writeFileSync(buildDir + '/facebook-www/VERSION_MODERN', versionString); + } + + const rnVersionString = ReactVersion + '-native-fb-' + sha + '-' + dateString; + if (fs.existsSync(buildDir + '/facebook-react-native')) { + updatePlaceholderReactVersionInCompiledArtifacts( + buildDir + '/facebook-react-native', + rnVersionString + ); + + // Also save a file with the version number + fs.writeFileSync( + buildDir + '/facebook-react-native/VERSION_NATIVE_FB', + rnVersionString ); } - [ - buildDir + '/react-native/implementations/', - buildDir + '/facebook-react-native/', - ].forEach(reactNativeBuildDir => { - if (fs.existsSync(reactNativeBuildDir)) { - updatePlaceholderReactVersionInCompiledArtifacts( - reactNativeBuildDir, - ReactVersion + '-' + canaryChannelLabel + '-%FILEHASH%' - ); - } - }); - - // Update remaining placeholders with canary channel version - updatePlaceholderReactVersionInCompiledArtifacts( - buildDir, - ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString - ); + if (fs.existsSync(buildDir + '/react-native')) { + updatePlaceholderReactVersionInCompiledArtifactsFb( + buildDir + '/react-native', + rnVersionString + ); + } if (fs.existsSync(buildDir + '/sizes')) { fs.renameSync(buildDir + '/sizes', buildDir + '/sizes-experimental'); @@ -362,11 +402,37 @@ function updatePlaceholderReactVersionInCompiledArtifacts( for (const artifactFilename of artifactFilenames) { const originalText = fs.readFileSync(artifactFilename, 'utf8'); - const fileHash = crypto.createHash('sha1'); - fileHash.update(originalText); const replacedText = originalText.replaceAll( PLACEHOLDER_REACT_VERSION, - newVersion.replace(/%FILEHASH%/g, fileHash.digest('hex').slice(0, 8)) + newVersion + ); + fs.writeFileSync(artifactFilename, replacedText); + } +} + +function updatePlaceholderReactVersionInCompiledArtifactsFb( + artifactsDirectory, + newVersion +) { + // Update the version of React in the compiled artifacts by searching for + // the placeholder string and replacing it with a new one. + const artifactFilenames = String( + spawnSync('grep', [ + '-lr', + PLACEHOLDER_REACT_VERSION, + '--', + artifactsDirectory, + ]).stdout + ) + .trim() + .split('\n') + .filter(filename => filename.endsWith('.fb.js')); + + for (const artifactFilename of artifactFilenames) { + const originalText = fs.readFileSync(artifactFilename, 'utf8'); + const replacedText = originalText.replaceAll( + PLACEHOLDER_REACT_VERSION, + newVersion ); fs.writeFileSync(artifactFilename, replacedText); } diff --git a/yarn.lock b/yarn.lock index 70432d0625..a72923e5c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6498,7 +6498,7 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== -default-gateway@^6.0.3: +default-gateway@^6.0.0, default-gateway@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-6.0.3.tgz#819494c888053bdb743edbf343d6cdf7f2943a71" integrity sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg== @@ -7226,7 +7226,7 @@ eslint-utils@^2.0.0, eslint-utils@^2.1.0: dependencies: eslint-visitor-keys "^1.1.0" -"eslint-v7@npm:eslint@^7.7.0": +"eslint-v7@npm:eslint@^7.7.0", eslint@^7.7.0: version "7.32.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== @@ -7389,52 +7389,6 @@ eslint@5.16.0: table "^5.2.3" text-table "^0.2.0" -eslint@^7.7.0: - version "7.32.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" - integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== - dependencies: - "@babel/code-frame" "7.12.11" - "@eslint/eslintrc" "^0.4.3" - "@humanwhocodes/config-array" "^0.5.0" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.0.1" - doctrine "^3.0.0" - enquirer "^2.3.5" - escape-string-regexp "^4.0.0" - eslint-scope "^5.1.1" - eslint-utils "^2.1.0" - eslint-visitor-keys "^2.0.0" - espree "^7.3.1" - esquery "^1.4.0" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^5.1.2" - globals "^13.6.0" - ignore "^4.0.6" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - js-yaml "^3.13.1" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash.merge "^4.6.2" - minimatch "^3.0.4" - natural-compare "^1.4.0" - optionator "^0.9.1" - progress "^2.0.0" - regexpp "^3.1.0" - semver "^7.2.1" - strip-ansi "^6.0.0" - strip-json-comments "^3.1.0" - table "^6.0.9" - text-table "^0.2.0" - v8-compile-cache "^2.0.3" - espree@6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" @@ -9489,6 +9443,16 @@ inquirer@^6.2.2: strip-ansi "^5.1.0" through "^2.3.6" +internal-ip@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-6.2.0.tgz#d5541e79716e406b74ac6b07b856ef18dc1621c1" + integrity sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg== + dependencies: + default-gateway "^6.0.0" + ipaddr.js "^1.9.1" + is-ip "^3.1.0" + p-event "^4.2.0" + interpret@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" @@ -9519,12 +9483,17 @@ invert-kv@^3.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-3.0.1.tgz#a93c7a3d4386a1dc8325b97da9bb1620c0282523" integrity sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw== -ip@^1.1.4, ip@^1.1.5: +ip-regex@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" + integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q== + +ip@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= -ipaddr.js@1.9.1: +ipaddr.js@1.9.1, ipaddr.js@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== @@ -9778,6 +9747,13 @@ is-installed-globally@^0.3.1: global-dirs "^2.0.1" is-path-inside "^3.0.1" +is-ip@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-3.1.0.tgz#2ae5ddfafaf05cb8008a62093cf29734f657c5d8" + integrity sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q== + dependencies: + ip-regex "^4.0.0" + is-jpg@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-jpg/-/is-jpg-2.0.0.tgz#2e1997fa6e9166eaac0242daae443403e4ef1d97" @@ -12389,6 +12365,13 @@ p-event@^2.1.0: dependencies: p-timeout "^2.0.1" +p-event@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/p-event/-/p-event-4.2.0.tgz#af4b049c8acd91ae81083ebd1e6f5cae2044c1b5" + integrity sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ== + dependencies: + p-timeout "^3.1.0" + p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" @@ -12485,6 +12468,13 @@ p-timeout@^2.0.1: dependencies: p-finally "^1.0.0" +p-timeout@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -14987,7 +14977,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -15022,15 +15012,6 @@ string-width@^4.0.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -15091,7 +15072,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15119,13 +15100,6 @@ strip-ansi@^5.1.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -16573,7 +16547,7 @@ workerize-loader@^2.0.2: dependencies: loader-utils "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16591,15 +16565,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"