Compare commits

..

2 Commits

Author SHA1 Message Date
Joe Savona
051be78079 [compiler] Fix false positive memo validation
Partial fix for #34262. Consider this example:

```js
function useInputValue(input) {
  const object = React.useMemo(() => {
    const {value} = transform(input);
    return {value};
  }, [input]);
  return object;
}
```

React Compiler breaks this code into two reactive scopes:
* One for `transform(input)`
* One for `{value}`

When we run ValidatePreserveExistingMemo, we see that the scope for `{value}` has the dependency `value`, whereas the original memoization had the dependency `input`, and throw an error that the dependencies didn't match.

In other words, we're flagging the fact that memoized _better than the user_ as a problem. The more complete solution would be to validate that there is a subgraph of reactive scopes with a single input and output node, where the input node has the same dependencies as the original useMemo, and the output has the same outputs. That is true in this case, with the subgraph being the two consecutive scopes mentioned above.

But that's complicated. As a shortcut, this PR checks for any dependencies that are defined after the start of the original useMemo. If we find one, we know that it's a case where we were able to memoize more precisely than the original, and we don't report an error on the dependency. We still check that the original _output_ value is able to be memoized, though. So if the scope of `object` were extended, eg with a call to `mutate(object)`, then we'd still correctly report an error that we couldn't preserve memoization.
2025-08-28 14:52:57 -07:00
Joe Savona
a2bc92a8be [compiler] Handle empty list of eslint suppression rules 2025-08-28 14:52:57 -07:00
15 changed files with 560 additions and 235 deletions

View File

