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 4068cb24a42..0752894d94f 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -149,7 +149,8 @@ function* runWithEnvironment( if ( !env.config.enablePreserveExistingManualUseMemo && - !env.config.disableMemoizationForDebugging + !env.config.disableMemoizationForDebugging && + !env.config.enableChangeDetectionForDebugging ) { dropManualMemoization(hir); yield log({ kind: "hir", name: "DropManualMemoization", value: hir }); 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 dc74077b63a..3d6612afd44 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/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index 6809742d80a..2a94eec79bd 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -359,6 +359,14 @@ const EnvironmentConfigSchema = z.object({ */ 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. @@ -478,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/ReactiveScopes/CodegenReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts index fac5ea32d38..159656611fb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts @@ -617,22 +617,83 @@ 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); + let memoStatement; const memoBlock = t.blockStatement(cacheLoadStatements); + if ( + cx.env.config.enableChangeDetectionForDebugging != null && + changeExpressions.length > 0 + ) { + const detectionFunction = + cx.env.config.enableChangeDetectionForDebugging.importSpecifierName; + const changeDetectionStatements: Array = []; + const oldVarDeclarationStatements: Array = []; + memoBlock.body.forEach((stmt) => { + if ( + stmt.type === "ExpressionStatement" && + stmt.expression.type === "AssignmentExpression" && + stmt.expression.left.type === "Identifier" + ) { + const name = stmt.expression.left.name; + const loadName = cx.synthesizeName(`old$${name}`); + oldVarDeclarationStatements.push( + t.variableDeclaration("let", [ + t.variableDeclarator(t.identifier(loadName)), + ]) + ); + stmt.expression.left = t.identifier(loadName); + changeDetectionStatements.push( + t.expressionStatement( + t.callExpression(t.identifier(detectionFunction), [ + t.identifier(loadName), + t.identifier(name), + t.stringLiteral(name), + t.stringLiteral(cx.fnName), + ]) + ) + ); + changeDetectionStatements.push( + t.expressionStatement( + t.assignmentExpression( + "=", + t.identifier(name), + t.identifier(loadName) + ) + ) + ); + } + }); + memoStatement = t.blockStatement([ + ...computationBlock.body, + t.ifStatement( + t.unaryExpression("!", testCondition), + t.blockStatement([ + ...oldVarDeclarationStatements, + ...memoBlock.body, + ...changeDetectionStatements, + ]) + ), + ...cacheStoreStatements, + ]); + } else { + computationBlock.body.push(...cacheStoreStatements); - const memoStatement = t.ifStatement( - testCondition, - computationBlock, - memoBlock - ); + memoStatement = t.ifStatement(testCondition, computationBlock, memoBlock); + } if (cx.env.config.enableMemoizationComments) { if (changeExpressionComments.length) { 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 00000000000..73d664f5938 --- /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 00000000000..ce93cd29f1a --- /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/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 00000000000..482fb5cbbdc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md @@ -0,0 +1,53 @@ + +## 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(4); + let t0; + { + t0 = f(props.x); + if (!($[0] !== props.x)) { + let old$t0; + old$t0 = $[1]; + $structuralCheck(old$t0, t0, "t0", "Component"); + t0 = old$t0; + } + $[0] = props.x; + $[1] = t0; + } + const [x] = useState(t0); + let t1; + { + t1 =
{x}
; + if (!($[2] !== x)) { + let old$t1; + old$t1 = $[3]; + $structuralCheck(old$t1, t1, "t1", "Component"); + t1 = old$t1; + } + $[2] = x; + $[3] = t1; + } + 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 00000000000..46a9c23fe94 --- /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/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index 743b7633aa1..aca78194ef2 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,170 @@ export function useRenderCounter(name: string): void { }; }); } + +const seenErrors = new Set(); + +export function $structuralCheck( + oldValue: any, + newValue: any, + variableName: string, + fnName: string +): void { + function error(l: string, r: string, path: string, depth: number) { + const str = `${fnName}: ${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 14ed51b2cc0..c6fdc12b727 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -495,6 +495,7 @@ const skipFilter = new Set([ "flag-enable-emit-hook-guards", "fast-refresh-refresh-on-const-changes-dev", + "useState-pruned-dependency-change-detect", ]); export default skipFilter; diff --git a/compiler/packages/snap/src/compiler.ts b/compiler/packages/snap/src/compiler.ts index ab2cf5cef88..a6646570972 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,