Compare commits

..

24 Commits

Author SHA1 Message Date
Joe Savona
55bf051afd [compiler][wip] Fix for ValidatePreserveMemo edge case w multiple return in useMemo
Potential fix for #34750. With a useMemo with multiple returns we can sometimes end up thinking the FinishMemoize value wasn't memoized, even though it definitely is. But the same case works if you rewrite to have a let binding, assign to the let instead of returning, and then have a single return with the let — which is the exact shape we convert to when we inline the useMemo! The only difference is that when we inline, we end up with an extra LoadLocal. Adding an extra LoadLocal "fixes" the issue but this is definitely a hack.

However, along the way this helped me find that we also have a bug in PruneAlwaysInvalidatingScopes, where we were forgetting to set FinishMemoize instructions to pruned if their declaration always invalidates.

Putting up for feedback, not necessarily expecting to land as-is.
2025-10-16 13:08:23 -07:00
Joe Savona
2a18d35301 [compiler] Cleanup and enable validateNoVoidUseMemo
This is a great validation, so let's enable by default. Changes:
* Move the validation logic into ValidateUseMemo alongside the new check that the useMemo result is used
* Update the lint description
* Make the void memo errors lint-only, they don't require us to skip compilation (as evidenced by the fact that we've had this validation off)
2025-10-16 13:08:23 -07:00
Joseph Savona
7f5ea1bf67 [compiler] More useMemo validation (#34868)
Two additional validations for useMemo:
* Disallow reassigning to values declared outside the useMemo callback
(always on)
* Disallow unused useMemo calls (part of the validateNoVoidUseMemo
feature flag, which in turn is off by default)

We should probably enable this flag though!

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34868).
* #34855
* #34882
* __->__ #34868
2025-10-16 13:05:18 -07:00
Damjan Petrovic
0e32da71c7 Add MIT license header to feature flag utility script (#34833)
Added the standard Meta Platforms, Inc. MIT license notice to the top of
the feature flag comparison script to ensure compliance with repository
licensing requirements and for code consistency.
**No functional or logic changes were made to the code.**
2025-10-16 14:20:21 -04:00
João Eirinha
2381ecc290 [ESLint] Disallow passing effect event down when inlined as a prop (#34820)
## Summary

Fixes https://github.com/facebook/react/issues/34793.

We are allowing passing down effect events when they are inlined as a
prop.

```
<Child onClick={useEffectEvent(...)} />
```

This seems like a case that someone not familiar with `useEffectEvent`'s
purpose could fall for so this PR introduces logic to disallow its
usage.

An alternative implementation would be to modify the name and function
of `recordAllUseEffectEventFunctions` to record all `useEffectEvent`
instances either assigned to a variable or not, but this seems clearer.
Or we could also specifically disallow its usage inside JSX. Feel free
to suggest any improvements.

## How did you test this change?

- Added a new test in
`packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js`.
All tests pass.
2025-10-16 14:18:01 -04:00
Ricky
5418d8bdc1 Fix changelog link (#34879)
Closes https://github.com/reactjs/react.dev/issues/8081
2025-10-16 13:40:26 -04:00
Henry Q. Dineen
ed1351c4fb [compiler] improve zod v3 backwards compat (#34877)
## Summary

When upgrading to `babel-plugin-react-compiler@1.0.0` in a project that
uses `zod@3` we are running into TypeScript errors like:

```
node_modules/babel-plugin-react-compiler/dist/index.d.ts:435:10 - error TS2694: Namespace '"/REDACTED/node_modules/zod/v3/external"' has no exported member 'core'.

435     }, z.core.$strip>>>;
             ~~~~
```

This problem seems to be related to
d6eb735938, which introduced zod v3/v4
compatibility. Since `zod` is bundled into the compiler source this does
not cause runtime issues and only manifests as TypeScript errors. My
proposed solution is this PR is to use zod's [subpath versioning
strategy](https://zod.dev/v4/versioning?id=versioning-in-zod-4) which
allows you to support v3 and v4 APIs on both major versions.

Changes in this PR include:

- Updated `zod` import paths to `zod/v4`
- Bumped min `zod` version to `^3.25.0` for zod which guarantees the
`zod/v4` subpath is available.
- Updated `zod-validation-error` import paths to
`zod-validation-error/v4`
- Bumped min `zod-validation-error ` version to `^3.5.0` 
- Updated `externals` tsup configuration where appropriate. 

Once the compiler drops zod v3 support we could optionally remove the
`/v4` subpath from the imports.

## How did you test this change?

Not totally sure the best way to test. I ran `NODE_ENV=production yarn
workspace babel-plugin-react-compiler run build --dts` and diffed the
`dist/` folder between my change and `v1.0.0` and it looks correct. We
have a `patch-package` patch to workaround this for now and it works as
expected.

```diff
diff --git a/node_modules/babel-plugin-react-compiler/dist/index.d.ts b/node_modules/babel-plugin-react-compiler/dist/index.d.ts
index 81c3f3d..daafc2c 100644
--- a/node_modules/babel-plugin-react-compiler/dist/index.d.ts
+++ b/node_modules/babel-plugin-react-compiler/dist/index.d.ts
@@ -1,7 +1,7 @@
 import * as BabelCore from '@babel/core';
 import { NodePath as NodePath$1 } from '@babel/core';
 import * as t from '@babel/types';
-import { z } from 'zod';
+import { z } from 'zod/v4';
 import { NodePath, Scope } from '@babel/traverse';
 
 interface Result<T, E> {
```

Co-authored-by: Henry Q. Dineen <henryqdineen@gmail.com>
2025-10-16 09:46:55 -07:00
Sebastian Markbåge
93f8593289 [DevTools] Adjust the rects size by one pixel smaller (#34876)
This ensures that the outline of a previous rectangle lines up on the
same pixel as the next rectangle so that they appear consecutive.

<img width="244" height="51" alt="Screenshot 2025-10-16 at 11 35 32 AM"
src="https://github.com/user-attachments/assets/75ffde6f-8cc6-49c1-8855-3953569546b4"
/>

I don't love this implementation. There's probably a smarter way. Was
trying to avoid adding another element.
2025-10-16 12:16:16 -04:00
Sebastian Markbåge
dc1becd893 [DevTools] Remove steps title from scrubber (#34878)
The hover now has a reach tooltip for the "environment" instead.
2025-10-16 12:16:04 -04:00
Sebastian "Sebbie" Silbermann
d8aa94b0f4 Only capture stacks for up to 10 frames for Owner Stacks (#34864) 2025-10-16 18:00:41 +02:00
Sebastian Markbåge
03ba0c76e1 [DevTools] Include some sub-pixel precision in rects (#34873)
Currently the sub-pixel precision is lost which can lead to things not
lining up properly and being slightly off or overlapping.

We need some sub-pixel precision.

Ideally we'd just keep the floating point as is. I'm not sure why the
operations is limited to integers. We don't send it as a typed array
anyway it seems which would ideally be more optimal. Even if we did, we
haven't defined a precision for the protocol. Is it 32bit integer?
64bit? If it's 64bit we can fit a float anyway. Ideally it would be more
variable precision like just pushing into a typed array directly with
the option to write whatever precision we want.
2025-10-16 10:50:41 -04:00
Sebastian Markbåge
4e00747378 [DevTools] Don't pluralize if already plural (#34870)
In a demo today, `cookies()` showed up as `cookieses`. While adorable,
is wrong.
2025-10-16 10:50:18 -04:00
Sebastian Markbåge
7bd8716acd [DevTools] Don't try to load anonymous or empty urls (#34869)
This triggers unnecessary fetches.
2025-10-16 10:49:37 -04:00
Sebastian Markbåge
7385d1f61a [DevTools] Add inspection button to Suspense tab (#34867)
Add inspection button to Suspense tab which lets you select only among
Suspense nodes. It highlights all the DOM nodes in the root of the
Suspense node instead of just the DOM element you hover. The name is
inferred.

<img width="1172" height="841" alt="Screenshot 2025-10-15 at 8 03 34 PM"
src="https://github.com/user-attachments/assets/f04d965b-ef6e-4196-9ba0-51626148fa1a"
/>
2025-10-16 10:49:23 -04:00
Joseph Savona
85f415e33b [compiler] Fix fbt for the ∞th time (#34865)
We now do a single pass over the HIR, building up two data structures:
* One tracks values that are known macro tags or macro calls.
* One tracks operands of macro-related instructions so that we can later
group them.

After building up these data structures, we do a pass over the latter
structure. For each macro call instruction, we recursively traverse its
operands to ensure they're in the same scope. Thus, something like
`fbt('hello' + fbt.param(foo(), "..."))` will correctly merge the fbt
call, the `+` binary expression, the `fbt.param()` call, and `foo()`
into a single scope.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34865).
* #34855
* __->__ #34865
2025-10-15 16:23:31 -07:00
Sebastian Markbåge
903366b8b1 [DevTools] Don't select on hover (#34860)
We should only persist a selection once you click. Currently, we persist
the selection if you just hover which means you lose your selection
immediately when just starting to inspect. That's not what Chrome
Elements tab does - it selects on click.
2025-10-15 13:43:55 -04:00
Sebastian Markbåge
0fbb9b3683 [DevTools] Don't highlight on timeline (#34861)
I find it very frustrating that the highlight covers up the content that
I'm trying to review when stepping through the timeline. It also
triggered on keyboard navigation due to the focus which was annoying.

We could highlight something in the rects instead potentially.
2025-10-15 13:43:43 -04:00
Joseph Savona
e096403c59 [compiler] Infer types for properties after holes in array patterns (#34847)
In InferTypes when we infer types for properties during destructuring,
we were breaking out of the loop when we encounter a hole in the array.
Instead we should just skip that element and continue inferring later
properties.

Closes #34748

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34847).
* #34855
* __->__ #34847
2025-10-15 09:45:06 -07:00
Sebastian Markbåge
1873ad7960 [DevTools] The bridge event types should only be defined in one direction (#34859)
This revealed that a lot of the event types were defined on the wrong
end of the bridge.

It was also a problem that events with the same name couldn't have
different arguments.
2025-10-15 11:42:03 -04:00
Sebastian Markbåge
77b2f909f6 [DevTools] Attempt at a better "unique suspender" text (#34854)
Nobody knows what this terminology means.

Also, this tooltip component sucks:

<img width="634" height="137" alt="Screenshot 2025-10-15 at 12 04 49 AM"
src="https://github.com/user-attachments/assets/a1c33650-7c7d-441f-8f8b-0ea7ebea9351"
/>
2025-10-15 10:26:46 -04:00
Sebastian Markbåge
6773248311 [DevTools] Track whether a boundary is currently suspended and make transparent (#34853)
This makes the rects that are currently in a suspended state appear
ghostly so that you can see where along the timeline you are in the
rects screen.

<img width="451" height="407" alt="Screenshot 2025-10-14 at 11 43 20 PM"
src="https://github.com/user-attachments/assets/f89e362b-a0d5-46e3-8171-564909715cd1"
/>
2025-10-15 10:26:07 -04:00
Sebastian Markbåge
5747cadf44 [DevTools] Don't hide overflow rectangles (#34852)
I get the wish to click the shadow but not all child boundaries are
within the bounds of the outer Suspense boundary's node.

Sometimes they overflow naturally and if we make it overflow hidden we
hide the boundaries. Maybe it would be ok if they're actually clipped by
the real DOM but right now it covers up boundaries that should be there.

Additionally, there's also a common case where the parent boundary
shrinks when suspending the children. That then causes the suspended
child boundaries to be clipped so that you can't restore them. Maybe the
virtual boundary shouldn't shrink in this case.
2025-10-15 10:25:46 -04:00
Sebastian Markbåge
751edd6e2c [DevTools] Measure text nodes (#34851)
We can't measure Text nodes directly but we can measure a Range around
them.

This is useful since it's common, at least in examples, to use text
nodes as children of a Suspense boundary. Especially fallbacks.
2025-10-15 10:24:45 -04:00
Sebastian Markbåge
6cfc9c1ff3 [DevTools] Don't measure fallbacks when suspended (#34850)
We already do this in the update pass. That's what
`shouldMeasureSuspenseNode` does.

We also don't update measurements when we're inside an offscreen tree.

However, we didn't check if the boundary itself was in a suspended state
when in the `measureUnchangedSuspenseNodesRecursively` path.

This caused boundaries to disappear when their fallback didn't have a
rect (including their timeline entries).
2025-10-15 10:12:26 -04:00
81 changed files with 1432 additions and 655 deletions

View File

@@ -9,7 +9,7 @@ Read the [React 19.2 release post](https://react.dev/blog/2025/10/01/react-19-2)
- [`<Activity>`](https://react.dev/reference/react/Activity): A new API to hide and restore the UI and internal state of its children.
- [`useEffectEvent`](https://react.dev/reference/react/useEffectEvent) is a React Hook that lets you extract non-reactive logic into an [Effect Event](https://react.dev/learn/separating-events-from-effects#declaring-an-effect-event).
- [`cacheSignal`](https://react.dev/reference/react/cacheSignal) (for RSCs) lets your know when the `cache()` lifetime is over.
- [React Performance tracks](https://react.dev/reference/developer-tooling/react-performance-tracks) appear on the Performance panels timeline in your browser developer tools
- [React Performance tracks](https://react.dev/reference/dev-tools/react-performance-tracks) appear on the Performance panels timeline in your browser developer tools
### New React DOM Features

View File

@@ -52,8 +52,8 @@
"react-dom": "0.0.0-experimental-4beb1fd8-20241118",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"zod": "^3.22.4 || ^4.0.0",
"zod-validation-error": "^3.0.3 || ^4.0.0"
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"resolutions": {
"./**/@babel/parser": "7.7.4",

View File

@@ -988,7 +988,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Error,
name: 'void-use-memo',
description:
'Validates that useMemos always return a value. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
'Validates that useMemos always return a value and that the result of the useMemo is used by the component/hook. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
preset: LintRulePreset.RecommendedLatest,
};
}

View File

@@ -6,7 +6,7 @@
*/
import * as t from '@babel/types';
import {z} from 'zod';
import {z} from 'zod/v4';
import {
CompilerDiagnostic,
CompilerError,
@@ -20,7 +20,7 @@ import {
tryParseExternalFunction,
} from '../HIR/Environment';
import {hasOwnProperty} from '../Utils/utils';
import {fromZodError} from 'zod-validation-error';
import {fromZodError} from 'zod-validation-error/v4';
import {CompilerPipelineValue} from './Pipeline';
const PanicThresholdOptionsSchema = z.enum([

View File

@@ -6,8 +6,8 @@
*/
import * as t from '@babel/types';
import {ZodError, z} from 'zod';
import {fromZodError} from 'zod-validation-error';
import {ZodError, z} from 'zod/v4';
import {fromZodError} from 'zod-validation-error/v4';
import {CompilerError} from '../CompilerError';
import {Logger, ProgramContext} from '../Entrypoint';
import {Err, Ok, Result} from '../Utils/Result';
@@ -659,7 +659,7 @@ export const EnvironmentConfigSchema = z.object({
* Invalid:
* useMemo(() => { ... }, [...]);
*/
validateNoVoidUseMemo: z.boolean().default(false),
validateNoVoidUseMemo: z.boolean().default(true),
/**
* Validates that Components/Hooks are always defined at module level. This prevents scope

View File

@@ -16,7 +16,7 @@ import {assertExhaustive} from '../Utils/utils';
import {Environment, ReactFunctionType} from './Environment';
import type {HookKind} from './ObjectShape';
import {Type, makeType} from './Types';
import {z} from 'zod';
import {z} from 'zod/v4';
import type {AliasingEffect} from '../Inference/AliasingEffects';
import {isReservedWord} from '../Utils/Keyword';
import {Err, Ok, Result} from '../Utils/Result';

View File

@@ -988,7 +988,7 @@ export function createTemporaryPlace(
identifier: makeTemporaryIdentifier(env.nextIdentifierId, loc),
reactive: false,
effect: Effect.Unknown,
loc: GeneratedSource,
loc,
};
}

View File

@@ -6,7 +6,7 @@
*/
import {isValidIdentifier} from '@babel/types';
import {z} from 'zod';
import {z} from 'zod/v4';
import {Effect, ValueKind} from '..';
import {
EffectSchema,

View File

@@ -11,7 +11,6 @@ import {
CallExpression,
Effect,
Environment,
FinishMemoize,
FunctionExpression,
HIRFunction,
IdentifierId,
@@ -25,7 +24,6 @@ import {
Place,
PropertyLoad,
SpreadPattern,
StartMemoize,
TInstruction,
getHookKindForType,
makeInstructionId,
@@ -184,36 +182,52 @@ function makeManualMemoizationMarkers(
depsList: Array<ManualMemoDependency> | null,
memoDecl: Place,
manualMemoId: number,
): [TInstruction<StartMemoize>, TInstruction<FinishMemoize>] {
): [Array<Instruction>, Array<Instruction>] {
const temp = createTemporaryPlace(env, memoDecl.loc);
return [
{
id: makeInstructionId(0),
lvalue: createTemporaryPlace(env, fnExpr.loc),
value: {
kind: 'StartMemoize',
manualMemoId,
/*
* Use deps list from source instead of inferred deps
* as dependencies
*/
deps: depsList,
[
{
id: makeInstructionId(0),
lvalue: createTemporaryPlace(env, fnExpr.loc),
value: {
kind: 'StartMemoize',
manualMemoId,
/*
* Use deps list from source instead of inferred deps
* as dependencies
*/
deps: depsList,
loc: fnExpr.loc,
},
effects: null,
loc: fnExpr.loc,
},
effects: null,
loc: fnExpr.loc,
},
{
id: makeInstructionId(0),
lvalue: createTemporaryPlace(env, fnExpr.loc),
value: {
kind: 'FinishMemoize',
manualMemoId,
decl: {...memoDecl},
loc: fnExpr.loc,
],
[
{
id: makeInstructionId(0),
lvalue: {...temp},
value: {
kind: 'LoadLocal',
place: {...memoDecl},
loc: memoDecl.loc,
},
effects: null,
loc: memoDecl.loc,
},
effects: null,
loc: fnExpr.loc,
},
{
id: makeInstructionId(0),
lvalue: createTemporaryPlace(env, memoDecl.loc),
value: {
kind: 'FinishMemoize',
manualMemoId,
decl: {...temp},
loc: memoDecl.loc,
},
effects: null,
loc: memoDecl.loc,
},
],
];
}
@@ -409,10 +423,7 @@ export function dropManualMemoization(
* LoadLocal fnArg
* - (if validation is enabled) collect manual memoization markers
*/
const queuedInserts: Map<
InstructionId,
TInstruction<StartMemoize> | TInstruction<FinishMemoize>
> = new Map();
const queuedInserts: Map<InstructionId, Array<Instruction>> = new Map();
for (const [_, block] of func.body.blocks) {
for (let i = 0; i < block.instructions.length; i++) {
const instr = block.instructions[i]!;
@@ -438,40 +449,6 @@ export function dropManualMemoization(
continue;
}
/**
* Bailout on void return useMemos. This is an anti-pattern where code might be using
* useMemo like useEffect: running arbirtary side-effects synced to changes in specific
* values.
*/
if (
func.env.config.validateNoVoidUseMemo &&
manualMemo.kind === 'useMemo'
) {
const funcToCheck = sidemap.functions.get(
fnPlace.identifier.id,
)?.value;
if (funcToCheck !== undefined && funcToCheck.loweredFunc.func) {
if (!hasNonVoidReturn(funcToCheck.loweredFunc.func)) {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.VoidUseMemo,
reason: 'useMemo() callbacks must return a value',
description: `This ${
manualMemo.loadInstr.value.kind === 'PropertyLoad'
? 'React.useMemo'
: 'useMemo'
} callback doesn't return a value. useMemo is for computing and caching values, not for arbitrary side effects`,
suggestions: null,
}).withDetails({
kind: 'error',
loc: instr.value.loc,
message: 'useMemo() callbacks must return a value',
}),
);
}
}
}
instr.value = getManualMemoizationReplacement(
fnPlace,
instr.value.loc,
@@ -557,11 +534,11 @@ export function dropManualMemoization(
let nextInstructions: Array<Instruction> | null = null;
for (let i = 0; i < block.instructions.length; i++) {
const instr = block.instructions[i];
const insertInstr = queuedInserts.get(instr.id);
if (insertInstr != null) {
const insertInstructions = queuedInserts.get(instr.id);
if (insertInstructions != null) {
nextInstructions = nextInstructions ?? block.instructions.slice(0, i);
nextInstructions.push(instr);
nextInstructions.push(insertInstr);
nextInstructions.push(...insertInstructions);
} else if (nextInstructions != null) {
nextInstructions.push(instr);
}
@@ -629,17 +606,3 @@ function findOptionalPlaces(fn: HIRFunction): Set<IdentifierId> {
}
return optionals;
}
function hasNonVoidReturn(func: HIRFunction): boolean {
for (const [, block] of func.body.blocks) {
if (block.terminal.kind === 'return') {
if (
block.terminal.returnVariant === 'Explicit' ||
block.terminal.returnVariant === 'Implicit'
) {
return true;
}
}
}
return false;
}

View File

@@ -7,14 +7,17 @@
import {
HIRFunction,
Identifier,
IdentifierId,
InstructionValue,
makeInstructionId,
MutableRange,
Place,
ReactiveValue,
ReactiveScope,
} from '../HIR';
import {Macro, MacroMethod} from '../HIR/Environment';
import {eachReactiveValueOperand} from './visitors';
import {eachInstructionValueOperand} from '../HIR/visitors';
import {Iterable_some} from '../Utils/utils';
/**
* This pass supports the `fbt` translation system (https://facebook.github.io/fbt/)
@@ -48,24 +51,49 @@ export function memoizeFbtAndMacroOperandsInSameScope(
...Array.from(FBT_TAGS).map((tag): Macro => [tag, []]),
...(fn.env.config.customMacros ?? []),
]);
const fbtValues: Set<IdentifierId> = new Set();
/**
* Set of all identifiers that load fbt or other macro functions or their nested
* properties, as well as values known to be the results of invoking macros
*/
const macroTagsCalls: Set<IdentifierId> = new Set();
/**
* Mapping of lvalue => list of operands for all expressions where either
* the lvalue is a known fbt/macro call and/or the operands transitively
* contain fbt/macro calls.
*
* This is the key data structure that powers the scope merging: we start
* at the lvalues and merge operands into the lvalue's scope.
*/
const macroValues: Map<Identifier, Array<Identifier>> = new Map();
// Tracks methods loaded from macros, like fbt.param or idx.foo
const macroMethods = new Map<IdentifierId, Array<Array<MacroMethod>>>();
while (true) {
let vsize = fbtValues.size;
let msize = macroMethods.size;
visit(fn, fbtMacroTags, fbtValues, macroMethods);
if (vsize === fbtValues.size && msize === macroMethods.size) {
break;
visit(fn, fbtMacroTags, macroTagsCalls, macroMethods, macroValues);
for (const root of macroValues.keys()) {
const scope = root.scope;
if (scope == null) {
continue;
}
// Merge the operands into the same scope if this is a known macro invocation
if (!macroTagsCalls.has(root.id)) {
continue;
}
mergeScopes(root, scope, macroValues, macroTagsCalls);
}
return fbtValues;
return macroTagsCalls;
}
export const FBT_TAGS: Set<string> = new Set([
'fbt',
'fbt:param',
'fbt:enum',
'fbt:plural',
'fbs',
'fbs:param',
'fbs:enum',
'fbs:plural',
]);
export const SINGLE_CHILD_FBT_TAGS: Set<string> = new Set([
'fbt:param',
@@ -75,10 +103,22 @@ export const SINGLE_CHILD_FBT_TAGS: Set<string> = new Set([
function visit(
fn: HIRFunction,
fbtMacroTags: Set<Macro>,
fbtValues: Set<IdentifierId>,
macroTagsCalls: Set<IdentifierId>,
macroMethods: Map<IdentifierId, Array<Array<MacroMethod>>>,
macroValues: Map<Identifier, Array<Identifier>>,
): void {
for (const [, block] of fn.body.blocks) {
for (const phi of block.phis) {
const macroOperands: Array<Identifier> = [];
for (const operand of phi.operands.values()) {
if (macroValues.has(operand.identifier)) {
macroOperands.push(operand.identifier);
}
}
if (macroOperands.length !== 0) {
macroValues.set(phi.place.identifier, macroOperands);
}
}
for (const instruction of block.instructions) {
const {lvalue, value} = instruction;
if (lvalue === null) {
@@ -93,13 +133,13 @@ function visit(
* We don't distinguish between tag names and strings, so record
* all `fbt` string literals in case they are used as a jsx tag.
*/
fbtValues.add(lvalue.identifier.id);
macroTagsCalls.add(lvalue.identifier.id);
} else if (
value.kind === 'LoadGlobal' &&
matchesExactTag(value.binding.name, fbtMacroTags)
) {
// Record references to `fbt` as a global
fbtValues.add(lvalue.identifier.id);
macroTagsCalls.add(lvalue.identifier.id);
} else if (
value.kind === 'LoadGlobal' &&
matchTagRoot(value.binding.name, fbtMacroTags) !== null
@@ -121,84 +161,66 @@ function visit(
if (method.length > 1) {
newMethods.push(method.slice(1));
} else {
fbtValues.add(lvalue.identifier.id);
macroTagsCalls.add(lvalue.identifier.id);
}
}
}
if (newMethods.length > 0) {
macroMethods.set(lvalue.identifier.id, newMethods);
}
} else if (isFbtCallExpression(fbtValues, value)) {
const fbtScope = lvalue.identifier.scope;
if (fbtScope === null) {
continue;
}
/*
* if the JSX element's tag was `fbt`, mark all its operands
* to ensure that they end up in the same scope as the jsx element
* itself.
*/
for (const operand of eachReactiveValueOperand(value)) {
operand.identifier.scope = fbtScope;
// Expand the jsx element's range to account for its operands
expandFbtScopeRange(fbtScope.range, operand.identifier.mutableRange);
fbtValues.add(operand.identifier.id);
}
} else if (
isFbtJsxExpression(fbtMacroTags, fbtValues, value) ||
isFbtJsxChild(fbtValues, lvalue, value)
value.kind === 'PropertyLoad' &&
macroTagsCalls.has(value.object.identifier.id)
) {
const fbtScope = lvalue.identifier.scope;
if (fbtScope === null) {
continue;
}
/*
* if the JSX element's tag was `fbt`, mark all its operands
* to ensure that they end up in the same scope as the jsx element
* itself.
*/
for (const operand of eachReactiveValueOperand(value)) {
operand.identifier.scope = fbtScope;
// Expand the jsx element's range to account for its operands
expandFbtScopeRange(fbtScope.range, operand.identifier.mutableRange);
/*
* NOTE: we add the operands as fbt values so that they are also
* grouped with this expression
*/
fbtValues.add(operand.identifier.id);
}
} else if (fbtValues.has(lvalue.identifier.id)) {
const fbtScope = lvalue.identifier.scope;
if (fbtScope === null) {
return;
}
for (const operand of eachReactiveValueOperand(value)) {
if (
operand.identifier.name !== null &&
operand.identifier.name.kind === 'named'
) {
/*
* named identifiers were already locals, we only have to force temporaries
* into the same scope
*/
continue;
macroTagsCalls.add(lvalue.identifier.id);
} else if (
isFbtJsxExpression(fbtMacroTags, macroTagsCalls, value) ||
isFbtJsxChild(macroTagsCalls, lvalue, value) ||
isFbtCallExpression(macroTagsCalls, value)
) {
macroTagsCalls.add(lvalue.identifier.id);
macroValues.set(
lvalue.identifier,
Array.from(
eachInstructionValueOperand(value),
operand => operand.identifier,
),
);
} else if (
Iterable_some(eachInstructionValueOperand(value), operand =>
macroValues.has(operand.identifier),
)
) {
const macroOperands: Array<Identifier> = [];
for (const operand of eachInstructionValueOperand(value)) {
if (macroValues.has(operand.identifier)) {
macroOperands.push(operand.identifier);
}
operand.identifier.scope = fbtScope;
// Expand the jsx element's range to account for its operands
expandFbtScopeRange(fbtScope.range, operand.identifier.mutableRange);
}
macroValues.set(lvalue.identifier, macroOperands);
}
}
}
}
function mergeScopes(
root: Identifier,
scope: ReactiveScope,
macroValues: Map<Identifier, Array<Identifier>>,
macroTagsCalls: Set<IdentifierId>,
): void {
const operands = macroValues.get(root);
if (operands == null) {
return;
}
for (const operand of operands) {
operand.scope = scope;
expandFbtScopeRange(scope.range, operand.mutableRange);
macroTagsCalls.add(operand.id);
mergeScopes(operand, scope, macroValues, macroTagsCalls);
}
}
function matchesExactTag(s: string, tags: Set<Macro>): boolean {
return Array.from(tags).some(macro =>
typeof macro === 'string'
@@ -229,39 +251,40 @@ function matchTagRoot(
}
function isFbtCallExpression(
fbtValues: Set<IdentifierId>,
value: ReactiveValue,
macroTagsCalls: Set<IdentifierId>,
value: InstructionValue,
): boolean {
return (
(value.kind === 'CallExpression' &&
fbtValues.has(value.callee.identifier.id)) ||
(value.kind === 'MethodCall' && fbtValues.has(value.property.identifier.id))
macroTagsCalls.has(value.callee.identifier.id)) ||
(value.kind === 'MethodCall' &&
macroTagsCalls.has(value.property.identifier.id))
);
}
function isFbtJsxExpression(
fbtMacroTags: Set<Macro>,
fbtValues: Set<IdentifierId>,
value: ReactiveValue,
macroTagsCalls: Set<IdentifierId>,
value: InstructionValue,
): boolean {
return (
value.kind === 'JsxExpression' &&
((value.tag.kind === 'Identifier' &&
fbtValues.has(value.tag.identifier.id)) ||
macroTagsCalls.has(value.tag.identifier.id)) ||
(value.tag.kind === 'BuiltinTag' &&
matchesExactTag(value.tag.name, fbtMacroTags)))
);
}
function isFbtJsxChild(
fbtValues: Set<IdentifierId>,
macroTagsCalls: Set<IdentifierId>,
lvalue: Place | null,
value: ReactiveValue,
value: InstructionValue,
): boolean {
return (
(value.kind === 'JsxExpression' || value.kind === 'JsxFragment') &&
lvalue !== null &&
fbtValues.has(lvalue.identifier.id)
macroTagsCalls.has(lvalue.identifier.id)
);
}

View File

@@ -77,6 +77,15 @@ class Transform extends ReactiveFunctionTransform<boolean> {
}
break;
}
case 'FinishMemoize': {
if (
!withinScope &&
this.alwaysInvalidatingValues.has(value.decl.identifier)
) {
value.pruned = true;
}
break;
}
}
return {kind: 'keep'};
}

View File

@@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {fromZodError} from 'zod-validation-error';
import {fromZodError} from 'zod-validation-error/v4';
import {CompilerError} from '../CompilerError';
import {
CompilationMode,

View File

@@ -10,16 +10,37 @@ import {
CompilerError,
ErrorCategory,
} from '../CompilerError';
import {FunctionExpression, HIRFunction, IdentifierId} from '../HIR';
import {
FunctionExpression,
HIRFunction,
IdentifierId,
SourceLocation,
} from '../HIR';
import {
eachInstructionValueOperand,
eachTerminalOperand,
} from '../HIR/visitors';
import {Result} from '../Utils/Result';
export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
const errors = new CompilerError();
const voidMemoErrors = new CompilerError();
const useMemos = new Set<IdentifierId>();
const react = new Set<IdentifierId>();
const functions = new Map<IdentifierId, FunctionExpression>();
const unusedUseMemos = new Map<IdentifierId, SourceLocation>();
for (const [, block] of fn.body.blocks) {
for (const {lvalue, value} of block.instructions) {
if (unusedUseMemos.size !== 0) {
/**
* Most of the time useMemo results are referenced immediately. Don't bother
* scanning instruction operands for useMemos unless there is an as-yet-unused
* useMemo.
*/
for (const operand of eachInstructionValueOperand(value)) {
unusedUseMemos.delete(operand.identifier.id);
}
}
switch (value.kind) {
case 'LoadGlobal': {
if (value.binding.name === 'useMemo') {
@@ -45,10 +66,8 @@ export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
case 'CallExpression': {
// Is the function being called useMemo, with at least 1 argument?
const callee =
value.kind === 'CallExpression'
? value.callee.identifier.id
: value.property.identifier.id;
const isUseMemo = useMemos.has(callee);
value.kind === 'CallExpression' ? value.callee : value.property;
const isUseMemo = useMemos.has(callee.identifier.id);
if (!isUseMemo || value.args.length === 0) {
continue;
}
@@ -104,10 +123,103 @@ export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
);
}
validateNoContextVariableAssignment(body.loweredFunc.func, errors);
if (fn.env.config.validateNoVoidUseMemo) {
if (!hasNonVoidReturn(body.loweredFunc.func)) {
voidMemoErrors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.VoidUseMemo,
reason: 'useMemo() callbacks must return a value',
description: `This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects`,
suggestions: null,
}).withDetails({
kind: 'error',
loc: body.loc,
message: 'useMemo() callbacks must return a value',
}),
);
} else {
unusedUseMemos.set(lvalue.identifier.id, callee.loc);
}
}
break;
}
}
}
if (unusedUseMemos.size !== 0) {
for (const operand of eachTerminalOperand(block.terminal)) {
unusedUseMemos.delete(operand.identifier.id);
}
}
}
if (unusedUseMemos.size !== 0) {
/**
* Basic check for unused memos, where the result of the call is never referenced. This runs
* before DCE so it's more of an AST-level check that something, _anything_, cares about the value.
*
* This is easy to defeat with e.g. `const _ = useMemo(...)` but it at least gives us something to teach.
* Even a DCE-based version could be bypassed with `noop(useMemo(...))`.
*/
for (const loc of unusedUseMemos.values()) {
voidMemoErrors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.VoidUseMemo,
reason: 'useMemo() result is unused',
description: `This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects`,
suggestions: null,
}).withDetails({
kind: 'error',
loc,
message: 'useMemo() result is unused',
}),
);
}
}
fn.env.logErrors(voidMemoErrors.asResult());
return errors.asResult();
}
function validateNoContextVariableAssignment(
fn: HIRFunction,
errors: CompilerError,
): void {
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
const value = instr.value;
switch (value.kind) {
case 'StoreContext': {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.UseMemo,
reason:
'useMemo() callbacks may not reassign variables declared outside of the callback',
description:
'useMemo() callbacks must be pure functions and cannot reassign variables defined outside of the callback function',
suggestions: null,
}).withDetails({
kind: 'error',
loc: value.lvalue.place.loc,
message: 'Cannot reassign variable',
}),
);
break;
}
}
}
}
return errors.asResult();
}
function hasNonVoidReturn(func: HIRFunction): boolean {
for (const [, block] of func.body.blocks) {
if (block.terminal.kind === 'return') {
if (
block.terminal.returnVariant === 'Explicit' ||
block.terminal.returnVariant === 'Implicit'
) {
return true;
}
}
}
return false;
}

View File

@@ -29,7 +29,7 @@ Found 1 error:
Invariant: Expected consistent kind for destructuring
Other places were `Reassign` but 'mutate? #t8$46[7:9]{reactive}' is const.
Other places were `Reassign` but 'mutate? #t8$47[7:9]{reactive}' is const.
error.bug-invariant-expected-consistent-destructuring.ts:9:9
7 |

View File

@@ -0,0 +1,38 @@
## Input
```javascript
function Component() {
let x;
const y = useMemo(() => {
let z;
x = [];
z = true;
return z;
}, []);
return [x, y];
}
```
## Error
```
Found 1 error:
Error: useMemo() callbacks may not reassign variables declared outside of the callback
useMemo() callbacks must be pure functions and cannot reassign variables defined outside of the callback function.
error.invalid-reassign-variable-in-usememo.ts:5:4
3 | const y = useMemo(() => {
4 | let z;
> 5 | x = [];
| ^ Cannot reassign variable
6 | z = true;
7 | return z;
8 | }, []);
```

View File

@@ -0,0 +1,10 @@
function Component() {
let x;
const y = useMemo(() => {
let z;
x = [];
z = true;
return z;
}, []);
return [x, y];
}

View File

@@ -1,64 +0,0 @@
## Input
```javascript
// @validateNoVoidUseMemo
function Component() {
const value = useMemo(() => {
console.log('computing');
}, []);
const value2 = React.useMemo(() => {
console.log('computing');
}, []);
return (
<div>
{value}
{value2}
</div>
);
}
```
## Error
```
Found 2 errors:
Error: useMemo() callbacks must return a value
This useMemo callback doesn't return a value. useMemo is for computing and caching values, not for arbitrary side effects.
error.useMemo-no-return-value.ts:3:16
1 | // @validateNoVoidUseMemo
2 | function Component() {
> 3 | const value = useMemo(() => {
| ^^^^^^^^^^^^^^^
> 4 | console.log('computing');
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 5 | }, []);
| ^^^^^^^^^ useMemo() callbacks must return a value
6 | const value2 = React.useMemo(() => {
7 | console.log('computing');
8 | }, []);
Error: useMemo() callbacks must return a value
This React.useMemo callback doesn't return a value. useMemo is for computing and caching values, not for arbitrary side effects.
error.useMemo-no-return-value.ts:6:17
4 | console.log('computing');
5 | }, []);
> 6 | const value2 = React.useMemo(() => {
| ^^^^^^^^^^^^^^^^^^^^^
> 7 | console.log('computing');
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 8 | }, []);
| ^^^^^^^^^ useMemo() callbacks must return a value
9 | return (
10 | <div>
11 | {value}
```

View File

@@ -1,56 +0,0 @@
## Input
```javascript
import fbt from 'fbt';
import {Stringify} from 'shared-runtime';
/**
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
* Expected fixture `fbt-param-call-arguments` to succeed but it failed with error:
* /fbt-param-call-arguments.ts: Line 19 Column 11: fbt: unsupported babel node: Identifier
* ---
* t3
* ---
*/
function Component({firstname, lastname}) {
'use memo';
return (
<Stringify>
{fbt(
[
'Name: ',
fbt.param('firstname', <Stringify key={0} name={firstname} />),
', ',
fbt.param(
'lastname',
<Stringify key={0} name={lastname}>
{fbt('(inner fbt)', 'Inner fbt value')}
</Stringify>
),
],
'Name'
)}
</Stringify>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstname: 'first', lastname: 'last'}],
sequentialRenders: [{firstname: 'first', lastname: 'last'}],
};
```
## Error
```
Line 19 Column 11: fbt: unsupported babel node: Identifier
---
t3
---
```

View File

@@ -37,27 +37,31 @@ import { c as _c } from "react/compiler-runtime";
import fbt from "fbt";
function Foo(t0) {
const $ = _c(3);
const $ = _c(7);
const { name1, name2 } = t0;
let t1;
if ($[0] !== name1 || $[1] !== name2) {
let t2;
if ($[3] !== name1) {
t2 = <b>{name1}</b>;
$[3] = name1;
$[4] = t2;
} else {
t2 = $[4];
}
let t3;
if ($[5] !== name2) {
t3 = <b>{name2}</b>;
$[5] = name2;
$[6] = t3;
} else {
t3 = $[6];
}
t1 = fbt._(
"{user1} and {user2} accepted your PR!",
[
fbt._param(
"user1",
<span key={name1}>
<b>{name1}</b>
</span>,
),
fbt._param(
"user2",
<span key={name2}>
<b>{name2}</b>
</span>,
),
fbt._param("user1", <span key={name1}>{t2}</span>),
fbt._param("user2", <span key={name2}>{t3}</span>),
],
{ hk: "2PxMie" },
);

View File

@@ -0,0 +1,111 @@
## Input
```javascript
import fbt from 'fbt';
import {Stringify} from 'shared-runtime';
/**
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
* Expected fixture `fbt-param-call-arguments` to succeed but it failed with error:
* /fbt-param-call-arguments.ts: Line 19 Column 11: fbt: unsupported babel node: Identifier
* ---
* t3
* ---
*/
function Component({firstname, lastname}) {
'use memo';
return (
<Stringify>
{fbt(
[
'Name: ',
fbt.param('firstname', <Stringify key={0} name={firstname} />),
', ',
fbt.param(
'lastname',
<Stringify key={0} name={lastname}>
{fbt('(inner fbt)', 'Inner fbt value')}
</Stringify>
),
],
'Name'
)}
</Stringify>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstname: 'first', lastname: 'last'}],
sequentialRenders: [{firstname: 'first', lastname: 'last'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import fbt from "fbt";
import { Stringify } from "shared-runtime";
/**
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
* Expected fixture `fbt-param-call-arguments` to succeed but it failed with error:
* /fbt-param-call-arguments.ts: Line 19 Column 11: fbt: unsupported babel node: Identifier
* ---
* t3
* ---
*/
function Component(t0) {
"use memo";
const $ = _c(5);
const { firstname, lastname } = t0;
let t1;
if ($[0] !== firstname || $[1] !== lastname) {
t1 = fbt._(
"Name: {firstname}, {lastname}",
[
fbt._param(
"firstname",
<Stringify key={0} name={firstname} />,
),
fbt._param(
"lastname",
<Stringify key={0} name={lastname}>
{fbt._("(inner fbt)", null, { hk: "36qNwF" })}
</Stringify>,
),
],
{ hk: "3AiIf8" },
);
$[0] = firstname;
$[1] = lastname;
$[2] = t1;
} else {
t1 = $[2];
}
let t2;
if ($[3] !== t1) {
t2 = <Stringify>{t1}</Stringify>;
$[3] = t1;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ firstname: "first", lastname: "last" }],
sequentialRenders: [{ firstname: "first", lastname: "last" }],
};
```
### Eval output
(kind: ok) <div>{"children":"Name: , "}</div>

View File

@@ -0,0 +1,78 @@
## Input
```javascript
import {fbt} from 'fbt';
import {useState} from 'react';
const MIN = 10;
function Component() {
const [count, setCount] = useState(0);
return fbt(
'Expected at least ' +
fbt.param('min', MIN, {number: true}) +
' items, but got ' +
fbt.param('count', count, {number: true}) +
' items.',
'Error description'
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { fbt } from "fbt";
import { useState } from "react";
const MIN = 10;
function Component() {
const $ = _c(2);
const [count] = useState(0);
let t0;
if ($[0] !== count) {
t0 = fbt._(
{ "*": { "*": "Expected at least {min} items, but got {count} items." } },
[
fbt._param(
"min",
MIN,
[0],
),
fbt._param(
"count",
count,
[0],
),
],
{ hk: "36gbz8" },
);
$[0] = count;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
### Eval output
(kind: ok) Expected at least 10 items, but got 0 items.

View File

@@ -0,0 +1,22 @@
import {fbt} from 'fbt';
import {useState} from 'react';
const MIN = 10;
function Component() {
const [count, setCount] = useState(0);
return fbt(
'Expected at least ' +
fbt.param('min', MIN, {number: true}) +
' items, but got ' +
fbt.param('count', count, {number: true}) +
' items.',
'Error description'
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};

View File

@@ -73,7 +73,7 @@ function Component(props) {
const groupName4 = t3;
let t4;
if ($[8] !== props) {
t4 = idx.hello_world.b.c(props, _temp3);
t4 = idx.hello_world.b.c(props, (__3) => __3.group.label);
$[8] = props;
$[9] = t4;
} else {
@@ -108,9 +108,6 @@ function Component(props) {
}
return t5;
}
function _temp3(__3) {
return __3.group.label;
}
function _temp2(__0) {
return __0.group.label;
}

View File

@@ -49,7 +49,7 @@ function Component(props) {
const groupName2 = t1;
let t2;
if ($[4] !== props) {
t2 = idx.a.b(props, _temp2);
t2 = idx.a.b(props, (__1) => __1.group.label);
$[4] = props;
$[5] = t2;
} else {
@@ -74,9 +74,6 @@ function Component(props) {
}
return t3;
}
function _temp2(__1) {
return __1.group.label;
}
function _temp(_) {
return _.group.label;
}

View File

@@ -0,0 +1,41 @@
## Input
```javascript
// @validateNoVoidUseMemo @loggerTestOnly
function Component() {
useMemo(() => {
return [];
}, []);
return <div />;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoVoidUseMemo @loggerTestOnly
function Component() {
const $ = _c(1);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = <div />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() result is unused","description":"This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":3,"column":2,"index":67},"end":{"line":3,"column":9,"index":74},"filename":"invalid-unused-usememo.ts","identifierName":"useMemo"},"message":"useMemo() result is unused"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":42},"end":{"line":7,"column":1,"index":127},"filename":"invalid-unused-usememo.ts"},"fnName":"Component","memoSlots":1,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,7 @@
// @validateNoVoidUseMemo @loggerTestOnly
function Component() {
useMemo(() => {
return [];
}, []);
return <div />;
}

View File

@@ -0,0 +1,59 @@
## Input
```javascript
// @validateNoVoidUseMemo @loggerTestOnly
function Component() {
const value = useMemo(() => {
console.log('computing');
}, []);
const value2 = React.useMemo(() => {
console.log('computing');
}, []);
return (
<div>
{value}
{value2}
</div>
);
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoVoidUseMemo @loggerTestOnly
function Component() {
const $ = _c(1);
console.log("computing");
console.log("computing");
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = (
<div>
{undefined}
{undefined}
</div>
);
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() callbacks must return a value","description":"This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":3,"column":24,"index":89},"end":{"line":5,"column":3,"index":130},"filename":"invalid-useMemo-no-return-value.ts"},"message":"useMemo() callbacks must return a value"}]}},"fnLoc":null}
{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() callbacks must return a value","description":"This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":6,"column":31,"index":168},"end":{"line":8,"column":3,"index":209},"filename":"invalid-useMemo-no-return-value.ts"},"message":"useMemo() callbacks must return a value"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":42},"end":{"line":15,"column":1,"index":283},"filename":"invalid-useMemo-no-return-value.ts"},"fnName":"Component","memoSlots":1,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -1,4 +1,4 @@
// @validateNoVoidUseMemo
// @validateNoVoidUseMemo @loggerTestOnly
function Component() {
const value = useMemo(() => {
console.log('computing');

View File

@@ -0,0 +1,33 @@
## Input
```javascript
// @loggerTestOnly
function component(a) {
let x = useMemo(() => {
mutate(a);
}, []);
return x;
}
```
## Code
```javascript
// @loggerTestOnly
function component(a) {
mutate(a);
}
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() callbacks must return a value","description":"This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":3,"column":18,"index":61},"end":{"line":5,"column":3,"index":87},"filename":"invalid-useMemo-return-empty.ts"},"message":"useMemo() callbacks must return a value"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":19},"end":{"line":7,"column":1,"index":107},"filename":"invalid-useMemo-return-empty.ts"},"fnName":"component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":1,"prunedMemoValues":0}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -1,49 +0,0 @@
## Input
```javascript
// @validatePreserveExistingMemoizationGuarantees
import {useMemo} from 'react';
import {useHook} from 'shared-runtime';
// useMemo values may not be memoized in Forget output if we
// infer that their deps always invalidate.
// This is technically a false positive as the useMemo in source
// was effectively a no-op
function useFoo(props) {
const x = [];
useHook();
x.push(props);
return useMemo(() => [x], [x]);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{}],
};
```
## Error
```
Found 1 error:
Compilation Skipped: Existing memoization could not be preserved
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.
error.false-positive-useMemo-dropped-infer-always-invalidating.ts:15:9
13 | x.push(props);
14 |
> 15 | return useMemo(() => [x], [x]);
| ^^^^^^^^^^^^^^^^^^^^^^^ Could not preserve existing memoization
16 | }
17 |
18 | export const FIXTURE_ENTRYPOINT = {
```

View File

@@ -1,21 +0,0 @@
// @validatePreserveExistingMemoizationGuarantees
import {useMemo} from 'react';
import {useHook} from 'shared-runtime';
// useMemo values may not be memoized in Forget output if we
// infer that their deps always invalidate.
// This is technically a false positive as the useMemo in source
// was effectively a no-op
function useFoo(props) {
const x = [];
useHook();
x.push(props);
return useMemo(() => [x], [x]);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{}],
};

View File

@@ -0,0 +1,56 @@
## Input
```javascript
// @validatePreserveExistingMemoizationGuarantees
import {useMemo} from 'react';
import {useHook} from 'shared-runtime';
// If we can prove that a useMemo was ineffective because it would always invalidate,
// then we shouldn't throw a "couldn't preserve existing memoization" error
// TODO: consider reporting a separate error to the user for this case, if you're going
// to memoize manually, then you probably want to know that it's a no-op
function useFoo(props) {
const x = [];
useHook();
x.push(props);
return useMemo(() => [x], [x]);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{}],
};
```
## Code
```javascript
// @validatePreserveExistingMemoizationGuarantees
import { useMemo } from "react";
import { useHook } from "shared-runtime";
// If we can prove that a useMemo was ineffective because it would always invalidate,
// then we shouldn't throw a "couldn't preserve existing memoization" error
// TODO: consider reporting a separate error to the user for this case, if you're going
// to memoize manually, then you probably want to know that it's a no-op
function useFoo(props) {
const x = [];
useHook();
x.push(props);
return [x];
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{}],
};
```
### Eval output
(kind: ok) [[{}]]

View File

@@ -0,0 +1,21 @@
// @validatePreserveExistingMemoizationGuarantees
import {useMemo} from 'react';
import {useHook} from 'shared-runtime';
// If we can prove that a useMemo was ineffective because it would always invalidate,
// then we shouldn't throw a "couldn't preserve existing memoization" error
// TODO: consider reporting a separate error to the user for this case, if you're going
// to memoize manually, then you probably want to know that it's a no-op
function useFoo(props) {
const x = [];
useHook();
x.push(props);
return useMemo(() => [x], [x]);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [{}],
};

View File

@@ -0,0 +1,98 @@
## Input
```javascript
import {useMemo} from 'react';
import {identity, useIdentity} from 'shared-runtime';
// Adapted from https://github.com/facebook/react/issues/34750
function useLocalCampaignBySlug(slug: string) {
const campaigns = useIdentity({a: {slug: 'a', name: 'campaign'}});
// The useMemo result is never assigned to a local so we did not previously ensure
// that there was a variable declaration for it when promoting the result temporary
return useMemo(() => {
for (const id of Object.keys(campaigns)) {
const campaign = campaigns[id];
if (campaign.slug === slug) {
return identity(campaign);
}
}
return null;
}, [campaigns, slug]);
}
function Component() {
const campaign = useLocalCampaignBySlug('a');
return <div>{campaign.name}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { useMemo } from "react";
import { identity, useIdentity } from "shared-runtime";
// Adapted from https://github.com/facebook/react/issues/34750
function useLocalCampaignBySlug(slug) {
const $ = _c(4);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = { a: { slug: "a", name: "campaign" } };
$[0] = t0;
} else {
t0 = $[0];
}
const campaigns = useIdentity(t0);
let t1;
if ($[1] !== campaigns || $[2] !== slug) {
bb0: {
for (const id of Object.keys(campaigns)) {
const campaign = campaigns[id];
if (campaign.slug === slug) {
t1 = identity(campaign);
break bb0;
}
}
t1 = null;
}
$[1] = campaigns;
$[2] = slug;
$[3] = t1;
} else {
t1 = $[3];
}
return t1;
}
function Component() {
const $ = _c(2);
const campaign = useLocalCampaignBySlug("a");
let t0;
if ($[0] !== campaign.name) {
t0 = <div>{campaign.name}</div>;
$[0] = campaign.name;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
### Eval output
(kind: ok) <div>campaign</div>

View File

@@ -0,0 +1,28 @@
import {useMemo} from 'react';
import {identity, useIdentity} from 'shared-runtime';
// Adapted from https://github.com/facebook/react/issues/34750
function useLocalCampaignBySlug(slug: string) {
const campaigns = useIdentity({a: {slug: 'a', name: 'campaign'}});
// The useMemo result is never assigned to a local so we did not previously ensure
// that there was a variable declaration for it when promoting the result temporary
return useMemo(() => {
for (const id of Object.keys(campaigns)) {
const campaign = campaigns[id];
if (campaign.slug === slug) {
return identity(campaign);
}
}
return null;
}, [campaigns, slug]);
}
function Component() {
const campaign = useLocalCampaignBySlug('a');
return <div>{campaign.name}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};

View File

@@ -2,6 +2,7 @@
## Input
```javascript
// @validateNoVoidUseMemo:false
function Component(props) {
const item = props.item;
const thumbnails = [];
@@ -22,7 +23,7 @@ function Component(props) {
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { c as _c } from "react/compiler-runtime"; // @validateNoVoidUseMemo:false
function Component(props) {
const $ = _c(6);
const item = props.item;

View File

@@ -1,3 +1,4 @@
// @validateNoVoidUseMemo:false
function Component(props) {
const item = props.item;
const thumbnails = [];

View File

@@ -6,6 +6,7 @@ function Component(props) {
const x = useMemo(() => {
if (props.cond) {
if (props.cond) {
return props.value;
}
}
}, [props.cond]);
@@ -24,10 +25,18 @@ export const FIXTURE_ENTRYPOINT = {
```javascript
function Component(props) {
if (props.cond) {
let t0;
bb0: {
if (props.cond) {
if (props.cond) {
t0 = props.value;
break bb0;
}
}
t0 = undefined;
}
const x = t0;
return x;
}
export const FIXTURE_ENTRYPOINT = {

View File

@@ -2,6 +2,7 @@ function Component(props) {
const x = useMemo(() => {
if (props.cond) {
if (props.cond) {
return props.value;
}
}
}, [props.cond]);

View File

@@ -1,22 +0,0 @@
## Input
```javascript
function component(a) {
let x = useMemo(() => {
mutate(a);
}, []);
return x;
}
```
## Code
```javascript
function component(a) {
mutate(a);
}
```

View File

@@ -120,7 +120,15 @@ testRule('plugin-recommended', TestRecommendedRules, {
return <Child x={state} />;
}`,
errors: [makeTestCaseError('useMemo() callbacks must return a value')],
errors: [
makeTestCaseError('useMemo() callbacks must return a value'),
makeTestCaseError(
'Calling setState from useMemo may trigger an infinite loop',
),
makeTestCaseError(
'Calling setState from useMemo may trigger an infinite loop',
),
],
},
{
name: 'Pipeline errors are reported',

View File

@@ -15,8 +15,8 @@
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.22.4 || ^4.0.0",
"zod-validation-error": "^3.0.3 || ^4.0.0"
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"devDependencies": {
"@babel/preset-env": "^7.22.4",

View File

@@ -10,7 +10,14 @@ import {defineConfig} from 'tsup';
export default defineConfig({
entry: ['./src/index.ts'],
outDir: './dist',
external: ['@babel/core', 'hermes-parser', 'zod', 'zod-validation-error'],
external: [
'@babel/core',
'hermes-parser',
'zod',
'zod/v4',
'zod-validation-error',
'zod-validation-error/v4',
],
splitting: false,
sourcemap: false,
dts: false,

View File

@@ -17,8 +17,8 @@
"fast-glob": "^3.3.2",
"ora": "5.4.1",
"yargs": "^17.7.2",
"zod": "^3.22.4 || ^4.0.0",
"zod-validation-error": "^3.0.3 || ^4.0.0"
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"devDependencies": {},
"engines": {

View File

@@ -18,7 +18,9 @@ export default defineConfig({
'ora',
'yargs',
'zod',
'zod/v4',
'zod-validation-error',
'zod-validation-error/v4',
],
splitting: false,
sourcemap: false,

View File

@@ -24,7 +24,7 @@
"html-to-text": "^9.0.5",
"prettier": "^3.3.3",
"puppeteer": "^24.7.2",
"zod": "^3.22.4 || ^4.0.0"
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@types/html-to-text": "^9.0.4",

View File

@@ -7,7 +7,7 @@
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
import {z} from 'zod';
import {z} from 'zod/v4';
import {compile, type PrintedCompilerPipelineValue} from './compiler';
import {
CompilerPipelineValue,

View File

@@ -37,7 +37,9 @@
"react": "0.0.0-experimental-4beb1fd8-20241118",
"react-dom": "0.0.0-experimental-4beb1fd8-20241118",
"readline": "^1.3.0",
"yargs": "^17.7.1"
"yargs": "^17.7.1",
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"devDependencies": {
"@babel/core": "^7.19.1",

View File

@@ -9,8 +9,8 @@ import {render} from '@testing-library/react';
import {JSDOM} from 'jsdom';
import React, {MutableRefObject} from 'react';
import util from 'util';
import {z} from 'zod';
import {fromZodError} from 'zod-validation-error';
import {z} from 'zod/v4';
import {fromZodError} from 'zod-validation-error/v4';
import {initFbt, toJSON} from './shared-runtime';
/**

View File

@@ -11505,17 +11505,17 @@ zod-to-json-schema@^3.24.1:
resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz"
integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==
"zod-validation-error@^3.0.3 || ^4.0.0":
"zod-validation-error@^3.5.0 || ^4.0.0":
version "4.0.2"
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918"
integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==
"zod@^3.22.4 || ^4.0.0":
version "4.1.11"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5"
integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==
zod@^3.23.8, zod@^3.24.1:
version "3.24.3"
resolved "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz"
integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==
"zod@^3.25.0 || ^4.0.0":
version "4.1.12"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0"
integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==

View File

@@ -1555,6 +1555,17 @@ const allTests = {
`,
errors: [useEffectEventError('onClick', false)],
},
{
code: normalizeIndent`
// Invalid because useEffectEvent is being passed down
function MyComponent({ theme }) {
return <Child onClick={useEffectEvent(() => {
showNotification(theme);
})} />;
}
`,
errors: [{...useEffectEventError(null, false), line: 4}],
},
{
code: normalizeIndent`
// This should error even though it shares an identifier name with the below
@@ -1726,6 +1737,14 @@ function classError(hook) {
}
function useEffectEventError(fn, called) {
if (fn === null) {
return {
message:
`React Hook "useEffectEvent" can only be called at the top level of your component.` +
` It cannot be passed down.`,
};
}
return {
message:
`\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +

View File

@@ -42,8 +42,8 @@
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.22.4 || ^4.0.0",
"zod-validation-error": "^3.0.3 || ^4.0.0"
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.11.4",

View File

@@ -171,7 +171,15 @@ function isUseEffectEventIdentifier(node: Node): boolean {
return node.type === 'Identifier' && node.name === 'useEffectEvent';
}
function useEffectEventError(fn: string, called: boolean): string {
function useEffectEventError(fn: string | null, called: boolean): string {
// no function identifier, i.e. it is not assigned to a variable
if (fn === null) {
return (
`React Hook "useEffectEvent" can only be called at the top level of your component.` +
` It cannot be passed down.`
);
}
return (
`\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
'Effects and Effect Events in the same component.' +
@@ -772,6 +780,22 @@ const rule = {
// comparison later when we exit
lastEffect = node;
}
// Specifically disallow <Child onClick={useEffectEvent(...)} /> because this
// case can't be caught by `recordAllUseEffectEventFunctions` as it isn't assigned to a variable
if (
isUseEffectEventIdentifier(nodeWithoutNamespace) &&
node.parent?.type !== 'VariableDeclarator' &&
// like in other hooks, calling useEffectEvent at component's top level without assignment is valid
node.parent?.type !== 'ExpressionStatement'
) {
const message = useEffectEventError(null, false);
context.report({
node,
message,
});
}
},
Identifier(node) {

View File

@@ -1546,7 +1546,7 @@ describe('Store', () => {
▸ <Wrapper>
`);
const deepestedNodeID = agent.getIDForHostInstance(ref.current);
const deepestedNodeID = agent.getIDForHostInstance(ref.current).id;
await act(() => store.toggleIsCollapsed(deepestedNodeID, false));
expect(store).toMatchInlineSnapshot(`

View File

@@ -455,7 +455,10 @@ export default class Agent extends EventEmitter<{
return renderer.getInstanceAndStyle(id);
}
getIDForHostInstance(target: HostInstance): number | null {
getIDForHostInstance(
target: HostInstance,
onlySuspenseNodes?: boolean,
): null | {id: number, rendererID: number} {
if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') {
// In React Native or non-DOM we simply pick any renderer that has a match.
for (const rendererID in this._rendererInterfaces) {
@@ -463,9 +466,14 @@ export default class Agent extends EventEmitter<{
(rendererID: any)
]: any): RendererInterface);
try {
const match = renderer.getElementIDForHostInstance(target);
if (match != null) {
return match;
const id = onlySuspenseNodes
? renderer.getSuspenseNodeIDForHostInstance(target)
: renderer.getElementIDForHostInstance(target);
if (id !== null) {
return {
id: id,
rendererID: +rendererID,
};
}
} catch (error) {
// Some old React versions might throw if they can't find a match.
@@ -478,6 +486,7 @@ export default class Agent extends EventEmitter<{
// that is registered if there isn't an exact match.
let bestMatch: null | Element = null;
let bestRenderer: null | RendererInterface = null;
let bestRendererID: number = 0;
// Find the nearest ancestor which is mounted by a React.
for (const rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[
@@ -491,6 +500,7 @@ export default class Agent extends EventEmitter<{
// Exact match we can exit early.
bestMatch = nearestNode;
bestRenderer = renderer;
bestRendererID = +rendererID;
break;
}
if (bestMatch === null || bestMatch.contains(nearestNode)) {
@@ -498,12 +508,21 @@ export default class Agent extends EventEmitter<{
// so the new match is a deeper and therefore better match.
bestMatch = nearestNode;
bestRenderer = renderer;
bestRendererID = +rendererID;
}
}
}
if (bestRenderer != null && bestMatch != null) {
try {
return bestRenderer.getElementIDForHostInstance(bestMatch);
const id = onlySuspenseNodes
? bestRenderer.getSuspenseNodeIDForHostInstance(bestMatch)
: bestRenderer.getElementIDForHostInstance(bestMatch);
if (id !== null) {
return {
id,
rendererID: bestRendererID,
};
}
} catch (error) {
// Some old React versions might throw if they can't find a match.
// If so we should ignore it...
@@ -514,65 +533,14 @@ export default class Agent extends EventEmitter<{
}
getComponentNameForHostInstance(target: HostInstance): string | null {
// We duplicate this code from getIDForHostInstance to avoid an object allocation.
if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') {
// In React Native or non-DOM we simply pick any renderer that has a match.
for (const rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[
(rendererID: any)
]: any): RendererInterface);
try {
const id = renderer.getElementIDForHostInstance(target);
if (id) {
return renderer.getDisplayNameForElementID(id);
}
} catch (error) {
// Some old React versions might throw if they can't find a match.
// If so we should ignore it...
}
}
return null;
} else {
// In the DOM we use a smarter mechanism to find the deepest a DOM node
// that is registered if there isn't an exact match.
let bestMatch: null | Element = null;
let bestRenderer: null | RendererInterface = null;
// Find the nearest ancestor which is mounted by a React.
for (const rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[
(rendererID: any)
]: any): RendererInterface);
const nearestNode: null | Element = renderer.getNearestMountedDOMNode(
(target: any),
);
if (nearestNode !== null) {
if (nearestNode === target) {
// Exact match we can exit early.
bestMatch = nearestNode;
bestRenderer = renderer;
break;
}
if (bestMatch === null || bestMatch.contains(nearestNode)) {
// If this is the first match or the previous match contains the new match,
// so the new match is a deeper and therefore better match.
bestMatch = nearestNode;
bestRenderer = renderer;
}
}
}
if (bestRenderer != null && bestMatch != null) {
try {
const id = bestRenderer.getElementIDForHostInstance(bestMatch);
if (id) {
return bestRenderer.getDisplayNameForElementID(id);
}
} catch (error) {
// Some old React versions might throw if they can't find a match.
// If so we should ignore it...
}
}
return null;
const match = this.getIDForHostInstance(target);
if (match !== null) {
const renderer = ((this._rendererInterfaces[
(match.rendererID: any)
]: any): RendererInterface);
return renderer.getDisplayNameForElementID(match.id);
}
return null;
}
getBackendVersion: () => void = () => {
@@ -971,9 +939,9 @@ export default class Agent extends EventEmitter<{
};
selectNode(target: HostInstance): void {
const id = this.getIDForHostInstance(target);
if (id !== null) {
this._bridge.send('selectElement', id);
const match = this.getIDForHostInstance(target);
if (match !== null) {
this._bridge.send('selectElement', match.id);
}
}

View File

@@ -2139,8 +2139,8 @@ export function attach(
// Regular operations
pendingOperations.length +
// All suspender changes are batched in a single message.
// [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders]]
(numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 2 : 0),
// [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders, isSuspended]]
(numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 3 : 0),
);
// Identify which renderer this update is coming from.
@@ -2225,6 +2225,14 @@ export function attach(
}
operations[i++] = fiberIdWithChanges;
operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
const instance = suspense.instance;
const isSuspended =
// TODO: Track if other SuspenseNode like SuspenseList rows are suspended.
(instance.kind === FIBER_INSTANCE ||
instance.kind === FILTERED_FIBER_INSTANCE) &&
instance.data.tag === SuspenseComponent &&
instance.data.memoizedState !== null;
operations[i++] = isSuspended ? 1 : 0;
operations[i++] = suspense.environments.size;
suspense.environments.forEach((count, env) => {
operations[i++] = getStringID(env);
@@ -2251,7 +2259,10 @@ export function attach(
if (typeof instance !== 'object' || instance === null) {
return null;
}
if (typeof instance.getClientRects === 'function') {
if (
typeof instance.getClientRects === 'function' ||
instance.nodeType === 3
) {
// DOM
const doc = instance.ownerDocument;
if (instance === doc.documentElement) {
@@ -2273,7 +2284,21 @@ export function attach(
const win = doc && doc.defaultView;
const scrollX = win ? win.scrollX : 0;
const scrollY = win ? win.scrollY : 0;
const rects = instance.getClientRects();
let rects;
if (instance.nodeType === 3) {
// Text nodes cannot be measured directly but we can measure a Range.
if (typeof doc.createRange !== 'function') {
return null;
}
const range = doc.createRange();
if (typeof range.getClientRects !== 'function') {
return null;
}
range.selectNodeContents(instance);
rects = range.getClientRects();
} else {
rects = instance.getClientRects();
}
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
result.push({
@@ -2640,9 +2665,15 @@ export function attach(
const fiber = fiberInstance.data;
const props = fiber.memoizedProps;
// TODO: Compute a fallback name based on Owner, key etc.
const name = props === null ? null : props.name || null;
const name =
fiber.tag !== SuspenseComponent || props === null
? null
: props.name || null;
const nameStringID = getStringID(name);
const isSuspended =
fiber.tag === SuspenseComponent && fiber.memoizedState !== null;
if (__DEBUG__) {
console.log('recordSuspenseMount()', suspenseInstance);
}
@@ -2653,6 +2684,7 @@ export function attach(
pushOperation(fiberID);
pushOperation(parentID);
pushOperation(nameStringID);
pushOperation(isSuspended ? 1 : 0);
const rects = suspenseInstance.rects;
if (rects === null) {
@@ -2661,10 +2693,10 @@ export function attach(
pushOperation(rects.length);
for (let i = 0; i < rects.length; ++i) {
const rect = rects[i];
pushOperation(Math.round(rect.x));
pushOperation(Math.round(rect.y));
pushOperation(Math.round(rect.width));
pushOperation(Math.round(rect.height));
pushOperation(Math.round(rect.x * 1000));
pushOperation(Math.round(rect.y * 1000));
pushOperation(Math.round(rect.width * 1000));
pushOperation(Math.round(rect.height * 1000));
}
}
}
@@ -2733,10 +2765,10 @@ export function attach(
pushOperation(rects.length);
for (let i = 0; i < rects.length; ++i) {
const rect = rects[i];
pushOperation(Math.round(rect.x));
pushOperation(Math.round(rect.y));
pushOperation(Math.round(rect.width));
pushOperation(Math.round(rect.height));
pushOperation(Math.round(rect.x * 1000));
pushOperation(Math.round(rect.y * 1000));
pushOperation(Math.round(rect.width * 1000));
pushOperation(Math.round(rect.height * 1000));
}
}
}
@@ -3262,14 +3294,22 @@ export function attach(
// We don't update rects inside disconnected subtrees.
return;
}
const nextRects = measureInstance(suspenseNode.instance);
const prevRects = suspenseNode.rects;
if (areEqualRects(prevRects, nextRects)) {
return; // Unchanged
const instance = suspenseNode.instance;
const isSuspendedSuspenseComponent =
(instance.kind === FIBER_INSTANCE ||
instance.kind === FILTERED_FIBER_INSTANCE) &&
instance.data.tag === SuspenseComponent &&
instance.data.memoizedState !== null;
if (isSuspendedSuspenseComponent) {
// This boundary itself was suspended and we don't measure those since that would measure
// the fallback. We want to keep a ghost of the rectangle of the content not currently shown.
return;
}
// The rect has changed. While the bailed out root wasn't in a disconnected subtree,
// While this boundary wasn't suspended and the bailed out root and wasn't in a disconnected subtree,
// it's possible that this node was in one. So we need to check if we're offscreen.
let parent = suspenseNode.instance.parent;
let parent = instance.parent;
while (parent !== null) {
if (
(parent.kind === FIBER_INSTANCE ||
@@ -3285,6 +3325,13 @@ export function attach(
}
parent = parent.parent;
}
const nextRects = measureInstance(suspenseNode.instance);
const prevRects = suspenseNode.rects;
if (areEqualRects(prevRects, nextRects)) {
return; // Unchanged
}
// We changed inside a visible tree.
// Since this boundary changed, it's possible it also affected its children so lets
// measure them as well.
@@ -5006,15 +5053,24 @@ export function attach(
const nextIsSuspended = isSuspendedOffscreen(nextFiber);
if (isLegacySuspense) {
if (
fiberInstance !== null &&
fiberInstance.suspenseNode !== null &&
(prevFiber.stateNode === null) !== (nextFiber.stateNode === null)
) {
trackThrownPromisesFromRetryCache(
fiberInstance.suspenseNode,
nextFiber.stateNode,
);
if (fiberInstance !== null && fiberInstance.suspenseNode !== null) {
const suspenseNode = fiberInstance.suspenseNode;
if (
(prevFiber.stateNode === null) !==
(nextFiber.stateNode === null)
) {
trackThrownPromisesFromRetryCache(
suspenseNode,
nextFiber.stateNode,
);
}
if (
(prevFiber.memoizedState === null) !==
(nextFiber.memoizedState === null)
) {
// Toggle suspended state.
recordSuspenseSuspenders(suspenseNode);
}
}
}
// The logic below is inspired by the code paths in updateSuspenseComponent()
@@ -5162,6 +5218,14 @@ export function attach(
);
}
if (
(prevFiber.memoizedState === null) !==
(nextFiber.memoizedState === null)
) {
// Toggle suspended state.
recordSuspenseSuspenders(suspenseNode);
}
shouldMeasureSuspenseNode = false;
updateFlags |= updateSuspenseChildrenRecursively(
nextContentFiber,
@@ -5188,6 +5252,8 @@ export function attach(
}
trackThrownPromisesFromRetryCache(suspenseNode, nextFiber.stateNode);
// Toggle suspended state.
recordSuspenseSuspenders(suspenseNode);
mountSuspenseChildrenRecursively(
nextContentFiber,
@@ -5727,7 +5793,28 @@ export function attach(
return null;
}
if (devtoolsInstance.kind === FIBER_INSTANCE) {
return getDisplayNameForFiber(devtoolsInstance.data);
const fiber = devtoolsInstance.data;
if (fiber.tag === HostRoot) {
// The only reason you'd inspect a HostRoot is to show it as a SuspenseNode.
return 'Initial Paint';
}
if (fiber.tag === SuspenseComponent || fiber.tag === ActivityComponent) {
// For Suspense and Activity components, we can show a better name
// by using the name prop or their owner.
const props = fiber.memoizedProps;
if (props.name != null) {
return props.name;
}
const owner = getUnfilteredOwner(fiber);
if (owner != null) {
if (typeof owner.tag === 'number') {
return getDisplayNameForFiber((owner: any));
} else {
return owner.name || '';
}
}
}
return getDisplayNameForFiber(fiber);
} else {
return devtoolsInstance.data.name || '';
}
@@ -5768,6 +5855,28 @@ export function attach(
return null;
}
function getSuspenseNodeIDForHostInstance(
publicInstance: HostInstance,
): number | null {
const instance = publicInstanceToDevToolsInstanceMap.get(publicInstance);
if (instance !== undefined) {
// Pick nearest unfiltered SuspenseNode instance.
let suspenseInstance = instance;
while (
suspenseInstance.suspenseNode === null ||
suspenseInstance.kind === FILTERED_FIBER_INSTANCE
) {
if (suspenseInstance.parent === null) {
// We shouldn't get here since we'll always have a suspenseNode at the root.
return null;
}
suspenseInstance = suspenseInstance.parent;
}
return suspenseInstance.id;
}
return null;
}
function getElementAttributeByPath(
id: number,
path: Array<string | number>,
@@ -8564,6 +8673,7 @@ export function attach(
getDisplayNameForElementID,
getNearestMountedDOMNode,
getElementIDForHostInstance,
getSuspenseNodeIDForHostInstance,
getInstanceAndStyle,
getOwnersList,
getPathForElement,

View File

@@ -169,6 +169,9 @@ export function attach(
getElementIDForHostInstance() {
return null;
},
getSuspenseNodeIDForHostInstance() {
return null;
},
getInstanceAndStyle() {
return {
instance: null,

View File

@@ -417,6 +417,7 @@ export function attach(
pushOperation(id);
pushOperation(parentID);
pushOperation(getStringID(null)); // name
pushOperation(0); // isSuspended
// TODO: Measure rect of root
pushOperation(-1);
} else {
@@ -1268,6 +1269,9 @@ export function attach(
getDisplayNameForElementID,
getNearestMountedDOMNode,
getElementIDForHostInstance,
getSuspenseNodeIDForHostInstance(id: number): null {
return null;
},
getInstanceAndStyle,
findHostInstancesForElementID: (id: number) => {
const hostInstance = findHostInstanceForInternalID(id);

View File

@@ -427,6 +427,7 @@ export type RendererInterface = {
getComponentStack?: GetComponentStack,
getNearestMountedDOMNode: (component: Element) => Element | null,
getElementIDForHostInstance: GetElementIDForHostInstance,
getSuspenseNodeIDForHostInstance: GetElementIDForHostInstance,
getDisplayNameForElementID: GetDisplayNameForElementID,
getInstanceAndStyle(id: number): InstanceAndStyle,
getProfilingData(): ProfilingDataBackend,

View File

@@ -187,10 +187,13 @@ export default class Overlay {
}
}
inspect(nodes: $ReadOnlyArray<HTMLElement>, name?: ?string) {
inspect(nodes: $ReadOnlyArray<HTMLElement | Text>, name?: ?string) {
// We can't get the size of text nodes or comment nodes. React as of v15
// heavily uses comment nodes to delimit text.
const elements = nodes.filter(node => node.nodeType === Node.ELEMENT_NODE);
// TODO: We actually can measure text nodes. We should.
const elements: $ReadOnlyArray<HTMLElement> = (nodes.filter(
node => node.nodeType === Node.ELEMENT_NODE,
): any);
while (this.rects.length > elements.length) {
const rect = this.rects.pop();

View File

@@ -20,6 +20,7 @@ import type {RendererInterface} from '../../types';
// That is done by the React Native Inspector component.
let iframesListeningTo: Set<HTMLIFrameElement> = new Set();
let inspectOnlySuspenseNodes = false;
export default function setupHighlighter(
bridge: BackendBridge,
@@ -33,7 +34,8 @@ export default function setupHighlighter(
bridge.addListener('startInspectingHost', startInspectingHost);
bridge.addListener('stopInspectingHost', stopInspectingHost);
function startInspectingHost() {
function startInspectingHost(onlySuspenseNodes: boolean) {
inspectOnlySuspenseNodes = onlySuspenseNodes;
registerListenersOnWindow(window);
}
@@ -363,11 +365,37 @@ export default function setupHighlighter(
}
}
// Don't pass the name explicitly.
// It will be inferred from DOM tag and Fiber owner.
showOverlay([target], null, agent, false);
selectElementForNode(target);
if (inspectOnlySuspenseNodes) {
// For Suspense nodes we want to highlight not the actual target but the nodes
// that are the root of the Suspense node.
// TODO: Consider if we should just do the same for other elements because the
// hovered node might just be one child of many in the Component.
const match = agent.getIDForHostInstance(
target,
inspectOnlySuspenseNodes,
);
if (match !== null) {
const renderer = agent.rendererInterfaces[match.rendererID];
if (renderer == null) {
console.warn(
`Invalid renderer id "${match.rendererID}" for element "${match.id}"`,
);
return;
}
highlightHostInstance({
displayName: renderer.getDisplayNameForElementID(match.id),
hideAfterTimeout: false,
id: match.id,
openBuiltinElementsPanel: false,
rendererID: match.rendererID,
scrollIntoView: false,
});
}
} else {
// Don't pass the name explicitly.
// It will be inferred from DOM tag and Fiber owner.
showOverlay([target], null, agent, false);
}
}
function onPointerUp(event: MouseEvent) {
@@ -376,9 +404,9 @@ export default function setupHighlighter(
}
const selectElementForNode = (node: HTMLElement) => {
const id = agent.getIDForHostInstance(node);
if (id !== null) {
bridge.send('selectElement', id);
const match = agent.getIDForHostInstance(node, inspectOnlySuspenseNodes);
if (match !== null) {
bridge.send('selectElement', match.id);
}
};

View File

@@ -217,10 +217,15 @@ export type BackendEvents = {
selectElement: [number],
shutdown: [],
stopInspectingHost: [boolean],
syncSelectionFromBuiltinElementsPanel: [],
syncSelectionToBuiltinElementsPanel: [],
unsupportedRendererVersion: [],
extensionComponentsPanelShown: [],
extensionComponentsPanelHidden: [],
resumeElementPolling: [],
pauseElementPolling: [],
// React Native style editor plug-in.
isNativeStyleEditorSupported: [
{isSupported: boolean, validAttributes: ?$ReadOnlyArray<string>},
@@ -240,8 +245,6 @@ type FrontendEvents = {
clearWarningsForElementID: [ElementAndRendererID],
copyElementPath: [CopyElementPathParams],
deletePath: [DeletePath],
extensionComponentsPanelShown: [],
extensionComponentsPanelHidden: [],
getBackendVersion: [],
getBridgeProtocol: [],
getIfHasUnsupportedRendererVersion: [],
@@ -263,9 +266,9 @@ type FrontendEvents = {
savedPreferences: [SavedPreferencesParams],
setTraceUpdatesEnabled: [boolean],
shutdown: [],
startInspectingHost: [],
startInspectingHost: [boolean],
startProfiling: [StartProfilingParams],
stopInspectingHost: [boolean],
stopInspectingHost: [],
scrollToHostInstance: [ScrollToHostInstance],
stopProfiling: [],
storeAsGlobal: [StoreAsGlobalParams],
@@ -275,6 +278,8 @@ type FrontendEvents = {
viewAttributeSource: [ViewAttributeSourceParams],
viewElementSource: [ElementAndRendererID],
syncSelectionFromBuiltinElementsPanel: [],
// React Native style editor plug-in.
NativeStyleEditor_measure: [ElementAndRendererID],
NativeStyleEditor_renameAttribute: [NativeStyleEditor_RenameAttributeParams],
@@ -295,19 +300,13 @@ type FrontendEvents = {
overrideProps: [OverrideValue],
overrideState: [OverrideValue],
resumeElementPolling: [],
pauseElementPolling: [],
getHookSettings: [],
};
class Bridge<
OutgoingEvents: Object,
IncomingEvents: Object,
> extends EventEmitter<{
...IncomingEvents,
...OutgoingEvents,
}> {
> extends EventEmitter<IncomingEvents> {
_isShutdown: boolean = false;
_messageQueue: Array<any> = [];
_scheduledFlush: boolean = false;

View File

@@ -1552,7 +1552,8 @@ export default class Store extends EventEmitter<{
const id = operations[i + 1];
const parentID = operations[i + 2];
const nameStringID = operations[i + 3];
const numRects = ((operations[i + 4]: any): number);
const isSuspended = operations[i + 4] === 1;
const numRects = ((operations[i + 5]: any): number);
let name = stringTable[nameStringID];
if (this._idToSuspense.has(id)) {
@@ -1579,17 +1580,17 @@ export default class Store extends EventEmitter<{
}
}
i += 5;
i += 6;
let rects: SuspenseNode['rects'];
if (numRects === -1) {
rects = null;
} else {
rects = [];
for (let rectIndex = 0; rectIndex < numRects; rectIndex++) {
const x = operations[i + 0];
const y = operations[i + 1];
const width = operations[i + 2];
const height = operations[i + 3];
const x = operations[i + 0] / 1000;
const y = operations[i + 1] / 1000;
const width = operations[i + 2] / 1000;
const height = operations[i + 3] / 1000;
rects.push({x, y, width, height});
i += 4;
}
@@ -1625,6 +1626,7 @@ export default class Store extends EventEmitter<{
name,
rects,
hasUniqueSuspenders: false,
isSuspended: isSuspended,
});
hasSuspenseTreeChanged = true;
@@ -1761,10 +1763,10 @@ export default class Store extends EventEmitter<{
} else {
nextRects = [];
for (let rectIndex = 0; rectIndex < numRects; rectIndex++) {
const x = operations[i + 0];
const y = operations[i + 1];
const width = operations[i + 2];
const height = operations[i + 3];
const x = operations[i + 0] / 1000;
const y = operations[i + 1] / 1000;
const width = operations[i + 2] / 1000;
const height = operations[i + 3] / 1000;
nextRects.push({x, y, width, height});
@@ -1801,6 +1803,7 @@ export default class Store extends EventEmitter<{
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const id = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const isSuspended = operations[i++] === 1;
const environmentNamesLength = operations[i++];
const environmentNames = [];
for (
@@ -1832,6 +1835,7 @@ export default class Store extends EventEmitter<{
}
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
suspense.isSuspended = isSuspended;
// TODO: Recompute the environment names.
}

View File

@@ -14,7 +14,11 @@ import Toggle from '../Toggle';
import ButtonIcon from '../ButtonIcon';
import {logEvent} from 'react-devtools-shared/src/Logger';
export default function InspectHostNodesToggle(): React.Node {
export default function InspectHostNodesToggle({
onlySuspenseNodes,
}: {
onlySuspenseNodes?: boolean,
}): React.Node {
const [isInspecting, setIsInspecting] = useState(false);
const bridge = useContext(BridgeContext);
@@ -24,9 +28,9 @@ export default function InspectHostNodesToggle(): React.Node {
if (isChecked) {
logEvent({event_name: 'inspect-element-button-clicked'});
bridge.send('startInspectingHost');
bridge.send('startInspectingHost', !!onlySuspenseNodes);
} else {
bridge.send('stopInspectingHost', false);
bridge.send('stopInspectingHost');
}
},
[bridge],

View File

@@ -378,7 +378,8 @@ function updateTree(
const fiberID = operations[i + 1];
const parentID = operations[i + 2];
const nameStringID = operations[i + 3];
const numRects = operations[i + 4];
const isSuspended = operations[i + 4];
const numRects = operations[i + 5];
const name = stringTable[nameStringID];
if (__DEBUG__) {
@@ -388,16 +389,16 @@ function updateTree(
} else {
rects =
'[' +
operations.slice(i + 5, i + 5 + numRects * 4).join(',') +
operations.slice(i + 6, i + 6 + numRects * 4).join(',') +
']';
}
debug(
'Add suspense',
`node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID}`,
`node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID} suspended ${isSuspended}`,
);
}
i += 5 + (numRects === -1 ? 0 : numRects * 4);
i += 6 + (numRects === -1 ? 0 : numRects * 4);
break;
}
@@ -459,12 +460,13 @@ function updateTree(
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const suspenseNodeId = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const isSuspended = operations[i++] === 1;
const environmentNamesLength = operations[i++];
i += environmentNamesLength;
if (__DEBUG__) {
debug(
'Suspender changes',
`Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
`Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
);
}
}

View File

@@ -19,11 +19,6 @@
.SuspenseRectsBoundaryChildren {
pointer-events: none;
/**
* So that the shadow of Boundaries within is clipped off.
* Otherwise it would look like this boundary is further elevated.
*/
overflow: hidden;
}
.SuspenseRectsScaledRect[data-visible='false'] > .SuspenseRectsBoundaryChildren {
@@ -49,6 +44,10 @@
outline-width: 0;
}
.SuspenseRectsScaledRect[data-suspended='true'] {
opacity: 0.3;
}
/* highlight this boundary */
.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect {
background-color: var(--color-background-hover);

View File

@@ -35,11 +35,15 @@ function ScaledRect({
className,
rect,
visible,
suspended,
adjust,
...props
}: {
className: string,
rect: Rect,
visible: boolean,
suspended: boolean,
adjust?: boolean,
...
}): React$Node {
const viewBox = useContext(ViewBox);
@@ -53,9 +57,11 @@ function ScaledRect({
{...props}
className={styles.SuspenseRectsScaledRect + ' ' + className}
data-visible={visible}
data-suspended={suspended}
style={{
width,
height,
// Shrink one pixel so that the bottom outline will line up with the top outline of the next one.
width: adjust ? 'calc(' + width + ' - 1px)' : width,
height: adjust ? 'calc(' + height + ' - 1px)' : height,
top: y,
left: x,
}}
@@ -145,7 +151,8 @@ function SuspenseRects({
<ScaledRect
rect={boundingBox}
className={styles.SuspenseRectsBoundary}
visible={visible}>
visible={visible}
suspended={suspense.isSuspended}>
<ViewBox.Provider value={boundingBox}>
{visible &&
suspense.rects !== null &&
@@ -156,6 +163,7 @@ function SuspenseRects({
className={styles.SuspenseRectsRect}
rect={rect}
data-highlighted={selected}
adjust={true}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerOver={handlePointerOver}

View File

@@ -31,9 +31,9 @@ export default function SuspenseScrubber({
max: number,
value: number,
highlight: number,
onBlur: () => void,
onBlur?: () => void,
onChange: (index: number) => void,
onFocus: () => void,
onFocus?: () => void,
onHoverSegment: (index: number) => void,
onHoverLeave: () => void,
}): React$Node {

View File

@@ -14,6 +14,7 @@ import {
useLayoutEffect,
useReducer,
useRef,
Fragment,
} from 'react';
import {
@@ -21,6 +22,7 @@ import {
localStorageSetItem,
} from 'react-devtools-shared/src/storage';
import ButtonIcon, {type IconType} from '../ButtonIcon';
import InspectHostNodesToggle from '../Components/InspectHostNodesToggle';
import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary';
import InspectedElement from '../Components/InspectedElement';
import portaledContent from '../portaledContent';
@@ -85,7 +87,9 @@ function ToggleUniqueSuspenders() {
<Toggle
isChecked={uniqueSuspendersOnly}
onChange={handleToggleUniqueSuspenders}
title={'Only include boundaries with unique suspenders'}>
title={
'Filter Suspense which does not suspend, or if the parent also suspend on the same.'
}>
<ButtonIcon type={uniqueSuspendersOnly ? 'filter-on' : 'filter-off'} />
</Toggle>
);
@@ -154,6 +158,7 @@ function ToggleInspectedElement({
}
function SuspenseTab(_: {}) {
const store = useContext(StoreContext);
const {hideSettings} = useContext(OptionsContext);
const [state, dispatch] = useReducer<LayoutState, null, LayoutAction>(
layoutReducer,
@@ -365,6 +370,12 @@ function SuspenseTab(_: {}) {
) : (
<ToggleTreeList dispatch={dispatch} state={state} />
)}
{store.supportsClickToInspect && (
<Fragment>
<InspectHostNodesToggle onlySuspenseNodes={true} />
<div className={styles.VRule} />
</Fragment>
)}
<div className={styles.SuspenseBreadcrumbs}>
<SuspenseBreadcrumbs />
</div>

View File

@@ -11,7 +11,7 @@ import * as React from 'react';
import {useContext, useEffect} from 'react';
import {BridgeContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks';
import {useScrollToHostInstance} from '../hooks';
import {
SuspenseTreeDispatcherContext,
SuspenseTreeStateContext,
@@ -25,8 +25,6 @@ function SuspenseTimelineInput() {
const bridge = useContext(BridgeContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
const scrollToHostInstance = useScrollToHostInstance();
const {timeline, timelineIndex, hoveredTimelineIndex, playing, autoScroll} =
@@ -37,7 +35,6 @@ function SuspenseTimelineInput() {
function switchSuspenseNode(nextTimelineIndex: number) {
const nextSelectedSuspenseID = timeline[nextTimelineIndex];
highlightHostInstance(nextSelectedSuspenseID);
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -52,23 +49,14 @@ function SuspenseTimelineInput() {
switchSuspenseNode(pendingTimelineIndex);
}
function handleBlur() {
clearHighlightHostInstance();
}
function handleFocus() {
switchSuspenseNode(timelineIndex);
}
function handleHoverSegment(hoveredValue: number) {
const suspenseID = timeline[hoveredValue];
if (suspenseID === undefined) {
throw new Error(
`Suspense node not found for value ${hoveredValue} in timeline.`,
);
}
highlightHostInstance(suspenseID);
// TODO: Consider highlighting the rect instead.
}
function handleUnhoverSegment() {}
function skipPrevious() {
const nextSelectedSuspenseID = timeline[timelineIndex - 1];
@@ -172,19 +160,16 @@ function SuspenseTimelineInput() {
onClick={skipForward}>
<ButtonIcon type={'skip-next'} />
</Button>
<div
className={styles.SuspenseTimelineInput}
title={timelineIndex + '/' + max}>
<div className={styles.SuspenseTimelineInput}>
<SuspenseScrubber
min={min}
max={max}
value={timelineIndex}
highlight={hoveredTimelineIndex}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onHoverSegment={handleHoverSegment}
onHoverLeave={clearHighlightHostInstance}
onHoverLeave={handleUnhoverSegment}
/>
</div>
</>

View File

@@ -205,6 +205,27 @@ export function pluralize(word: string): string {
return word;
}
// Bail out if it's already plural.
switch (word) {
case 'men':
case 'women':
case 'children':
case 'feet':
case 'teeth':
case 'mice':
case 'people':
return word;
}
if (
/(ches|shes|ses|xes|zes)$/i.test(word) ||
/[^s]ies$/i.test(word) ||
/ves$/i.test(word) ||
/[^s]s$/i.test(word)
) {
return word;
}
switch (word) {
case 'man':
return 'men';

View File

@@ -200,6 +200,7 @@ export type SuspenseNode = {
name: string | null,
rects: null | Array<Rect>,
hasUniqueSuspenders: boolean,
isSuspended: boolean,
};
// Serialized version of ReactIOInfo

View File

@@ -475,23 +475,25 @@ function loadSourceFiles(
const fetchPromise =
dedupedFetchPromises.get(runtimeSourceURL) ||
fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => {
// TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps,
// because then we need to parse the full source file as an AST.
if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {
throw Error('Source code too large to parse');
}
(runtimeSourceURL && !runtimeSourceURL.startsWith('<anonymous')
? fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => {
// TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps,
// because then we need to parse the full source file as an AST.
if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {
throw Error('Source code too large to parse');
}
if (__DEBUG__) {
console.groupCollapsed(
`loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`,
);
console.log(runtimeSourceCode);
console.groupEnd();
}
if (__DEBUG__) {
console.groupCollapsed(
`loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`,
);
console.log(runtimeSourceCode);
console.groupEnd();
}
return runtimeSourceCode;
});
return runtimeSourceCode;
})
: Promise.reject(new Error('Empty url')));
dedupedFetchPromises.set(runtimeSourceURL, fetchPromise);
setterPromises.push(

View File

@@ -52,6 +52,9 @@ export async function symbolicateSource(
lineNumber: number, // 1-based
columnNumber: number, // 1-based
): Promise<SourceMappedLocation | null> {
if (!sourceURL || sourceURL.startsWith('<anonymous')) {
return null;
}
const resource = await fetchFileWithCaching(sourceURL).catch(() => null);
if (resource == null) {
return null;

View File

@@ -340,9 +340,10 @@ export function printOperationsArray(operations: Array<number>) {
const fiberID = operations[i + 1];
const parentID = operations[i + 2];
const nameStringID = operations[i + 3];
const numRects = operations[i + 4];
const isSuspended = operations[i + 4];
const numRects = operations[i + 5];
i += 5;
i += 6;
const name = stringTable[nameStringID];
let rects: string;
@@ -368,7 +369,7 @@ export function printOperationsArray(operations: Array<number>) {
}
logs.push(
`Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID}`,
`Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID} suspended ${isSuspended}`,
);
break;
}
@@ -431,10 +432,11 @@ export function printOperationsArray(operations: Array<number>) {
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const id = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const isSuspended = operations[i++] === 1;
const environmentNamesLength = operations[i++];
i += environmentNamesLength;
logs.push(
`Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
`Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
);
}

View File

@@ -57,6 +57,14 @@ function getOwner() {
return null;
}
// v8 (Chromium, Node.js) defaults to 10
// SpiderMonkey (Firefox) does not support Error.stackTraceLimit
// JSC (Safari) defaults to 100
// The lower the limit, the more likely we'll not reach react_stack_bottom_frame
// The higher the limit, the slower Error() is when not inspecting with a debugger.
// When inspecting with a debugger, Error.stackTraceLimit has no impact on Error() performance (in v8).
const ownerStackTraceLimit = 10;
/** @noinline */
function UnknownOwner() {
/** @noinline */
@@ -352,15 +360,24 @@ export function jsxProdSignatureRunningInDevWithDynamicChildren(
const trackActualOwner =
__DEV__ &&
ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit;
let debugStackDEV = false;
if (__DEV__) {
if (trackActualOwner) {
const previousStackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = ownerStackTraceLimit;
debugStackDEV = Error('react-stack-top-frame');
Error.stackTraceLimit = previousStackTraceLimit;
} else {
debugStackDEV = unknownOwnerDebugStack;
}
}
return jsxDEVImpl(
type,
config,
maybeKey,
isStaticChildren,
__DEV__ &&
(trackActualOwner
? Error('react-stack-top-frame')
: unknownOwnerDebugStack),
debugStackDEV,
__DEV__ &&
(trackActualOwner
? createTask(getTaskName(type))
@@ -379,15 +396,23 @@ export function jsxProdSignatureRunningInDevWithStaticChildren(
const trackActualOwner =
__DEV__ &&
ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit;
let debugStackDEV = false;
if (__DEV__) {
if (trackActualOwner) {
const previousStackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = ownerStackTraceLimit;
debugStackDEV = Error('react-stack-top-frame');
Error.stackTraceLimit = previousStackTraceLimit;
} else {
debugStackDEV = unknownOwnerDebugStack;
}
}
return jsxDEVImpl(
type,
config,
maybeKey,
isStaticChildren,
__DEV__ &&
(trackActualOwner
? Error('react-stack-top-frame')
: unknownOwnerDebugStack),
debugStackDEV,
__DEV__ &&
(trackActualOwner
? createTask(getTaskName(type))
@@ -408,15 +433,23 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren) {
const trackActualOwner =
__DEV__ &&
ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit;
let debugStackDEV = false;
if (__DEV__) {
if (trackActualOwner) {
const previousStackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = ownerStackTraceLimit;
debugStackDEV = Error('react-stack-top-frame');
Error.stackTraceLimit = previousStackTraceLimit;
} else {
debugStackDEV = unknownOwnerDebugStack;
}
}
return jsxDEVImpl(
type,
config,
maybeKey,
isStaticChildren,
__DEV__ &&
(trackActualOwner
? Error('react-stack-top-frame')
: unknownOwnerDebugStack),
debugStackDEV,
__DEV__ &&
(trackActualOwner
? createTask(getTaskName(type))
@@ -667,15 +700,23 @@ export function createElement(type, config, children) {
const trackActualOwner =
__DEV__ &&
ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit;
let debugStackDEV = false;
if (__DEV__) {
if (trackActualOwner) {
const previousStackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = ownerStackTraceLimit;
debugStackDEV = Error('react-stack-top-frame');
Error.stackTraceLimit = previousStackTraceLimit;
} else {
debugStackDEV = unknownOwnerDebugStack;
}
}
return ReactElement(
type,
key,
props,
getOwner(),
__DEV__ &&
(trackActualOwner
? Error('react-stack-top-frame')
: unknownOwnerDebugStack),
debugStackDEV,
__DEV__ &&
(trackActualOwner
? createTask(getTaskName(type))

View File

@@ -1,3 +1,9 @@
/**
* 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.
*/
'use strict';
const babel = require('@babel/register');

View File

@@ -1255,7 +1255,9 @@ const bundles = [
'@babel/core',
'hermes-parser',
'zod',
'zod/v4',
'zod-validation-error',
'zod-validation-error/v4',
'crypto',
'util',
],

View File

@@ -18245,12 +18245,12 @@ zip-stream@^2.1.2:
compress-commons "^2.1.1"
readable-stream "^3.4.0"
"zod-validation-error@^3.0.3 || ^4.0.0":
"zod-validation-error@^3.5.0 || ^4.0.0":
version "4.0.2"
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918"
integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==
"zod@^3.22.4 || ^4.0.0":
version "4.1.11"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5"
integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==
"zod@^3.25.0 || ^4.0.0":
version "4.1.12"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0"
integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==