@@ -135,7 +135,12 @@ export type PluginOptions = {
*/
eslintSuppressionRules: Array<string> | null | undefined;
/**
* Whether to report "suppression" errors for Flow suppressions. If false, suppression errors
* are only emitted for ESLint suppressions
*/
flowSuppressions: boolean;
/*
* Ignore 'use no forget' annotations. Helpful during testing but should not be used in production.
*/

View File

@@ -87,12 +87,18 @@ export function findProgramSuppressions(
let enableComment: t.Comment | null = null;
let source: SuppressionSource | null = null;
const rulePattern = `(${ruleNames.join('|')})`;
const disableNextLinePattern = new RegExp(
`eslint-disable-next-line ${rulePattern}`,
);
const disablePattern = new RegExp(`eslint-disable ${rulePattern}`);
const enablePattern = new RegExp(`eslint-enable ${rulePattern}`);
let disableNextLinePattern: RegExp | null = null;
let disablePattern: RegExp | null = null;
let enablePattern: RegExp | null = null;
if (ruleNames.length !== 0) {
const rulePattern = `(${ruleNames.join('|')})`;
disableNextLinePattern = new RegExp(
`eslint-disable-next-line ${rulePattern}`,
);
disablePattern = new RegExp(`eslint-disable ${rulePattern}`);
enablePattern = new RegExp(`eslint-enable ${rulePattern}`);
}
const flowSuppressionPattern = new RegExp(
'\\$(FlowFixMe\\w*|FlowExpectedError|FlowIssue)\\[react\\-rule',
);
@@ -108,6 +114,7 @@ export function findProgramSuppressions(
* CommentLine within the block.
*/
disableComment == null &&
disableNextLinePattern != null &&
disableNextLinePattern.test(comment.value)
) {
disableComment = comment;
@@ -125,12 +132,16 @@ export function findProgramSuppressions(
source = 'Flow';
}
if (disablePattern.test(comment.value)) {
if (disablePattern != null && disablePattern.test(comment.value)) {
disableComment = comment;
source = 'Eslint';
}
if (enablePattern.test(comment.value) && source === 'Eslint') {
if (
enablePattern != null &&
enablePattern.test(comment.value) &&
source === 'Eslint'
) {
enableComment = comment;
}

View File

@@ -25,6 +25,7 @@ import {
IdentifierId,
Instruction,
InstructionKind,
InstructionValue,
isArrayType,
isJsxType,
isMapType,
@@ -56,6 +57,7 @@ import {
printAliasingSignature,
printIdentifier,
printInstruction,
printInstructionValue,
printPlace,
printSourceLocation,
} from '../HIR/PrintHIR';
@@ -106,11 +108,11 @@ export function inferMutationAliasingEffects(
const statesByBlock: Map<BlockId, InferenceState> = new Map();
for (const ref of fn.context) {
const value: AliasingEffect = {
kind: 'Create',
into: ref,
value: ValueKind.Context,
reason: ValueReason.Other,
// TODO: using InstructionValue as a bit of a hack, but it's pragmatic
const value: InstructionValue = {
kind: 'ObjectExpression',
properties: [],
loc: ref.loc,
};
initialState.initialize(value, {
kind: ValueKind.Context,
@@ -143,11 +145,10 @@ export function inferMutationAliasingEffects(
}
if (ref != null) {
const place = ref.kind === 'Identifier' ? ref : ref.place;
const value: AliasingEffect = {
kind: 'Create',
into: place,
value: ValueKind.Mutable,
reason: ValueReason.Other,
const value: InstructionValue = {
kind: 'ObjectExpression',
properties: [],
loc: place.loc,
};
initialState.initialize(value, {
kind: ValueKind.Mutable,
@@ -264,6 +265,8 @@ function findHoistedContextDeclarations(
class Context {
internedEffects: Map<string, AliasingEffect> = new Map();
instructionSignatureCache: Map<Instruction, InstructionSignature> = new Map();
effectInstructionValueCache: Map<AliasingEffect, InstructionValue> =
new Map();
applySignatureCache: Map<
AliasingSignature,
Map<AliasingEffect, Array<AliasingEffect> | null>
@@ -315,11 +318,10 @@ function inferParam(
paramKind: AbstractValue,
): void {
const place = param.kind === 'Identifier' ? param : param.place;
const value: AliasingEffect = {
kind: 'Create',
into: place,
value: paramKind.kind,
reason: ValueReason.Other,
const value: InstructionValue = {
kind: 'Primitive',
loc: place.loc,
value: undefined,
};
initialState.initialize(value, paramKind);
initialState.define(place, value);
@@ -540,11 +542,20 @@ function applyEffect(
});
initialized.add(effect.into.identifier.id);
state.initialize(effect, {
let value = context.effectInstructionValueCache.get(effect);
if (value == null) {
value = {
kind: 'ObjectExpression',
properties: [],
loc: effect.into.loc,
};
context.effectInstructionValueCache.set(effect, value);
}
state.initialize(value, {
kind: effect.value,
reason: new Set([effect.reason]),
});
state.define(effect.into, effect);
state.define(effect.into, value);
effects.push(effect);
break;
}
@@ -571,11 +582,20 @@ function applyEffect(
initialized.add(effect.into.identifier.id);
const fromValue = state.kind(effect.from);
state.initialize(effect, {
let value = context.effectInstructionValueCache.get(effect);
if (value == null) {
value = {
kind: 'ObjectExpression',
properties: [],
loc: effect.into.loc,
};
context.effectInstructionValueCache.set(effect, value);
}
state.initialize(value, {
kind: fromValue.kind,
reason: new Set(fromValue.reason),
});
state.define(effect.into, effect);
state.define(effect.into, value);
switch (fromValue.kind) {
case ValueKind.Primitive:
case ValueKind.Global: {
@@ -663,11 +683,11 @@ function applyEffect(
operand.effect = Effect.Read;
}
}
state.initialize(effect, {
state.initialize(effect.function, {
kind: isMutable ? ValueKind.Mutable : ValueKind.Frozen,
reason: new Set([]),
});
state.define(effect.into, effect);
state.define(effect.into, effect.function);
for (const capture of effect.captures) {
applyEffect(
context,
@@ -773,20 +793,38 @@ function applyEffect(
initialized,
effects,
);
state.initialize(effect, {
let value = context.effectInstructionValueCache.get(effect);
if (value == null) {
value = {
kind: 'Primitive',
value: undefined,
loc: effect.from.loc,
};
context.effectInstructionValueCache.set(effect, value);
}
state.initialize(value, {
kind: fromKind,
reason: new Set(fromValue.reason),
});
state.define(effect.into, effect);
state.define(effect.into, value);
break;
}
case ValueKind.Global:
case ValueKind.Primitive: {
state.initialize(effect, {
let value = context.effectInstructionValueCache.get(effect);
if (value == null) {
value = {
kind: 'Primitive',
value: undefined,
loc: effect.from.loc,
};
context.effectInstructionValueCache.set(effect, value);
}
state.initialize(value, {
kind: fromKind,
reason: new Set(fromValue.reason),
});
state.define(effect.into, effect);
state.define(effect.into, value);
break;
}
default: {
@@ -801,15 +839,14 @@ function applyEffect(
const functionValues = state.values(effect.function);
if (
functionValues.length === 1 &&
functionValues[0].kind === 'CreateFunction' &&
functionValues[0].function.kind === 'FunctionExpression' &&
functionValues[0].function.loweredFunc.func.aliasingEffects != null
functionValues[0].kind === 'FunctionExpression' &&
functionValues[0].loweredFunc.func.aliasingEffects != null
) {
/*
* We're calling a locally declared function, we already know it's effects!
* We just have to substitute in the args for the params
*/
const functionExpr = functionValues[0].function;
const functionExpr = functionValues[0];
let signature = context.functionSignatureCache.get(functionExpr);
if (signature == null) {
signature = buildSignatureFromFunctionExpression(
@@ -1099,19 +1136,19 @@ class InferenceState {
#isFunctionExpression: boolean;
// The kind of each value, based on its allocation site
#values: Map<AliasingEffect, AbstractValue>;
#values: Map<InstructionValue, AbstractValue>;
/*
* The set of values pointed to by each identifier. This is a set
* to accomodate phi points (where a variable may have different
* values from different control flow paths).
*/
#variables: Map<IdentifierId, Set<AliasingEffect>>;
#variables: Map<IdentifierId, Set<InstructionValue>>;
constructor(
env: Environment,
isFunctionExpression: boolean,
values: Map<AliasingEffect, AbstractValue>,
variables: Map<IdentifierId, Set<AliasingEffect>>,
values: Map<InstructionValue, AbstractValue>,
variables: Map<IdentifierId, Set<InstructionValue>>,
) {
this.env = env;
this.#isFunctionExpression = isFunctionExpression;
@@ -1131,11 +1168,18 @@ class InferenceState {
}
// (Re)initializes a @param value with its default @param kind.
initialize(value: AliasingEffect, kind: AbstractValue): void {
initialize(value: InstructionValue, kind: AbstractValue): void {
CompilerError.invariant(value.kind !== 'LoadLocal', {
reason:
'[InferMutationAliasingEffects] Expected all top-level identifiers to be defined as variables, not values',
description: null,
loc: value.loc,
suggestions: null,
});
this.#values.set(value, kind);
}
values(place: Place): Array<AliasingEffect> {
values(place: Place): Array<InstructionValue> {
const values = this.#variables.get(place.identifier.id);
CompilerError.invariant(values != null, {
reason: `[InferMutationAliasingEffects] Expected value kind to be initialized`,
@@ -1198,13 +1242,13 @@ class InferenceState {
}
// Defines (initializing or updating) a variable with a specific kind of value.
define(place: Place, value: AliasingEffect): void {
define(place: Place, value: InstructionValue): void {
CompilerError.invariant(this.#values.has(value), {
reason: `[InferMutationAliasingEffects] Expected value to be initialized at '${printSourceLocation(
place.loc,
value.loc,
)}'`,
description: printAliasingEffect(value),
loc: place.loc,
description: printInstructionValue(value),
loc: value.loc,
suggestions: null,
});
this.#variables.set(place.identifier.id, new Set([value]));
@@ -1244,17 +1288,17 @@ class InferenceState {
}
}
freezeValue(value: AliasingEffect, reason: ValueReason): void {
freezeValue(value: InstructionValue, reason: ValueReason): void {
this.#values.set(value, {
kind: ValueKind.Frozen,
reason: new Set([reason]),
});
if (
value.kind === 'CreateFunction' &&
value.kind === 'FunctionExpression' &&
(this.env.config.enablePreserveExistingMemoizationGuarantees ||
this.env.config.enableTransitivelyFreezeFunctionExpressions)
) {
for (const place of value.function.loweredFunc.func.context) {
for (const place of value.loweredFunc.func.context) {
this.freeze(place, reason);
}
}
@@ -1329,8 +1373,8 @@ class InferenceState {
* termination.
*/
merge(other: InferenceState): InferenceState | null {
let nextValues: Map<AliasingEffect, AbstractValue> | null = null;
let nextVariables: Map<IdentifierId, Set<AliasingEffect>> | null = null;
let nextValues: Map<InstructionValue, AbstractValue> | null = null;
let nextVariables: Map<IdentifierId, Set<InstructionValue>> | null = null;
for (const [id, thisValue] of this.#values) {
const otherValue = other.#values.get(id);
@@ -1354,7 +1398,7 @@ class InferenceState {
for (const [id, thisValues] of this.#variables) {
const otherValues = other.#variables.get(id);
if (otherValues !== undefined) {
let mergedValues: Set<AliasingEffect> | null = null;
let mergedValues: Set<InstructionValue> | null = null;
for (const otherValue of otherValues) {
if (!thisValues.has(otherValue)) {
mergedValues = mergedValues ?? new Set(thisValues);
@@ -1407,8 +1451,8 @@ class InferenceState {
*/
debug(): any {
const result: any = {values: {}, variables: {}};
const objects: Map<AliasingEffect, number> = new Map();
function identify(value: AliasingEffect): number {
const objects: Map<InstructionValue, number> = new Map();
function identify(value: InstructionValue): number {
let id = objects.get(value);
if (id == null) {
id = objects.size;
@@ -1420,7 +1464,7 @@ class InferenceState {
const id = identify(value);
result.values[id] = {
abstract: this.debugAbstractValue(kind),
value: printAliasingEffect(value),
value: printInstructionValue(value),
};
}
for (const [variable, values] of this.#variables) {
@@ -1437,7 +1481,7 @@ class InferenceState {
}
inferPhi(phi: Phi): void {
const values: Set<AliasingEffect> = new Set();
const values: Set<InstructionValue> = new Set();
for (const [_, operand] of phi.operands) {
const operandValues = this.#variables.get(operand.identifier.id);
// This is a backedge that will be handled later by State.merge
@@ -2303,9 +2347,8 @@ function areArgumentsImmutableAndNonMutating(
const values = state.values(place);
for (const value of values) {
if (
value.kind === 'CreateFunction' &&
value.function.kind === 'FunctionExpression' &&
value.function.loweredFunc.func.params.some(param => {
value.kind === 'FunctionExpression' &&
value.loweredFunc.func.params.some(param => {
const place = param.kind === 'Identifier' ? param : param.place;
const range = place.identifier.mutableRange;
return range.end > range.start + 1;
@@ -2498,47 +2541,10 @@ function computeEffectsForSignature(
break;
}
case 'CreateFunction': {
const applyInto = substitutions.get(effect.into.identifier.id);
if (applyInto == null || applyInto.length !== 1) {
return null;
}
const captures: Array<Place> = [];
for (let i = 0; i < effect.captures.length; i++) {
const substitution = substitutions.get(
effect.captures[i].identifier.id,
);
if (substitution == null || substitution.length !== 1) {
return null;
}
captures.push(substitution[0]);
}
const context: Array<Place> = [];
const originalContext = effect.function.loweredFunc.func.context;
for (let i = 0; i < originalContext.length; i++) {
const substitution = substitutions.get(
originalContext[i].identifier.id,
);
if (substitution == null || substitution.length !== 1) {
return null;
}
context.push(substitution[0]);
}
effects.push({
kind: 'CreateFunction',
into: applyInto[0],
function: {
...effect.function,
loweredFunc: {
...effect.function.loweredFunc,
func: {
...effect.function.loweredFunc.func,
context,
},
},
},
captures,
CompilerError.throwTodo({
reason: `Support CreateFrom effects in signatures`,
loc: receiver.loc,
});
break;
}
default: {
assertExhaustive(

View File

@@ -141,7 +141,7 @@ export function inferMutationAliasingRanges(
} else if (effect.kind === 'CreateFunction') {
state.create(effect.into, {
kind: 'Function',
effect,
function: effect.function.loweredFunc.func,
});
} else if (effect.kind === 'CreateFrom') {
state.createFrom(index++, effect.from, effect.into);
@@ -156,7 +156,7 @@ export function inferMutationAliasingRanges(
* invariant here.
*/
if (!state.nodes.has(effect.into.identifier)) {
state.create(effect.into, {kind: 'Assign'});
state.create(effect.into, {kind: 'Object'});
}
state.assign(index++, effect.from, effect.into);
} else if (effect.kind === 'Alias') {
@@ -474,99 +474,6 @@ export function inferMutationAliasingRanges(
}
}
const tracked: Array<Place> = [];
for (const param of [...fn.params, ...fn.context, fn.returns]) {
const place = param.kind === 'Identifier' ? param : param.place;
tracked.push(place);
}
const returned: Set<Node> = new Set();
const queue: Array<Node> = [state.nodes.get(fn.returns.identifier)!];
const seen: Set<Node> = new Set();
while (queue.length !== 0) {
const node = queue.pop()!;
if (seen.has(node)) {
continue;
}
seen.add(node);
for (const id of node.aliases.keys()) {
queue.push(state.nodes.get(id)!);
}
for (const id of node.createdFrom.keys()) {
queue.push(state.nodes.get(id)!);
}
if (node.id.id === fn.returns.identifier.id) {
continue;
}
switch (node.value.kind) {
case 'Assign':
case 'CreateFrom': {
break;
}
case 'Phi':
case 'Object':
case 'Function': {
returned.add(node);
break;
}
default: {
assertExhaustive(
node.value,
`Unexpected node value kind '${(node.value as any).kind}'`,
);
}
}
}
const returnedValues = [...returned];
if (
returnedValues.length === 1 &&
returnedValues[0].value.kind === 'Object' &&
tracked.some(place => place.identifier.id === returnedValues[0].id.id)
) {
const from = tracked.find(
place => place.identifier.id === returnedValues[0].id.id,
)!;
functionEffects.push({
kind: 'Assign',
from,
into: fn.returns,
});
} else if (
returnedValues.length === 1 &&
returnedValues[0].value.kind === 'Function'
) {
const outerContext = new Set(fn.context.map(p => p.identifier.id));
const effect = returnedValues[0].value.effect;
functionEffects.push({
kind: 'CreateFunction',
function: {
...effect.function,
loweredFunc: {
func: {
...effect.function.loweredFunc.func,
context: effect.function.loweredFunc.func.context.filter(p =>
outerContext.has(p.identifier.id),
),
},
},
},
captures: effect.captures.filter(p => outerContext.has(p.identifier.id)),
into: fn.returns,
});
} else {
const returns = fn.returns.identifier;
functionEffects.push({
kind: 'Create',
into: fn.returns,
value: isPrimitiveType(returns)
? ValueKind.Primitive
: isJsxType(returns.type)
? ValueKind.Frozen
: ValueKind.Mutable,
reason: ValueReason.KnownReturnSignature,
});
}
/**
* Part 3
* Finish populating the externally visible effects. Above we bubble-up the side effects
@@ -574,12 +481,28 @@ export function inferMutationAliasingRanges(
* Here we populate an effect to create the return value as well as populating alias/capture
* effects for how data flows between the params, context vars, and return.
*/
const returns = fn.returns.identifier;
functionEffects.push({
kind: 'Create',
into: fn.returns,
value: isPrimitiveType(returns)
? ValueKind.Primitive
: isJsxType(returns.type)
? ValueKind.Frozen
: ValueKind.Mutable,
reason: ValueReason.KnownReturnSignature,
});
/**
* Determine precise data-flow effects by simulating transitive mutations of the params/
* captures and seeing what other params/context variables are affected. Anything that
* would be transitively mutated needs a capture relationship.
*/
const tracked: Array<Place> = [];
const ignoredErrors = new CompilerError();
for (const param of [...fn.params, ...fn.context, fn.returns]) {
const place = param.kind === 'Identifier' ? param : param.place;
tracked.push(place);
}
for (const into of tracked) {
const mutationIndex = index++;
state.mutate(
@@ -665,14 +588,9 @@ type Node = {
lastMutated: number;
mutationReason: MutationReason | null;
value:
| {kind: 'Assign'}
| {kind: 'CreateFrom'}
| {kind: 'Object'}
| {kind: 'Phi'}
| {
kind: 'Function';
effect: Extract<AliasingEffect, {kind: 'CreateFunction'}>;
};
| {kind: 'Function'; function: HIRFunction};
};
class AliasingState {
nodes: Map<Identifier, Node> = new Map();
@@ -694,7 +612,7 @@ class AliasingState {
}
createFrom(index: number, from: Place, into: Place): void {
this.create(into, {kind: 'CreateFrom'});
this.create(into, {kind: 'Object'});
const fromNode = this.nodes.get(from.identifier);
const toNode = this.nodes.get(into.identifier);
if (fromNode == null || toNode == null) {
@@ -756,10 +674,7 @@ class AliasingState {
continue;
}
if (node.value.kind === 'Function') {
appendFunctionErrors(
errors,
node.value.effect.function.loweredFunc.func,
);
appendFunctionErrors(errors, node.value.function);
}
for (const [alias, when] of node.createdFrom) {
if (when >= index) {
@@ -823,10 +738,7 @@ class AliasingState {
node.transitive == null &&
node.local == null
) {
appendFunctionErrors(
errors,
node.value.effect.function.loweredFunc.func,
);
appendFunctionErrors(errors, node.value.function);
}
if (transitive) {
if (node.transitive == null || node.transitive.kind < kind) {

View File

@@ -17,6 +17,7 @@ import {
GeneratedSource,
Identifier,
IdentifierId,
InstructionId,
InstructionValue,
ManualMemoDependency,
Place,
@@ -109,6 +110,7 @@ type ManualMemoBlockState = {
*/
depsFromSource: Array<ManualMemoDependency> | null;
manualMemoId: number;
start: InstructionId;
};
type VisitorState = {
@@ -234,6 +236,8 @@ function validateInferredDep(
validDepsInMemoBlock: Array<ManualMemoDependency>,
errorState: CompilerError,
memoLocation: SourceLocation,
memoStartInstruction: InstructionId,
scopes: Set<ScopeId>,
): void {
let normalizedDep: ManualMemoDependency;
const maybeNormalizedRoot = temporaries.get(dep.identifier.id);
@@ -271,6 +275,13 @@ function validateInferredDep(
return;
}
}
if (
dep.identifier.mutableRange.start > memoStartInstruction &&
!isUnmemoized(dep.identifier, scopes)
) {
return;
}
let errorDiagnostic: CompareDependencyResult | null = null;
for (const originalDep of validDepsInMemoBlock) {
const compareResult = compareDeps(normalizedDep, originalDep);
@@ -433,6 +444,8 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
state.manualMemoState.depsFromSource,
state.errors,
state.manualMemoState.loc,
state.manualMemoState.start,
this.scopes,
);
}
}
@@ -508,6 +521,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
depsFromSource,
manualMemoId: value.manualMemoId,
reassignments: new Map(),
start: instruction.id,
};
/**

View File

@@ -0,0 +1,53 @@
## Input
```javascript
// @eslintSuppressionRules:[]
// The suppression here shouldn't cause compilation to get skipped
// Previously we had a bug where an empty list of suppressions would
// create a regexp that matched any suppression
function Component(props) {
'use forget';
// eslint-disable-next-line foo/not-react-related
return <div>{props.text}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{text: 'Hello'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @eslintSuppressionRules:[]
// The suppression here shouldn't cause compilation to get skipped
// Previously we had a bug where an empty list of suppressions would
// create a regexp that matched any suppression
function Component(props) {
"use forget";
const $ = _c(2);
let t0;
if ($[0] !== props.text) {
t0 = <div>{props.text}</div>;
$[0] = props.text;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ text: "Hello" }],
};
```
### Eval output
(kind: ok) <div>Hello</div>

View File

@@ -0,0 +1,15 @@
// @eslintSuppressionRules:[]
// The suppression here shouldn't cause compilation to get skipped
// Previously we had a bug where an empty list of suppressions would
// create a regexp that matched any suppression
function Component(props) {
'use forget';
// eslint-disable-next-line foo/not-react-related
return <div>{props.text}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{text: 'Hello'}],
};

View File

@@ -1,7 +0,0 @@
function Component() {
const f = () => () => {
global.property = true;
};
f()();
return <div>Ooops</div>;
}

View File

@@ -0,0 +1,59 @@
## Input
```javascript
// @validatePreserveExistingMemoizationGuarantees
/**
* Repro from https://github.com/facebook/react/issues/34262
*
* The compiler memoizes more precisely than the original code, with two reactive scopes:
* - One for `transform(input)` with `input` as dep
* - One for `{value}` with `value` as dep
*
* When we validate preserving manual memoization we incorrectly reject this, because
* the original memoization had `object` depending on `input` but our scope depends on
* `value`.
*
* This fixture adds a later potential mutation, which extends the scope and should
* fail validation. This confirms that even though we allow the dependency to diverge,
* we still check that the output value is memoized.
*/
function useInputValue(input) {
const object = React.useMemo(() => {
const {value} = transform(input);
return {value};
}, [input]);
mutate(object);
return object;
}
```
## Error
```
Found 1 error:
Memoization: Compilation skipped because existing memoization could not be preserved
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.
error.repro-preserve-memoization-inner-destructured-value-mistaken-as-dependency-later-mutation.ts:19:17
17 | */
18 | function useInputValue(input) {
> 19 | const object = React.useMemo(() => {
| ^^^^^^^^^^^^^^^^^^^^^
> 20 | const {value} = transform(input);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 21 | return {value};
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 22 | }, [input]);
| ^^^^^^^^^^^^^^ Could not preserve existing memoization
23 | mutate(object);
24 | return object;
25 | }
```

View File

@@ -0,0 +1,25 @@
// @validatePreserveExistingMemoizationGuarantees
/**
* Repro from https://github.com/facebook/react/issues/34262
*
* The compiler memoizes more precisely than the original code, with two reactive scopes:
* - One for `transform(input)` with `input` as dep
* - One for `{value}` with `value` as dep
*
* When we validate preserving manual memoization we incorrectly reject this, because
* the original memoization had `object` depending on `input` but our scope depends on
* `value`.
*
* This fixture adds a later potential mutation, which extends the scope and should
* fail validation. This confirms that even though we allow the dependency to diverge,
* we still check that the output value is memoized.
*/
function useInputValue(input) {
const object = React.useMemo(() => {
const {value} = transform(input);
return {value};
}, [input]);
mutate(object);
return object;
}

View File

@@ -0,0 +1,64 @@
## Input
```javascript
// @validatePreserveExistingMemoizationGuarantees
import {identity, Stringify, useHook} from 'shared-runtime';
/**
* Repro from https://github.com/facebook/react/issues/34262
*
* The compiler memoizes more precisely than the original code, with two reactive scopes:
* - One for `transform(input)` with `input` as dep
* - One for `{value}` with `value` as dep
*
* When we validate preserving manual memoization we incorrectly reject this, because
* the original memoization had `object` depending on `input` but our scope depends on
* `value`.
*/
function useInputValue(input) {
// Conflate the `identity(input, x)` call with something outside the useMemo,
// to try and break memoization of `value`. This gets correctly flagged since
// the dependency is being mutated
let x = {};
useHook();
const object = React.useMemo(() => {
const {value} = identity(input, x);
return {value};
}, [input, x]);
return object;
}
function Component() {
return <Stringify value={useInputValue({value: 42}).value} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
## Error
```
Found 1 error:
Memoization: Compilation skipped because existing memoization could not be preserved
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This dependency may be mutated later, which could cause the value to change unexpectedly.
error.repro-preserve-memoization-inner-destructured-value-mistaken-as-dependency-mutated-dep.ts:25:13
23 | const {value} = identity(input, x);
24 | return {value};
> 25 | }, [input, x]);
| ^ This dependency may be modified later
26 | return object;
27 | }
28 |
```

View File

@@ -0,0 +1,36 @@
// @validatePreserveExistingMemoizationGuarantees
import {identity, Stringify, useHook} from 'shared-runtime';
/**
* Repro from https://github.com/facebook/react/issues/34262
*
* The compiler memoizes more precisely than the original code, with two reactive scopes:
* - One for `transform(input)` with `input` as dep
* - One for `{value}` with `value` as dep
*
* When we validate preserving manual memoization we incorrectly reject this, because
* the original memoization had `object` depending on `input` but our scope depends on
* `value`.
*/
function useInputValue(input) {
// Conflate the `identity(input, x)` call with something outside the useMemo,
// to try and break memoization of `value`. This gets correctly flagged since
// the dependency is being mutated
let x = {};
useHook();
const object = React.useMemo(() => {
const {value} = identity(input, x);
return {value};
}, [input, x]);
return object;
}
function Component() {
return <Stringify value={useInputValue({value: 42}).value} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};

View File

@@ -0,0 +1,109 @@
## Input
```javascript
// @validatePreserveExistingMemoizationGuarantees
import {identity, Stringify} from 'shared-runtime';
/**
* Repro from https://github.com/facebook/react/issues/34262
*
* The compiler memoizes more precisely than the original code, with two reactive scopes:
* - One for `transform(input)` with `input` as dep
* - One for `{value}` with `value` as dep
*
* Previously ValidatePreservedManualMemoization rejected this input, because
* the original memoization had `object` depending on `input` but we split the scope per above,
* and the scope for the FinishMemoize instruction is the second scope which depends on `value`
*/
function useInputValue(input) {
const object = React.useMemo(() => {
const {value} = identity(input);
return {value};
}, [input]);
return object;
}
function Component() {
return <Stringify value={useInputValue({value: 42}).value} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees
import { identity, Stringify } from "shared-runtime";
/**
* Repro from https://github.com/facebook/react/issues/34262
*
* The compiler memoizes more precisely than the original code, with two reactive scopes:
* - One for `transform(input)` with `input` as dep
* - One for `{value}` with `value` as dep
*
* Previously ValidatePreservedManualMemoization rejected this input, because
* the original memoization had `object` depending on `input` but we split the scope per above,
* and the scope for the FinishMemoize instruction is the second scope which depends on `value`
*/
function useInputValue(input) {
const $ = _c(4);
let t0;
if ($[0] !== input) {
t0 = identity(input);
$[0] = input;
$[1] = t0;
} else {
t0 = $[1];
}
const { value } = t0;
let t1;
if ($[2] !== value) {
t1 = { value };
$[2] = value;
$[3] = t1;
} else {
t1 = $[3];
}
const object = t1;
return object;
}
function Component() {
const $ = _c(3);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = { value: 42 };
$[0] = t0;
} else {
t0 = $[0];
}
const t1 = useInputValue(t0);
let t2;
if ($[1] !== t1.value) {
t2 = <Stringify value={t1.value} />;
$[1] = t1.value;
$[2] = t2;
} else {
t2 = $[2];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
### Eval output
(kind: ok) <div>{"value":42}</div>

View File

@@ -0,0 +1,31 @@
// @validatePreserveExistingMemoizationGuarantees
import {identity, Stringify} from 'shared-runtime';
/**
* Repro from https://github.com/facebook/react/issues/34262
*
* The compiler memoizes more precisely than the original code, with two reactive scopes:
* - One for `transform(input)` with `input` as dep
* - One for `{value}` with `value` as dep
*
* Previously ValidatePreservedManualMemoization rejected this input, because
* the original memoization had `object` depending on `input` but we split the scope per above,
* and the scope for the FinishMemoize instruction is the second scope which depends on `value`
*/
function useInputValue(input) {
const object = React.useMemo(() => {
const {value} = identity(input);
return {value};
}, [input]);
return object;
}
function Component() {
return <Stringify value={useInputValue({value: 42}).value} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};

View File

@@ -55,7 +55,7 @@ import { makeArray, Stringify, useIdentity } from "shared-runtime";
*/
function Foo(t0) {
"use memo";
const $ = _c(5);
const $ = _c(3);
const { b } = t0;
const fnFactory = () => () => {
@@ -66,26 +66,18 @@ function Foo(t0) {
useIdentity();
const fn = fnFactory();
let t1;
if ($[0] !== b) {
t1 = makeArray(b);
$[0] = b;
$[1] = t1;
} else {
t1 = $[1];
}
const arr = t1;
const arr = makeArray(b);
fn(arr);
let t2;
if ($[2] !== arr || $[3] !== myVar) {
t2 = <Stringify cb={myVar} value={arr} shouldInvokeFns={true} />;
$[2] = arr;
$[3] = myVar;
$[4] = t2;
let t1;
if ($[0] !== arr || $[1] !== myVar) {
t1 = <Stringify cb={myVar} value={arr} shouldInvokeFns={true} />;
$[0] = arr;
$[1] = myVar;
$[2] = t1;
} else {
t2 = $[4];
t1 = $[2];
}
return t2;
return t1;
}
function _temp2() {
return console.log("b");