Compare commits

..

15 Commits

Author SHA1 Message Date
Joe Savona
49afd49939 [compiler][wip] Remove old mutation/aliasing implementation 2025-08-15 15:10:39 -07:00
Joseph Savona
724b324b96 [compiler] Add hint to name variables with "Ref" suffix (#34125)
If you have a ref that the compiler doesn't know is a ref (say, a value
returned from a custom hook) and try to assign its `.current = ...`, we
currently fail with a generic error that hook return values are not
mutable. However, an assignment to `.current` specifically is a very
strong hint that the value is likely to be a ref. So in this PR, we
track the reason for the mutation and if it ends up being an error, we
use it to show an additional hint to the user. See the fixture for an
example of the message.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34125).
* #34126
* __->__ #34125
* #34124
2025-08-15 15:05:29 -07:00
Jack Pope
45a6532a08 Add compareDocumentPosition to Fabric FragmentInstance (#34103)
Stacked on https://github.com/facebook/react/pull/34069

Same basic semantics as the react-dom for determining document position
of a Fragment compared to a given node. It's simpler here because we
don't have to deal with inserted nodes or portals. So we can skip a
bunch of the validation logic.

The logic for handling empty fragments is the same so I've split out
`compareDocumentPositionForEmptyFragment` into a shared module. There
doesn't seem to be a great place to put shared DOM logic between Fabric
and DOM configs at the moment. There may be more of this coming as we
add more and more DOM APIs to RN.

For testing I've written Fantom tests internally which pass the basic
cases on this build. The renderer we have configured for Fabric tests in
the repo doesn't support the Element APIs we need like
`compareDocumentPosition`.
2025-08-15 15:07:42 -04:00
Sebastian "Sebbie" Silbermann
8dba9311e5 [DevTools] Handle fallback unmount in Suspense update path (#34199) 2025-08-15 19:40:35 +02:00
Sebastian "Sebbie" Silbermann
2d98b45d92 [DevTools] Fix Suspense boundaries always being marked as not suspended (#34206) 2025-08-15 19:39:59 +02:00
Sebastian Markbåge
2ba7b07ce1 [DevTools] Compute a min and max range for the currently selected suspense boundary (#34201)
This computes a min and max range for the whole suspense boundary even
when selecting a single component so that each component in a boundary
has a consistent range.

The start of this range is the earliest start of I/O in that boundary or
the end of the previous suspense boundary, whatever is earlier. If the
end of the previous boundary would make the range large, then we cap it
since it's likely that the other boundary was just an independent
render.

The end of the range is the latest end of I/O in that boundary. If this
is smaller than the end of the previous boundary plus the 300ms
throttle, then we extend the end. This visualizes what throttling could
potentially do if the previous boundary committed right at its end. Ofc,
it might not have committed exactly at that time in this render. So this
is just showing a potential throttle that could happen. To see actual
throttle, you look in the Performance Track.

<img width="661" height="353" alt="Screenshot 2025-08-14 at 12 41 43 AM"
src="https://github.com/user-attachments/assets/b0155e5e-a83f-400c-a6b9-5c38a9d8a34f"
/>

We could come up with some annotation to highlight that this is eligible
to be throttled in this case. If the lines don't extend to the edge,
then it's likely it was throttled.
2025-08-15 13:34:07 -04:00
Jack Pope
a96a0f3903 Fix fragmentInstance#compareDocumentPosition nesting and portal cases (#34069)
Found a couple of issues while integrating
FragmentInstance#compareDocumentPosition into Fabric.

1. Basic checks of nested host instances were inaccurate. For example,
checking the first child of the first child of the Fragment would not
return CONTAINED_BY.
2. Then fixing that logic exposed issues with Portals. The DOM
positioning relied on the assumption that the first and last top-level
children were in the same order as the Fiber tree. I added additional
checks against the parent's position in the DOM, and special cased a
portaled Fragment by getting its DOM parent from the child instance,
rather than taking the instance from the Fiber return. This should be
accurate in more cases. Though its still a guess and I'm not sure yet
I've covered every variation of this. Portals are hard to deal with and
we may end up having to push more results towards
IMPLEMENTATION_SPECIFIC if accuracy is an issue.
2025-08-15 12:14:23 -04:00
Sebastian "Sebbie" Silbermann
02a8811864 [SuspenseTab] Scuffed version of Suspense rects (#34188) 2025-08-14 18:24:41 +02:00
Sebastian "Sebbie" Silbermann
379a083b9a Include stack of cause in React instrumentation errors (#34198) 2025-08-13 19:18:02 +02:00
Sebastian "Sebbie" Silbermann
534bed5fa7 Use yarn run in Flight fixture (#34197) 2025-08-13 15:49:44 +02:00
Sebastian Markbåge
db06f6b751 [DevTools] Track virtual debug info from suspensey images (#34181)
Same as #34166 but for Suspensey images.

The trick here is to check the `SuspenseyImagesMode` since not all
versions of React and not all subtrees will have Suspensey images
enabled yet.

The other trick is to read back from `currentSrc` to get the image url
we actually resolved to in this case. Similar to how for Suspensey CSS
we check if the media query would've matched.

<img width="591" height="205" alt="Screenshot 2025-08-11 at 9 32 56 PM"
src="https://github.com/user-attachments/assets/ac98785c-d3e0-407c-84e0-c27f86c0ecac"
/>
2025-08-13 09:26:21 -04:00
Sebastian "Sebbie" Silbermann
9433fe357a Fail tests if unasserted console calls contain undefined (#34191) 2025-08-13 08:48:04 +02:00
Sebastian "Sebbie" Silbermann
0032b2a3ee [Flight] Log error if prod elements are rendered (#34189) 2025-08-13 08:47:09 +02:00
Sebastian Markbåge
14c50e344c [DevTools] Use Visually Lighter Skeletons (#34185)
The skeletons right now are too jarring because they're visually heavier
than the content that comes in later. This makes them draw attention to
themselves as flashing things.

A good skeleton and loading indicator should ideally start as invisible
as possible and then gradually become more visible the longer time
passes so that if it loads quickly then it was never much visible at
all.

Even at its max it should never be heavier weight than the final content
so that it visually reverts into lesser. Another rule of thumb is that
it should be as close as possible to the final content in size but if
it's unknown it should always be smaller than the final content so that
the content grows into its slot rather than the slot contracting.

This makes the skeleton fade from invisible into the dimmest color just
as a subtle hint that something is still loading.

I also added a missing skeleton since the stack traces in rendered by
can now suspend while source mapping.

The other tweak I did is use disabled buttons in all the cases where we
load the ability to enable a button. This is more subtle and if you
hover over you can see why it's still disabled. Rather than flashing the
button each time you change element.
2025-08-12 23:10:31 -04:00
Sebastian Markbåge
f1222f7652 [Fiber] Don't bind retry listener if it's in the cache (#34183)
This did an unnecessary bind allocation even if there's cache hit.
2025-08-12 21:42:24 -04:00
91 changed files with 1511 additions and 5110 deletions

View File

@@ -33,9 +33,7 @@ import {findContextIdentifiers} from '../HIR/FindContextIdentifiers';
import {
analyseFunctions,
dropManualMemoization,
inferMutableRanges,
inferReactivePlaces,
inferReferenceEffects,
inlineImmediatelyInvokedFunctionExpressions,
inferEffectDependencies,
} from '../Inference';
@@ -100,7 +98,6 @@ import {outlineJSX} from '../Optimization/OutlineJsx';
import {optimizePropsMethodCalls} from '../Optimization/OptimizePropsMethodCalls';
import {transformFire} from '../Transform';
import {validateNoImpureFunctionsInRender} from '../Validation/ValidateNoImpureFunctionsInRender';
import {CompilerError} from '..';
import {validateStaticComponents} from '../Validation/ValidateStaticComponents';
import {validateNoFreezingKnownMutableFunctions} from '../Validation/ValidateNoFreezingKnownMutableFunctions';
import {inferMutationAliasingEffects} from '../Inference/InferMutationAliasingEffects';
@@ -229,26 +226,12 @@ function runWithEnvironment(
analyseFunctions(hir);
log({kind: 'hir', name: 'AnalyseFunctions', value: hir});
if (!env.config.enableNewMutationAliasingModel) {
const fnEffectErrors = inferReferenceEffects(hir);
if (env.isInferredMemoEnabled) {
if (fnEffectErrors.length > 0) {
CompilerError.throw(fnEffectErrors[0]);
}
const mutabilityAliasingErrors = inferMutationAliasingEffects(hir);
log({kind: 'hir', name: 'InferMutationAliasingEffects', value: hir});
if (env.isInferredMemoEnabled) {
if (mutabilityAliasingErrors.isErr()) {
throw mutabilityAliasingErrors.unwrapErr();
}
log({kind: 'hir', name: 'InferReferenceEffects', value: hir});
} else {
const mutabilityAliasingErrors = inferMutationAliasingEffects(hir);
log({kind: 'hir', name: 'InferMutationAliasingEffects', value: hir});
if (env.isInferredMemoEnabled) {
if (mutabilityAliasingErrors.isErr()) {
throw mutabilityAliasingErrors.unwrapErr();
}
}
}
if (!env.config.enableNewMutationAliasingModel) {
validateLocalsNotReassignedAfterRender(hir);
}
// Note: Has to come after infer reference effects because "dead" code may still affect inference
@@ -263,20 +246,15 @@ function runWithEnvironment(
pruneMaybeThrows(hir);
log({kind: 'hir', name: 'PruneMaybeThrows', value: hir});
if (!env.config.enableNewMutationAliasingModel) {
inferMutableRanges(hir);
log({kind: 'hir', name: 'InferMutableRanges', value: hir});
} else {
const mutabilityAliasingErrors = inferMutationAliasingRanges(hir, {
isFunctionExpression: false,
});
log({kind: 'hir', name: 'InferMutationAliasingRanges', value: hir});
if (env.isInferredMemoEnabled) {
if (mutabilityAliasingErrors.isErr()) {
throw mutabilityAliasingErrors.unwrapErr();
}
validateLocalsNotReassignedAfterRender(hir);
const mutabilityAliasingRangeErrors = inferMutationAliasingRanges(hir, {
isFunctionExpression: false,
});
log({kind: 'hir', name: 'InferMutationAliasingRanges', value: hir});
if (env.isInferredMemoEnabled) {
if (mutabilityAliasingRangeErrors.isErr()) {
throw mutabilityAliasingRangeErrors.unwrapErr();
}
validateLocalsNotReassignedAfterRender(hir);
}
if (env.isInferredMemoEnabled) {
@@ -308,12 +286,7 @@ function runWithEnvironment(
validateNoImpureFunctionsInRender(hir).unwrap();
}
if (
env.config.validateNoFreezingKnownMutableFunctions ||
env.config.enableNewMutationAliasingModel
) {
validateNoFreezingKnownMutableFunctions(hir).unwrap();
}
validateNoFreezingKnownMutableFunctions(hir).unwrap();
}
inferReactivePlaces(hir);

View File

@@ -250,11 +250,6 @@ export const EnvironmentConfigSchema = z.object({
*/
flowTypeProvider: z.nullable(z.function().args(z.string())).default(null),
/**
* Enable a new model for mutability and aliasing inference
*/
enableNewMutationAliasingModel: z.boolean().default(true),
/**
* Enables inference of optional dependency chains. Without this flag
* a property chain such as `props?.items?.foo` will infer as a dep on

View File

@@ -50,7 +50,7 @@ export type AliasingEffect =
/**
* Mutate the value and any direct aliases (not captures). Errors if the value is not mutable.
*/
| {kind: 'Mutate'; value: Place}
| {kind: 'Mutate'; value: Place; reason?: MutationReason | null}
/**
* Mutate the value and any direct aliases (not captures), but only if the value is known mutable.
* This should be rare.
@@ -174,6 +174,8 @@ export type AliasingEffect =
place: Place;
};
export type MutationReason = {kind: 'AssignCurrentProperty'};
export function hashEffect(effect: AliasingEffect): string {
switch (effect.kind) {
case 'Apply': {

View File

@@ -6,20 +6,10 @@
*/
import {CompilerError} from '../CompilerError';
import {
Effect,
HIRFunction,
Identifier,
IdentifierId,
LoweredFunction,
isRefOrRefValue,
makeInstructionId,
} from '../HIR';
import {Effect, HIRFunction, IdentifierId, makeInstructionId} from '../HIR';
import {deadCodeElimination} from '../Optimization';
import {inferReactiveScopeVariables} from '../ReactiveScopes';
import {rewriteInstructionKindsBasedOnReassignment} from '../SSA';
import {inferMutableRanges} from './InferMutableRanges';
import inferReferenceEffects from './InferReferenceEffects';
import {assertExhaustive} from '../Utils/utils';
import {inferMutationAliasingEffects} from './InferMutationAliasingEffects';
import {inferMutationAliasingRanges} from './InferMutationAliasingRanges';
@@ -30,12 +20,7 @@ export default function analyseFunctions(func: HIRFunction): void {
switch (instr.value.kind) {
case 'ObjectMethod':
case 'FunctionExpression': {
if (!func.env.config.enableNewMutationAliasingModel) {
lower(instr.value.loweredFunc.func);
infer(instr.value.loweredFunc);
} else {
lowerWithMutationAliasing(instr.value.loweredFunc.func);
}
lowerWithMutationAliasing(instr.value.loweredFunc.func);
/**
* Reset mutable range for outer inferReferenceEffects
@@ -140,58 +125,3 @@ function lowerWithMutationAliasing(fn: HIRFunction): void {
value: fn,
});
}
function lower(func: HIRFunction): void {
analyseFunctions(func);
inferReferenceEffects(func, {isFunctionExpression: true});
deadCodeElimination(func);
inferMutableRanges(func);
rewriteInstructionKindsBasedOnReassignment(func);
inferReactiveScopeVariables(func);
func.env.logger?.debugLogIRs?.({
kind: 'hir',
name: 'AnalyseFunction (inner)',
value: func,
});
}
function infer(loweredFunc: LoweredFunction): void {
for (const operand of loweredFunc.func.context) {
const identifier = operand.identifier;
CompilerError.invariant(operand.effect === Effect.Unknown, {
reason:
'[AnalyseFunctions] Expected Function context effects to not have been set',
loc: operand.loc,
});
if (isRefOrRefValue(identifier)) {
/*
* TODO: this is a hack to ensure we treat functions which reference refs
* as having a capture and therefore being considered mutable. this ensures
* the function gets a mutable range which accounts for anywhere that it
* could be called, and allows us to help ensure it isn't called during
* render
*/
operand.effect = Effect.Capture;
} else if (isMutatedOrReassigned(identifier)) {
/**
* Reflects direct reassignments, PropertyStores, and ConditionallyMutate
* (directly or through maybe-aliases)
*/
operand.effect = Effect.Capture;
} else {
operand.effect = Effect.Read;
}
}
}
function isMutatedOrReassigned(id: Identifier): boolean {
/*
* This check checks for mutation and reassingnment, so the usual check for
* mutation (ie, `mutableRange.end - mutableRange.start > 1`) isn't quite
* enough.
*
* We need to track re-assignments in context refs as we need to reflect the
* re-assignment back to the captured refs.
*/
return id.mutableRange.end > id.mutableRange.start;
}

View File

@@ -1,134 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
Effect,
HIRFunction,
Identifier,
isMutableEffect,
isRefOrRefLikeMutableType,
makeInstructionId,
} from '../HIR/HIR';
import {eachInstructionValueOperand} from '../HIR/visitors';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
import DisjointSet from '../Utils/DisjointSet';
/**
* If a function captures a mutable value but never gets called, we don't infer a
* mutable range for that function. This means that we also don't alias the function
* with its mutable captures.
*
* This case is tricky, because we don't generally know for sure what is a mutation
* and what may just be a normal function call. For example:
*
* ```
* hook useFoo() {
* const x = makeObject();
* return () => {
* return readObject(x); // could be a mutation!
* }
* }
* ```
*
* If we pessimistically assume that all such cases are mutations, we'd have to group
* lots of memo scopes together unnecessarily. However, if there is definitely a mutation:
*
* ```
* hook useFoo(createEntryForKey) {
* const cache = new WeakMap();
* return (key) => {
* let entry = cache.get(key);
* if (entry == null) {
* entry = createEntryForKey(key);
* cache.set(key, entry); // known mutation!
* }
* return entry;
* }
* }
* ```
*
* Then we have to ensure that the function and its mutable captures alias together and
* end up in the same scope. However, aliasing together isn't enough if the function
* and operands all have empty mutable ranges (end = start + 1).
*
* This pass finds function expressions and object methods that have an empty mutable range
* and known-mutable operands which also don't have a mutable range, and ensures that the
* function and those operands are aliased together *and* that their ranges are updated to
* end after the function expression. This is sufficient to ensure that a reactive scope is
* created for the alias set.
*/
export function inferAliasForUncalledFunctions(
fn: HIRFunction,
aliases: DisjointSet<Identifier>,
): void {
for (const block of fn.body.blocks.values()) {
instrs: for (const instr of block.instructions) {
const {lvalue, value} = instr;
if (
value.kind !== 'ObjectMethod' &&
value.kind !== 'FunctionExpression'
) {
continue;
}
/*
* If the function is known to be mutated, we will have
* already aliased any mutable operands with it
*/
const range = lvalue.identifier.mutableRange;
if (range.end > range.start + 1) {
continue;
}
/*
* If the function already has operands with an active mutable range,
* then we don't need to do anything — the function will have already
* been visited and included in some mutable alias set. This case can
* also occur due to visiting the same function in an earlier iteration
* of the outer fixpoint loop.
*/
for (const operand of eachInstructionValueOperand(value)) {
if (isMutable(instr, operand)) {
continue instrs;
}
}
const operands: Set<Identifier> = new Set();
for (const effect of value.loweredFunc.func.effects ?? []) {
if (effect.kind !== 'ContextMutation') {
continue;
}
/*
* We're looking for known-mutations only, so we look at the effects
* rather than function context
*/
if (effect.effect === Effect.Store || effect.effect === Effect.Mutate) {
for (const operand of effect.places) {
/*
* It's possible that function effect analysis thinks there was a context mutation,
* but then InferReferenceEffects figures out some operands are globals and therefore
* creates a non-mutable effect for those operands.
* We should change InferReferenceEffects to swap the ContextMutation for a global
* mutation in that case, but for now we just filter them out here
*/
if (
isMutableEffect(operand.effect, operand.loc) &&
!isRefOrRefLikeMutableType(operand.identifier.type)
) {
operands.add(operand.identifier);
}
}
}
}
if (operands.size !== 0) {
operands.add(lvalue.identifier);
aliases.union([...operands]);
// Update mutable ranges, if the ranges are empty then a reactive scope isn't created
for (const operand of operands) {
operand.mutableRange.end = makeInstructionId(instr.id + 1);
}
}
}
}
}

View File

@@ -1,68 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
HIRFunction,
Identifier,
Instruction,
isPrimitiveType,
Place,
} from '../HIR/HIR';
import DisjointSet from '../Utils/DisjointSet';
export type AliasSet = Set<Identifier>;
export function inferAliases(func: HIRFunction): DisjointSet<Identifier> {
const aliases = new DisjointSet<Identifier>();
for (const [_, block] of func.body.blocks) {
for (const instr of block.instructions) {
inferInstr(instr, aliases);
}
}
return aliases;
}
function inferInstr(
instr: Instruction,
aliases: DisjointSet<Identifier>,
): void {
const {lvalue, value: instrValue} = instr;
let alias: Place | null = null;
switch (instrValue.kind) {
case 'LoadLocal':
case 'LoadContext': {
if (isPrimitiveType(instrValue.place.identifier)) {
return;
}
alias = instrValue.place;
break;
}
case 'StoreLocal':
case 'StoreContext': {
alias = instrValue.value;
break;
}
case 'Destructure': {
alias = instrValue.value;
break;
}
case 'ComputedLoad':
case 'PropertyLoad': {
alias = instrValue.object;
break;
}
case 'TypeCastExpression': {
alias = instrValue.value;
break;
}
default:
return;
}
aliases.union([lvalue.identifier, alias.identifier]);
}

View File

@@ -1,27 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {HIRFunction, Identifier} from '../HIR/HIR';
import DisjointSet from '../Utils/DisjointSet';
export function inferAliasForPhis(
func: HIRFunction,
aliases: DisjointSet<Identifier>,
): void {
for (const [_, block] of func.body.blocks) {
for (const phi of block.phis) {
const isPhiMutatedAfterCreation: boolean =
phi.place.identifier.mutableRange.end >
(block.instructions.at(0)?.id ?? block.terminal.id);
if (isPhiMutatedAfterCreation) {
for (const [, operand] of phi.operands) {
aliases.union([phi.place.identifier, operand.identifier]);
}
}
}
}
}

View File

@@ -1,68 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
Effect,
HIRFunction,
Identifier,
InstructionId,
Place,
} from '../HIR/HIR';
import {
eachInstructionLValue,
eachInstructionValueOperand,
} from '../HIR/visitors';
import DisjointSet from '../Utils/DisjointSet';
export function inferAliasForStores(
func: HIRFunction,
aliases: DisjointSet<Identifier>,
): void {
for (const [_, block] of func.body.blocks) {
for (const instr of block.instructions) {
const {value, lvalue} = instr;
const isStore =
lvalue.effect === Effect.Store ||
/*
* Some typed functions annotate callees or arguments
* as Effect.Store.
*/
![...eachInstructionValueOperand(value)].every(
operand => operand.effect !== Effect.Store,
);
if (!isStore) {
continue;
}
for (const operand of eachInstructionLValue(instr)) {
maybeAlias(aliases, lvalue, operand, instr.id);
}
for (const operand of eachInstructionValueOperand(value)) {
if (
operand.effect === Effect.Capture ||
operand.effect === Effect.Store
) {
maybeAlias(aliases, lvalue, operand, instr.id);
}
}
}
}
}
function maybeAlias(
aliases: DisjointSet<Identifier>,
lvalue: Place,
rvalue: Place,
id: InstructionId,
): void {
if (
lvalue.identifier.mutableRange.end > id + 1 ||
rvalue.identifier.mutableRange.end > id
) {
aliases.union([lvalue.identifier, rvalue.identifier]);
}
}

View File

@@ -1,351 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
CompilerError,
CompilerErrorDetailOptions,
ErrorSeverity,
ValueKind,
} from '..';
import {
AbstractValue,
BasicBlock,
Effect,
Environment,
FunctionEffect,
Instruction,
InstructionValue,
Place,
ValueReason,
getHookKind,
isRefOrRefValue,
} from '../HIR';
import {eachInstructionOperand, eachTerminalOperand} from '../HIR/visitors';
import {assertExhaustive} from '../Utils/utils';
interface State {
kind(place: Place): AbstractValue;
values(place: Place): Array<InstructionValue>;
isDefined(place: Place): boolean;
}
function inferOperandEffect(state: State, place: Place): null | FunctionEffect {
const value = state.kind(place);
CompilerError.invariant(value != null, {
reason: 'Expected operand to have a kind',
loc: null,
});
switch (place.effect) {
case Effect.Store:
case Effect.Mutate: {
if (isRefOrRefValue(place.identifier)) {
break;
} else if (value.kind === ValueKind.Context) {
CompilerError.invariant(value.context.size > 0, {
reason:
"[InferFunctionEffects] Expected Context-kind value's capture list to be non-empty.",
loc: place.loc,
});
return {
kind: 'ContextMutation',
loc: place.loc,
effect: place.effect,
places: value.context,
};
} else if (
value.kind !== ValueKind.Mutable &&
// We ignore mutations of primitives since this is not a React-specific problem
value.kind !== ValueKind.Primitive
) {
let reason = getWriteErrorReason(value);
return {
kind:
value.reason.size === 1 && value.reason.has(ValueReason.Global)
? 'GlobalMutation'
: 'ReactMutation',
error: {
reason,
description:
place.identifier.name !== null &&
place.identifier.name.kind === 'named'
? `Found mutation of \`${place.identifier.name.value}\``
: null,
loc: place.loc,
suggestions: null,
severity: ErrorSeverity.InvalidReact,
},
};
}
break;
}
}
return null;
}
function inheritFunctionEffects(
state: State,
place: Place,
): Array<FunctionEffect> {
const effects = inferFunctionInstrEffects(state, place);
return effects
.flatMap(effect => {
if (effect.kind === 'GlobalMutation' || effect.kind === 'ReactMutation') {
return [effect];
} else {
const effects: Array<FunctionEffect | null> = [];
CompilerError.invariant(effect.kind === 'ContextMutation', {
reason: 'Expected ContextMutation',
loc: null,
});
/**
* Contextual effects need to be replayed against the current inference
* state, which may know more about the value to which the effect applied.
* The main cases are:
* 1. The mutated context value is _still_ a context value in the current scope,
* so we have to continue propagating the original context mutation.
* 2. The mutated context value is a mutable value in the current scope,
* so the context mutation was fine and we can skip propagating the effect.
* 3. The mutated context value is an immutable value in the current scope,
* resulting in a non-ContextMutation FunctionEffect. We propagate that new,
* more detailed effect to the current function context.
*/
for (const place of effect.places) {
if (state.isDefined(place)) {
const replayedEffect = inferOperandEffect(state, {
...place,
loc: effect.loc,
effect: effect.effect,
});
if (replayedEffect != null) {
if (replayedEffect.kind === 'ContextMutation') {
// Case 1, still a context value so propagate the original effect
effects.push(effect);
} else {
// Case 3, immutable value so propagate the more precise effect
effects.push(replayedEffect);
}
} // else case 2, local mutable value so this effect was fine
}
}
return effects;
}
})
.filter((effect): effect is FunctionEffect => effect != null);
}
function inferFunctionInstrEffects(
state: State,
place: Place,
): Array<FunctionEffect> {
const effects: Array<FunctionEffect> = [];
const instrs = state.values(place);
CompilerError.invariant(instrs != null, {
reason: 'Expected operand to have instructions',
loc: null,
});
for (const instr of instrs) {
if (
(instr.kind === 'FunctionExpression' || instr.kind === 'ObjectMethod') &&
instr.loweredFunc.func.effects != null
) {
effects.push(...instr.loweredFunc.func.effects);
}
}
return effects;
}
function operandEffects(
state: State,
place: Place,
filterRenderSafe: boolean,
): Array<FunctionEffect> {
const functionEffects: Array<FunctionEffect> = [];
const effect = inferOperandEffect(state, place);
effect && functionEffects.push(effect);
functionEffects.push(...inheritFunctionEffects(state, place));
if (filterRenderSafe) {
return functionEffects.filter(effect => !isEffectSafeOutsideRender(effect));
} else {
return functionEffects;
}
}
export function inferInstructionFunctionEffects(
env: Environment,
state: State,
instr: Instruction,
): Array<FunctionEffect> {
const functionEffects: Array<FunctionEffect> = [];
switch (instr.value.kind) {
case 'JsxExpression': {
if (instr.value.tag.kind === 'Identifier') {
functionEffects.push(...operandEffects(state, instr.value.tag, false));
}
instr.value.children?.forEach(child =>
functionEffects.push(...operandEffects(state, child, false)),
);
for (const attr of instr.value.props) {
if (attr.kind === 'JsxSpreadAttribute') {
functionEffects.push(...operandEffects(state, attr.argument, false));
} else {
functionEffects.push(...operandEffects(state, attr.place, true));
}
}
break;
}
case 'ObjectMethod':
case 'FunctionExpression': {
/**
* If this function references other functions, propagate the referenced function's
* effects to this function.
*
* ```
* let f = () => global = true;
* let g = () => f();
* g();
* ```
*
* In this example, because `g` references `f`, we propagate the GlobalMutation from
* `f` to `g`. Thus, referencing `g` in `g()` will evaluate the GlobalMutation in the outer
* function effect context and report an error. But if instead we do:
*
* ```
* let f = () => global = true;
* let g = () => f();
* useEffect(() => g(), [g])
* ```
*
* Now `g`'s effects will be discarded since they're in a useEffect.
*/
for (const operand of eachInstructionOperand(instr)) {
instr.value.loweredFunc.func.effects ??= [];
instr.value.loweredFunc.func.effects.push(
...inferFunctionInstrEffects(state, operand),
);
}
break;
}
case 'MethodCall':
case 'CallExpression': {
let callee;
if (instr.value.kind === 'MethodCall') {
callee = instr.value.property;
functionEffects.push(
...operandEffects(state, instr.value.receiver, false),
);
} else {
callee = instr.value.callee;
}
functionEffects.push(...operandEffects(state, callee, false));
let isHook = getHookKind(env, callee.identifier) != null;
for (const arg of instr.value.args) {
const place = arg.kind === 'Identifier' ? arg : arg.place;
/*
* Join the effects of the argument with the effects of the enclosing function,
* unless the we're detecting a global mutation inside a useEffect hook
*/
functionEffects.push(...operandEffects(state, place, isHook));
}
break;
}
case 'StartMemoize':
case 'FinishMemoize':
case 'LoadLocal':
case 'StoreLocal': {
break;
}
case 'StoreGlobal': {
functionEffects.push({
kind: 'GlobalMutation',
error: {
reason:
'Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)',
loc: instr.loc,
suggestions: null,
severity: ErrorSeverity.InvalidReact,
},
});
break;
}
default: {
for (const operand of eachInstructionOperand(instr)) {
functionEffects.push(...operandEffects(state, operand, false));
}
}
}
return functionEffects;
}
export function inferTerminalFunctionEffects(
state: State,
block: BasicBlock,
): Array<FunctionEffect> {
const functionEffects: Array<FunctionEffect> = [];
for (const operand of eachTerminalOperand(block.terminal)) {
functionEffects.push(...operandEffects(state, operand, true));
}
return functionEffects;
}
export function transformFunctionEffectErrors(
functionEffects: Array<FunctionEffect>,
): Array<CompilerErrorDetailOptions> {
return functionEffects.map(eff => {
switch (eff.kind) {
case 'ReactMutation':
case 'GlobalMutation': {
return eff.error;
}
case 'ContextMutation': {
return {
severity: ErrorSeverity.Invariant,
reason: `Unexpected ContextMutation in top-level function effects`,
loc: eff.loc,
};
}
default:
assertExhaustive(
eff,
`Unexpected function effect kind \`${(eff as any).kind}\``,
);
}
});
}
function isEffectSafeOutsideRender(effect: FunctionEffect): boolean {
return effect.kind === 'GlobalMutation';
}
export function getWriteErrorReason(abstractValue: AbstractValue): string {
if (abstractValue.reason.has(ValueReason.Global)) {
return 'Modifying a variable defined outside a component or hook is not allowed. Consider using an effect';
} else if (abstractValue.reason.has(ValueReason.JsxCaptured)) {
return 'Modifying a value used previously in JSX is not allowed. Consider moving the modification before the JSX';
} else if (abstractValue.reason.has(ValueReason.Context)) {
return `Modifying a value returned from 'useContext()' is not allowed.`;
} else if (abstractValue.reason.has(ValueReason.KnownReturnSignature)) {
return 'Modifying a value returned from a function whose return value should not be mutated';
} else if (abstractValue.reason.has(ValueReason.ReactiveFunctionArgument)) {
return 'Modifying component props or hook arguments is not allowed. Consider using a local variable instead';
} else if (abstractValue.reason.has(ValueReason.State)) {
return "Modifying a value returned from 'useState()', which should not be modified directly. Use the setter function to update instead";
} else if (abstractValue.reason.has(ValueReason.ReducerState)) {
return "Modifying a value returned from 'useReducer()', which should not be modified directly. Use the dispatch function to update instead";
} else if (abstractValue.reason.has(ValueReason.Effect)) {
return 'Modifying a value used previously in an effect function or as an effect dependency is not allowed. Consider moving the modification before calling useEffect()';
} else if (abstractValue.reason.has(ValueReason.HookCaptured)) {
return 'Modifying a value previously passed as an argument to a hook is not allowed. Consider moving the modification before calling the hook';
} else if (abstractValue.reason.has(ValueReason.HookReturn)) {
return 'Modifying a value returned from a hook is not allowed. Consider moving the modification into the hook where the value is constructed';
} else {
return 'This modifies a variable that React considers immutable';
}
}

View File

@@ -1,218 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
Effect,
HIRFunction,
Identifier,
InstructionId,
InstructionKind,
isArrayType,
isMapType,
isRefOrRefValue,
isSetType,
makeInstructionId,
Place,
} from '../HIR/HIR';
import {printPlace} from '../HIR/PrintHIR';
import {
eachInstructionLValue,
eachInstructionOperand,
eachTerminalOperand,
} from '../HIR/visitors';
import {assertExhaustive} from '../Utils/utils';
/*
* For each usage of a value in the given function, determines if the usage
* may be succeeded by a mutable usage of that same value and if so updates
* the usage to be mutable.
*
* Stated differently, this inference ensures that inferred capabilities of
* each reference are as follows:
* - freeze: the value is frozen at this point
* - readonly: the value is not modified at this point *or any subsequent
* point*
* - mutable: the value is modified at this point *or some subsequent point*.
*
* Note that this refines the capabilities inferered by InferReferenceCapability,
* which looks at individual references and not the lifetime of a value's mutability.
*
* == Algorithm
*
* TODO:
* 1. Forward data-flow analysis to determine aliasing. Unlike InferReferenceCapability
* which only tracks aliasing of top-level variables (`y = x`), this analysis needs
* to know if a value is aliased anywhere (`y.x = x`). The forward data flow tracks
* all possible locations which may have aliased a value. The concrete result is
* a mapping of each Place to the set of possibly-mutable values it may alias.
*
* ```
* const x = []; // {x: v0; v0: mutable []}
* const y = {}; // {x: v0, y: v1; v0: mutable [], v1: mutable []}
* y.x = x; // {x: v0, y: v1; v0: mutable [v1], v1: mutable [v0]}
* read(x); // {x: v0, y: v1; v0: mutable [v1], v1: mutable [v0]}
* mutate(y); // can infer that y mutates v0 and v1
* ```
*
* DONE:
* 2. Forward data-flow analysis to compute mutability liveness. Walk forwards over
* the CFG and track which values are mutated in a successor.
*
* ```
* mutate(y); // mutable y => v0, v1 mutated
* read(x); // x maps to v0, v1, those are in the mutated-later set, so x is mutable here
* ...
* ```
*/
function infer(place: Place, instrId: InstructionId): void {
if (!isRefOrRefValue(place.identifier)) {
place.identifier.mutableRange.end = makeInstructionId(instrId + 1);
}
}
function inferPlace(
place: Place,
instrId: InstructionId,
inferMutableRangeForStores: boolean,
): void {
switch (place.effect) {
case Effect.Unknown: {
throw new Error(`Found an unknown place ${printPlace(place)}}!`);
}
case Effect.Capture:
case Effect.Read:
case Effect.Freeze:
return;
case Effect.Store:
if (inferMutableRangeForStores) {
infer(place, instrId);
}
return;
case Effect.ConditionallyMutateIterator: {
const identifier = place.identifier;
if (
!isArrayType(identifier) &&
!isSetType(identifier) &&
!isMapType(identifier)
) {
infer(place, instrId);
}
return;
}
case Effect.ConditionallyMutate:
case Effect.Mutate: {
infer(place, instrId);
return;
}
default:
assertExhaustive(place.effect, `Unexpected ${printPlace(place)} effect`);
}
}
export function inferMutableLifetimes(
func: HIRFunction,
inferMutableRangeForStores: boolean,
): void {
/*
* Context variables only appear to mutate where they are assigned, but we need
* to force their range to start at their declaration. Track the declaring instruction
* id so that the ranges can be extended if/when they are reassigned
*/
const contextVariableDeclarationInstructions = new Map<
Identifier,
InstructionId
>();
for (const [_, block] of func.body.blocks) {
for (const phi of block.phis) {
const isPhiMutatedAfterCreation: boolean =
phi.place.identifier.mutableRange.end >
(block.instructions.at(0)?.id ?? block.terminal.id);
if (
inferMutableRangeForStores &&
isPhiMutatedAfterCreation &&
phi.place.identifier.mutableRange.start === 0
) {
for (const [, operand] of phi.operands) {
if (phi.place.identifier.mutableRange.start === 0) {
phi.place.identifier.mutableRange.start =
operand.identifier.mutableRange.start;
} else {
phi.place.identifier.mutableRange.start = makeInstructionId(
Math.min(
phi.place.identifier.mutableRange.start,
operand.identifier.mutableRange.start,
),
);
}
}
}
}
for (const instr of block.instructions) {
for (const operand of eachInstructionLValue(instr)) {
const lvalueId = operand.identifier;
/*
* lvalue start being mutable when they're initially assigned a
* value.
*/
lvalueId.mutableRange.start = instr.id;
/*
* Let's be optimistic and assume this lvalue is not mutable by
* default.
*/
lvalueId.mutableRange.end = makeInstructionId(instr.id + 1);
}
for (const operand of eachInstructionOperand(instr)) {
inferPlace(operand, instr.id, inferMutableRangeForStores);
}
if (
instr.value.kind === 'DeclareContext' ||
(instr.value.kind === 'StoreContext' &&
instr.value.lvalue.kind !== InstructionKind.Reassign &&
!contextVariableDeclarationInstructions.has(
instr.value.lvalue.place.identifier,
))
) {
/**
* Save declarations of context variables if they hasn't already been
* declared (due to hoisted declarations).
*/
contextVariableDeclarationInstructions.set(
instr.value.lvalue.place.identifier,
instr.id,
);
} else if (instr.value.kind === 'StoreContext') {
/*
* Else this is a reassignment, extend the range from the declaration (if present).
* Note that declarations may not be present for context variables that are reassigned
* within a function expression before (or without) a read of the same variable
*/
const declaration = contextVariableDeclarationInstructions.get(
instr.value.lvalue.place.identifier,
);
if (
declaration != null &&
!isRefOrRefValue(instr.value.lvalue.place.identifier)
) {
const range = instr.value.lvalue.place.identifier.mutableRange;
if (range.start === 0) {
range.start = declaration;
} else {
range.start = makeInstructionId(Math.min(range.start, declaration));
}
}
}
}
for (const operand of eachTerminalOperand(block.terminal)) {
inferPlace(operand, block.terminal.id, inferMutableRangeForStores);
}
}
}

View File

@@ -1,102 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {HIRFunction, Identifier} from '../HIR/HIR';
import {inferAliasForUncalledFunctions} from './InerAliasForUncalledFunctions';
import {inferAliases} from './InferAlias';
import {inferAliasForPhis} from './InferAliasForPhis';
import {inferAliasForStores} from './InferAliasForStores';
import {inferMutableLifetimes} from './InferMutableLifetimes';
import {inferMutableRangesForAlias} from './InferMutableRangesForAlias';
import {inferTryCatchAliases} from './InferTryCatchAliases';
export function inferMutableRanges(ir: HIRFunction): void {
// Infer mutable ranges for non fields
inferMutableLifetimes(ir, false);
// Calculate aliases
const aliases = inferAliases(ir);
/*
* Calculate aliases for try/catch, where any value created
* in the try block could be aliased to the catch param
*/
inferTryCatchAliases(ir, aliases);
/*
* Eagerly canonicalize so that if nothing changes we can bail out
* after a single iteration
*/
let prevAliases: Map<Identifier, Identifier> = aliases.canonicalize();
while (true) {
// Infer mutable ranges for aliases that are not fields
inferMutableRangesForAlias(ir, aliases);
// Update aliasing information of fields
inferAliasForStores(ir, aliases);
// Update aliasing information of phis
inferAliasForPhis(ir, aliases);
const nextAliases = aliases.canonicalize();
if (areEqualMaps(prevAliases, nextAliases)) {
break;
}
prevAliases = nextAliases;
}
// Re-infer mutable ranges for all values
inferMutableLifetimes(ir, true);
/**
* The second inferMutableLifetimes() call updates mutable ranges
* of values to account for Store effects. Now we need to update
* all aliases of such values to extend their ranges as well. Note
* that the store only mutates the the directly aliased value and
* not any of its inner captured references. For example:
*
* ```
* let y;
* if (cond) {
* y = [];
* } else {
* y = [{}];
* }
* y.push(z);
* ```
*
* The Store effect from the `y.push` modifies the values that `y`
* directly aliases - the two arrays from the if/else branches -
* but does not modify values that `y` "contains" such as the
* object literal or `z`.
*/
prevAliases = aliases.canonicalize();
while (true) {
inferMutableRangesForAlias(ir, aliases);
inferAliasForPhis(ir, aliases);
inferAliasForUncalledFunctions(ir, aliases);
const nextAliases = aliases.canonicalize();
if (areEqualMaps(prevAliases, nextAliases)) {
break;
}
prevAliases = nextAliases;
}
}
function areEqualMaps<T, U>(a: Map<T, U>, b: Map<T, U>): boolean {
if (a.size !== b.size) {
return false;
}
for (const [key, value] of a) {
if (!b.has(key)) {
return false;
}
if (b.get(key) !== value) {
return false;
}
}
return true;
}

View File

@@ -1,54 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
HIRFunction,
Identifier,
InstructionId,
isRefOrRefValue,
} from '../HIR/HIR';
import DisjointSet from '../Utils/DisjointSet';
export function inferMutableRangesForAlias(
_fn: HIRFunction,
aliases: DisjointSet<Identifier>,
): void {
const aliasSets = aliases.buildSets();
for (const aliasSet of aliasSets) {
/*
* Update mutableRange.end only if the identifiers have actually been
* mutated.
*/
const mutatingIdentifiers = [...aliasSet].filter(
id =>
id.mutableRange.end - id.mutableRange.start > 1 && !isRefOrRefValue(id),
);
if (mutatingIdentifiers.length > 0) {
// Find final instruction which mutates this alias set.
let lastMutatingInstructionId = 0;
for (const id of mutatingIdentifiers) {
if (id.mutableRange.end > lastMutatingInstructionId) {
lastMutatingInstructionId = id.mutableRange.end;
}
}
/*
* Update mutableRange.end for all aliases in this set ending before the
* last mutation.
*/
for (const alias of aliasSet) {
if (
alias.mutableRange.end < lastMutatingInstructionId &&
!isRefOrRefValue(alias)
) {
alias.mutableRange.end = lastMutatingInstructionId as InstructionId;
}
}
}
}
}

View File

@@ -19,6 +19,7 @@ import {
DeclarationId,
Environment,
FunctionExpression,
GeneratedSource,
HIRFunction,
Hole,
IdentifierId,
@@ -34,6 +35,7 @@ import {
Phi,
Place,
SpreadPattern,
Type,
ValueReason,
} from '../HIR';
import {
@@ -43,12 +45,6 @@ import {
eachTerminalSuccessor,
} from '../HIR/visitors';
import {Ok, Result} from '../Utils/Result';
import {
getArgumentEffect,
getFunctionCallSignature,
isKnownMutableEffect,
mergeValueKinds,
} from './InferReferenceEffects';
import {
assertExhaustive,
getOrInsertDefault,
@@ -65,10 +61,14 @@ import {
printSourceLocation,
} from '../HIR/PrintHIR';
import {FunctionSignature} from '../HIR/ObjectShape';
import {getWriteErrorReason} from './InferFunctionEffects';
import prettyFormat from 'pretty-format';
import {createTemporaryPlace} from '../HIR/HIRBuilder';
import {AliasingEffect, AliasingSignature, hashEffect} from './AliasingEffects';
import {
AliasingEffect,
AliasingSignature,
hashEffect,
MutationReason,
} from './AliasingEffects';
const DEBUG = false;
@@ -445,25 +445,35 @@ function applySignature(
const reason = getWriteErrorReason({
kind: value.kind,
reason: value.reason,
context: new Set(),
});
const variable =
effect.value.identifier.name !== null &&
effect.value.identifier.name.kind === 'named'
? `\`${effect.value.identifier.name.value}\``
: 'value';
const diagnostic = CompilerDiagnostic.create({
severity: ErrorSeverity.InvalidReact,
category: 'This value cannot be modified',
description: `${reason}.`,
}).withDetail({
kind: 'error',
loc: effect.value.loc,
message: `${variable} cannot be modified`,
});
if (
effect.kind === 'Mutate' &&
effect.reason?.kind === 'AssignCurrentProperty'
) {
diagnostic.withDetail({
kind: 'error',
loc: effect.value.loc,
message: `Hint: If this value is a Ref (value returned by \`useRef()\`), rename the variable to end in "Ref".`,
});
}
effects.push({
kind: 'MutateFrozen',
place: effect.value,
error: CompilerDiagnostic.create({
severity: ErrorSeverity.InvalidReact,
category: 'This value cannot be modified',
description: `${reason}.`,
}).withDetail({
kind: 'error',
loc: effect.value.loc,
message: `${variable} cannot be modified`,
}),
error: diagnostic,
});
}
}
@@ -1059,13 +1069,31 @@ function applyEffect(
const reason = getWriteErrorReason({
kind: value.kind,
reason: value.reason,
context: new Set(),
});
const variable =
effect.value.identifier.name !== null &&
effect.value.identifier.name.kind === 'named'
? `\`${effect.value.identifier.name.value}\``
: 'value';
const diagnostic = CompilerDiagnostic.create({
severity: ErrorSeverity.InvalidReact,
category: 'This value cannot be modified',
description: `${reason}.`,
}).withDetail({
kind: 'error',
loc: effect.value.loc,
message: `${variable} cannot be modified`,
});
if (
effect.kind === 'Mutate' &&
effect.reason?.kind === 'AssignCurrentProperty'
) {
diagnostic.withDetail({
kind: 'error',
loc: effect.value.loc,
message: `Hint: If this value is a Ref (value returned by \`useRef()\`), rename the variable to end in "Ref".`,
});
}
applyEffect(
context,
state,
@@ -1075,15 +1103,7 @@ function applyEffect(
? 'MutateFrozen'
: 'MutateGlobal',
place: effect.value,
error: CompilerDiagnostic.create({
severity: ErrorSeverity.InvalidReact,
category: 'This value cannot be modified',
description: `${reason}.`,
}).withDetail({
kind: 'error',
loc: effect.value.loc,
message: `${variable} cannot be modified`,
}),
error: diagnostic,
},
initialized,
effects,
@@ -1680,7 +1700,15 @@ function computeSignatureForInstruction(
}
case 'PropertyStore':
case 'ComputedStore': {
effects.push({kind: 'Mutate', value: value.object});
const mutationReason: MutationReason | null =
value.kind === 'PropertyStore' && value.property === 'current'
? {kind: 'AssignCurrentProperty'}
: null;
effects.push({
kind: 'Mutate',
value: value.object,
reason: mutationReason,
});
effects.push({
kind: 'Capture',
from: value.value,
@@ -2534,3 +2562,196 @@ export type AbstractValue = {
kind: ValueKind;
reason: ReadonlySet<ValueReason>;
};
export function getWriteErrorReason(abstractValue: AbstractValue): string {
if (abstractValue.reason.has(ValueReason.Global)) {
return 'Modifying a variable defined outside a component or hook is not allowed. Consider using an effect';
} else if (abstractValue.reason.has(ValueReason.JsxCaptured)) {
return 'Modifying a value used previously in JSX is not allowed. Consider moving the modification before the JSX';
} else if (abstractValue.reason.has(ValueReason.Context)) {
return `Modifying a value returned from 'useContext()' is not allowed.`;
} else if (abstractValue.reason.has(ValueReason.KnownReturnSignature)) {
return 'Modifying a value returned from a function whose return value should not be mutated';
} else if (abstractValue.reason.has(ValueReason.ReactiveFunctionArgument)) {
return 'Modifying component props or hook arguments is not allowed. Consider using a local variable instead';
} else if (abstractValue.reason.has(ValueReason.State)) {
return "Modifying a value returned from 'useState()', which should not be modified directly. Use the setter function to update instead";
} else if (abstractValue.reason.has(ValueReason.ReducerState)) {
return "Modifying a value returned from 'useReducer()', which should not be modified directly. Use the dispatch function to update instead";
} else if (abstractValue.reason.has(ValueReason.Effect)) {
return 'Modifying a value used previously in an effect function or as an effect dependency is not allowed. Consider moving the modification before calling useEffect()';
} else if (abstractValue.reason.has(ValueReason.HookCaptured)) {
return 'Modifying a value previously passed as an argument to a hook is not allowed. Consider moving the modification before calling the hook';
} else if (abstractValue.reason.has(ValueReason.HookReturn)) {
return 'Modifying a value returned from a hook is not allowed. Consider moving the modification into the hook where the value is constructed';
} else {
return 'This modifies a variable that React considers immutable';
}
}
function getArgumentEffect(
signatureEffect: Effect | null,
arg: Place | SpreadPattern,
): Effect {
if (signatureEffect != null) {
if (arg.kind === 'Identifier') {
return signatureEffect;
} else if (
signatureEffect === Effect.Mutate ||
signatureEffect === Effect.ConditionallyMutate
) {
return signatureEffect;
} else {
// see call-spread-argument-mutable-iterator test fixture
if (signatureEffect === Effect.Freeze) {
CompilerError.throwTodo({
reason: 'Support spread syntax for hook arguments',
loc: arg.place.loc,
});
}
// effects[i] is Effect.Capture | Effect.Read | Effect.Store
return Effect.ConditionallyMutateIterator;
}
} else {
return Effect.ConditionallyMutate;
}
}
export function getFunctionCallSignature(
env: Environment,
type: Type,
): FunctionSignature | null {
if (type.kind !== 'Function') {
return null;
}
return env.getFunctionSignature(type);
}
export function isKnownMutableEffect(effect: Effect): boolean {
switch (effect) {
case Effect.Store:
case Effect.ConditionallyMutate:
case Effect.ConditionallyMutateIterator:
case Effect.Mutate: {
return true;
}
case Effect.Unknown: {
CompilerError.invariant(false, {
reason: 'Unexpected unknown effect',
description: null,
loc: GeneratedSource,
suggestions: null,
});
}
case Effect.Read:
case Effect.Capture:
case Effect.Freeze: {
return false;
}
default: {
assertExhaustive(effect, `Unexpected effect \`${effect}\``);
}
}
}
/**
* Joins two values using the following rules:
* == Effect Transitions ==
*
* Freezing an immutable value has not effect:
* ┌───────────────┐
* │ │
* ▼ │ Freeze
* ┌──────────────────────────┐ │
* │ Immutable │──┘
* └──────────────────────────┘
*
* Freezing a mutable or maybe-frozen value makes it frozen. Freezing a frozen
* value has no effect:
* ┌───────────────┐
* ┌─────────────────────────┐ Freeze │ │
* │ MaybeFrozen │────┐ ▼ │ Freeze
* └─────────────────────────┘ │ ┌──────────────────────────┐ │
* ├────▶│ Frozen │──┘
* │ └──────────────────────────┘
* ┌─────────────────────────┐ │
* │ Mutable │────┘
* └─────────────────────────┘
*
* == Join Lattice ==
* - immutable | mutable => mutable
* The justification is that immutable and mutable values are different types,
* and functions can introspect them to tell the difference (if the argument
* is null return early, else if its an object mutate it).
* - frozen | mutable => maybe-frozen
* Frozen values are indistinguishable from mutable values at runtime, so callers
* cannot dynamically avoid mutation of "frozen" values. If a value could be
* frozen we have to distinguish it from a mutable value. But it also isn't known
* frozen yet, so we distinguish as maybe-frozen.
* - immutable | frozen => frozen
* This is subtle and falls out of the above rules. If a value could be any of
* immutable, mutable, or frozen, then at runtime it could either be a primitive
* or a reference type, and callers can't distinguish frozen or not for reference
* types. To ensure that any sequence of joins btw those three states yields the
* correct maybe-frozen, these two have to produce a frozen value.
* - <any> | maybe-frozen => maybe-frozen
* - immutable | context => context
* - mutable | context => context
* - frozen | context => maybe-frozen
*
* ┌──────────────────────────┐
* │ Immutable │───┐
* └──────────────────────────┘ │
* │ ┌─────────────────────────┐
* ├───▶│ Frozen │──┐
* ┌──────────────────────────┐ │ └─────────────────────────┘ │
* │ Frozen │───┤ │ ┌─────────────────────────┐
* └──────────────────────────┘ │ ├─▶│ MaybeFrozen │
* │ ┌─────────────────────────┐ │ └─────────────────────────┘
* ├───▶│ MaybeFrozen │──┘
* ┌──────────────────────────┐ │ └─────────────────────────┘
* │ Mutable │───┘
* └──────────────────────────┘
*/
function mergeValueKinds(a: ValueKind, b: ValueKind): ValueKind {
if (a === b) {
return a;
} else if (a === ValueKind.MaybeFrozen || b === ValueKind.MaybeFrozen) {
return ValueKind.MaybeFrozen;
// after this a and b differ and neither are MaybeFrozen
} else if (a === ValueKind.Mutable || b === ValueKind.Mutable) {
if (a === ValueKind.Frozen || b === ValueKind.Frozen) {
// frozen | mutable
return ValueKind.MaybeFrozen;
} else if (a === ValueKind.Context || b === ValueKind.Context) {
// context | mutable
return ValueKind.Context;
} else {
// mutable | immutable
return ValueKind.Mutable;
}
} else if (a === ValueKind.Context || b === ValueKind.Context) {
if (a === ValueKind.Frozen || b === ValueKind.Frozen) {
// frozen | context
return ValueKind.MaybeFrozen;
} else {
// context | immutable
return ValueKind.Context;
}
} else if (a === ValueKind.Frozen || b === ValueKind.Frozen) {
return ValueKind.Frozen;
} else if (a === ValueKind.Global || b === ValueKind.Global) {
return ValueKind.Global;
} else {
CompilerError.invariant(
a === ValueKind.Primitive && b == ValueKind.Primitive,
{
reason: `Unexpected value kind in mergeValues()`,
description: `Found kinds ${a} and ${b}`,
loc: GeneratedSource,
},
);
return ValueKind.Primitive;
}
}

View File

@@ -1,49 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {BlockId, HIRFunction, Identifier} from '../HIR';
import DisjointSet from '../Utils/DisjointSet';
/*
* Any values created within a try/catch block could be aliased to the try handler.
* Our lowering ensures that every instruction within a try block will be lowered into a
* basic block ending in a maybe-throw terminal that points to its catch block, so we can
* iterate such blocks and alias their instruction lvalues to the handler's param (if present).
*/
export function inferTryCatchAliases(
fn: HIRFunction,
aliases: DisjointSet<Identifier>,
): void {
const handlerParams: Map<BlockId, Identifier> = new Map();
for (const [_, block] of fn.body.blocks) {
if (
block.terminal.kind === 'try' &&
block.terminal.handlerBinding !== null
) {
handlerParams.set(
block.terminal.handler,
block.terminal.handlerBinding.identifier,
);
} else if (block.terminal.kind === 'maybe-throw') {
const handlerParam = handlerParams.get(block.terminal.handler);
if (handlerParam === undefined) {
/*
* There's no catch clause param, nothing to alias to so
* skip this block
*/
continue;
}
/*
* Otherwise alias all values created in this block to the
* catch clause param
*/
for (const instr of block.instructions) {
aliases.union([handlerParam, instr.lvalue.identifier]);
}
}
}
}

View File

@@ -7,8 +7,6 @@
export {default as analyseFunctions} from './AnalyseFunctions';
export {dropManualMemoization} from './DropManualMemoization';
export {inferMutableRanges} from './InferMutableRanges';
export {inferReactivePlaces} from './InferReactivePlaces';
export {default as inferReferenceEffects} from './InferReferenceEffects';
export {inlineImmediatelyInvokedFunctionExpressions} from './InlineImmediatelyInvokedFunctionExpressions';
export {inferEffectDependencies} from './InferEffectDependencies';

View File

@@ -24,7 +24,6 @@ import {
getHookKind,
isMutableEffect,
} from '../HIR';
import {getFunctionCallSignature} from '../Inference/InferReferenceEffects';
import {assertExhaustive, getOrInsertDefault} from '../Utils/utils';
import {getPlaceScope, ReactiveScope} from '../HIR/HIR';
import {
@@ -35,6 +34,7 @@ import {
visitReactiveFunction,
} from './visitors';
import {printPlace} from '../HIR/PrintHIR';
import {getFunctionCallSignature} from '../Inference/InferMutationAliasingEffects';
/*
* This pass prunes reactive scopes that are not necessary to bound downstream computation.

View File

@@ -12,7 +12,7 @@ import {
eachInstructionValueOperand,
eachTerminalOperand,
} from '../HIR/visitors';
import {getFunctionCallSignature} from '../Inference/InferReferenceEffects';
import {getFunctionCallSignature} from '../Inference/InferMutationAliasingEffects';
/**
* Validates that local variables cannot be reassigned after render.

View File

@@ -12,21 +12,14 @@ import {
FunctionExpression,
HIRFunction,
IdentifierId,
Place,
isSetStateType,
isUseEffectHookType,
} from '../HIR';
import {printInstruction, printPlace} from '../HIR/PrintHIR';
import {
eachInstructionValueOperand,
eachTerminalOperand,
} from '../HIR/visitors';
type SetStateCall = {
loc: SourceLocation;
propsSource: Place | null; // null means state-derived, non-null means props-derived
};
/**
* Validates that useEffect is not used for derived computations which could/should
* be performed in render.
@@ -54,96 +47,12 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
const candidateDependencies: Map<IdentifierId, ArrayExpression> = new Map();
const functions: Map<IdentifierId, FunctionExpression> = new Map();
const locals: Map<IdentifierId, IdentifierId> = new Map();
const derivedFromProps: Map<IdentifierId, Place> = new Map();
const errors = new CompilerError();
if (fn.fnType === 'Hook') {
for (const param of fn.params) {
if (param.kind === 'Identifier') {
derivedFromProps.set(param.identifier.id, param);
}
}
} else if (fn.fnType === 'Component') {
const props = fn.params[0];
if (props != null && props.kind === 'Identifier') {
derivedFromProps.set(props.identifier.id, props);
}
}
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
const {lvalue, value} = instr;
// Track props derivation through instruction effects
if (instr.effects != null) {
for (const effect of instr.effects) {
switch (effect.kind) {
case 'Assign':
case 'Alias':
case 'MaybeAlias':
case 'Capture': {
const source = derivedFromProps.get(effect.from.identifier.id);
if (source != null) {
derivedFromProps.set(effect.into.identifier.id, source);
}
break;
}
}
}
}
/**
* TODO: figure out why property access off of props does not create an Assign or Alias/Maybe
* Alias
*
* import {useEffect, useState} from 'react'
*
* function Component(props) {
* const [displayValue, setDisplayValue] = useState('');
*
* useEffect(() => {
* const computed = props.prefix + props.value + props.suffix;
* ^^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^
* we want to track that these are from props
* setDisplayValue(computed);
* }, [props.prefix, props.value, props.suffix]);
*
* return <div>{displayValue}</div>;
* }
*/
if (value.kind === 'FunctionExpression') {
for (const [, block] of value.loweredFunc.func.body.blocks) {
for (const instr of block.instructions) {
if (instr.effects != null) {
console.group(printInstruction(instr));
for (const effect of instr.effects) {
console.log(effect);
switch (effect.kind) {
case 'Assign':
case 'Alias':
case 'MaybeAlias':
case 'Capture': {
const source = derivedFromProps.get(
effect.from.identifier.id,
);
if (source != null) {
derivedFromProps.set(effect.into.identifier.id, source);
}
break;
}
}
}
}
console.groupEnd();
}
}
}
for (const [, place] of derivedFromProps) {
console.log(printPlace(place));
}
if (value.kind === 'LoadLocal') {
locals.set(lvalue.identifier.id, value.place.identifier.id);
} else if (value.kind === 'ArrayExpression') {
@@ -180,7 +89,6 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
validateEffect(
effectFunction.loweredFunc.func,
dependencies,
derivedFromProps,
errors,
);
}
@@ -196,7 +104,6 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
function validateEffect(
effectFunction: HIRFunction,
effectDeps: Array<IdentifierId>,
derivedFromProps: Map<IdentifierId, Place>,
errors: CompilerError,
): void {
for (const operand of effectFunction.context) {
@@ -204,22 +111,16 @@ function validateEffect(
continue;
} else if (effectDeps.find(dep => dep === operand.identifier.id) != null) {
continue;
} else if (derivedFromProps.has(operand.identifier.id)) {
continue;
} else {
// Captured something other than the effect dep or setState
console.log('early return 1');
return;
}
}
for (const dep of effectDeps) {
console.log({dep});
if (
effectFunction.context.find(operand => operand.identifier.id === dep) ==
null ||
derivedFromProps.has(dep) === false
null
) {
console.log('early return 2');
// effect dep wasn't actually used in the function
return;
}
@@ -227,18 +128,11 @@ function validateEffect(
const seenBlocks: Set<BlockId> = new Set();
const values: Map<IdentifierId, Array<IdentifierId>> = new Map();
const effectDerivedFromProps: Map<IdentifierId, Place> = new Map();
for (const dep of effectDeps) {
console.log({dep});
values.set(dep, [dep]);
const propsSource = derivedFromProps.get(dep);
if (propsSource != null) {
effectDerivedFromProps.set(dep, propsSource);
}
}
const setStateCalls: Array<SetStateCall> = [];
const setStateLocations: Array<SourceLocation> = [];
for (const block of effectFunction.body.blocks.values()) {
for (const pred of block.preds) {
if (!seenBlocks.has(pred)) {
@@ -248,8 +142,6 @@ function validateEffect(
}
for (const phi of block.phis) {
const aggregateDeps: Set<IdentifierId> = new Set();
let propsSource: Place | null = null;
for (const operand of phi.operands.values()) {
const deps = values.get(operand.identifier.id);
if (deps != null) {
@@ -257,18 +149,10 @@ function validateEffect(
aggregateDeps.add(dep);
}
}
const source = effectDerivedFromProps.get(operand.identifier.id);
if (source != null) {
propsSource = source;
}
}
if (aggregateDeps.size !== 0) {
values.set(phi.place.identifier.id, Array.from(aggregateDeps));
}
if (propsSource != null) {
effectDerivedFromProps.set(phi.place.identifier.id, propsSource);
}
}
for (const instr of block.instructions) {
switch (instr.value.kind) {
@@ -311,16 +195,9 @@ function validateEffect(
) {
const deps = values.get(instr.value.args[0].identifier.id);
if (deps != null && new Set(deps).size === effectDeps.length) {
const propsSource = effectDerivedFromProps.get(
instr.value.args[0].identifier.id,
);
setStateCalls.push({
loc: instr.value.callee.loc,
propsSource: propsSource ?? null,
});
setStateLocations.push(instr.value.callee.loc);
} else {
// doesn't depend on all deps
// doesn't depend on any deps
return;
}
}
@@ -330,26 +207,6 @@ function validateEffect(
return;
}
}
// Track props derivation through instruction effects
if (instr.effects != null) {
for (const effect of instr.effects) {
switch (effect.kind) {
case 'Assign':
case 'Alias':
case 'MaybeAlias':
case 'Capture': {
const source = effectDerivedFromProps.get(
effect.from.identifier.id,
);
if (source != null) {
effectDerivedFromProps.set(effect.into.identifier.id, source);
}
break;
}
}
}
}
}
for (const operand of eachTerminalOperand(block.terminal)) {
if (values.has(operand.identifier.id)) {
@@ -360,29 +217,14 @@ function validateEffect(
seenBlocks.add(block.id);
}
for (const call of setStateCalls) {
if (call.propsSource != null) {
const propName = call.propsSource.identifier.name?.value;
const propInfo = propName != null ? ` (from prop '${propName}')` : '';
errors.push({
reason: `Consider lifting state up to the parent component to make this a controlled component. (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)`,
description: `You are using props${propInfo} to update local state in an effect.`,
severity: ErrorSeverity.InvalidReact,
loc: call.loc,
suggestions: null,
});
} else {
errors.push({
reason:
'You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)',
description:
'This effect updates state based on other state values. ' +
'Consider calculating this value directly during render',
severity: ErrorSeverity.InvalidReact,
loc: call.loc,
suggestions: null,
});
}
for (const loc of setStateLocations) {
errors.push({
reason:
'Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)',
description: null,
severity: ErrorSeverity.InvalidReact,
loc,
suggestions: null,
});
}
}

View File

@@ -125,10 +125,7 @@ export function validateNoFreezingKnownMutableFunctions(
);
if (knownMutation && knownMutation.kind === 'ContextMutation') {
contextMutationEffects.set(lvalue.identifier.id, knownMutation);
} else if (
fn.env.config.enableNewMutationAliasingModel &&
value.loweredFunc.func.aliasingEffects != null
) {
} else if (value.loweredFunc.func.aliasingEffects != null) {
const context = new Set(
value.loweredFunc.func.context.map(p => p.identifier.id),
);

View File

@@ -7,7 +7,7 @@
import {CompilerDiagnostic, CompilerError, ErrorSeverity} from '..';
import {HIRFunction} from '../HIR';
import {getFunctionCallSignature} from '../Inference/InferReferenceEffects';
import {getFunctionCallSignature} from '../Inference/InferMutationAliasingEffects';
import {Result} from '../Utils/Result';
/**

View File

@@ -0,0 +1,39 @@
## Input
```javascript
// @enableNewMutationAliasingModel:false
function Component() {
const foo = () => {
someGlobal = true;
};
// spreading a function is weird, but it doesn't call the function so this is allowed
return <div {...foo} />;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel:false
function Component() {
const $ = _c(1);
const foo = _temp;
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = <div {...foo} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
function _temp() {
someGlobal = true;
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -3,5 +3,6 @@ function Component() {
const foo = () => {
someGlobal = true;
};
// spreading a function is weird, but it doesn't call the function so this is allowed
return <div {...foo} />;
}

View File

@@ -1,107 +0,0 @@
## Input
```javascript
// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false
import {arrayPush, setPropertyByKey, Stringify} from 'shared-runtime';
/**
* 1. `InferMutableRanges` derives the mutable range of identifiers and their
* aliases from `LoadLocal`, `PropertyLoad`, etc
* - After this pass, y's mutable range only extends to `arrayPush(x, y)`
* - We avoid assigning mutable ranges to loads after y's mutable range, as
* these are working with an immutable value. As a result, `LoadLocal y` and
* `PropertyLoad y` do not get mutable ranges
* 2. `InferReactiveScopeVariables` extends mutable ranges and creates scopes,
* as according to the 'co-mutation' of different values
* - Here, we infer that
* - `arrayPush(y, x)` might alias `x` and `y` to each other
* - `setPropertyKey(x, ...)` may mutate both `x` and `y`
* - This pass correctly extends the mutable range of `y`
* - Since we didn't run `InferMutableRange` logic again, the LoadLocal /
* PropertyLoads still don't have a mutable range
*
* Note that the this bug is an edge case. Compiler output is only invalid for:
* - function expressions with
* `enableTransitivelyFreezeFunctionExpressions:false`
* - functions that throw and get retried without clearing the memocache
*
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok)
* <div>{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}</div>
* <div>{"cb":{"kind":"Function","result":11},"shouldInvokeFns":true}</div>
* Forget:
* (kind: ok)
* <div>{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}</div>
* <div>{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}</div>
*/
function useFoo({a, b}: {a: number, b: number}) {
const x = [];
const y = {value: a};
arrayPush(x, y); // x and y co-mutate
const y_alias = y;
const cb = () => y_alias.value;
setPropertyByKey(x[0], 'value', b); // might overwrite y.value
return <Stringify cb={cb} shouldInvokeFns={true} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{a: 2, b: 10}],
sequentialRenders: [
{a: 2, b: 10},
{a: 2, b: 11},
],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { arrayPush, setPropertyByKey, Stringify } from "shared-runtime";
function useFoo(t0) {
const $ = _c(5);
const { a, b } = t0;
let t1;
if ($[0] !== a || $[1] !== b) {
const x = [];
const y = { value: a };
arrayPush(x, y);
const y_alias = y;
let t2;
if ($[3] !== y_alias.value) {
t2 = () => y_alias.value;
$[3] = y_alias.value;
$[4] = t2;
} else {
t2 = $[4];
}
const cb = t2;
setPropertyByKey(x[0], "value", b);
t1 = <Stringify cb={cb} shouldInvokeFns={true} />;
$[0] = a;
$[1] = b;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{ a: 2, b: 10 }],
sequentialRenders: [
{ a: 2, b: 10 },
{ a: 2, b: 11 },
],
};
```

View File

@@ -1,53 +0,0 @@
// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false
import {arrayPush, setPropertyByKey, Stringify} from 'shared-runtime';
/**
* 1. `InferMutableRanges` derives the mutable range of identifiers and their
* aliases from `LoadLocal`, `PropertyLoad`, etc
* - After this pass, y's mutable range only extends to `arrayPush(x, y)`
* - We avoid assigning mutable ranges to loads after y's mutable range, as
* these are working with an immutable value. As a result, `LoadLocal y` and
* `PropertyLoad y` do not get mutable ranges
* 2. `InferReactiveScopeVariables` extends mutable ranges and creates scopes,
* as according to the 'co-mutation' of different values
* - Here, we infer that
* - `arrayPush(y, x)` might alias `x` and `y` to each other
* - `setPropertyKey(x, ...)` may mutate both `x` and `y`
* - This pass correctly extends the mutable range of `y`
* - Since we didn't run `InferMutableRange` logic again, the LoadLocal /
* PropertyLoads still don't have a mutable range
*
* Note that the this bug is an edge case. Compiler output is only invalid for:
* - function expressions with
* `enableTransitivelyFreezeFunctionExpressions:false`
* - functions that throw and get retried without clearing the memocache
*
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok)
* <div>{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}</div>
* <div>{"cb":{"kind":"Function","result":11},"shouldInvokeFns":true}</div>
* Forget:
* (kind: ok)
* <div>{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}</div>
* <div>{"cb":{"kind":"Function","result":10},"shouldInvokeFns":true}</div>
*/
function useFoo({a, b}: {a: number, b: number}) {
const x = [];
const y = {value: a};
arrayPush(x, y); // x and y co-mutate
const y_alias = y;
const cb = () => y_alias.value;
setPropertyByKey(x[0], 'value', b); // might overwrite y.value
return <Stringify cb={cb} shouldInvokeFns={true} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{a: 2, b: 10}],
sequentialRenders: [
{a: 2, b: 10},
{a: 2, b: 11},
],
};

View File

@@ -1,87 +0,0 @@
## Input
```javascript
// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false
import {setPropertyByKey, Stringify} from 'shared-runtime';
/**
* Variation of bug in `bug-aliased-capture-aliased-mutate`
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok)
* <div>{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}</div>
* <div>{"cb":{"kind":"Function","result":3},"shouldInvokeFns":true}</div>
* Forget:
* (kind: ok)
* <div>{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}</div>
* <div>{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}</div>
*/
function useFoo({a}: {a: number, b: number}) {
const arr = [];
const obj = {value: a};
setPropertyByKey(obj, 'arr', arr);
const obj_alias = obj;
const cb = () => obj_alias.arr.length;
for (let i = 0; i < a; i++) {
arr.push(i);
}
return <Stringify cb={cb} shouldInvokeFns={true} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{a: 2}],
sequentialRenders: [{a: 2}, {a: 3}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { setPropertyByKey, Stringify } from "shared-runtime";
function useFoo(t0) {
const $ = _c(4);
const { a } = t0;
let t1;
if ($[0] !== a) {
const arr = [];
const obj = { value: a };
setPropertyByKey(obj, "arr", arr);
const obj_alias = obj;
let t2;
if ($[2] !== obj_alias.arr.length) {
t2 = () => obj_alias.arr.length;
$[2] = obj_alias.arr.length;
$[3] = t2;
} else {
t2 = $[3];
}
const cb = t2;
for (let i = 0; i < a; i++) {
arr.push(i);
}
t1 = <Stringify cb={cb} shouldInvokeFns={true} />;
$[0] = a;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{ a: 2 }],
sequentialRenders: [{ a: 2 }, { a: 3 }],
};
```

View File

@@ -1,34 +0,0 @@
// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false
import {setPropertyByKey, Stringify} from 'shared-runtime';
/**
* Variation of bug in `bug-aliased-capture-aliased-mutate`
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok)
* <div>{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}</div>
* <div>{"cb":{"kind":"Function","result":3},"shouldInvokeFns":true}</div>
* Forget:
* (kind: ok)
* <div>{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}</div>
* <div>{"cb":{"kind":"Function","result":2},"shouldInvokeFns":true}</div>
*/
function useFoo({a}: {a: number, b: number}) {
const arr = [];
const obj = {value: a};
setPropertyByKey(obj, 'arr', arr);
const obj_alias = obj;
const cb = () => obj_alias.arr.length;
for (let i = 0; i < a; i++) {
arr.push(i);
}
return <Stringify cb={cb} shouldInvokeFns={true} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{a: 2}],
sequentialRenders: [{a: 2}, {a: 3}],
};

View File

@@ -85,19 +85,11 @@ import { makeArray, mutate } from "shared-runtime";
* used when we analyze CallExpressions.
*/
function Component(t0) {
const $ = _c(5);
const $ = _c(3);
const { foo, bar } = t0;
let t1;
if ($[0] !== foo) {
t1 = { foo };
$[0] = foo;
$[1] = t1;
} else {
t1 = $[1];
}
const x = t1;
let y;
if ($[2] !== bar || $[3] !== x) {
if ($[0] !== bar || $[1] !== foo) {
const x = { foo };
y = { bar };
const f0 = function () {
const a = makeArray(y);
@@ -108,11 +100,11 @@ function Component(t0) {
f0();
mutate(y.x);
$[2] = bar;
$[3] = x;
$[4] = y;
$[0] = bar;
$[1] = foo;
$[2] = y;
} else {
y = $[4];
y = $[2];
}
return y;
}

View File

@@ -1,92 +0,0 @@
## Input
```javascript
// @enableNewMutationAliasingModel:false
import {CONST_TRUE, Stringify, mutate, useIdentity} from 'shared-runtime';
/**
* Fixture showing an edge case for ReactiveScope variable propagation.
*
* Found differences in evaluator results
* Non-forget (expected):
* <div>{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}</div>
* <div>{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}</div>
* Forget:
* <div>{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}</div>
* [[ (exception in render) Error: invariant broken ]]
*
*/
function Component() {
const obj = CONST_TRUE ? {inner: {value: 'hello'}} : null;
const boxedInner = [obj?.inner];
useIdentity(null);
mutate(obj);
if (boxedInner[0] !== obj?.inner) {
throw new Error('invariant broken');
}
return <Stringify obj={obj} inner={boxedInner} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{arg: 0}],
sequentialRenders: [{arg: 0}, {arg: 1}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel:false
import { CONST_TRUE, Stringify, mutate, useIdentity } from "shared-runtime";
/**
* Fixture showing an edge case for ReactiveScope variable propagation.
*
* Found differences in evaluator results
* Non-forget (expected):
* <div>{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}</div>
* <div>{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}</div>
* Forget:
* <div>{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}</div>
* [[ (exception in render) Error: invariant broken ]]
*
*/
function Component() {
const $ = _c(4);
const obj = CONST_TRUE ? { inner: { value: "hello" } } : null;
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = [obj?.inner];
$[0] = t0;
} else {
t0 = $[0];
}
const boxedInner = t0;
useIdentity(null);
mutate(obj);
if (boxedInner[0] !== obj?.inner) {
throw new Error("invariant broken");
}
let t1;
if ($[1] !== boxedInner || $[2] !== obj) {
t1 = <Stringify obj={obj} inner={boxedInner} />;
$[1] = boxedInner;
$[2] = obj;
$[3] = t1;
} else {
t1 = $[3];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ arg: 0 }],
sequentialRenders: [{ arg: 0 }, { arg: 1 }],
};
```

View File

@@ -1,31 +0,0 @@
// @enableNewMutationAliasingModel:false
import {CONST_TRUE, Stringify, mutate, useIdentity} from 'shared-runtime';
/**
* Fixture showing an edge case for ReactiveScope variable propagation.
*
* Found differences in evaluator results
* Non-forget (expected):
* <div>{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}</div>
* <div>{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}</div>
* Forget:
* <div>{"obj":{"inner":{"value":"hello"},"wat0":"joe"},"inner":["[[ cyclic ref *2 ]]"]}</div>
* [[ (exception in render) Error: invariant broken ]]
*
*/
function Component() {
const obj = CONST_TRUE ? {inner: {value: 'hello'}} : null;
const boxedInner = [obj?.inner];
useIdentity(null);
mutate(obj);
if (boxedInner[0] !== obj?.inner) {
throw new Error('invariant broken');
}
return <Stringify obj={obj} inner={boxedInner} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{arg: 0}],
sequentialRenders: [{arg: 0}, {arg: 1}],
};

View File

@@ -1,110 +0,0 @@
## Input
```javascript
// @enableNewMutationAliasingModel:false
import {identity, mutate} from 'shared-runtime';
/**
* Bug: copy of error.todo-object-expression-computed-key-modified-during-after-construction-sequence-expr
* with the mutation hoisted to a named variable instead of being directly
* inlined into the Object key.
*
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}]
* [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}]
* Forget:
* (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}]
* [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe","wat2":"joe"}]
*/
function Component(props) {
const key = {};
const tmp = (mutate(key), key);
const context = {
// Here, `tmp` is frozen (as it's inferred to be a primitive/string)
[tmp]: identity([props.value]),
};
mutate(key);
return [context, key];
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 42}],
sequentialRenders: [{value: 42}, {value: 42}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel:false
import { identity, mutate } from "shared-runtime";
/**
* Bug: copy of error.todo-object-expression-computed-key-modified-during-after-construction-sequence-expr
* with the mutation hoisted to a named variable instead of being directly
* inlined into the Object key.
*
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}]
* [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}]
* Forget:
* (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}]
* [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe","wat2":"joe"}]
*/
function Component(props) {
const $ = _c(8);
let key;
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
key = {};
t0 = (mutate(key), key);
$[0] = key;
$[1] = t0;
} else {
key = $[0];
t0 = $[1];
}
const tmp = t0;
let t1;
if ($[2] !== props.value) {
t1 = identity([props.value]);
$[2] = props.value;
$[3] = t1;
} else {
t1 = $[3];
}
let t2;
if ($[4] !== t1) {
t2 = { [tmp]: t1 };
$[4] = t1;
$[5] = t2;
} else {
t2 = $[5];
}
const context = t2;
mutate(key);
let t3;
if ($[6] !== context) {
t3 = [context, key];
$[6] = context;
$[7] = t3;
} else {
t3 = $[7];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ value: 42 }],
sequentialRenders: [{ value: 42 }, { value: 42 }],
};
```

View File

@@ -1,32 +0,0 @@
// @enableNewMutationAliasingModel:false
import {identity, mutate} from 'shared-runtime';
/**
* Bug: copy of error.todo-object-expression-computed-key-modified-during-after-construction-sequence-expr
* with the mutation hoisted to a named variable instead of being directly
* inlined into the Object key.
*
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}]
* [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}]
* Forget:
* (kind: ok) [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe"}]
* [{"[object Object]":[42]},{"wat0":"joe","wat1":"joe","wat2":"joe"}]
*/
function Component(props) {
const key = {};
const tmp = (mutate(key), key);
const context = {
// Here, `tmp` is frozen (as it's inferred to be a primitive/string)
[tmp]: identity([props.value]),
};
mutate(key);
return [context, key];
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 42}],
sequentialRenders: [{value: 42}, {value: 42}],
};

View File

@@ -1,33 +0,0 @@
## Input
```javascript
// @enableNewMutationAliasingModel:false
function Component() {
const foo = () => {
someGlobal = true;
};
return <div {...foo} />;
}
```
## Error
```
Found 1 error:
Error: Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)
error.assign-global-in-jsx-spread-attribute.ts:4:4
2 | function Component() {
3 | const foo = () => {
> 4 | someGlobal = true;
| ^^^^^^^^^^ Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)
5 | };
6 | return <div {...foo} />;
7 | }
```

View File

@@ -1,72 +0,0 @@
## Input
```javascript
// @validateNoFreezingKnownMutableFunctions @enableNewMutationAliasingModel:false
import {useCallback, useEffect, useRef} from 'react';
import {useHook} from 'shared-runtime';
function Component() {
const params = useHook();
const update = useCallback(
partialParams => {
const nextParams = {
...params,
...partialParams,
};
nextParams.param = 'value';
console.log(nextParams);
},
[params]
);
const ref = useRef(null);
useEffect(() => {
if (ref.current === null) {
update();
}
}, [update]);
return 'ok';
}
```
## Error
```
Found 1 error:
Error: Cannot modify local variables after render completes
This argument is a function which may reassign or mutate a local variable after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead.
error.bug-old-inference-false-positive-ref-validation-in-use-effect.ts:20:12
18 | );
19 | const ref = useRef(null);
> 20 | useEffect(() => {
| ^^^^^^^
> 21 | if (ref.current === null) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 22 | update();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 23 | }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 24 | }, [update]);
| ^^^^ This function may (indirectly) reassign or modify a local variable after render
25 |
26 | return 'ok';
27 | }
error.bug-old-inference-false-positive-ref-validation-in-use-effect.ts:14:6
12 | ...partialParams,
13 | };
> 14 | nextParams.param = 'value';
| ^^^^^^^^^^ This modifies a local variable
15 | console.log(nextParams);
16 | },
17 | [params]
```

View File

@@ -1,27 +0,0 @@
// @validateNoFreezingKnownMutableFunctions @enableNewMutationAliasingModel:false
import {useCallback, useEffect, useRef} from 'react';
import {useHook} from 'shared-runtime';
function Component() {
const params = useHook();
const update = useCallback(
partialParams => {
const nextParams = {
...params,
...partialParams,
};
nextParams.param = 'value';
console.log(nextParams);
},
[params]
);
const ref = useRef(null);
useEffect(() => {
if (ref.current === null) {
update();
}
}, [update]);
return 'ok';
}

View File

@@ -0,0 +1,42 @@
## Input
```javascript
// @flow
component Foo() {
const foo = useFoo();
foo.current = true;
return <div />;
}
```
## Error
```
Found 1 error:
Error: This value cannot be modified
Modifying a value returned from a hook is not allowed. Consider moving the modification into the hook where the value is constructed.
3 | component Foo() {
4 | const foo = useFoo();
> 5 | foo.current = true;
| ^^^ value cannot be modified
6 | return <div />;
7 | }
8 |
3 | component Foo() {
4 | const foo = useFoo();
> 5 | foo.current = true;
| ^^^ Hint: If this value is a Ref (value returned by `useRef()`), rename the variable to end in "Ref".
6 | return <div />;
7 | }
8 |
```

View File

@@ -0,0 +1,7 @@
// @flow
component Foo() {
const foo = useFoo();
foo.current = true;
return <div />;
}

View File

@@ -24,15 +24,13 @@ function BadExample() {
```
Found 1 error:
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
This effect updates state based on other state values. Consider calculating this value directly during render.
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.invalid-derived-computation-in-effect.ts:9:4
7 | const [fullName, setFullName] = useState('');
8 | useEffect(() => {
> 9 | setFullName(capitalize(firstName + ' ' + lastName));
| ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
10 | }, [firstName, lastName]);
11 |
12 | return <div>{fullName}</div>;

View File

@@ -1,39 +0,0 @@
## Input
```javascript
// @enableNewMutationAliasingModel:false
function Foo() {
const x = () => {
window.href = 'foo';
};
const y = {x};
return <Bar y={y} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [],
};
```
## Error
```
Found 1 error:
Error: Modifying a variable defined outside a component or hook is not allowed. Consider using an effect
error.object-capture-global-mutation.ts:4:4
2 | function Foo() {
3 | const x = () => {
> 4 | window.href = 'foo';
| ^^^^^^ Modifying a variable defined outside a component or hook is not allowed. Consider using an effect
5 | };
6 | const y = {x};
7 | return <Bar y={y} />;
```

View File

@@ -0,0 +1,49 @@
## Input
```javascript
function Foo() {
const x = () => {
window.href = 'foo';
};
const y = {x};
return <Bar y={y} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
function Foo() {
const $ = _c(1);
const x = _temp;
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
const y = { x };
t0 = <Bar y={y} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
function _temp() {
window.href = "foo";
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [],
};
```
### Eval output
(kind: exception) Bar is not defined

View File

@@ -1,87 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({initialName}) {
const [name, setName] = useState('');
useEffect(() => {
setName(initialName);
}, []);
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{initialName: 'John'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects
import { useEffect, useState } from "react";
function Component(t0) {
const $ = _c(6);
const { initialName } = t0;
const [name, setName] = useState("");
let t1;
if ($[0] !== initialName) {
t1 = () => {
setName(initialName);
};
$[0] = initialName;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = [];
$[2] = t2;
} else {
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
t3 = (e) => setName(e.target.value);
$[3] = t3;
} else {
t3 = $[3];
}
let t4;
if ($[4] !== name) {
t4 = (
<div>
<input value={name} onChange={t3} />
</div>
);
$[4] = name;
$[5] = t4;
} else {
t4 = $[5];
}
return t4;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ initialName: "John" }],
};
```
### Eval output
(kind: ok) <div><input value="John"></div>

View File

@@ -1,21 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({initialName}) {
const [name, setName] = useState('');
useEffect(() => {
setName(initialName);
}, []);
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{initialName: 'John'}],
};

View File

@@ -1,79 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({value, enabled}) {
const [localValue, setLocalValue] = useState('');
useEffect(() => {
if (enabled) {
setLocalValue(value);
} else {
setLocalValue('disabled');
}
}, [value, enabled]);
return <div>{localValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 'test', enabled: true}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects
import { useEffect, useState } from "react";
function Component(t0) {
const $ = _c(6);
const { value, enabled } = t0;
const [localValue, setLocalValue] = useState("");
let t1;
let t2;
if ($[0] !== enabled || $[1] !== value) {
t1 = () => {
if (enabled) {
setLocalValue(value);
} else {
setLocalValue("disabled");
}
};
t2 = [value, enabled];
$[0] = enabled;
$[1] = value;
$[2] = t1;
$[3] = t2;
} else {
t1 = $[2];
t2 = $[3];
}
useEffect(t1, t2);
let t3;
if ($[4] !== localValue) {
t3 = <div>{localValue}</div>;
$[4] = localValue;
$[5] = t3;
} else {
t3 = $[5];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ value: "test", enabled: true }],
};
```
### Eval output
(kind: ok) <div>test</div>

View File

@@ -1,21 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({value, enabled}) {
const [localValue, setLocalValue] = useState('');
useEffect(() => {
if (enabled) {
setLocalValue(value);
} else {
setLocalValue('disabled');
}
}, [value, enabled]);
return <div>{localValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 'test', enabled: true}],
};

View File

@@ -1,74 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({value}) {
const [localValue, setLocalValue] = useState('');
useEffect(() => {
console.log('Value changed:', value);
setLocalValue(value);
document.title = `Value: ${value}`;
}, [value]);
return <div>{localValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 'test'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects
import { useEffect, useState } from "react";
function Component(t0) {
const $ = _c(5);
const { value } = t0;
const [localValue, setLocalValue] = useState("");
let t1;
let t2;
if ($[0] !== value) {
t1 = () => {
console.log("Value changed:", value);
setLocalValue(value);
document.title = `Value: ${value}`;
};
t2 = [value];
$[0] = value;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== localValue) {
t3 = <div>{localValue}</div>;
$[3] = localValue;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ value: "test" }],
};
```
### Eval output
(kind: ok) <div>test</div>
logs: ['Value changed:','test']

View File

@@ -1,19 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({value}) {
const [localValue, setLocalValue] = useState('');
useEffect(() => {
console.log('Value changed:', value);
setLocalValue(value);
document.title = `Value: ${value}`;
}, [value]);
return <div>{localValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 'test'}],
};

View File

@@ -1,51 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({prefix}) {
const [name, setName] = useState('');
const [displayName, setDisplayName] = useState('');
useEffect(() => {
setDisplayName(prefix + name);
}, [prefix, name]);
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<div>{displayName}</div>
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{prefix: 'Hello, '}],
};
```
## Error
```
Found 1 error:
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
This effect updates state based on other state values. Consider calculating this value directly during render.
error.bug-derived-state-from-mixed-deps.ts:9:4
7 |
8 | useEffect(() => {
> 9 | setDisplayName(prefix + name);
| ^^^^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
10 | }, [prefix, name]);
11 |
12 | return (
```

View File

@@ -1,23 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({prefix}) {
const [name, setName] = useState('');
const [displayName, setDisplayName] = useState('');
useEffect(() => {
setDisplayName(prefix + name);
}, [prefix, name]);
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<div>{displayName}</div>
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{prefix: 'Hello, '}],
};

View File

@@ -1,45 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({user: {firstName, lastName}}) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{user: {firstName: 'John', lastName: 'Doe'}}],
};
```
## Error
```
Found 1 error:
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
This effect updates state based on other state values. Consider calculating this value directly during render.
error.invalid-derived-state-from-props-destructured.ts:8:4
6 |
7 | useEffect(() => {
> 8 | setFullName(firstName + ' ' + lastName);
| ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
9 | }, [firstName, lastName]);
10 |
11 | return <div>{fullName}</div>;
```

View File

@@ -1,17 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({firstName, lastName}) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstName: 'John', lastName: 'Doe'}],
};

View File

@@ -1,45 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({firstName, lastName}) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstName: 'John', lastName: 'Doe'}],
};
```
## Error
```
Found 1 error:
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
This effect updates state based on other state values. Consider calculating this value directly during render.
error.invalid-derived-state-from-props-in-effect.ts:8:4
6 |
7 | useEffect(() => {
> 8 | setFullName(firstName + ' ' + lastName);
| ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
9 | }, [firstName, lastName]);
10 |
11 | return <div>{fullName}</div>;
```

View File

@@ -1,17 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({firstName, lastName}) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstName: 'John', lastName: 'Doe'}],
};

View File

@@ -1,53 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component() {
const [firstName, setFirstName] = useState('John');
const [lastName, setLastName] = useState('Doe');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return (
<div>
<input value={firstName} onChange={e => setFirstName(e.target.value)} />
<input value={lastName} onChange={e => setLastName(e.target.value)} />
<div>{fullName}</div>
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
};
```
## Error
```
Found 1 error:
Error: You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
This effect updates state based on other state values. Consider calculating this value directly during render.
error.invalid-derived-state-from-state-in-effect.ts:10:4
8 |
9 | useEffect(() => {
> 10 | setFullName(firstName + ' ' + lastName);
| ^^^^^^^^^^^ You may not need this effect. Values derived from state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
11 | }, [firstName, lastName]);
12 |
13 | return (
```

View File

@@ -1,25 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component() {
const [firstName, setFirstName] = useState('John');
const [lastName, setLastName] = useState('Doe');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return (
<div>
<input value={firstName} onChange={e => setFirstName(e.target.value)} />
<input value={lastName} onChange={e => setLastName(e.target.value)} />
<div>{fullName}</div>
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
};

View File

@@ -1,72 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component(props) {
const [displayValue, setDisplayValue] = useState('');
useEffect(() => {
const computed = props.prefix + props.value + props.suffix;
setDisplayValue(computed);
}, [props.prefix, props.value, props.suffix]);
return <div>{displayValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{prefix: '[', value: 'test', suffix: ']'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects
import { useEffect, useState } from "react";
function Component(props) {
const $ = _c(7);
const [displayValue, setDisplayValue] = useState("");
let t0;
let t1;
if ($[0] !== props.prefix || $[1] !== props.suffix || $[2] !== props.value) {
t0 = () => {
const computed = props.prefix + props.value + props.suffix;
setDisplayValue(computed);
};
t1 = [props.prefix, props.value, props.suffix];
$[0] = props.prefix;
$[1] = props.suffix;
$[2] = props.value;
$[3] = t0;
$[4] = t1;
} else {
t0 = $[3];
t1 = $[4];
}
useEffect(t0, t1);
let t2;
if ($[5] !== displayValue) {
t2 = <div>{displayValue}</div>;
$[5] = displayValue;
$[6] = t2;
} else {
t2 = $[6];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ prefix: "[", value: "test", suffix: "]" }],
};
```
### Eval output
(kind: ok) <div>[test]</div>

View File

@@ -1,18 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component(props) {
const [displayValue, setDisplayValue] = useState('');
useEffect(() => {
const computed = props.prefix + props.value + props.suffix;
setDisplayValue(computed);
}, [props.prefix, props.value, props.suffix]);
return <div>{displayValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{prefix: '[', value: 'test', suffix: ']'}],
};

View File

@@ -71,10 +71,10 @@
"scripts": {
"predev": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;",
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;",
"dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"",
"dev": "concurrently \"yarn run dev:region\" \"yarn run dev:global\"",
"dev:global": "NODE_ENV=development BUILD_PATH=dist node --experimental-loader ./loader/global.js --inspect=127.0.0.1:9230 server/global",
"dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --enable-source-maps --experimental-loader ./loader/region.js --conditions=react-server --inspect=127.0.0.1:9229 server/region",
"start": "node scripts/build.js && concurrently \"npm run start:region\" \"npm run start:global\"",
"start": "node scripts/build.js && concurrently \"yarn run start:region\" \"yarn 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",
"build": "node scripts/build.js",

View File

@@ -2169,6 +2169,29 @@ describe('ReactInternalTestUtils console assertions', () => {
+ Bye in div (at **)"
`);
});
// @gate __DEV__
it('fails if last received error containing "undefined" is not included', () => {
const message = expectToThrowFailure(() => {
console.error('Hi');
console.error(
"TypeError: Cannot read properties of undefined (reading 'stack')\n" +
' in Foo (at **)'
);
assertConsoleErrorDev([['Hi', {withoutStack: true}]]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Unexpected error(s) recorded.
- Expected errors
+ Received errors
Hi
+ TypeError: Cannot read properties of undefined (reading 'stack') in Foo (at **)"
`);
});
// @gate __DEV__
it('fails if only error does not contain a stack', () => {
const message = expectToThrowFailure(() => {

View File

@@ -355,7 +355,7 @@ export function createLogAssertion(
let argIndex = 0;
// console.* could have been called with a non-string e.g. `console.error(new Error())`
// eslint-disable-next-line react-internal/safe-string-coercion
String(format).replace(/%s|%c/g, () => argIndex++);
String(format).replace(/%s|%c|%o/g, () => argIndex++);
if (argIndex !== args.length) {
if (format.includes('%c%s')) {
// We intentionally use mismatching formatting when printing badging because we don't know
@@ -382,8 +382,9 @@ export function createLogAssertion(
// Main logic to check if log is expected, with the component stack.
if (
normalizedMessage === expectedMessage ||
normalizedMessage.includes(expectedMessage)
typeof expectedMessage === 'string' &&
(normalizedMessage === expectedMessage ||
normalizedMessage.includes(expectedMessage))
) {
if (isLikelyAComponentStack(normalizedMessage)) {
if (expectedWithoutStack === true) {

View File

@@ -30,7 +30,7 @@ export function injectInternals(internals: Object): boolean {
} catch (err) {
// Catch all errors because it is unsafe to throw during initialization.
if (__DEV__) {
console.error('React instrumentation encountered an error: %s.', err);
console.error('React instrumentation encountered an error: %o.', err);
}
}
if (hook.checkDCE) {

View File

@@ -141,7 +141,7 @@ function patchConsoleForTestingBeforeHookInstallation() {
// if they use this code path.
firstArg = firstArg.slice(9);
}
if (firstArg === 'React instrumentation encountered an error: %s') {
if (firstArg === 'React instrumentation encountered an error: %o') {
// Rethrow errors from React.
throw args[1];
} else if (

View File

@@ -2696,4 +2696,83 @@ describe('Store', () => {
<ClientComponent key="D">
`);
});
// @reactVersion >= 18.0
it('can reconcile Suspense in fallback positions', async () => {
let resolveFallback;
const fallbackPromise = new Promise(resolve => {
resolveFallback = resolve;
});
let resolveContent;
const contentPromise = new Promise(resolve => {
resolveContent = resolve;
});
function Component({children, promise}) {
if (promise) {
React.use(promise);
}
return <div>{children}</div>;
}
await actAsync(() =>
render(
<React.Suspense
name="content"
fallback={
<React.Suspense
name="fallback"
fallback={
<Component key="fallback-fallback">
Loading fallback...
</Component>
}>
<Component key="fallback-content" promise={fallbackPromise}>
Loading...
</Component>
</React.Suspense>
}>
<Component key="content" promise={contentPromise}>
done
</Component>
</React.Suspense>,
),
);
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense name="content">
▾ <Suspense name="fallback">
<Component key="fallback-fallback">
[shell]
<Suspense name="content" rects={null}>
<Suspense name="fallback" rects={null}>
`);
await actAsync(() => {
resolveFallback();
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense name="content">
▾ <Suspense name="fallback">
<Component key="fallback-content">
[shell]
<Suspense name="content" rects={null}>
<Suspense name="fallback" rects={[{x:1,y:2,width:10,height:1}]}>
`);
await actAsync(() => {
resolveContent();
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense name="content">
<Component key="content">
[shell]
<Suspense name="content" rects={[{x:1,y:2,width:4,height:1}]}>
`);
});
});

View File

@@ -367,6 +367,7 @@ export function getInternalReactConstants(version: string): {
ReactPriorityLevels: ReactPriorityLevelsType,
ReactTypeOfWork: WorkTagMap,
StrictModeBits: number,
SuspenseyImagesMode: number,
} {
// **********************************************************
// The section below is copied from files in React repo.
@@ -407,6 +408,8 @@ export function getInternalReactConstants(version: string): {
StrictModeBits = 0b10;
}
const SuspenseyImagesMode = 0b0100000;
let ReactTypeOfWork: WorkTagMap = ((null: any): WorkTagMap);
// **********************************************************
@@ -820,6 +823,7 @@ export function getInternalReactConstants(version: string): {
ReactPriorityLevels,
ReactTypeOfWork,
StrictModeBits,
SuspenseyImagesMode,
};
}
@@ -988,6 +992,7 @@ export function attach(
ReactPriorityLevels,
ReactTypeOfWork,
StrictModeBits,
SuspenseyImagesMode,
} = getInternalReactConstants(version);
const {
ActivityComponent,
@@ -2930,7 +2935,7 @@ export function attach(
}
if (suspenseNode.parent !== parentNode) {
throw new Error(
'Cannot remove a node from a different parent than is being reconciled.',
'Cannot remove a Suspense node from a different parent than is being reconciled.',
);
}
let previousSuspenseSibling = remainingReconcilingChildrenSuspenseNodes;
@@ -3345,6 +3350,114 @@ export function attach(
insertSuspendedBy(asyncInfo);
}
function trackDebugInfoFromHostComponent(
devtoolsInstance: DevToolsInstance,
fiber: Fiber,
): void {
if (fiber.tag !== HostComponent) {
return;
}
if ((fiber.mode & SuspenseyImagesMode) === 0) {
// In any released version, Suspensey Images are only enabled inside a ViewTransition
// subtree, which is enabled by the SuspenseyImagesMode.
// TODO: If we ever enable the enableSuspenseyImages flag then it would be enabled for
// all images and we'd need some other check for if the version of React has that enabled.
return;
}
const type = fiber.type;
const props: {
src?: string,
onLoad?: (event: any) => void,
loading?: 'eager' | 'lazy',
...
} = fiber.memoizedProps;
const maySuspendCommit =
type === 'img' &&
props.src != null &&
props.src !== '' &&
props.onLoad == null &&
props.loading !== 'lazy';
// Note: We don't track "maySuspendCommitOnUpdate" separately because it doesn't matter if
// it didn't suspend this particular update if it would've suspended if it mounted in this
// state, since we're tracking the dependencies inside the current state.
if (!maySuspendCommit) {
return;
}
const instance = fiber.stateNode;
if (instance == null) {
// Should never happen.
return;
}
// Unlike props.src, currentSrc will be fully qualified which we need for comparison below.
// Unlike instance.src it will be resolved into the media queries currently matching which is
// the state we're inspecting.
const src = instance.currentSrc;
if (typeof src !== 'string' || src === '') {
return;
}
let start = -1;
let end = -1;
let fileSize = 0;
// $FlowFixMe[method-unbinding]
if (typeof performance.getEntriesByType === 'function') {
// We may be able to collect the start and end time of this resource from Performance Observer.
const resourceEntries = performance.getEntriesByType('resource');
for (let i = 0; i < resourceEntries.length; i++) {
const resourceEntry = resourceEntries[i];
if (resourceEntry.name === src) {
start = resourceEntry.startTime;
end = start + resourceEntry.duration;
// $FlowFixMe[prop-missing]
fileSize = (resourceEntry.encodedBodySize: any) || 0;
}
}
}
// A representation of the image data itself.
// TODO: We could render a little preview in the front end from the resource API.
const value: {
currentSrc: string,
naturalWidth?: number,
naturalHeight?: number,
fileSize?: number,
} = {
currentSrc: src,
};
if (instance.naturalWidth > 0 && instance.naturalHeight > 0) {
// The intrinsic size of the file value itself, if it's loaded
value.naturalWidth = instance.naturalWidth;
value.naturalHeight = instance.naturalHeight;
}
if (fileSize > 0) {
// Cross-origin images won't have a file size that we can access.
value.fileSize = fileSize;
}
const promise = Promise.resolve(value);
(promise: any).status = 'fulfilled';
(promise: any).value = value;
const ioInfo: ReactIOInfo = {
name: 'img',
start,
end,
value: promise,
// $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file.
owner: fiber, // Allow linking to the <link> if it's not filtered.
};
const asyncInfo: ReactAsyncInfo = {
awaited: ioInfo,
// $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file.
owner: fiber._debugOwner == null ? null : fiber._debugOwner,
debugStack: fiber._debugStack == null ? null : fiber._debugStack,
debugTask: fiber._debugTask == null ? null : fiber._debugTask,
};
insertSuspendedBy(asyncInfo);
}
function mountVirtualChildrenRecursively(
firstChild: Fiber,
lastChild: null | Fiber, // non-inclusive
@@ -3619,6 +3732,7 @@ export function attach(
throw new Error('Did not expect a host hoistable to be the root');
}
aquireHostInstance(nearestInstance, fiber.stateNode);
trackDebugInfoFromHostComponent(nearestInstance, fiber);
}
if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
@@ -4447,20 +4561,22 @@ export function attach(
aquireHostResource(nearestInstance, nextFiber.memoizedState);
trackDebugInfoFromHostResource(nearestInstance, nextFiber);
} else if (
(nextFiber.tag === HostComponent ||
nextFiber.tag === HostText ||
nextFiber.tag === HostSingleton) &&
prevFiber.stateNode !== nextFiber.stateNode
nextFiber.tag === HostComponent ||
nextFiber.tag === HostText ||
nextFiber.tag === HostSingleton
) {
// In persistent mode, it's possible for the stateNode to update with
// a new clone. In that case we need to release the old one and aquire
// new one instead.
const nearestInstance = reconcilingParent;
if (nearestInstance === null) {
throw new Error('Did not expect a host hoistable to be the root');
}
releaseHostInstance(nearestInstance, prevFiber.stateNode);
aquireHostInstance(nearestInstance, nextFiber.stateNode);
if (prevFiber.stateNode !== nextFiber.stateNode) {
// In persistent mode, it's possible for the stateNode to update with
// a new clone. In that case we need to release the old one and aquire
// new one instead.
releaseHostInstance(nearestInstance, prevFiber.stateNode);
aquireHostInstance(nearestInstance, nextFiber.stateNode);
}
trackDebugInfoFromHostComponent(nearestInstance, nextFiber);
}
let updateFlags = NoUpdate;
@@ -4620,26 +4736,30 @@ export function attach(
);
shouldMeasureSuspenseNode = false;
if (nextFallbackFiber !== null) {
if (prevFallbackFiber !== null || nextFallbackFiber !== null) {
const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode;
const fallbackStashedSuspensePrevious =
previouslyReconciledSiblingSuspenseNode;
const fallbackStashedSuspenseRemaining =
remainingReconcilingChildrenSuspenseNodes;
// Next, we'll pop back out of the SuspenseNode that we added above and now we'll
// reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode.
// reconcile the fallback, reconciling anything in the context of the parent SuspenseNode.
// Since the fallback conceptually blocks the parent.
reconcilingParentSuspenseNode = stashedSuspenseParent;
previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
try {
updateFlags |= updateVirtualChildrenRecursively(
nextFallbackFiber,
null,
prevFallbackFiber,
traceNearestHostComponentUpdate,
0,
);
if (nextFallbackFiber === null) {
unmountRemainingChildren();
} else {
updateFlags |= updateVirtualChildrenRecursively(
nextFallbackFiber,
null,
prevFallbackFiber,
traceNearestHostComponentUpdate,
0,
);
}
} finally {
reconcilingParentSuspenseNode = fallbackStashedSuspenseParent;
previouslyReconciledSiblingSuspenseNode =
@@ -4647,7 +4767,8 @@ export function attach(
remainingReconcilingChildrenSuspenseNodes =
fallbackStashedSuspenseRemaining;
}
} else if (nextFiber.memoizedState === null) {
}
if (nextFiber.memoizedState === null) {
// Measure this Suspense node in case it changed. We don't update the rect while
// we're inside a disconnected subtree nor if we are the Suspense boundary that
// is suspended. This lets us keep the rectangle of the displayed content while
@@ -5152,6 +5273,18 @@ export function attach(
}
}
function getNearestSuspenseNode(instance: DevToolsInstance): SuspenseNode {
while (instance.suspenseNode === null) {
if (instance.parent === null) {
throw new Error(
'There should always be a SuspenseNode parent on a mounted instance.',
);
}
instance = instance.parent;
}
return instance.suspenseNode;
}
function getNearestMountedDOMNode(publicInstance: Element): null | Element {
let domNode: null | Element = publicInstance;
while (domNode && !publicInstanceToDevToolsInstanceMap.has(domNode)) {
@@ -5440,6 +5573,56 @@ export function attach(
return result;
}
const FALLBACK_THROTTLE_MS: number = 300;
function getSuspendedByRange(
suspenseNode: SuspenseNode,
): null | [number, number] {
let min = Infinity;
let max = -Infinity;
suspenseNode.suspendedBy.forEach((_, ioInfo) => {
if (ioInfo.end > max) {
max = ioInfo.end;
}
if (ioInfo.start < min) {
min = ioInfo.start;
}
});
const parentSuspenseNode = suspenseNode.parent;
if (parentSuspenseNode !== null) {
let parentMax = -Infinity;
parentSuspenseNode.suspendedBy.forEach((_, ioInfo) => {
if (ioInfo.end > parentMax) {
parentMax = ioInfo.end;
}
});
// The parent max is theoretically the earlier the parent could've committed.
// Therefore, the theoretical max that the child could be throttled is that plus 300ms.
const throttleTime = parentMax + FALLBACK_THROTTLE_MS;
if (throttleTime > max) {
// If the theoretical throttle time is later than the earliest reveal then we extend
// the max time to show that this is timespan could possibly get throttled.
max = throttleTime;
}
// We use the end of the previous boundary as the start time for this boundary unless,
// that's earlier than we'd need to expand to the full fallback throttle range. It
// suggests that the parent was loaded earlier than this one.
let startTime = max - FALLBACK_THROTTLE_MS;
if (parentMax > startTime) {
startTime = parentMax;
}
// If the first fetch of this boundary starts before that, then we use that as the start.
if (startTime < min) {
min = startTime;
}
}
if (min < Infinity && max > -Infinity) {
return [min, max];
}
return null;
}
function getAwaitStackFromHooks(
hooks: HooksTree,
asyncInfo: ReactAsyncInfo,
@@ -5893,6 +6076,11 @@ export function attach(
nativeTag = getNativeTag(fiber.stateNode);
}
let isSuspended: boolean | null = null;
if (tag === SuspenseComponent) {
isSuspended = memoizedState !== null;
}
const suspendedBy =
fiberInstance.suspenseNode !== null
? // If this is a Suspense boundary, then we include everything in the subtree that might suspend
@@ -5908,6 +6096,10 @@ export function attach(
: fiberInstance.suspendedBy.map(info =>
serializeAsyncInfo(info, fiberInstance, hooks),
);
const suspendedByRange = getSuspendedByRange(
getNearestSuspenseNode(fiberInstance),
);
return {
id: fiberInstance.id,
@@ -5939,6 +6131,7 @@ export function attach(
forceFallbackForFibers.has(fiber) ||
(fiber.alternate !== null &&
forceFallbackForFibers.has(fiber.alternate))),
isSuspended: isSuspended,
source,
@@ -5970,6 +6163,7 @@ export function attach(
: Array.from(componentLogsEntry.warnings.entries()),
suspendedBy: suspendedBy,
suspendedByRange: suspendedByRange,
// List of owners
owners,
@@ -6026,8 +6220,12 @@ export function attach(
const componentLogsEntry =
componentInfoToComponentLogsMap.get(componentInfo);
const isSuspended = null;
// Things that Suspended this Server Component (use(), awaits and direct child promises)
const suspendedBy = virtualInstance.suspendedBy;
const suspendedByRange = getSuspendedByRange(
getNearestSuspenseNode(virtualInstance),
);
return {
id: virtualInstance.id,
@@ -6044,6 +6242,7 @@ export function attach(
isErrored: false,
canToggleSuspense: supportsTogglingSuspense && hasSuspenseBoundary,
isSuspended: isSuspended,
source,
@@ -6080,6 +6279,7 @@ export function attach(
: suspendedBy.map(info =>
serializeAsyncInfo(info, virtualInstance, null),
),
suspendedByRange: suspendedByRange,
// List of owners
owners,

View File

@@ -836,6 +836,7 @@ export function attach(
// Suspense did not exist in legacy versions
canToggleSuspense: false,
isSuspended: null,
source: null,
@@ -858,6 +859,7 @@ export function attach(
// Not supported in legacy renderers.
suspendedBy: [],
suspendedByRange: null,
// List of owners
owners,

View File

@@ -285,6 +285,8 @@ export type InspectedElement = {
// Is this Suspense, and can its value be overridden now?
canToggleSuspense: boolean,
// If this Element is suspended. Currently only set on Suspense boundaries.
isSuspended: boolean | null,
// Does the component have legacy context attached to it.
hasLegacyContext: boolean,
@@ -300,6 +302,7 @@ export type InspectedElement = {
// Things that suspended this Instances
suspendedBy: Object, // DehydratedData or Array<SerializedAsyncInfo>
suspendedByRange: null | [number, number],
// List of owners
owners: Array<SerializedElement> | null,

View File

@@ -251,6 +251,7 @@ export function convertInspectedElementBackendToFrontend(
canToggleError,
isErrored,
canToggleSuspense,
isSuspended,
hasLegacyContext,
id,
type,
@@ -270,6 +271,7 @@ export function convertInspectedElementBackendToFrontend(
errors,
warnings,
suspendedBy,
suspendedByRange,
nativeTag,
} = inspectedElementBackend;
@@ -286,6 +288,7 @@ export function convertInspectedElementBackendToFrontend(
canToggleError,
isErrored,
canToggleSuspense,
isSuspended,
hasLegacyContext,
id,
key,
@@ -313,6 +316,7 @@ export function convertInspectedElementBackendToFrontend(
hydratedSuspendedBy == null // backwards compat
? []
: hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo),
suspendedByRange,
nativeTag,
};

View File

@@ -24,7 +24,6 @@ import FetchFileWithCachingContext from './FetchFileWithCachingContext';
import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource';
import OpenInEditorButton from './OpenInEditorButton';
import InspectedElementViewSourceButton from './InspectedElementViewSourceButton';
import Skeleton from './Skeleton';
import useEditorURL from '../useEditorURL';
import styles from './InspectedElement.css';
@@ -114,7 +113,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
element !== null &&
element.type === ElementTypeSuspense &&
inspectedElement != null &&
inspectedElement.state != null;
inspectedElement.isSuspended;
const canToggleError =
!hideToggleErrorAction &&
@@ -203,7 +202,9 @@ export default function InspectedElementWrapper(_: Props): React.Node {
}
return (
<div className={styles.InspectedElement}>
<div
className={styles.InspectedElement}
key={inspectedElementID /* Force reset when selected Element changes */}>
<div className={styles.TitleRow} data-testname="InspectedElement-Title">
{strictModeBadge}
@@ -232,13 +233,11 @@ export default function InspectedElementWrapper(_: Props): React.Node {
!!editorURL &&
source != null &&
symbolicatedSourcePromise != null && (
<React.Suspense fallback={<Skeleton height={16} width={24} />}>
<OpenInEditorButton
editorURL={editorURL}
source={source}
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
</React.Suspense>
<OpenInEditorButton
editorURL={editorURL}
source={source}
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
)}
{canToggleError && (
@@ -294,9 +293,6 @@ export default function InspectedElementWrapper(_: Props): React.Node {
{inspectedElement !== null && symbolicatedSourcePromise != null && (
<InspectedElementView
key={
inspectedElementID /* Force reset when selected Element changes */
}
element={element}
hookNames={hookNames}
inspectedElement={inspectedElement}

View File

@@ -36,7 +36,12 @@ function InspectedElementSourcePanel({
<div className={styles.SourceHeaderRow}>
<div className={styles.SourceHeader}>source</div>
<React.Suspense fallback={<Skeleton height={16} width={16} />}>
<React.Suspense
fallback={
<Button disabled={true} title="Loading source maps...">
<ButtonIcon type="copy" />
</Button>
}>
<CopySourceButton
source={source}
symbolicatedSourcePromise={symbolicatedSourcePromise}

View File

@@ -292,7 +292,7 @@ export default function InspectedElementSuspendedBy({
inspectedElement,
store,
}: Props): React.Node {
const {suspendedBy} = inspectedElement;
const {suspendedBy, suspendedByRange} = inspectedElement;
// Skip the section if nothing suspended this component.
if (suspendedBy == null || suspendedBy.length === 0) {
@@ -306,6 +306,11 @@ export default function InspectedElementSuspendedBy({
let minTime = Infinity;
let maxTime = -Infinity;
if (suspendedByRange !== null) {
// The range of the whole suspense boundary.
minTime = suspendedByRange[0];
maxTime = suspendedByRange[1];
}
for (let i = 0; i < suspendedBy.length; i++) {
const asyncInfo: SerializedAsyncInfo = suspendedBy[i];
if (asyncInfo.awaited.start < minTime) {

View File

@@ -30,15 +30,13 @@ export default function InspectedElementSuspenseToggle({
}: Props): React.Node {
const {readOnly} = React.useContext(OptionsContext);
const {id, state, type} = inspectedElement;
const {id, isSuspended, type} = inspectedElement;
const canToggleSuspense = !readOnly && inspectedElement.canToggleSuspense;
if (type !== ElementTypeSuspense) {
return null;
}
const isSuspended = state !== null;
const toggleSuspense = (path: any, value: boolean) => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID !== null) {

View File

@@ -24,3 +24,7 @@
overflow: hidden;
text-overflow: ellipsis;
}
.RenderedBySkeleton {
padding-left: 1.25rem;
}

View File

@@ -24,6 +24,7 @@ import {enableStyleXFeatures} from 'react-devtools-feature-flags';
import InspectedElementSourcePanel from './InspectedElementSourcePanel';
import StackTraceView from './StackTraceView';
import OwnerView from './OwnerView';
import Skeleton from './Skeleton';
import styles from './InspectedElementView.css';
@@ -170,34 +171,40 @@ export default function InspectedElementView({
className={styles.InspectedElementSection}
data-testname="InspectedElementView-Owners">
<div className={styles.OwnersHeader}>rendered by</div>
<React.Suspense
fallback={
<div className={styles.RenderedBySkeleton}>
<Skeleton height={16} width="40%" />
</div>
}>
{showStack ? <StackTraceView stack={stack} /> : null}
{showOwnersList &&
owners?.map(owner => (
<Fragment key={owner.id}>
<OwnerView
displayName={owner.displayName || 'Anonymous'}
hocDisplayNames={owner.hocDisplayNames}
environmentName={
inspectedElement.env === owner.env ? null : owner.env
}
compiledWithForget={owner.compiledWithForget}
id={owner.id}
isInStore={store.containsElement(owner.id)}
type={owner.type}
/>
{owner.stack != null && owner.stack.length > 0 ? (
<StackTraceView stack={owner.stack} />
) : null}
</Fragment>
))}
{showStack ? <StackTraceView stack={stack} /> : null}
{showOwnersList &&
owners?.map(owner => (
<Fragment key={owner.id}>
<OwnerView
displayName={owner.displayName || 'Anonymous'}
hocDisplayNames={owner.hocDisplayNames}
environmentName={
inspectedElement.env === owner.env ? null : owner.env
}
compiledWithForget={owner.compiledWithForget}
id={owner.id}
isInStore={store.containsElement(owner.id)}
type={owner.type}
/>
{owner.stack != null && owner.stack.length > 0 ? (
<StackTraceView stack={owner.stack} />
) : null}
</Fragment>
))}
{rootType !== null && (
<div className={styles.OwnersMetaField}>{rootType}</div>
)}
{rendererLabel !== null && (
<div className={styles.OwnersMetaField}>{rendererLabel}</div>
)}
{rootType !== null && (
<div className={styles.OwnersMetaField}>{rootType}</div>
)}
{rendererLabel !== null && (
<div className={styles.OwnersMetaField}>{rendererLabel}</div>
)}
</React.Suspense>
</div>
)}

View File

@@ -11,7 +11,6 @@ import * as React from 'react';
import ButtonIcon from '../ButtonIcon';
import Button from '../Button';
import Skeleton from './Skeleton';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
@@ -27,7 +26,12 @@ function InspectedElementViewSourceButton({
symbolicatedSourcePromise,
}: Props): React.Node {
return (
<React.Suspense fallback={<Skeleton height={16} width={24} />}>
<React.Suspense
fallback={
<Button disabled={true} title="Loading source maps...">
<ButtonIcon type="view-source" />
</Button>
}>
<ActualSourceButton
source={source}
symbolicatedSourcePromise={symbolicatedSourcePromise}

View File

@@ -5,9 +5,9 @@
@keyframes pulse {
0%, 100% {
background-color: var(--color-dim);
background-color: none;
}
50% {
background-color: var(--color-dimmest)
background-color: var(--color-dimmest);
}
}

View File

@@ -24,7 +24,11 @@ type Props = {
className?: string,
};
function OpenInEditorButton({editorURL, source, className}: Props): React.Node {
function ActualOpenInEditorButton({
editorURL,
source,
className,
}: Props): React.Node {
let disable;
if (source == null) {
disable = true;
@@ -68,4 +72,22 @@ function OpenInEditorButton({editorURL, source, className}: Props): React.Node {
);
}
function OpenInEditorButton({editorURL, source, className}: Props): React.Node {
return (
<React.Suspense
fallback={
<Button disabled={true} className={className}>
<ButtonIcon type="editor" />
<ButtonLabel>Loading source maps...</ButtonLabel>
</Button>
}>
<ActualOpenInEditorButton
editorURL={editorURL}
source={source}
className={className}
/>
</React.Suspense>
);
}
export default OpenInEditorButton;

View File

@@ -0,0 +1,15 @@
.SuspenseRectsContainer {
padding: .25rem;
}
.SuspenseRect {
fill: transparent;
stroke: var(--color-background-selected);
stroke-width: 1px;
vector-effect: non-scaling-stroke;
paint-order: stroke;
}
[data-highlighted='true'] > .SuspenseRect {
fill: var(--color-selected-tree-highlight-active);
}

View File

@@ -0,0 +1,200 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type Store from 'react-devtools-shared/src/devtools/store';
import type {
SuspenseNode,
Rect,
} from 'react-devtools-shared/src/frontend/types';
import * as React from 'react';
import {useContext} from 'react';
import {
TreeDispatcherContext,
TreeStateContext,
} from '../Components/TreeContext';
import {StoreContext} from '../context';
import {useHighlightHostInstance} from '../hooks';
import styles from './SuspenseRects.css';
import {SuspenseTreeStateContext} from './SuspenseTreeContext';
function SuspenseRect({rect}: {rect: Rect}): React$Node {
return (
<rect
className={styles.SuspenseRect}
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
/>
);
}
function SuspenseRects({
suspenseID,
}: {
suspenseID: SuspenseNode['id'],
}): React$Node {
const dispatch = useContext(TreeDispatcherContext);
const store = useContext(StoreContext);
const {inspectedElementID} = useContext(TreeStateContext);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
const suspense = store.getSuspenseByID(suspenseID);
if (suspense === null) {
console.warn(`<Element> Could not find suspense node id ${suspenseID}`);
return null;
}
function handleClick(event: SyntheticMouseEvent<>) {
if (event.defaultPrevented) {
// Already clicked on an inner rect
return;
}
event.preventDefault();
dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID});
}
function handlePointerOver(event: SyntheticPointerEvent<>) {
if (event.defaultPrevented) {
// Already hovered an inner rect
return;
}
event.preventDefault();
highlightHostInstance(suspenseID);
}
function handlePointerLeave(event: SyntheticPointerEvent<>) {
if (event.defaultPrevented) {
// Already hovered an inner rect
return;
}
event.preventDefault();
clearHighlightHostInstance();
}
// TODO: Use the nearest Suspense boundary
const selected = inspectedElementID === suspenseID;
return (
<g
data-highlighted={selected}
onClick={handleClick}
onPointerOver={handlePointerOver}
onPointerLeave={handlePointerLeave}>
<title>{suspense.name}</title>
{suspense.rects !== null &&
suspense.rects.map((rect, index) => {
return <SuspenseRect key={index} rect={rect} />;
})}
{suspense.children.map(childID => {
return <SuspenseRects key={childID} suspenseID={childID} />;
})}
</g>
);
}
function getDocumentBoundingRect(
store: Store,
shells: $ReadOnlyArray<SuspenseNode['id']>,
): Rect {
if (shells.length === 0) {
return {x: 0, y: 0, width: 0, height: 0};
}
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (let i = 0; i < shells.length; i++) {
const shellID = shells[i];
const shell = store.getSuspenseByID(shellID);
if (shell === null) {
continue;
}
const rects = shell.rects;
if (rects === null) {
continue;
}
for (let j = 0; j < rects.length; j++) {
const rect = rects[j];
minX = Math.min(minX, rect.x);
minY = Math.min(minY, rect.y);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
}
if (minX === Number.POSITIVE_INFINITY) {
// No rects found, return empty rect
return {x: 0, y: 0, width: 0, height: 0};
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function SuspenseRectsShell({
shellID,
}: {
shellID: SuspenseNode['id'],
}): React$Node {
const store = useContext(StoreContext);
const shell = store.getSuspenseByID(shellID);
if (shell === null) {
console.warn(`<Element> Could not find suspense node id ${shellID}`);
return null;
}
return (
<g>
{shell.children.map(childID => {
return <SuspenseRects key={childID} suspenseID={childID} />;
})}
</g>
);
}
function SuspenseRectsContainer(): React$Node {
const store = useContext(StoreContext);
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
const {shells} = useContext(SuspenseTreeStateContext);
const boundingRect = getDocumentBoundingRect(store, shells);
const width = '100%';
const boundingRectWidth = boundingRect.width;
const height =
(boundingRectWidth === 0 ? 0 : boundingRect.height / boundingRect.width) *
100 +
'%';
return (
<div className={styles.SuspenseRectsContainer}>
<svg
style={{width, height}}
viewBox={`${boundingRect.x} ${boundingRect.y} ${boundingRect.width} ${boundingRect.height}`}>
{shells.map(shellID => {
return <SuspenseRectsShell key={shellID} shellID={shellID} />;
})}
</svg>
</div>
);
}
export default SuspenseRectsContainer;

View File

@@ -19,6 +19,7 @@ import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBo
import InspectedElement from '../Components/InspectedElement';
import portaledContent from '../portaledContent';
import styles from './SuspenseTab.css';
import SuspenseRects from './SuspenseRects';
import SuspenseTreeList from './SuspenseTreeList';
import Button from '../Button';
@@ -48,10 +49,6 @@ function SuspenseTimeline() {
return <div className={styles.Timeline}>timeline</div>;
}
function SuspenseRects() {
return <div>rects</div>;
}
function ToggleTreeList({
dispatch,
state,

View File

@@ -17,9 +17,12 @@ import {
useMemo,
useReducer,
} from 'react';
import type {SuspenseNode} from '../../../frontend/types';
import {StoreContext} from '../context';
export type SuspenseTreeState = {};
export type SuspenseTreeState = {
shells: $ReadOnlyArray<SuspenseNode['id']>,
};
type ACTION_HANDLE_SUSPENSE_TREE_MUTATION = {
type: 'HANDLE_SUSPENSE_TREE_MUTATION',
@@ -56,7 +59,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
const {type} = action;
switch (type) {
case 'HANDLE_SUSPENSE_TREE_MUTATION':
return {...state};
return {...state, shells: store.roots};
default:
throw new Error(`Unrecognized action "${type}"`);
}
@@ -64,7 +67,10 @@ function SuspenseTreeContextController({children}: Props): React.Node {
[],
);
const [state, dispatch] = useReducer(reducer, {});
const initialState: SuspenseTreeState = {
shells: store.roots,
};
const [state, dispatch] = useReducer(reducer, initialState);
const transitionDispatch = useMemo(
() => (action: SuspenseTreeAction) =>
startTransition(() => {

View File

@@ -264,6 +264,8 @@ export type InspectedElement = {
// Is this Suspense, and can its value be overridden now?
canToggleSuspense: boolean,
// If this Element is suspended. Currently only set on Suspense boundaries.
isSuspended: boolean | null,
// Does the component have legacy context attached to it.
hasLegacyContext: boolean,
@@ -279,6 +281,8 @@ export type InspectedElement = {
// Things that suspended this Instances
suspendedBy: Object,
// Minimum start time to maximum end time + a potential (not actual) throttle, within the nearest boundary.
suspendedByRange: null | [number, number],
// List of owners
owners: Array<SerializedElement> | null,

View File

@@ -38,9 +38,15 @@ import hasOwnProperty from 'shared/hasOwnProperty';
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
import {
isFiberContainedBy,
isFiberContainedByFragment,
isFiberFollowing,
isFiberPreceding,
isFragmentContainedByFiber,
traverseFragmentInstance,
getFragmentParentHostFiber,
getInstanceFromHostFiber,
traverseFragmentInstanceDeeply,
fiberIsPortaledIntoHost,
} from 'react-reconciler/src/ReactFiberTreeReflection';
export {
@@ -63,13 +69,7 @@ import {
markNodeAsHoistable,
isOwnedInstance,
} from './ReactDOMComponentTree';
import {
traverseFragmentInstance,
getFragmentParentHostFiber,
getNextSiblingHostFiber,
getInstanceFromHostFiber,
traverseFragmentInstanceDeeply,
} from 'react-reconciler/src/ReactFiberTreeReflection';
import {compareDocumentPositionForEmptyFragment} from 'shared/ReactDOMFragmentRefShared';
export {detachDeletedInstance};
import {hasRole} from './DOMAccessibilityRoles';
@@ -3052,58 +3052,71 @@ FragmentInstance.prototype.compareDocumentPosition = function (
}
const children: Array<Fiber> = [];
traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
const parentHostInstance =
getInstanceFromHostFiber<Instance>(parentHostFiber);
let result = Node.DOCUMENT_POSITION_DISCONNECTED;
if (children.length === 0) {
// If the fragment has no children, we can use the parent and
// siblings to determine a position.
const parentHostInstance =
getInstanceFromHostFiber<Instance>(parentHostFiber);
const parentResult = parentHostInstance.compareDocumentPosition(otherNode);
result = parentResult;
if (parentHostInstance === otherNode) {
result = Node.DOCUMENT_POSITION_CONTAINS;
} else {
if (parentResult & Node.DOCUMENT_POSITION_CONTAINED_BY) {
// otherNode is one of the fragment's siblings. Use the next
// sibling to determine if its preceding or following.
const nextSiblingFiber = getNextSiblingHostFiber(this._fragmentFiber);
if (nextSiblingFiber === null) {
result = Node.DOCUMENT_POSITION_PRECEDING;
} else {
const nextSiblingInstance =
getInstanceFromHostFiber<Instance>(nextSiblingFiber);
const nextSiblingResult =
nextSiblingInstance.compareDocumentPosition(otherNode);
if (
nextSiblingResult === 0 ||
nextSiblingResult & Node.DOCUMENT_POSITION_FOLLOWING
) {
result = Node.DOCUMENT_POSITION_FOLLOWING;
} else {
result = Node.DOCUMENT_POSITION_PRECEDING;
}
}
}
}
result |= Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC;
return result;
return compareDocumentPositionForEmptyFragment(
this._fragmentFiber,
parentHostInstance,
otherNode,
getInstanceFromHostFiber,
);
}
const firstElement = getInstanceFromHostFiber<Instance>(children[0]);
const lastElement = getInstanceFromHostFiber<Instance>(
children[children.length - 1],
);
// If the fragment has been portaled into another host instance, we need to
// our best guess is to use the parent of the child instance, rather than
// the fiber tree host parent.
const firstInstance = getInstanceFromHostFiber<Instance>(children[0]);
const parentHostInstanceFromDOM = fiberIsPortaledIntoHost(this._fragmentFiber)
? (firstInstance.parentElement: ?Instance)
: parentHostInstance;
if (parentHostInstanceFromDOM == null) {
return Node.DOCUMENT_POSITION_DISCONNECTED;
}
// Check if first and last element are actually in the expected document position
// before relying on them as source of truth for other contained elements
const firstElementIsContained =
parentHostInstanceFromDOM.compareDocumentPosition(firstElement) &
Node.DOCUMENT_POSITION_CONTAINED_BY;
const lastElementIsContained =
parentHostInstanceFromDOM.compareDocumentPosition(lastElement) &
Node.DOCUMENT_POSITION_CONTAINED_BY;
const firstResult = firstElement.compareDocumentPosition(otherNode);
const lastResult = lastElement.compareDocumentPosition(otherNode);
const otherNodeIsFirstOrLastChild =
(firstElementIsContained && firstElement === otherNode) ||
(lastElementIsContained && lastElement === otherNode);
const otherNodeIsFirstOrLastChildDisconnected =
(!firstElementIsContained && firstElement === otherNode) ||
(!lastElementIsContained && lastElement === otherNode);
const otherNodeIsWithinFirstOrLastChild =
firstResult & Node.DOCUMENT_POSITION_CONTAINED_BY ||
lastResult & Node.DOCUMENT_POSITION_CONTAINED_BY;
const otherNodeIsBetweenFirstAndLastChildren =
firstElementIsContained &&
lastElementIsContained &&
firstResult & Node.DOCUMENT_POSITION_FOLLOWING &&
lastResult & Node.DOCUMENT_POSITION_PRECEDING;
let result = Node.DOCUMENT_POSITION_DISCONNECTED;
if (
(firstResult & Node.DOCUMENT_POSITION_FOLLOWING &&
lastResult & Node.DOCUMENT_POSITION_PRECEDING) ||
otherNode === firstElement ||
otherNode === lastElement
otherNodeIsFirstOrLastChild ||
otherNodeIsWithinFirstOrLastChild ||
otherNodeIsBetweenFirstAndLastChildren
) {
result = Node.DOCUMENT_POSITION_CONTAINED_BY;
} else if (otherNodeIsFirstOrLastChildDisconnected) {
// otherNode has been portaled into another container
result = Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC;
} else {
result = firstResult;
}
@@ -3141,7 +3154,9 @@ function validateDocumentPositionWithFiberTree(
): boolean {
const otherFiber = getClosestInstanceFromNode(otherNode);
if (documentPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) {
return !!otherFiber && isFiberContainedBy(fragmentFiber, otherFiber);
return (
!!otherFiber && isFiberContainedByFragment(otherFiber, fragmentFiber)
);
}
if (documentPosition & Node.DOCUMENT_POSITION_CONTAINS) {
if (otherFiber === null) {
@@ -3149,7 +3164,7 @@ function validateDocumentPositionWithFiberTree(
const ownerDocument = otherNode.ownerDocument;
return otherNode === ownerDocument || otherNode === ownerDocument.body;
}
return isFiberContainedBy(otherFiber, fragmentFiber);
return isFragmentContainedByFiber(fragmentFiber, otherFiber);
}
if (documentPosition & Node.DOCUMENT_POSITION_PRECEDING) {
return (

View File

@@ -1197,14 +1197,14 @@ describe('FragmentRefs', () => {
function Test() {
return (
<div ref={containerRef}>
<div ref={beforeRef} />
<div ref={containerRef} id="container">
<div ref={beforeRef} id="before" />
<React.Fragment ref={fragmentRef}>
<div ref={firstChildRef} />
<div ref={middleChildRef} />
<div ref={lastChildRef} />
<div ref={firstChildRef} id="first" />
<div ref={middleChildRef} id="middle" />
<div ref={lastChildRef} id="last" />
</React.Fragment>
<div ref={afterRef} />
<div ref={afterRef} id="after" />
</div>
);
}
@@ -1289,7 +1289,7 @@ describe('FragmentRefs', () => {
},
);
// containerRef preceds and contains the fragment
// containerRef precedes and contains the fragment
expectPosition(
fragmentRef.current.compareDocumentPosition(containerRef.current),
{
@@ -1328,7 +1328,7 @@ describe('FragmentRefs', () => {
function Test() {
return (
<div id="container" ref={containerRef}>
<div>
<div id="innercontainer">
<div ref={beforeRef} id="before" />
<React.Fragment ref={fragmentRef}>
<div ref={onlyChildRef} id="within" />
@@ -1491,6 +1491,77 @@ describe('FragmentRefs', () => {
);
});
// @gate enableFragmentRefs
it('handles nested children', async () => {
const fragmentRef = React.createRef();
const nestedFragmentRef = React.createRef();
const childARef = React.createRef();
const childBRef = React.createRef();
const childCRef = React.createRef();
document.body.appendChild(container);
const root = ReactDOMClient.createRoot(container);
function Child() {
return (
<div ref={childCRef} id="C">
C
</div>
);
}
function Test() {
return (
<React.Fragment ref={fragmentRef}>
<div ref={childARef} id="A">
A
</div>
<React.Fragment ref={nestedFragmentRef}>
<div ref={childBRef} id="B">
B
</div>
</React.Fragment>
<Child />
</React.Fragment>
);
}
await act(() => root.render(<Test />));
expectPosition(
fragmentRef.current.compareDocumentPosition(childARef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childBRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childCRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
});
// @gate enableFragmentRefs
it('returns disconnected for comparison with an unmounted fragment instance', async () => {
const fragmentRef = React.createRef();
@@ -1551,11 +1622,11 @@ describe('FragmentRefs', () => {
function Test() {
return (
<div>
{createPortal(<div ref={portaledSiblingRef} />, document.body)}
<div id="wrapper">
{createPortal(<div ref={portaledSiblingRef} id="A" />, container)}
<Fragment ref={fragmentRef}>
{createPortal(<div ref={portaledChildRef} />, document.body)}
<div />
{createPortal(<div ref={portaledChildRef} id="B" />, container)}
<div id="C" />
</Fragment>
</div>
);
@@ -1600,6 +1671,8 @@ describe('FragmentRefs', () => {
const childARef = React.createRef();
const childBRef = React.createRef();
const childCRef = React.createRef();
const childDRef = React.createRef();
const childERef = React.createRef();
function Test() {
const [c, setC] = React.useState(false);
@@ -1612,23 +1685,30 @@ describe('FragmentRefs', () => {
{createPortal(
<Fragment ref={fragmentRef}>
<div id="A" ref={childARef} />
{c ? <div id="C" ref={childCRef} /> : null}
{c ? (
<div id="C" ref={childCRef}>
<div id="D" ref={childDRef} />
</div>
) : null}
</Fragment>,
document.body,
)}
{createPortal(<p id="B" ref={childBRef} />, document.body)}
<div id="E" ref={childERef} />
</>
);
}
await act(() => root.render(<Test />));
// Due to effect, order is A->B->C
expect(document.body.innerHTML).toBe(
'<div></div>' +
// Due to effect, order is E / A->B->C->D
expect(document.body.outerHTML).toBe(
'<body>' +
'<div><div id="E"></div></div>' +
'<div id="A"></div>' +
'<p id="B"></p>' +
'<div id="C"></div>',
'<div id="C"><div id="D"></div></div>' +
'</body>',
);
expectPosition(
@@ -1642,7 +1722,6 @@ describe('FragmentRefs', () => {
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childARef.current),
{
@@ -1654,6 +1733,7 @@ describe('FragmentRefs', () => {
implementationSpecific: false,
},
);
// Contained by in DOM, but following in React tree
expectPosition(
fragmentRef.current.compareDocumentPosition(childBRef.current),
{
@@ -1676,6 +1756,29 @@ describe('FragmentRefs', () => {
implementationSpecific: false,
},
);
expectPosition(
fragmentRef.current.compareDocumentPosition(childDRef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: true,
disconnected: false,
implementationSpecific: false,
},
);
// Preceding DOM but following in React tree
expectPosition(
fragmentRef.current.compareDocumentPosition(childERef.current),
{
preceding: false,
following: false,
contains: false,
containedBy: false,
disconnected: false,
implementationSpecific: true,
},
);
});
// @gate enableFragmentRefs

View File

@@ -24,6 +24,7 @@ import {
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import {HostText} from 'react-reconciler/src/ReactWorkTags';
import {
getFragmentParentHostFiber,
getInstanceFromHostFiber,
traverseFragmentInstance,
} from 'react-reconciler/src/ReactFiberTreeReflection';
@@ -59,6 +60,7 @@ const {
} = nativeFabricUIManager;
import {getClosestInstanceFromNode} from './ReactFabricComponentTree';
import {compareDocumentPositionForEmptyFragment} from 'shared/ReactDOMFragmentRefShared';
import {
getInspectorDataForViewTag,
@@ -87,7 +89,7 @@ const {get: getViewConfigForType} = ReactNativeViewConfigRegistry;
let nextReactTag = 2;
type InternalInstanceHandle = Object;
type Node = Object;
export type Type = string;
export type Props = Object;
export type Instance = {
@@ -344,6 +346,15 @@ export function getPublicInstanceFromInternalInstanceHandle(
return getPublicInstance(elementInstance);
}
function getPublicInstanceFromHostFiber(fiber: Fiber): PublicInstance {
const instance = getInstanceFromHostFiber<Instance>(fiber);
const publicInstance = getPublicInstance(instance);
if (publicInstance == null) {
throw new Error('Expected to find a host node. This is a bug in React.');
}
return publicInstance;
}
export function prepareForCommit(containerInfo: Container): null | Object {
// Noop
return null;
@@ -610,6 +621,7 @@ export type FragmentInstanceType = {
_observers: null | Set<IntersectionObserver>,
observeUsing: (observer: IntersectionObserver) => void,
unobserveUsing: (observer: IntersectionObserver) => void,
compareDocumentPosition: (otherNode: PublicInstance) => number,
};
function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
@@ -629,12 +641,8 @@ FragmentInstance.prototype.observeUsing = function (
traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
};
function observeChild(child: Fiber, observer: IntersectionObserver) {
const instance = getInstanceFromHostFiber<Instance>(child);
const publicInstance = getPublicInstance(instance);
if (publicInstance == null) {
throw new Error('Expected to find a host node. This is a bug in React.');
}
// $FlowFixMe[incompatible-call] Element types are behind a flag in RN
const publicInstance = getPublicInstanceFromHostFiber(child);
// $FlowFixMe[incompatible-call] DOM types expect Element
observer.observe(publicInstance);
return false;
}
@@ -656,16 +664,72 @@ FragmentInstance.prototype.unobserveUsing = function (
}
};
function unobserveChild(child: Fiber, observer: IntersectionObserver) {
const instance = getInstanceFromHostFiber<Instance>(child);
const publicInstance = getPublicInstance(instance);
if (publicInstance == null) {
throw new Error('Expected to find a host node. This is a bug in React.');
}
// $FlowFixMe[incompatible-call] Element types are behind a flag in RN
const publicInstance = getPublicInstanceFromHostFiber(child);
// $FlowFixMe[incompatible-call] DOM types expect Element
observer.unobserve(publicInstance);
return false;
}
// $FlowFixMe[prop-missing]
FragmentInstance.prototype.compareDocumentPosition = function (
this: FragmentInstanceType,
otherNode: PublicInstance,
): number {
const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
if (parentHostFiber === null) {
return Node.DOCUMENT_POSITION_DISCONNECTED;
}
const parentHostInstance = getPublicInstanceFromHostFiber(parentHostFiber);
const children: Array<Fiber> = [];
traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
if (children.length === 0) {
return compareDocumentPositionForEmptyFragment(
this._fragmentFiber,
parentHostInstance,
otherNode,
getPublicInstanceFromHostFiber,
);
}
const firstInstance = getPublicInstanceFromHostFiber(children[0]);
const lastInstance = getPublicInstanceFromHostFiber(
children[children.length - 1],
);
// $FlowFixMe[incompatible-use] Fabric PublicInstance is opaque
// $FlowFixMe[prop-missing]
const firstResult = firstInstance.compareDocumentPosition(otherNode);
// $FlowFixMe[incompatible-use] Fabric PublicInstance is opaque
// $FlowFixMe[prop-missing]
const lastResult = lastInstance.compareDocumentPosition(otherNode);
const otherNodeIsFirstOrLastChild =
firstInstance === otherNode || lastInstance === otherNode;
const otherNodeIsWithinFirstOrLastChild =
firstResult & Node.DOCUMENT_POSITION_CONTAINED_BY ||
lastResult & Node.DOCUMENT_POSITION_CONTAINED_BY;
const otherNodeIsBetweenFirstAndLastChildren =
firstResult & Node.DOCUMENT_POSITION_FOLLOWING &&
lastResult & Node.DOCUMENT_POSITION_PRECEDING;
let result;
if (
otherNodeIsFirstOrLastChild ||
otherNodeIsWithinFirstOrLastChild ||
otherNodeIsBetweenFirstAndLastChildren
) {
result = Node.DOCUMENT_POSITION_CONTAINED_BY;
} else {
result = firstResult;
}
return result;
};
function collectChildren(child: Fiber, collection: Array<Fiber>): boolean {
collection.push(child);
return false;
}
export function createFragmentInstance(
fragmentFiber: Fiber,
): FragmentInstanceType {

View File

@@ -1894,7 +1894,6 @@ function attachSuspenseRetryListeners(
const retryCache = getRetryCache(finishedWork);
wakeables.forEach(wakeable => {
// Memoize using the boundary fiber to prevent redundant listeners.
const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable);
if (!retryCache.has(wakeable)) {
retryCache.add(wakeable);
@@ -1911,6 +1910,7 @@ function attachSuspenseRetryListeners(
}
}
const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable);
wakeable.then(retry, retry);
}
});

View File

@@ -78,7 +78,7 @@ export function injectInternals(internals: Object): boolean {
} catch (err) {
// Catch all errors because it is unsafe to throw during initialization.
if (__DEV__) {
console.error('React instrumentation encountered an error: %s.', err);
console.error('React instrumentation encountered an error: %o.', err);
}
}
if (hook.checkDCE) {
@@ -101,7 +101,7 @@ export function onScheduleRoot(root: FiberRoot, children: ReactNodeList) {
} catch (err) {
if (__DEV__ && !hasLoggedError) {
hasLoggedError = true;
console.error('React instrumentation encountered an error: %s', err);
console.error('React instrumentation encountered an error: %o', err);
}
}
}
@@ -144,7 +144,7 @@ export function onCommitRoot(root: FiberRoot, eventPriority: EventPriority) {
if (__DEV__) {
if (!hasLoggedError) {
hasLoggedError = true;
console.error('React instrumentation encountered an error: %s', err);
console.error('React instrumentation encountered an error: %o', err);
}
}
}
@@ -162,7 +162,7 @@ export function onPostCommitRoot(root: FiberRoot) {
if (__DEV__) {
if (!hasLoggedError) {
hasLoggedError = true;
console.error('React instrumentation encountered an error: %s', err);
console.error('React instrumentation encountered an error: %o', err);
}
}
}
@@ -177,7 +177,7 @@ export function onCommitUnmount(fiber: Fiber) {
if (__DEV__) {
if (!hasLoggedError) {
hasLoggedError = true;
console.error('React instrumentation encountered an error: %s', err);
console.error('React instrumentation encountered an error: %o', err);
}
}
}
@@ -199,7 +199,7 @@ export function setIsStrictModeForDevtools(newIsStrictMode: boolean) {
if (__DEV__) {
if (!hasLoggedError) {
hasLoggedError = true;
console.error('React instrumentation encountered an error: %s', err);
console.error('React instrumentation encountered an error: %o', err);
}
}
}

View File

@@ -26,6 +26,7 @@ import {
ActivityComponent,
SuspenseComponent,
OffscreenComponent,
Fragment,
} from './ReactWorkTags';
import {NoFlags, Placement, Hydrating} from './ReactFiberFlags';
@@ -405,6 +406,21 @@ export function getFragmentParentHostFiber(fiber: Fiber): null | Fiber {
return null;
}
export function fiberIsPortaledIntoHost(fiber: Fiber): boolean {
let foundPortalParent = false;
let parent = fiber.return;
while (parent !== null) {
if (parent.tag === HostPortal) {
foundPortalParent = true;
}
if (parent.tag === HostRoot || parent.tag === HostComponent) {
break;
}
parent = parent.return;
}
return foundPortalParent;
}
export function getInstanceFromHostFiber<I>(fiber: Fiber): I {
switch (fiber.tag) {
case HostComponent:
@@ -443,22 +459,38 @@ function findNextSibling(child: Fiber): boolean {
return true;
}
export function isFiberContainedBy(
maybeChild: Fiber,
maybeParent: Fiber,
export function isFiberContainedByFragment(
fiber: Fiber,
fragmentFiber: Fiber,
): boolean {
let parent = maybeParent.return;
if (parent === maybeChild || parent === maybeChild.alternate) {
return true;
}
while (parent !== null && parent !== maybeChild) {
let current: Fiber | null = fiber;
while (current !== null) {
if (
(parent.tag === HostComponent || parent.tag === HostRoot) &&
(parent.return === maybeChild || parent.return === maybeChild.alternate)
current.tag === Fragment &&
(current === fragmentFiber || current.alternate === fragmentFiber)
) {
return true;
}
parent = parent.return;
current = current.return;
}
return false;
}
export function isFragmentContainedByFiber(
fragmentFiber: Fiber,
otherFiber: Fiber,
): boolean {
let current: Fiber | null = fragmentFiber;
const fiberHostParent: Fiber | null =
getFragmentParentHostFiber(fragmentFiber);
while (current !== null) {
if (
(current.tag === HostComponent || current.tag === HostRoot) &&
(current === fiberHostParent || current.alternate === fiberHostParent)
) {
return true;
}
current = current.return;
}
return false;
}

View File

@@ -3354,6 +3354,27 @@ function renderModelDestructive(
task.debugOwner = element._owner;
task.debugStack = element._debugStack;
task.debugTask = element._debugTask;
if (
element._owner === undefined ||
element._debugStack === undefined ||
element._debugTask === undefined
) {
let key = '';
if (element.key !== null) {
key = ' key="' + element.key + '"';
}
console.error(
'Attempted to render <%s%s> without development properties. ' +
'This is not supported. It can happen if:' +
'\n- The element is created with a production version of React but rendered in development.' +
'\n- The element was cloned with a custom function instead of `React.cloneElement`.\n' +
'The props of this element may help locate this element: %o',
element.type,
key,
element.props,
);
}
// TODO: Pop this. Since we currently don't have a point where we can pop the stack
// this debug information will be used for errors inside sibling properties that
// are not elements. Leading to the wrong attribution on the server. We could fix

View File

@@ -36,6 +36,7 @@ let ReactNoopFlightServer;
let Scheduler;
let advanceTimersByTime;
let assertLog;
let assertConsoleErrorDev;
describe('ReactFlight', () => {
beforeEach(() => {
@@ -64,6 +65,7 @@ describe('ReactFlight', () => {
Scheduler = require('scheduler');
const InternalTestUtils = require('internal-test-utils');
assertLog = InternalTestUtils.assertLog;
assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
});
afterEach(() => {
@@ -175,4 +177,26 @@ describe('ReactFlight', () => {
stackTwo: '\n in OwnerStackDelayed (at **)' + '\n in App (at **)',
});
});
it('logs an error when prod elements are rendered', async () => {
const element = ReactServer.createElement('span', {
key: 'one',
children: 'Free!',
});
ReactNoopFlightServer.render(
// bad clone
{...element},
);
assertConsoleErrorDev([
[
'Attempted to render <span key="one"> without development properties. This is not supported. It can happen if:' +
'\n- The element is created with a production version of React but rendered in development.' +
'\n- The element was cloned with a custom function instead of `React.cloneElement`.\n' +
"The props of this element may help locate this element: { children: 'Free!', [key]: [Getter] }",
{withoutStack: true},
],
"TypeError: Cannot read properties of undefined (reading 'stack')",
]);
});
});

View File

@@ -0,0 +1,58 @@
/**
* 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.
*
* Shared logic for Fragment Ref operations for DOM and Fabric configs
*
* @flow
*/
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import {getNextSiblingHostFiber} from 'react-reconciler/src/ReactFiberTreeReflection';
export function compareDocumentPositionForEmptyFragment<TPublicInstance>(
fragmentFiber: Fiber,
parentHostInstance: TPublicInstance,
otherNode: TPublicInstance,
getPublicInstance: (fiber: Fiber) => TPublicInstance,
): number {
let result;
// If the fragment has no children, we can use the parent and
// siblings to determine a position.
// $FlowFixMe[incompatible-use] Fabric PublicInstance is opaque
// $FlowFixMe[prop-missing]
const parentResult = parentHostInstance.compareDocumentPosition(otherNode);
result = parentResult;
if (parentHostInstance === otherNode) {
result = Node.DOCUMENT_POSITION_CONTAINS;
} else {
if (parentResult & Node.DOCUMENT_POSITION_CONTAINED_BY) {
// otherNode is one of the fragment's siblings. Use the next
// sibling to determine if its preceding or following.
const nextSiblingFiber = getNextSiblingHostFiber(fragmentFiber);
if (nextSiblingFiber === null) {
result = Node.DOCUMENT_POSITION_PRECEDING;
} else {
const nextSiblingInstance = getPublicInstance(nextSiblingFiber);
const nextSiblingResult =
// $FlowFixMe[incompatible-use] Fabric PublicInstance is opaque
// $FlowFixMe[prop-missing]
nextSiblingInstance.compareDocumentPosition(otherNode);
if (
nextSiblingResult === 0 ||
nextSiblingResult & Node.DOCUMENT_POSITION_FOLLOWING
) {
result = Node.DOCUMENT_POSITION_FOLLOWING;
} else {
result = Node.DOCUMENT_POSITION_PRECEDING;
}
}
}
}
result |= Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC;
return result;
}

View File

@@ -26,6 +26,10 @@ export function getIODescription(value: any): string {
return value.url;
} else if (typeof value.href === 'string') {
return value.href;
} else if (typeof value.src === 'string') {
return value.src;
} else if (typeof value.currentSrc === 'string') {
return value.currentSrc;
} else if (typeof value.command === 'string') {
return value.command;
} else if (