Compare commits

..

69 Commits

Author SHA1 Message Date
Joe Savona
a59c9fc6bf [compiler] More useMemo validation
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!
2025-10-16 10:54:09 -07: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
Eugene Choi
e7984651e4 [playground] Allow accordion tabs to open on error (#34844)
There was a bug where the other output passes (aside from the "Output"
tab) were unable to open on compiler error. This PR still allows for the
"Output" tab to automatically open on error, but also allows other tabs
to be opened.


https://github.com/user-attachments/assets/157bf5d6-c289-46fd-bafb-073c2e0ff52b
2025-10-14 15:07:27 -04:00
Sebastian Markbåge
5f2b571878 [DevTools] Filter out built-in stack frames (#34828)
Treat fake eval anonymous stacks as built-in. Hide built-in stack frames
unless they're used to call into a non-ignored stack frame.

The two main things to fix here is that 1) we're showing a linkified
stack for fake anonymous and 2) we're showing only built-ins when the
stack is completely internal. Meaning framework code is all noise.
2025-10-14 09:34:57 -04:00
Sebastian Markbåge
56e846921d [Flight] Exclude RSC Stream if the stream resolves in a task (#34838) 2025-10-14 14:28:47 +02:00
Sebastian Markbåge
19b71673b1 [Flight] Forward the current environment when forwarding I/O entries (#34836) 2025-10-14 13:57:48 +02:00
Sebastian "Sebbie" Silbermann
73507ec457 [DevTools] Exclude Suspense boundaries in hidden Activity (#34756) 2025-10-14 13:57:08 +02:00
Sebastian Markbåge
03a62b20fd [Flight] Look for moved debugInfo when logging component performance track (#34839) 2025-10-14 13:21:12 +02:00
Ruslan Lesiutin
b9ec735de2 [Perf Tracks]: Clear potentially large measures (#34803)
Fixes https://github.com/facebook/react/issues/34770.

We need to clear measures at some point, otherwise all these copies of
props that we end up recording will allocate too much memory in
Chromium. This adds `performance.clearMeasures(...)` calls to such cases
in DEV.

Validated that entries are still shown on Performance panel timeline.
2025-10-13 17:42:13 -04:00
Ruslan Lesiutin
47905a7950 Fix/add missing else branch for renders with no props change (#34837)
Stacked on https://github.com/facebook/react/pull/34822.
Fixes a bug introduced in https://github.com/facebook/react/pull/34370.

Just copying the lower else branch to the `properties.length` else
branch at the top.
2025-10-13 17:23:04 -04:00
Sebastian "Sebbie" Silbermann
7b971c0a55 Current behavior for excluding Component render with unchanged props from Components track (#34822)
If we rerender with the same props, the render time will not be
accounted for in the Components track. The attached test reproduces the
behavior observed in
https://codesandbox.io/p/sandbox/patient-fast-j94f2g:
<img width="1118" height="354" alt="CleanShot 2025-10-13 at 00 13 41@2x"
src="https://github.com/user-attachments/assets/4be10ee9-d529-4d98-9035-4f26f9587f52"
/>
2025-10-13 17:14:51 -04:00
Sebastian Markbåge
83ea655a0b [DevTools] Group consecutive suspended by rows by the same name (#34830)
Stacked on #34829.

This lets you get an overview more easily when there's lots of things
like scripts downloading. Pluralized the name. E.g. `script` ->
`scripts` or `fetch` -> `fetches`.

This only groups them consecutively when they'd have the same place in
the list anyway because otherwise it might cover up some kind of
waterfall effects.

<img width="404" height="225" alt="Screenshot 2025-10-13 at 12 06 51 AM"
src="https://github.com/user-attachments/assets/da204a8e-d5f7-4eb0-8c51-4cc5bfd184c4"
/>

Expanded:

<img width="407" height="360" alt="Screenshot 2025-10-13 at 12 07 00 AM"
src="https://github.com/user-attachments/assets/de3c3de9-f314-4c87-b606-31bc49eb4aba"
/>
2025-10-13 13:07:39 -04:00
Sebastian Markbåge
026abeaa5f [Flight] Respect displayName of Promise instances on the server (#34825)
This lets you assign a name to a Promise that's passed into first party
code from third party since it otherwise would have no other stack frame
to indicate its name since the whole creation stack would be in third
party.

We already respect the `displayName` on the client but it's more
complicated on the server because we don't only consider the exact
instance passed to `use()` but the whole await sequence and we can pick
any Promise along the way for consideration. Therefore this also adds a
change where we pick the Promise node for consideration if it has a name
but no stack. Where we otherwise would've picked the I/O node.

Another thing that this PR does is treat anonymous stack frames (empty
url) as third party for purposes of heuristics like "hasUnfilteredFrame"
and the name assignment. This lets you include these in the actual
generated stacks (by overriding `filterStackFrame`) but we don't
actually want them to be considered first party code in the heuristics
since it ends up favoring those stacks and using internals like
`Function.all` in name assignment.
2025-10-13 12:29:00 -04:00
Sebastian Markbåge
d7215b4970 [DevTools] Preserve the original index when sorting suspended by (#34829)
The index is both used as the key and for hydration purposes. Previously
we didn't preserve the index when sorting so the index didn't line up
which caused hydration to be the wrong slot when sorted.
2025-10-13 12:12:12 -04:00
Sebastian Markbåge
e2ce64acb9 [DevTools] Don't show the root as being non-compliant (#34827)
`isStrictModeNonCompliant` on the root just means that it supports
strict mode. It's inherited by other nodes.

It's not possible to opt-in to strict mode on the root itself but rather
right below it. So we should not mark the root as being non-compliant.

This lets you select the root in the suspense tab and it shouldn't show
as red with a warning.
2025-10-13 12:11:52 -04:00
Sebastian Markbåge
34b1567427 [DevTools] Ignore suspense boundaries, without visual representation, in the timeline (#34824)
This ignore a Suspense boundary from the timeline when it has no visual
representation. No rect. In effect, this is not blocking the user
experience.

Technically it could be an effect that mounts which can have a
side-effect which is visible.

It could also be a meta-data tag like `<title>` which is visible. We
could hoistables a virtual representation by giving them a virtual rect.
E.g. at the top of the page. This could be added after the fact.
2025-10-13 12:10:54 -04:00
Sebastian Markbåge
b467c6e949 [DevTools] Explicitly say which id to scroll to and only once (#34823)
This ensures that we don't scroll on changes to the timeline such as
when loading a new page or while the timeline is still loading.

We only auto scroll to a boundary when we perform an explicit operation
from the user.
2025-10-13 12:09:45 -04:00
Sebastian "Sebbie" Silbermann
93d4458fdc [Fiber] Ensure useEffectEvent reads latest values in forwardRef and memo() Components (#34831) 2025-10-13 17:58:43 +02:00
Sebastian Markbåge
1d68bce19c [Fiber] Don't unhide a node if a direct parent offscreen is still hidden (#34821)
If an inner Offscreen commits an unhide, but an outer Offscreen is still
hidden but they're controlling the same DOM node then we shouldn't
unhide the DOM node yet.

This keeps track of whether we're directly inside a hidden offscreen. It
might be better to just do the tree search instead of keeping the stack
state since it's a rare case. Although this hide/unhide path does
trigger a lot of times even when there's no change.

This was technically a bug with Suspense too but it doesn't appear
because a suspended Suspense boundary never commits its partial state.
If it did, it would trigger this same path. But it can happen with an
outer Activity and inner Suspense.
2025-10-12 19:50:06 -04:00
Hendrik Liebau
ead92181bd [Flight] Avoid unnecessary indirection when serializing debug info (#34797)
When a debug channel is hooked up, and we're serializing debug models,
if the result is an already outlined reference, we can emit it directly,
without also outlining the reference. This would create an unnecessary
indirection.

Before:

```
:N1760023808330.2688
0:D"$2"
0:D"$3"
0:D"$4"
0:"hi"

1:{"name":"Component","key":null,"env":"Server","stack":[],"props":{}}
2:{"time":3.0989999999999327}
3:"$1"
4:{"time":3.261792000000014}
```

After:

```
:N1760023786873.8916
0:D"$2"
0:D"$1"
0:D"$3"
0:"hi"

1:{"name":"Component","key":null,"env":"Server","stack":[],"props":{}}
2:{"time":2.4145829999999933}
3:{"time":2.5488749999999527}
```

Notice how the second debug info chunk is now directly referencing chunk
`1` in the debug channel, without outlining and referencing `"$1"` as
its own debug chunk `3`.

This not only simplifies the RSC payload, and reduces overhead. But more
importantly it helps the client resolve cyclic references when a model
has debug info that has a reference back to the model. The client is
currently not able to resolve such a cycle when those chunk indirections
are involved. Ideally, it would also be able to resolve them regardless,
but that requires more work. In the meantime, this fixes an immediate
issue.
2025-10-10 21:44:28 +02:00
Hendrik Liebau
d44659744f [Flight] Fix preload as attribute for stylesheets (#34760)
Follow-up to #34604. For a stylesheet, we need to render `<link
rel="preload" as="style" ...>`, and not `<link rel="preload"
as="stylesheet" ...>`.
([ref](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/preload#what_types_of_content_can_be_preloaded))

fixes vercel/next.js#84569
2025-10-10 21:40:56 +02:00
Sophie Alpert
8454a32f3c devtools: fix ellipsis truncation for key values (#34796)
before
<img width="349" height="73" alt="Screenshot 2025-10-09 at 11 38 03"
src="https://github.com/user-attachments/assets/93fec45d-4ef2-498f-9550-36ff807b63f9"
/>

after
<img width="349" height="73" alt="Screenshot 2025-10-09 at 11 38 39"
src="https://github.com/user-attachments/assets/cb279384-4229-4d56-a803-93c2df897754"
/>
2025-10-10 14:05:49 -04:00
Ian Duvall
06fcc8f380 [playground] Fix syntax error from crashing the Compiler playground (#34623)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

Before submitting a pull request, please make sure the following is
done:

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

<!--
Explain the **motivation** for making this change. What existing problem
does the pull request solve?
-->

Fixes a syntax error causing the Compiler playground to crash. Resolves
https://github.com/facebook/react/issues/34622.

## How did you test this change?

<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
  If you leave this empty, your PR will very likely be closed.
-->

Tested locally and added a test.

<img width="1470" height="836" alt="Screenshot 2025-09-27 at 8 13 07 AM"
src="https://github.com/user-attachments/assets/29473682-94c3-49dc-9ee9-c2004062aaea"
/>
2025-10-09 12:02:55 -07:00
Anatole-Godard
91e5c3daf1 fix(devtools): remove duplicated "Display density" field in General settings (#34792)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

Before submitting a pull request, please make sure the following is
done:

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->
## Summary

This pull request fixes a small UI issue in the React Developer Tools
settings panel.
The “Display density” field was appearing twice in the General tab.

Fix : https://github.com/facebook/react/issues/34791
2025-10-09 10:38:23 -07:00
lauren
4b3e662e4c [compiler] Add VoidUseMemo rule to RecommendedLatest (#34783)
Adds a new error category VoidUseMemo which is only enabled in the
RecommendedLatest preset for now.
2025-10-08 15:55:13 -04:00
lauren
3e1b34dc51 [compiler] Setup RecommendedLatest preset (#34782)
Renames the `recommended` property on LintRule to `preset`, to allow
exporting rules for different presets. For now the `Recommended` and
`RecommendedLatest` presets are the same, but in the next PR I will
enable more rules for the latest preset.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34782).
* #34783
* __->__ #34782
2025-10-08 15:45:22 -04:00
lauren
7568e71854 [eprh] Prepare for 7.0.0 (#34757)
For 7.0.0:

Slim down presets to just 2 configurations:

- `recommended`: legacy and flat config with all recommended rules, and
- `recommended-latest`: legacy and flat config with all recommended
rules plus new bleeding edge experimental compiler rules

Removed:
- `recommended-latest-legacy`
- `flat/recommended`

Please see the README for new install instructions.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34757).
* #34783
* #34782
* __->__ #34757
2025-10-08 15:17:31 -04:00
Ruslan Lesiutin
9724e3e66e [DevTools] Gate SuspenseTab (#34754) 2025-10-08 05:47:50 -07:00
lauren
848e0e3a4f [eprh] Update plugin config to be compatible with flat and legacy (#34762)
This has been incredibly frustrating as [ESLint's own
docs](https://eslint.org/docs/latest/extend/plugins#backwards-compatibility-for-legacy-configs)
are clearly wrong (see #34679).

This PR uses [eslint-plugin-react's
setup](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/index.js)
as a reference, where the presets are assigned to `configs.flat` (not
documented by eslint).
2025-10-07 16:23:56 -04:00
lauren
5c15c1cd34 [ci] Dry run with debug mode (#34767)
Adds `--debug` to our dry run command so we can see the npm dry run
output
2025-10-07 15:16:18 -04:00
lauren
69b4cb8df4 [ci] Allow dry run (#34765)
Allow running the compiler release script as dry run.
2025-10-07 14:44:46 -04:00
lauren
a664f5f2ee [compiler] Fix incorrect version name (#34764)
Script was using the wrong version name.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34764).
* #34765
* __->__ #34764
2025-10-07 14:32:51 -04:00
lauren
1384ea8230 [compiler] Update release script for latest tag (#34763)
Updates our release script so we can publish to the `latest` tag.
2025-10-07 14:14:50 -04:00
Sebastian Markbåge
3025aa3964 [Flight] Don't serialize toJSON in Debug path and omit wide arrays (#34759)
There's a couple of issues with serializing Buffer in the debug renders.

For one, the Node.js Buffer has a `toJSON` on it which turns the binary
data into a JSON array which is very inefficient to serialize compared
to the real buffer. For debug info we never really want to resolve these
and unlike the regular render we can't error. So this uses the trick
where we read the original value. It's still unfortunate that this
intermediate gets created at all but at least now we're not serializing
it.

Second, we have a limit on depth of objects but we didn't have a limit
on width like large arrays or typed arrays. This omits large arrays from
the payload when possible and make them deferred when there's a debug
channel.
2025-10-07 06:59:34 -07:00
Sebastian "Sebbie" Silbermann
a4eb2dfa6f Release Fragment refs to Canary (#34720)
## Overview

This PR adds the `ref` prop to `<Fragment>` in `react@canary`.

This means this API is ready for final feedback and prepared for a
semver stable release.

## What this means

Shipping Fragment refs to canary means they have gone through extensive
testing in production, we are confident in the stability of the APIs,
and we are preparing to release it in a future semver stable version.

Libraries and frameworks following the [Canary
Workflow](https://react.dev/blog/2023/05/03/react-canaries) should begin
implementing and testing these features.

## Why we follow the Canary Workflow

To prepare for semver stable, libraries should test canary features like
Fragment refs with `react@canary` to confirm compatibility and prepare
for the next semver release in a myriad of environments and
configurations used throughout the React ecosystem. This provides
libraries with ample time to catch any issues we missed before slamming
them with problems in the wider semver release.

Since these features have already gone through extensive production
testing, and we are confident they are stable, frameworks following the
[Canary Workflow](https://react.dev/blog/2023/05/03/react-canaries) can
also begin adopting canary features like Fragment refs.

This adoption is similar to how different Browsers implement new
proposed browser features before they are added to the standard. If a
frameworks adopts a canary feature, they are committing to stability for
their users by ensuring any API changes before a semver stable release
are opaque and non-breaking to their users.

Apps not using a framework are also free to adopt canary features like
Fragment refs as long as they follow the [Canary
Workflow](https://react.dev/blog/2023/05/03/react-canaries), but we
generally recommend waiting for a semver stable release unless you have
the capacity to commit to following along with the canary changes and
debugging library compatibility issues.

Waiting for semver stable means you're able to benefit from libraries
testing and confirming support, and use semver as signal for which
version of a library you can use with support of the feature.

## Docs 

Check out the ["React Labs: View Transitions, Activity, and
more"](https://react.dev/blog/2025/04/23/react-labs-view-transitions-activity-and-more#fragment-refs)
blog post, and [the new docs for Fragment
refs`](https://react.dev/reference/react/Fragment#fragmentinstance) for
more info.
2025-10-06 21:24:24 -07:00
Sebastian "Sebbie" Silbermann
6a8c7fb6f1 Release <ViewTransition /> to Canary (#34712)
## Overview

This PR ships the View Transition APIs to `react@canary`:
- [`<ViewTransition
/>`](https://react.dev/reference/react/ViewTransition)
-
[`addTransitionType`](https://react.dev/reference/react/addTransitionType)

This means these APIs are ready for final feedback and prepare for
semver stable release.

## What this means

Shipping `<ViewTransition />` and `addTransitionType` to canary means
they have gone through extensive testing in production, we are confident
in the stability of the APIs, and we are preparing to release it in a
future semver stable version.

Libraries and frameworks following the [Canary
Workflow](https://react.dev/blog/2023/05/03/react-canaries) should begin
implementing and testing these features.

## Why we follow the Canary Workflow

To prepare for semver stable, libraries should test canary features like
`<ViewTransition />` with `react@canary` to confirm compatibility and
prepare for the next semver release in a myriad of environments and
configurations used throughout the React ecosystem. This provides
libraries with ample time to catch any issues we missed before slamming
them with problems in the wider semver release.

Since these features have already gone through extensive production
testing, and we are confident they are stable, frameworks following the
[Canary Workflow](https://react.dev/blog/2023/05/03/react-canaries) can
also begin adopting canary features like `<ViewTransition />`.

This adoption is similar to how different Browsers implement new
proposed browser features before they are added to the standard. If a
frameworks adopts a canary feature, they are committing to stability for
their users by ensuring any API changes before a semver stable release
are opaque and non-breaking to their users.

Apps not using a framework are also free to adopt canary features like
`<ViewTransition>` as long as they follow the [Canary
Workflow](https://react.dev/blog/2023/05/03/react-canaries), but we
generally recommend waiting for a semver stable release unless you have
the capacity to commit to following along with the canary changes and
debugging library compatibility issues.

Waiting for semver stable means you're able to benefit from libraries
testing and confirming support, and use semver as signal for which
version of a library you can use with support of the feature.

## Docs 

Check out the ["React Labs: View Transitions, Activity, and
more"](https://react.dev/blog/2025/04/23/react-labs-view-transitions-activity-and-more#view-transitions)
blog post, and [the new docs for `<ViewTransition
/>`](https://react.dev/reference/react/ViewTransition) and
[`addTransitionType`](https://react.dev/reference/react/addTransitionType)
for more info.
2025-10-06 21:23:34 -07:00
lauren
b65e6fc58b Revert [eprh] Remove hermes-parser (#34747)
Adds back HermesParser to eslint-plugin-react-hooks. There are still
[external users of
Flow](https://github.com/facebook/react/pull/34719#issuecomment-3368137743)
using the plugin, so we shouldn't break the plugin for them. However, we
still have the problem of double parsing: once from eslint (which we
discard) and then another via babel/hermes parser.

In the long run we should investigate a translation layer from estree to
babel (or alternatively, update the compiler to take estree as input).
But for now, I am reverting the PR.

This does mean that [Sandpack in
react.dev](11cb6b5915/src/components/MDX/Sandpack/runESLint.tsx (L31))
cannot update to the latest eprh as HermesParser does not appear to be
able to be run in a browser. I discovered this while trying to update
eprh on react.dev last week, but didn't investigate deeply. I'll need to
double check that again to find out more.
2025-10-06 12:43:39 -04:00
lauren
c786258422 [eprh] Fix config type not being exported correctly (#34746)
Another attempt to fix #34745. I updated our fixture for eslint-v9 to
include running tsc. I believe there were 2 issues:

1. `export * from './cjs/eslint-plugin-react-hooks'` in npm/index.d.ts
was no longer correct as we updated index.ts to export default instead
of named exports
2. After fixing ^ there was a typescript error which I fixed by making
some small tweaks
2025-10-06 00:53:21 -04:00
Sebastian "Sebbie" Silbermann
1be3ce9996 [Fiber] Bail out of diffing wide objects and arrays (#34742) 2025-10-06 01:13:22 +02:00
Ruslan Lesiutin
3b2a398106 [DevTools] Bump version of react-devtools-core for react-devtools (#34740)
This one was overlooked and yarn.lock was not synced.

Related:
- https://github.com/facebook/react/pull/34692
- https://github.com/facebook/react/pull/34723
2025-10-05 13:45:41 +01:00
Ruslan Lesiutin
62ff1e61fc Revert "[DevTools] Always include the root in the timeline and select it by default" (#34739)
Reverts facebook/react#34654
2025-10-05 13:35:07 +01:00
Sebastian Markbåge
0e79784702 [DevTools] Use documentElement to override cmd+F (#34734)
We override Cmd+F to jump to our search input instead of searching
through the HTML. This is ofc critical since our view virtualized.

However, Chrome DevTools installs its own listener on the document as
well (in the bubble phase) so if we prevent it at the document level
it's too late and it ends up stealing the focus instead. If we instead
listen at the documentElement it works as intended.
2025-10-05 13:13:22 +01:00
lauren
a2329c10ff [eprh] 6.1.1 changelog (#34726)
Update changelog for 6.1.1
2025-10-03 17:58:06 -04:00
Ruslan Lesiutin
d3f84a433a [DevTools] Bump version for extensions (#34723)
`./scripts/devtools/prepare-release.js` actually does automate the
version bump, but only path / minor ones.
2025-10-03 22:03:48 +01:00
lauren
bc2356176b [ci] Fix incorrect filtering logic for prereleases (#34725)
The workflow was correctly publishing the package(s) specified in
`only`, but due to incorrect logic it would also run the 'Publish all
packages' step.
2025-10-03 16:37:55 -04:00
lauren
4fdf7cf249 [ci] Fix runtime_prereleases (#34722)
When using the "only" or "skip" option in the workflow, we were adding
the `--skipTests` param, but that isn't an actual option:
1de32a5e75/scripts/release/publish-commands/parse-params.js
2025-10-03 14:41:34 -04:00
Sebastian "Sebbie" Silbermann
614a945d9d React DevTools 7.0.0 (#34692)
[Preview](https://github.com/eps1lon/react/blob/sebbie/09-28-react_devtools_7.0.0/packages/react-devtools/CHANGELOG.md#700)

Suspense tab is omitted since that's gated on Canary or 19.3.

Will draft a separate blog post for suspended by and open-in-editor
instructions while the extension is in review.
2025-10-03 18:48:28 +01:00
Joseph Savona
d6eb735938 [compiler] Update for Zod v3/v4 compatibility (#34717)
Partial redo of #34710. The changes there tried to use `z.function(args,
return)` to be compatible across Zod v3 and v4, but Zod 4's function API
has completely changed. Instead, I've updated to just use `z.any()`
where we expect a function, and manually validate that it's a function
before we call the value. We already have validation of the return type
(also using Zod).

Co-authored-by: kolvian <eliot@pontarelli.com>
2025-10-03 10:08:20 -07:00
lauren
71753ac90a [eprh] Remove hermes-parser (#34719)
We will be focusing eslint-plugin-react-hooks as the primary OSS-only
package for our lint plugin. eslint-plugin-react-compiler will remain as
a Meta only package as some limitations of our internal infra require us
to use packages that aren't widely adopted by the rest of the industry.

This PR removes `hermes-parser`, which was meant to support parsing Flow
syntax.
2025-10-03 12:58:00 -04:00
Joseph Savona
f24d3bbc70 Update readme for eprh (#34714) 2025-10-03 09:47:34 -07:00
Joseph Savona
85c427d822 [compiler] Remove @babel/plugin-proposal-private-methods (#34715)
redo of #34458 but fixing up prettier

Co-authored-by: Arnaud Barré <arnaud.barre@carbometrix.com>
2025-10-03 09:13:55 -07:00
Sebastian Markbåge
02bd4458f7 [DevTools] Double clicking the root should jump to the beginning of the timeline (#34704)
Unlike the rects, this never toggles. It just jumps.
2025-10-03 11:52:44 -04:00
154 changed files with 3816 additions and 1384 deletions

View File

@@ -19,6 +19,9 @@ on:
tag_version:
required: false
type: string
dry_run:
required: false
type: boolean
secrets:
NPM_TOKEN:
required: true
@@ -55,7 +58,13 @@ jobs:
key: compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('compiler/yarn.lock') }}
- run: yarn install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
- name: Publish packages to npm
- if: inputs.dry_run == true
name: Publish packages to npm (dry run)
run: |
cp ./scripts/release/ci-npmrc ~/.npmrc
scripts/release/publish.js --frfr --debug --ci --versionName=${{ inputs.version_name }} --tag=${{ inputs.dist_tag }} ${{ inputs.tag_version && format('--tagVersion={0}', inputs.tag_version) || '' }}
- if: inputs.dry_run != true
name: Publish packages to npm
run: |
cp ./scripts/release/ci-npmrc ~/.npmrc
scripts/release/publish.js --frfr --ci --versionName=${{ inputs.version_name }} --tag=${{ inputs.dist_tag }} ${{ inputs.tag_version && format('--tagVersion={0}', inputs.tag_version) || '' }}

View File

@@ -17,6 +17,9 @@ on:
tag_version:
required: false
type: string
dry_run:
required: false
type: boolean
permissions: {}
@@ -33,5 +36,6 @@ jobs:
dist_tag: ${{ inputs.dist_tag }}
version_name: ${{ inputs.version_name }}
tag_version: ${{ inputs.tag_version }}
dry_run: ${{ inputs.dry_run }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -19,5 +19,6 @@ jobs:
release_channel: experimental
dist_tag: experimental
version_name: '0.0.0'
dry_run: false
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -82,7 +82,6 @@ jobs:
run: |
scripts/release/publish.js \
--ci \
--skipTests \
--tags=${{ inputs.dist_tag }} \
--onlyPackages=${{ inputs.only_packages }} ${{ (inputs.dry && '') || '\'}}
${{ inputs.dry && '--dry' || '' }}
@@ -91,11 +90,10 @@ jobs:
run: |
scripts/release/publish.js \
--ci \
--skipTests \
--tags=${{ inputs.dist_tag }} \
--skipPackages=${{ inputs.skip_packages }} ${{ (inputs.dry && '') || '\'}}
${{ inputs.dry && '--dry' || '' }}
- if: '${{ !(inputs.skip_packages && inputs.only_packages) }}'
- if: '${{ !inputs.skip_packages && !inputs.only_packages }}'
name: 'Publish all packages'
run: |
scripts/release/publish.js \

View File

@@ -33,7 +33,7 @@ const canaryChannelLabel = 'canary';
const rcNumber = 0;
const stablePackages = {
'eslint-plugin-react-hooks': '6.2.0',
'eslint-plugin-react-hooks': '7.0.0',
'jest-react': '0.18.0',
react: ReactVersion,
'react-art': ReactVersion,

View File

@@ -314,6 +314,36 @@ test('disableMemoizationForDebugging flag works as expected', async ({
expect(output).toMatchSnapshot('disableMemoizationForDebugging-output.txt');
});
test('error is displayed when source has syntax error', async ({page}) => {
const syntaxErrorSource = `function TestComponent(props) {
const oops = props.
return (
<>{oops}</>
);
}`;
const store: Store = {
source: syntaxErrorSource,
config: defaultConfig,
showInternals: false,
};
const hash = encodeStore(store);
await page.goto(`/#${hash}`);
await page.waitForFunction(isMonacoLoaded);
await expandConfigs(page);
await page.screenshot({
fullPage: true,
path: 'test-results/08-source-syntax-error.png',
});
const text =
(await page.locator('.monaco-editor-output').allInnerTexts()) ?? [];
const output = text.join('');
expect(output.replace(/\s+/g, ' ')).toContain(
'Expected identifier to be defined before being used',
);
});
TEST_CASE_INPUTS.forEach((t, idx) =>
test(`playground compiles: ${t.name}`, async ({page}) => {
const store: Store = {

View File

@@ -22,7 +22,6 @@ export default function AccordionWindow(props: {
tabsOpen: Set<string>;
setTabsOpen: (newTab: Set<string>) => void;
changedPasses: Set<string>;
isFailure: boolean;
}): React.ReactElement {
return (
<div className="flex-1 min-w-[550px] sm:min-w-0">
@@ -36,7 +35,6 @@ export default function AccordionWindow(props: {
tabsOpen={props.tabsOpen}
setTabsOpen={props.setTabsOpen}
hasChanged={props.changedPasses.has(name)}
isFailure={props.isFailure}
/>
);
})}
@@ -51,7 +49,6 @@ function AccordionWindowItem({
tabsOpen,
setTabsOpen,
hasChanged,
isFailure,
}: {
name: string;
tabs: TabsRecord;
@@ -61,7 +58,7 @@ function AccordionWindowItem({
isFailure: boolean;
}): React.ReactElement {
const id = useId();
const isShow = isFailure ? name === 'Output' : tabsOpen.has(name);
const isShow = tabsOpen.has(name);
const transitionName = `accordion-window-item-${id}`;

View File

@@ -27,6 +27,8 @@ import {
useState,
Suspense,
unstable_ViewTransition as ViewTransition,
unstable_addTransitionType as addTransitionType,
startTransition,
} from 'react';
import AccordionWindow from '../AccordionWindow';
import TabbedWindow from '../TabbedWindow';
@@ -35,6 +37,7 @@ import {BabelFileResult} from '@babel/core';
import {
CONFIG_PANEL_TRANSITION,
TOGGLE_INTERNALS_TRANSITION,
EXPAND_ACCORDION_TRANSITION,
} from '../../lib/transitionTypes';
import {LRUCache} from 'lru-cache';
@@ -265,8 +268,22 @@ function OutputContent({store, compilerOutput}: Props): JSX.Element {
* Update the active tab back to the output or errors tab when the compilation state
* changes between success/failure.
*/
const [previousOutputKind, setPreviousOutputKind] = useState(
compilerOutput.kind,
);
const isFailure = compilerOutput.kind !== 'ok';
if (compilerOutput.kind !== previousOutputKind) {
setPreviousOutputKind(compilerOutput.kind);
if (isFailure) {
startTransition(() => {
addTransitionType(EXPAND_ACCORDION_TRANSITION);
setTabsOpen(prev => new Set(prev).add('Output'));
setActiveTab('Output');
});
}
}
const changedPasses: Set<string> = new Set(['Output', 'HIR']); // Initial and final passes should always be bold
let lastResult: string = '';
for (const [passName, results] of compilerOutput.results) {
@@ -295,8 +312,6 @@ function OutputContent({store, compilerOutput}: Props): JSX.Element {
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
// Display the Output tab on compilation failure
activeTabOverride={isFailure ? 'Output' : undefined}
/>
</ViewTransition>
);
@@ -315,7 +330,6 @@ function OutputContent({store, compilerOutput}: Props): JSX.Element {
tabsOpen={tabsOpen}
tabs={tabs}
changedPasses={changedPasses}
isFailure={isFailure}
/>
</ViewTransition>
);

View File

@@ -17,15 +17,11 @@ export default function TabbedWindow({
tabs,
activeTab,
onTabChange,
activeTabOverride,
}: {
tabs: Map<string, React.ReactNode>;
activeTab: string;
onTabChange: (tab: string) => void;
activeTabOverride?: string;
}): React.ReactElement {
const currentActiveTab = activeTabOverride ? activeTabOverride : activeTab;
const id = useId();
const transitionName = `tab-highlight-${id}`;
@@ -41,7 +37,7 @@ export default function TabbedWindow({
<div className="flex flex-col h-full max-w-full">
<div className="flex p-2 flex-shrink-0">
{Array.from(tabs.keys()).map(tab => {
const isActive = currentActiveTab === tab;
const isActive = activeTab === tab;
return (
<button
key={tab}
@@ -77,7 +73,7 @@ export default function TabbedWindow({
})}
</div>
<div className="flex-1 overflow-hidden w-full h-full">
{tabs.get(currentActiveTab)}
{tabs.get(activeTab)}
</div>
</div>
</div>

View File

@@ -297,7 +297,7 @@ export function compile(
if (!error.hasErrors() && otherErrors.length !== 0) {
otherErrors.forEach(e => error.details.push(e));
}
if (error.hasErrors()) {
if (error.hasErrors() || !transformOutput) {
return [{kind: 'err', results, error}, language, baseOpts];
}
return [

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",
"zod-validation-error": "^2.1.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

@@ -536,7 +536,8 @@ function printErrorSummary(category: ErrorCategory, message: string): string {
case ErrorCategory.StaticComponents:
case ErrorCategory.Suppression:
case ErrorCategory.Syntax:
case ErrorCategory.UseMemo: {
case ErrorCategory.UseMemo:
case ErrorCategory.VoidUseMemo: {
heading = 'Error';
break;
}
@@ -582,6 +583,10 @@ export enum ErrorCategory {
* Checking for valid usage of manual memoization
*/
UseMemo = 'UseMemo',
/**
* Checking that useMemos always return a value
*/
VoidUseMemo = 'VoidUseMemo',
/**
* Checking for higher order functions acting as factories for components/hooks
*/
@@ -669,6 +674,21 @@ export enum ErrorCategory {
FBT = 'FBT',
}
export enum LintRulePreset {
/**
* Rules that are stable and included in the `recommended` preset.
*/
Recommended = 'recommended',
/**
* Rules that are more experimental and only included in the `recommended-latest` preset.
*/
RecommendedLatest = 'recommended-latest',
/**
* Rules that are disabled.
*/
Off = 'off',
}
export type LintRule = {
// Stores the category the rule corresponds to, used to filter errors when reporting
category: ErrorCategory;
@@ -689,15 +709,14 @@ export type LintRule = {
description: string;
/**
* If true, this rule will automatically appear in the default, "recommended" ESLint
* rule set. Otherwise it will be part of an `allRules` export that developers can
* use to opt-in to showing output of all possible rules.
* Configures the preset in which the rule is enabled. If 'off', the rule will not be included in
* any preset.
*
* NOTE: not all validations are enabled by default! Setting this flag only affects
* whether a given rule is part of the recommended set. The corresponding validation
* also should be enabled by default if you want the error to actually show up!
*/
recommended: boolean;
preset: LintRulePreset;
};
const RULE_NAME_PATTERN = /^[a-z]+(-[a-z]+)*$/;
@@ -720,7 +739,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'automatic-effect-dependencies',
description:
'Verifies that automatic effect dependencies are compiled if opted-in',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.CapitalizedCalls: {
@@ -730,7 +749,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'capitalized-calls',
description:
'Validates against calling capitalized functions/methods instead of using JSX',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.Config: {
@@ -739,7 +758,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Error,
name: 'config',
description: 'Validates the compiler configuration options',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.EffectDependencies: {
@@ -748,7 +767,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Error,
name: 'memoized-effect-dependencies',
description: 'Validates that effect dependencies are memoized',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.EffectDerivationsOfState: {
@@ -758,7 +777,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'no-deriving-state-in-effects',
description:
'Validates against deriving values from state in an effect',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.EffectSetState: {
@@ -768,7 +787,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'set-state-in-effect',
description:
'Validates against calling setState synchronously in an effect, which can lead to re-renders that degrade performance',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.ErrorBoundaries: {
@@ -778,7 +797,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'error-boundaries',
description:
'Validates usage of error boundaries instead of try/catch for errors in child components',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.Factories: {
@@ -789,7 +808,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
description:
'Validates against higher order functions defining nested components or hooks. ' +
'Components and hooks should be defined at the module level',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.FBT: {
@@ -798,7 +817,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Error,
name: 'fbt',
description: 'Validates usage of fbt',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.Fire: {
@@ -807,7 +826,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Error,
name: 'fire',
description: 'Validates usage of `fire`',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.Gating: {
@@ -817,7 +836,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'gating',
description:
'Validates configuration of [gating mode](https://react.dev/reference/react-compiler/gating)',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.Globals: {
@@ -828,7 +847,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
description:
'Validates against assignment/mutation of globals during render, part of ensuring that ' +
'[side effects must render outside of render](https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.Hooks: {
@@ -842,7 +861,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
* We need to dedeupe these (moving the remaining bits into the compiler) and then enable
* this rule.
*/
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.Immutability: {
@@ -852,7 +871,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'immutability',
description:
'Validates against mutating props, state, and other values that [are immutable](https://react.dev/reference/rules/components-and-hooks-must-be-pure#props-and-state-are-immutable)',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.Invariant: {
@@ -861,7 +880,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Error,
name: 'invariant',
description: 'Internal invariants',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.PreserveManualMemo: {
@@ -873,7 +892,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
'Validates that existing manual memoized is preserved by the compiler. ' +
'React Compiler will only compile components and hooks if its inference ' +
'[matches or exceeds the existing manual memoization](https://react.dev/learn/react-compiler/introduction#what-should-i-do-about-usememo-usecallback-and-reactmemo)',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.Purity: {
@@ -883,7 +902,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'purity',
description:
'Validates that [components/hooks are pure](https://react.dev/reference/rules/components-and-hooks-must-be-pure) by checking that they do not call known-impure functions',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.Refs: {
@@ -893,7 +912,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'refs',
description:
'Validates correct usage of refs, not reading/writing during render. See the "pitfalls" section in [`useRef()` usage](https://react.dev/reference/react/useRef#usage)',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.RenderSetState: {
@@ -903,7 +922,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'set-state-in-render',
description:
'Validates against setting state during render, which can trigger additional renders and potential infinite render loops',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.StaticComponents: {
@@ -913,7 +932,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'static-components',
description:
'Validates that components are static, not recreated every render. Components that are recreated dynamically can reset state and trigger excessive re-rendering',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.Suppression: {
@@ -922,7 +941,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Error,
name: 'rule-suppression',
description: 'Validates against suppression of other rules',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.Syntax: {
@@ -931,7 +950,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Error,
name: 'syntax',
description: 'Validates against invalid syntax',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.Todo: {
@@ -940,7 +959,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Hint,
name: 'todo',
description: 'Unimplemented features',
recommended: false,
preset: LintRulePreset.Off,
};
}
case ErrorCategory.UnsupportedSyntax: {
@@ -950,7 +969,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'unsupported-syntax',
description:
'Validates against syntax that we do not plan to support in React Compiler',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.UseMemo: {
@@ -960,7 +979,17 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'use-memo',
description:
'Validates usage of the useMemo() hook against common mistakes. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.VoidUseMemo: {
return {
category,
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.',
preset: LintRulePreset.RecommendedLatest,
};
}
case ErrorCategory.IncompatibleLibrary: {
@@ -970,7 +999,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
name: 'incompatible-library',
description:
'Validates against usage of libraries which are incompatible with memoization (manual or automatic)',
recommended: true,
preset: LintRulePreset.Recommended,
};
}
default: {

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';
@@ -159,7 +159,7 @@ export const EnvironmentConfigSchema = z.object({
* A function that, given the name of a module, can optionally return a description
* of that module's type signature.
*/
moduleTypeProvider: z.nullable(z.function().args(z.string())).default(null),
moduleTypeProvider: z.nullable(z.any()).default(null),
/**
* A list of functions which the application compiles as macros, where
@@ -249,7 +249,7 @@ export const EnvironmentConfigSchema = z.object({
* Allows specifying a function that can populate HIR with type information from
* Flow
*/
flowTypeProvider: z.nullable(z.function().args(z.string())).default(null),
flowTypeProvider: z.nullable(z.any()).default(null),
/**
* Enables inference of optional dependency chains. Without this flag
@@ -906,6 +906,12 @@ export class Environment {
if (moduleTypeProvider == null) {
return null;
}
if (typeof moduleTypeProvider !== 'function') {
CompilerError.throwInvalidConfig({
reason: `Expected a function for \`moduleTypeProvider\``,
loc,
});
}
const unparsedModuleConfig = moduleTypeProvider(moduleName);
if (unparsedModuleConfig != null) {
const parsedModuleConfig = TypeSchema.safeParse(unparsedModuleConfig);

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

@@ -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

@@ -454,13 +454,13 @@ export function dropManualMemoization(
if (!hasNonVoidReturn(funcToCheck.loweredFunc.func)) {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.UseMemo,
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`,
? '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',

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

@@ -393,7 +393,7 @@ function* generateInstructionTypes(
shapeId: BuiltInArrayId,
});
} else {
break;
continue;
}
}
} else {

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,7 +10,16 @@ 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> {
@@ -18,8 +27,19 @@ export function validateUseMemo(fn: HIRFunction): Result<void, 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 +65,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 +122,73 @@ export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
);
}
validateNoContextVariableAssignment(body.loweredFunc.func, errors);
if (fn.env.config.validateNoVoidUseMemo) {
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()) {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.VoidUseMemo,
reason: 'Unused useMemo()',
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',
}),
);
}
}
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();
}

View File

@@ -20,7 +20,7 @@ describe('parseConfigPragma()', () => {
validateHooksUsage: 1,
} as any);
}).toThrowErrorMatchingInlineSnapshot(
`"Error: Could not validate environment config. Update React Compiler config to fix the error. Validation error: Expected boolean, received number at "validateHooksUsage"."`,
`"Error: Could not validate environment config. Update React Compiler config to fix the error. Validation error: Invalid input: expected boolean, received number at "validateHooksUsage"."`,
);
});
@@ -38,7 +38,7 @@ describe('parseConfigPragma()', () => {
],
} as any);
}).toThrowErrorMatchingInlineSnapshot(
`"Error: Could not validate environment config. Update React Compiler config to fix the error. Validation error: autodepsIndex must be > 0 at "inferEffectDependencies[0].autodepsIndex"."`,
`"Error: Could not validate environment config. Update React Compiler config to fix the error. Validation error: AutodepsIndex must be > 0 at "inferEffectDependencies[0].autodepsIndex"."`,
);
});

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

@@ -0,0 +1,41 @@
## Input
```javascript
function Component(props) {
// Intentionally don't bind state, this repros a bug where we didn't
// infer the type of destructured properties after a hole in the array
let [, setState] = useState();
setState(1);
return props.foo;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: ['TodoAdd'],
isComponent: 'TodoAdd',
};
```
## Error
```
Found 1 error:
Error: Calling setState during render may trigger an infinite loop
Calling setState during render will trigger another render, and can lead to infinite loops. (https://react.dev/reference/react/useState).
error.invalid-setState-in-render-unbound-state.ts:5:2
3 | // infer the type of destructured properties after a hole in the array
4 | let [, setState] = useState();
> 5 | setState(1);
| ^^^^^^^^ Found setState() in render
6 | return props.foo;
7 | }
8 |
```

View File

@@ -0,0 +1,13 @@
function Component(props) {
// Intentionally don't bind state, this repros a bug where we didn't
// infer the type of destructured properties after a hole in the array
let [, setState] = useState();
setState(1);
return props.foo;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: ['TodoAdd'],
isComponent: 'TodoAdd',
};

View File

@@ -0,0 +1,35 @@
## Input
```javascript
// @validateNoVoidUseMemo
function Component() {
useMemo(() => {
return [];
}, []);
return <div />;
}
```
## Error
```
Found 1 error:
Error: Unused useMemo()
This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects.
error.invalid-unused-usememo.ts:3:2
1 | // @validateNoVoidUseMemo
2 | function Component() {
> 3 | useMemo(() => {
| ^^^^^^^ useMemo() result is unused
4 | return [];
5 | }, []);
6 | return <div />;
```

View File

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

View File

@@ -28,7 +28,7 @@ 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.
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
@@ -45,7 +45,7 @@ error.useMemo-no-return-value.ts:3:16
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.
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');

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

@@ -1,35 +0,0 @@
## Input
```javascript
function t(props) {
let [, setstate] = useState();
setstate(1);
return props.foo;
}
export const FIXTURE_ENTRYPOINT = {
fn: t,
params: ['TodoAdd'],
isComponent: 'TodoAdd',
};
```
## Code
```javascript
function t(props) {
const [, setstate] = useState();
setstate(1);
return props.foo;
}
export const FIXTURE_ENTRYPOINT = {
fn: t,
params: ["TodoAdd"],
isComponent: "TodoAdd",
};
```

View File

@@ -1,11 +0,0 @@
function t(props) {
let [, setstate] = useState();
setstate(1);
return props.foo;
}
export const FIXTURE_ENTRYPOINT = {
fn: t,
params: ['TodoAdd'],
isComponent: 'TodoAdd',
};

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,52 @@
## Input
```javascript
// @validatePreserveExistingMemoizationGuarantees
import {useCallback, useTransition} from 'react';
function useFoo() {
const [, /* isPending intentionally not captured */ start] = useTransition();
return useCallback(() => {
start();
}, []);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees
import { useCallback, useTransition } from "react";
function useFoo() {
const $ = _c(1);
const [, start] = useTransition();
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
start();
};
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```
### Eval output
(kind: ok) "[[ function params=0 ]]"

View File

@@ -0,0 +1,15 @@
// @validatePreserveExistingMemoizationGuarantees
import {useCallback, useTransition} from 'react';
function useFoo() {
const [, /* isPending intentionally not captured */ start] = useTransition();
return useCallback(() => {
start();
}, []);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};

View File

@@ -0,0 +1,55 @@
## Input
```javascript
// @validatePreserveExistingMemoizationGuarantees
import {useCallback, useTransition} from 'react';
function useFoo() {
const [, /* state value intentionally not captured */ setState] = useState();
return useCallback(() => {
setState(x => x + 1);
}, []);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees
import { useCallback, useTransition } from "react";
function useFoo() {
const $ = _c(1);
const [, setState] = useState();
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
setState(_temp);
};
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
function _temp(x) {
return x + 1;
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```
### Eval output
(kind: exception) useState is not defined

View File

@@ -0,0 +1,15 @@
// @validatePreserveExistingMemoizationGuarantees
import {useCallback, useTransition} from 'react';
function useFoo() {
const [, /* state value intentionally not captured */ setState] = useState();
return useCallback(() => {
setState(x => x + 1);
}, []);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};

View File

@@ -14,6 +14,7 @@ export {
ErrorSeverity,
ErrorCategory,
LintRules,
LintRulePreset,
type CompilerErrorDetailOptions,
type CompilerDiagnosticOptions,
type CompilerDiagnosticDetail,

View File

@@ -120,7 +120,7 @@ testRule('plugin-recommended', TestRecommendedRules, {
return <Child x={state} />;
}`,
errors: [makeTestCaseError('useMemo() callbacks must return a value')],
errors: [makeTestCaseError('Unused useMemo()')],
},
{
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",
"zod-validation-error": "^3.0.3"
"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

@@ -15,6 +15,7 @@ import type {Linter, Rule} from 'eslint';
import runReactCompiler, {RunCacheEntry} from '../shared/RunReactCompiler';
import {
ErrorSeverity,
LintRulePreset,
LintRules,
type LintRule,
} from 'babel-plugin-react-compiler/src/CompilerError';
@@ -150,7 +151,7 @@ function makeRule(rule: LintRule): Rule.RuleModule {
type: 'problem',
docs: {
description: rule.description,
recommended: rule.recommended,
recommended: rule.preset === LintRulePreset.Recommended,
},
fixable: 'code',
hasSuggestions: true,
@@ -171,7 +172,16 @@ export const allRules: RulesConfig = LintRules.reduce((acc, rule) => {
}, {} as RulesConfig);
export const recommendedRules: RulesConfig = LintRules.filter(
rule => rule.recommended,
rule => rule.preset === LintRulePreset.Recommended,
).reduce((acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;
}, {} as RulesConfig);
export const recommendedLatestRules: RulesConfig = LintRules.filter(
rule =>
rule.preset === LintRulePreset.Recommended ||
rule.preset === LintRulePreset.RecommendedLatest,
).reduce((acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;

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",
"zod-validation-error": "^3.0.3"
"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.23.8"
"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

@@ -68,7 +68,7 @@ async function main() {
.option('tag', {
description: 'Tag to publish to npm',
type: 'choices',
choices: ['experimental', 'beta', 'rc'],
choices: ['experimental', 'beta', 'rc', 'latest'],
default: 'experimental',
})
.option('tag-version', {
@@ -145,10 +145,15 @@ async function main() {
files: {exclude: ['.DS_Store']},
});
const truncatedHash = hash.slice(0, 7);
let newVersion =
argv.tagVersion == null || argv.tagVersion === ''
? `${argv.versionName}-${argv.tag}`
: `${argv.versionName}-${argv.tag}.${argv.tagVersion}`;
let newVersion;
if (argv.tag === 'latest') {
newVersion = argv.versionName;
} else {
newVersion =
argv.tagVersion == null || argv.tagVersion === ''
? `${argv.versionName}-${argv.tag}`
: `${argv.versionName}-${argv.tag}.${argv.tagVersion}`;
}
if (argv.tag === 'experimental' || argv.tag === 'beta') {
newVersion = `${newVersion}-${truncatedHash}-${dateString}`;
}
@@ -181,21 +186,9 @@ async function main() {
if (otp != null) {
opts.push(`--otp=${otp}`);
}
/**
* Typically, the `latest` tag is reserved for stable package versions. Since the the compiler
* is still pre-release, until we have a stable release let's only add the
* `latest` tag to non-experimental releases.
*
* `latest` is added by default, so we only override it for experimental releases so that
* those don't get the `latest` tag.
*
* TODO: Update this when we have a stable release.
*/
if (argv.tag === 'experimental') {
opts.push('--tag=experimental');
} else {
opts.push('--tag=latest');
}
opts.push(`--tag=${argv.tag}`);
try {
await spawnHelper(
'npm',

View File

@@ -10486,16 +10486,7 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -10568,14 +10559,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -11352,7 +11336,7 @@ workerpool@^6.5.1:
resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz"
integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -11370,15 +11354,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"
@@ -11530,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@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-2.1.0.tgz"
integrity sha512-VJh93e2wb4c3tWtGgTa0OF/dTt/zoPCPzXq4V11ZjxmEAFaPi/Zss1xIZdEB5RD8GD00U0/iVXgqkF77RV7pdQ==
"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-validation-error@^3.0.3:
version "3.0.3"
resolved "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.0.3.tgz"
integrity sha512-cETTrcMq3Ze58vhdR0zD37uJm/694I6mAxcf/ei5bl89cC++fBNxrC2z8lkFze/8hVMPwrbtrwXHR2LB50fpHw==
zod@^3.22.4, zod@^3.23.8, zod@^3.24.1:
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

@@ -55,11 +55,11 @@ export default function ScrollIntoViewCase() {
const scrollContainerRef = useRef(null);
const scrollVertical = () => {
fragmentRef.current.experimental_scrollIntoView(alignToTop);
fragmentRef.current.scrollIntoView(alignToTop);
};
const scrollVerticalNoChildren = () => {
noChildRef.current.experimental_scrollIntoView(alignToTop);
noChildRef.current.scrollIntoView(alignToTop);
};
useEffect(() => {

View File

@@ -1,6 +1,6 @@
{
"root": true,
"extends": ["plugin:react-hooks/recommended-latest-legacy"],
"extends": ["plugin:react-hooks/recommended"],
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",

View File

@@ -1,6 +1,6 @@
{
"root": true,
"extends": ["plugin:react-hooks/recommended-latest-legacy"],
"extends": ["plugin:react-hooks/recommended"],
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",

View File

@@ -1,6 +1,6 @@
{
"root": true,
"extends": ["plugin:react-hooks/recommended-latest-legacy"],
"extends": ["plugin:react-hooks/recommended"],
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",

View File

@@ -2,6 +2,7 @@ import {defineConfig} from 'eslint/config';
import reactHooks from 'eslint-plugin-react-hooks';
export default defineConfig([
reactHooks.configs.flat['recommended-latest'],
{
languageOptions: {
ecmaVersion: 'latest',
@@ -12,10 +13,6 @@ export default defineConfig([
},
},
},
plugins: {
'react-hooks': reactHooks,
},
extends: ['react-hooks/recommended-latest'],
rules: {
'react-hooks/exhaustive-deps': 'error',
},

View File

@@ -8,6 +8,9 @@
},
"scripts": {
"build": "node build.mjs && yarn",
"lint": "eslint index.js --report-unused-disable-directives"
"lint": "tsc --noEmit && eslint index.js --report-unused-disable-directives"
},
"devDependencies": {
"typescript": "^5.4.3"
}
}

View File

@@ -1,2 +1,20 @@
{
"compilerOptions": {
"lib": [
"es2022"
],
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"typeRoots": [
"./node_modules/@types"
],
"skipLibCheck": true
},
"exclude": [
"node_modules",
"**/node_modules",
"../node_modules",
"../../node_modules"
]
}

View File

@@ -48,13 +48,6 @@
"@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2"
"@babel/helper-annotate-as-pure@^7.27.3":
version "7.27.3"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5"
integrity sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==
dependencies:
"@babel/types" "^7.27.3"
"@babel/helper-compilation-targets@^7.27.2":
version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d"
@@ -66,32 +59,11 @@
lru-cache "^5.1.1"
semver "^6.3.1"
"@babel/helper-create-class-features-plugin@^7.18.6":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz#3e747434ea007910c320c4d39a6b46f20f371d46"
integrity sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==
dependencies:
"@babel/helper-annotate-as-pure" "^7.27.3"
"@babel/helper-member-expression-to-functions" "^7.27.1"
"@babel/helper-optimise-call-expression" "^7.27.1"
"@babel/helper-replace-supers" "^7.27.1"
"@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
"@babel/traverse" "^7.28.3"
semver "^6.3.1"
"@babel/helper-globals@^7.28.0":
version "7.28.0"
resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674"
integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
"@babel/helper-member-expression-to-functions@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz#ea1211276be93e798ce19037da6f06fbb994fa44"
integrity sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==
dependencies:
"@babel/traverse" "^7.27.1"
"@babel/types" "^7.27.1"
"@babel/helper-module-imports@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204"
@@ -109,35 +81,6 @@
"@babel/helper-validator-identifier" "^7.27.1"
"@babel/traverse" "^7.28.3"
"@babel/helper-optimise-call-expression@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz#c65221b61a643f3e62705e5dd2b5f115e35f9200"
integrity sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==
dependencies:
"@babel/types" "^7.27.1"
"@babel/helper-plugin-utils@^7.18.6":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c"
integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==
"@babel/helper-replace-supers@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz#b1ed2d634ce3bdb730e4b52de30f8cccfd692bc0"
integrity sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==
dependencies:
"@babel/helper-member-expression-to-functions" "^7.27.1"
"@babel/helper-optimise-call-expression" "^7.27.1"
"@babel/traverse" "^7.27.1"
"@babel/helper-skip-transparent-expression-wrappers@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz#62bb91b3abba8c7f1fec0252d9dbea11b3ee7a56"
integrity sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==
dependencies:
"@babel/traverse" "^7.27.1"
"@babel/types" "^7.27.1"
"@babel/helper-string-parser@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
@@ -168,14 +111,6 @@
dependencies:
"@babel/types" "^7.28.4"
"@babel/plugin-proposal-private-methods@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz#5209de7d213457548a98436fa2882f52f4be6bea"
integrity sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==
dependencies:
"@babel/helper-create-class-features-plugin" "^7.18.6"
"@babel/helper-plugin-utils" "^7.18.6"
"@babel/template@^7.27.2":
version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
@@ -198,7 +133,7 @@
"@babel/types" "^7.28.4"
debug "^4.3.1"
"@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.4":
"@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a"
integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==
@@ -668,18 +603,6 @@ has-flag@^4.0.0:
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
hermes-estree@0.25.1:
version "0.25.1"
resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480"
integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==
hermes-parser@^0.25.1:
version "0.25.1"
resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1"
integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==
dependencies:
hermes-estree "0.25.1"
ignore@^5.2.0:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
@@ -912,6 +835,11 @@ type-check@^0.4.0, type-check@~0.4.0:
dependencies:
prelude-ls "^1.2.1"
typescript@^5.4.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
update-browserslist-db@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420"
@@ -949,12 +877,12 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zod-validation-error@^3.0.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.5.3.tgz#85ba33290200d8db9f043621e284f40dddefb7e5"
integrity sha512-OT5Y8lbUadqVZCsnyFaTQ4/O2mys4tj7PqhdbBCp7McPwvIEKfPtdA6QfPeFQK2/Rz5LgwmAXRJTugBNBi0btw==
"zod-validation-error@^3.0.3 || ^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:
version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
"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==

View File

@@ -3,7 +3,7 @@ import React, {
useLayoutEffect,
useEffect,
useState,
unstable_addTransitionType as addTransitionType,
addTransitionType,
} from 'react';
import Chrome from './Chrome.js';

View File

@@ -1,6 +1,6 @@
import React, {
unstable_addTransitionType as addTransitionType,
unstable_ViewTransition as ViewTransition,
addTransitionType,
ViewTransition,
Activity,
useLayoutEffect,
useEffect,

View File

@@ -1,6 +1,23 @@
## 7.0.0
This release slims down presets to just 2 configurations (`recommended` and `recommended-latest`), and all compiler rules are enabled by default.
- **Breaking:** Removed `recommended-latest-legacy` and `flat/recommended` configs. The plugin now provides `recommended` (legacy and flat configs with all recommended rules), and `recommended-latest` (legacy and flat configs with all recommended rules plus new bleeding edge experimental compiler rules). ([@poteto](https://github.com/poteto) in [#34757](https://github.com/facebook/react/pull/34757))
## 6.1.1
**Note:** 6.1.0 accidentally allowed use of `recommended` without flat config, causing errors when used with ESLint v9's `defineConfig()` helper. This has been fixed in 6.1.1.
- Fix `recommended` config for flat config compatibility. The `recommended` config has been converted to flat config format. Non-flat config users should use `recommended-legacy` instead. ([@poteto](https://github.com/poteto) in [#34700](https://github.com/facebook/react/pull/34700))
- Add `recommended-latest` and `recommended-latest-legacy` configs that include React Compiler rules. ([@poteto](https://github.com/poteto) in [#34675](https://github.com/facebook/react/pull/34675))
- Remove unused `NoUnusedOptOutDirectives` rule. ([@poteto](https://github.com/poteto) in [#34703](https://github.com/facebook/react/pull/34703))
- Remove `hermes-parser` and dependency. ([@poteto](https://github.com/poteto) in [#34719](https://github.com/facebook/react/pull/34719))
- Remove `@babel/plugin-proposal-private-methods` dependency. ([@ArnaudBarre](https://github.com/ArnaudBarre) and [@josephsavona](https://github.com/josephsavona) in [#34715](https://github.com/facebook/react/pull/34715))
- Update for Zod v3/v4 compatibility. ([@kolian](https://github.com/kolvian) and [@josephsavona](https://github.com/josephsavona) in [#34717](https://github.com/facebook/react/pull/34717))
## 6.1.0
**Note:** Version 6.0.0 was mistakenly released and immediately deprecated and untagged on npm. This is the first official 6.x major release and includes breaking changes.
**Note:** Version 6.0.0 was mistakenly released and immediately deprecated and untagged on npm. This is the first official 6.x major release and includes breaking changes.
- **Breaking:** Require Node.js 18 or newer. ([@michaelfaith](https://github.com/michaelfaith) in [#32458](https://github.com/facebook/react/pull/32458))
- **Breaking:** Flat config is now the default `recommended` preset. Legacy config moved to `recommended-legacy`. ([@michaelfaith](https://github.com/michaelfaith) in [#32457](https://github.com/facebook/react/pull/32457))

View File

@@ -1,13 +1,9 @@
# `eslint-plugin-react-hooks`
This ESLint plugin enforces the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks).
It is a part of the [Hooks API](https://react.dev/reference/react/hooks) for React.
The official ESLint plugin for [React](https://react.dev) which enforces the [Rules of React](https://react.dev/reference/eslint-plugin-react-hooks) and other best practices.
## Installation
**Note: If you're using Create React App, please use `react-scripts` >= 3 instead of adding it directly.**
Assuming you already have ESLint installed, run:
```sh
@@ -20,9 +16,7 @@ yarn add eslint-plugin-react-hooks --dev
### Flat Config (eslint.config.js|ts)
#### >= 6.0.0
For users of 6.0 and beyond, add the `recommended` config.
Add the `recommended` config for all recommended rules:
```js
// eslint.config.js
@@ -30,71 +24,42 @@ import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';
export default defineConfig([
{
files: ["src/**/*.{js,jsx,ts,tsx}"],
plugins: {
'react-hooks': reactHooks,
},
extends: ['react-hooks/recommended'],
},
reactHooks.configs.flat.recommended,
]);
```
#### 5.2.0
For users of 5.2.0 (the first version with flat config support), add the `recommended-latest` config.
If you want to try bleeding edge experimental compiler rules, use `recommended-latest`.
```js
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';
export default defineConfig([
{
files: ["src/**/*.{js,jsx,ts,tsx}"],
plugins: {
'react-hooks': reactHooks,
},
extends: ['react-hooks/recommended-latest'],
},
reactHooks.configs.flat['recommended-latest'],
]);
```
### Legacy Config (.eslintrc)
#### >= 5.2.0
If you are still using ESLint below 9.0.0, you can use `recommended-legacy` for accessing a legacy version of the recommended config.
If you are still using ESLint below 9.0.0, the `recommended` preset can also be used to enable all recommended rules.
```js
{
"extends": [
// ...
"plugin:react-hooks/recommended-legacy"
]
"extends": ["plugin:react-hooks/recommended"],
// ...
}
```
#### < 5.2.0
If you're using a version earlier than 5.2.0, the legacy config was simply `recommended`.
```js
{
"extends": [
// ...
"plugin:react-hooks/recommended"
]
}
```
### Custom Configuration
If you want more fine-grained configuration, you can instead add a snippet like this to your ESLint configuration file:
If you want more fine-grained configuration, you can instead choose to enable specific rules. However, we strongly encourage using the recommended presets — see above — so that you will automatically receive new recommended rules as we add them in future versions of the plugin.
#### Flat Config (eslint.config.js|ts)
```js
import * as reactHooks from 'eslint-plugin-react-hooks';
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{
@@ -102,8 +67,26 @@ export default [
plugins: { 'react-hooks': reactHooks },
// ...
rules: {
// Core hooks rules
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// React Compiler rules
'react-hooks/config': 'error',
'react-hooks/error-boundaries': 'error',
'react-hooks/component-hook-factories': 'error',
'react-hooks/gating': 'error',
'react-hooks/globals': 'error',
'react-hooks/immutability': 'error',
'react-hooks/preserve-manual-memoization': 'error',
'react-hooks/purity': 'error',
'react-hooks/refs': 'error',
'react-hooks/set-state-in-effect': 'error',
'react-hooks/set-state-in-render': 'error',
'react-hooks/static-components': 'error',
'react-hooks/unsupported-syntax': 'warn',
'react-hooks/use-memo': 'error',
'react-hooks/incompatible-library': 'warn',
}
},
];
@@ -118,8 +101,26 @@ export default [
],
"rules": {
// ...
// Core hooks rules
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
"react-hooks/exhaustive-deps": "warn",
// React Compiler rules
"react-hooks/config": "error",
"react-hooks/error-boundaries": "error",
"react-hooks/component-hook-factories": "error",
"react-hooks/gating": "error",
"react-hooks/globals": "error",
"react-hooks/immutability": "error",
"react-hooks/preserve-manual-memoization": "error",
"react-hooks/purity": "error",
"react-hooks/refs": "error",
"react-hooks/set-state-in-effect": "error",
"react-hooks/set-state-in-render": "error",
"react-hooks/static-components": "error",
"react-hooks/unsupported-syntax": "warn",
"react-hooks/use-memo": "error",
"react-hooks/incompatible-library": "warn"
}
}
```

View File

@@ -5,4 +5,4 @@
* LICENSE file in the root directory of this source tree.
*/
export * from './cjs/eslint-plugin-react-hooks';
export {default} from './cjs/eslint-plugin-react-hooks';

View File

@@ -1,7 +1,7 @@
{
"name": "eslint-plugin-react-hooks",
"description": "ESLint rules for React Hooks",
"version": "5.2.0",
"version": "7.0.0",
"repository": {
"type": "git",
"url": "https://github.com/facebook/react.git",
@@ -42,22 +42,22 @@
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.22.4",
"zod-validation-error": "^3.0.3"
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.11.4",
"@babel/preset-typescript": "^7.26.0",
"@babel/types": "^7.19.0",
"@tsconfig/strictest": "^2.0.5",
"@types/eslint": "^9.6.1",
"@types/estree": "^1.0.6",
"@types/estree-jsx": "^1.0.5",
"@types/node": "^20.2.5",
"@typescript-eslint/parser-v2": "npm:@typescript-eslint/parser@^2.26.0",
"@typescript-eslint/parser-v3": "npm:@typescript-eslint/parser@^3.10.0",
"@typescript-eslint/parser-v4": "npm:@typescript-eslint/parser@^4.1.0",
"@typescript-eslint/parser-v5": "npm:@typescript-eslint/parser@^5.62.0",
"@types/eslint": "^8.56.12",
"@types/estree": "^1.0.6",
"@types/estree-jsx": "^1.0.5",
"@types/node": "^20.2.5",
"babel-eslint": "^10.0.3",
"eslint-v7": "npm:eslint@^7.7.0",
"eslint-v8": "npm:eslint@^8.57.1",

View File

@@ -11,10 +11,10 @@ import {
allRules,
mapErrorSeverityToESlint,
recommendedRules,
recommendedLatestRules,
} from './shared/ReactCompiler';
import RulesOfHooks from './rules/RulesOfHooks';
// All rules
const rules = {
'exhaustive-deps': ExhaustiveDeps,
'rules-of-hooks': RulesOfHooks,
@@ -23,13 +23,12 @@ const rules = {
),
} satisfies Record<string, Rule.RuleModule>;
// Basic hooks rules (for recommended config)
const basicRuleConfigs = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
} as const satisfies Linter.RulesRecord;
const compilerRuleConfigs = Object.fromEntries(
const recommendedCompilerRuleConfigs = Object.fromEntries(
Object.entries(recommendedRules).map(([name, ruleConfig]) => {
return [
`react-hooks/${name}` as const,
@@ -38,69 +37,61 @@ const compilerRuleConfigs = Object.fromEntries(
}),
) as Record<`react-hooks/${string}`, Linter.RuleEntry>;
// All rules including compiler rules (for recommended-latest config)
const allRuleConfigs: Linter.RulesRecord = {
const recommendedLatestCompilerRuleConfigs = Object.fromEntries(
Object.entries(recommendedLatestRules).map(([name, ruleConfig]) => {
return [
`react-hooks/${name}` as const,
mapErrorSeverityToESlint(ruleConfig.severity),
] as const;
}),
) as Record<`react-hooks/${string}`, Linter.RuleEntry>;
const recommendedRuleConfigs: Linter.RulesRecord = {
...basicRuleConfigs,
...compilerRuleConfigs,
...recommendedCompilerRuleConfigs,
};
const recommendedLatestRuleConfigs: Linter.RulesRecord = {
...basicRuleConfigs,
...recommendedLatestCompilerRuleConfigs,
};
const plugins = ['react-hooks'];
type ReactHooksFlatConfig = {
plugins: {react: any};
rules: Linter.RulesRecord;
};
const configs = {
recommended: {
plugins,
rules: recommendedRuleConfigs,
},
'recommended-latest': {
plugins,
rules: recommendedLatestRuleConfigs,
},
flat: {} as Record<string, ReactHooksFlatConfig>,
};
const plugin = {
meta: {
name: 'eslint-plugin-react-hooks',
version: '7.0.0',
},
rules,
configs: {} as {
'recommended-legacy': {
plugins: Array<string>;
rules: Linter.RulesRecord;
};
'recommended-latest-legacy': {
plugins: Array<string>;
rules: Linter.RulesRecord;
};
'flat/recommended': Array<Linter.Config>;
'recommended-latest': Array<Linter.Config>;
recommended: Array<Linter.Config>;
},
configs,
};
Object.assign(plugin.configs, {
'recommended-legacy': {
plugins: ['react-hooks'],
rules: basicRuleConfigs,
Object.assign(configs.flat, {
'recommended-latest': {
plugins: {'react-hooks': plugin},
rules: configs['recommended-latest'].rules,
},
'recommended-latest-legacy': {
plugins: ['react-hooks'],
rules: allRuleConfigs,
recommended: {
plugins: {'react-hooks': plugin},
rules: configs.recommended.rules,
},
'flat/recommended': [
{
plugins: {
'react-hooks': plugin,
},
rules: basicRuleConfigs,
},
],
'recommended-latest': [
{
plugins: {
'react-hooks': plugin,
},
rules: allRuleConfigs,
},
],
recommended: [
{
plugins: {
'react-hooks': plugin,
},
rules: basicRuleConfigs,
},
],
});
export default plugin;

View File

@@ -14,6 +14,7 @@ import {
LintRules,
type LintRule,
ErrorSeverity,
LintRulePreset,
} from 'babel-plugin-react-compiler';
import {type Linter, type Rule} from 'eslint';
import runReactCompiler, {RunCacheEntry} from './RunReactCompiler';
@@ -149,7 +150,7 @@ function makeRule(rule: LintRule): Rule.RuleModule {
type: 'problem',
docs: {
description: rule.description,
recommended: rule.recommended,
recommended: rule.preset === LintRulePreset.Recommended,
},
fixable: 'code',
hasSuggestions: true,
@@ -164,23 +165,26 @@ type RulesConfig = {
[name: string]: {rule: Rule.RuleModule; severity: ErrorSeverity};
};
export const allRules: RulesConfig = LintRules.reduce(
(acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;
},
{} as RulesConfig,
);
export const allRules: RulesConfig = LintRules.reduce((acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;
}, {} as RulesConfig);
export const recommendedRules: RulesConfig = LintRules.filter(
rule => rule.recommended,
).reduce(
(acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;
},
{} as RulesConfig,
);
rule => rule.preset === LintRulePreset.Recommended,
).reduce((acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;
}, {} as RulesConfig);
export const recommendedLatestRules: RulesConfig = LintRules.filter(
rule =>
rule.preset === LintRulePreset.Recommended ||
rule.preset === LintRulePreset.RecommendedLatest,
).reduce((acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;
}, {} as RulesConfig);
export function mapErrorSeverityToESlint(
severity: ErrorSeverity,

View File

@@ -78,7 +78,6 @@ function getFlowSuppressions(
return results;
}
function runReactCompilerImpl({
sourceCode,
filename,
@@ -115,6 +114,7 @@ function runReactCompilerImpl({
}
let babelAST: ParseResult<File> | null = null;
if (filename.endsWith('.tsx') || filename.endsWith('.ts')) {
try {
babelAST = babelParse(sourceCode.text, {

View File

@@ -367,6 +367,7 @@ type Response = {
_debugRootStack?: null | Error, // DEV-only
_debugRootTask?: null | ConsoleTask, // DEV-only
_debugStartTime: number, // DEV-only
_debugIOStarted: boolean, // DEV-only
_debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only
_debugChannel?: void | DebugChannel, // DEV-only
_blockedConsole?: null | SomeChunk<ConsoleEntry>, // DEV-only
@@ -500,7 +501,7 @@ function createErrorChunk<T>(
}
function moveDebugInfoFromChunkToInnerValue<T>(
chunk: InitializedChunk<T>,
chunk: InitializedChunk<T> | InitializedStreamChunk<any>,
value: T,
): void {
// Remove the debug info from the initialized chunk, and add it to the inner
@@ -1569,6 +1570,10 @@ function fulfillReference(
initializedChunk.reason = handler.reason; // Used by streaming chunks
if (resolveListeners !== null) {
wakeChunk(resolveListeners, handler.value, initializedChunk);
} else {
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(initializedChunk, handler.value);
}
}
}
}
@@ -1818,6 +1823,10 @@ function loadServerReference<A: Iterable<any>, T>(
initializedChunk.value = handler.value;
if (resolveListeners !== null) {
wakeChunk(resolveListeners, handler.value, initializedChunk);
} else {
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(initializedChunk, handler.value);
}
}
}
}
@@ -2536,6 +2545,10 @@ function missingCall() {
);
}
function markIOStarted(this: Response) {
this._debugIOStarted = true;
}
function ResponseInstance(
this: $FlowFixMe,
bundlerConfig: ServerConsumerModuleMap,
@@ -2609,6 +2622,10 @@ function ResponseInstance(
// where as if you use createFromReadableStream from the body of the fetch
// then the start time is when the headers resolved.
this._debugStartTime = performance.now();
this._debugIOStarted = false;
// We consider everything before the first setTimeout task to be cached data
// and is not considered I/O required to load the stream.
setTimeout(markIOStarted.bind(this), 0);
}
this._debugFindSourceMapURL = findSourceMapURL;
this._debugChannel = debugChannel;
@@ -2762,7 +2779,7 @@ function incrementChunkDebugInfo(
}
}
function addDebugInfo(chunk: SomeChunk<any>, debugInfo: ReactDebugInfo): void {
function addAsyncInfo(chunk: SomeChunk<any>, asyncInfo: ReactAsyncInfo): void {
const value = resolveLazy(chunk.value);
if (
typeof value === 'object' &&
@@ -2774,34 +2791,39 @@ function addDebugInfo(chunk: SomeChunk<any>, debugInfo: ReactDebugInfo): void {
) {
if (isArray(value._debugInfo)) {
// $FlowFixMe[method-unbinding]
value._debugInfo.push.apply(value._debugInfo, debugInfo);
value._debugInfo.push(asyncInfo);
} else {
Object.defineProperty((value: any), '_debugInfo', {
configurable: false,
enumerable: false,
writable: true,
value: debugInfo,
value: [asyncInfo],
});
}
} else {
// $FlowFixMe[method-unbinding]
chunk._debugInfo.push.apply(chunk._debugInfo, debugInfo);
chunk._debugInfo.push(asyncInfo);
}
}
function resolveChunkDebugInfo(
response: Response,
streamState: StreamState,
chunk: SomeChunk<any>,
): void {
if (__DEV__ && enableAsyncDebugInfo) {
// Add the currently resolving chunk's debug info representing the stream
// to the Promise that was waiting on the stream, or its underlying value.
const debugInfo: ReactDebugInfo = [{awaited: streamState._debugInfo}];
if (chunk.status === PENDING || chunk.status === BLOCKED) {
const boundAddDebugInfo = addDebugInfo.bind(null, chunk, debugInfo);
chunk.then(boundAddDebugInfo, boundAddDebugInfo);
} else {
addDebugInfo(chunk, debugInfo);
// Only include stream information after a macrotask. Any chunk processed
// before that is considered cached data.
if (response._debugIOStarted) {
// Add the currently resolving chunk's debug info representing the stream
// to the Promise that was waiting on the stream, or its underlying value.
const asyncInfo: ReactAsyncInfo = {awaited: streamState._debugInfo};
if (chunk.status === PENDING || chunk.status === BLOCKED) {
const boundAddAsyncInfo = addAsyncInfo.bind(null, chunk, asyncInfo);
chunk.then(boundAddAsyncInfo, boundAddAsyncInfo);
} else {
addAsyncInfo(chunk, asyncInfo);
}
}
}
}
@@ -2837,12 +2859,12 @@ function resolveModel(
model,
);
if (__DEV__) {
resolveChunkDebugInfo(streamState, newChunk);
resolveChunkDebugInfo(response, streamState, newChunk);
}
chunks.set(id, newChunk);
} else {
if (__DEV__) {
resolveChunkDebugInfo(streamState, chunk);
resolveChunkDebugInfo(response, streamState, chunk);
}
resolveModelChunk(response, chunk, model);
}
@@ -2869,7 +2891,7 @@ function resolveText(
}
const newChunk = createInitializedTextChunk(response, text);
if (__DEV__) {
resolveChunkDebugInfo(streamState, newChunk);
resolveChunkDebugInfo(response, streamState, newChunk);
}
chunks.set(id, newChunk);
}
@@ -2895,7 +2917,7 @@ function resolveBuffer(
}
const newChunk = createInitializedBufferChunk(response, buffer);
if (__DEV__) {
resolveChunkDebugInfo(streamState, newChunk);
resolveChunkDebugInfo(response, streamState, newChunk);
}
chunks.set(id, newChunk);
}
@@ -2942,7 +2964,7 @@ function resolveModule(
blockedChunk.status = BLOCKED;
}
if (__DEV__) {
resolveChunkDebugInfo(streamState, blockedChunk);
resolveChunkDebugInfo(response, streamState, blockedChunk);
}
promise.then(
() => resolveModuleChunk(response, blockedChunk, clientReference),
@@ -2952,12 +2974,12 @@ function resolveModule(
if (!chunk) {
const newChunk = createResolvedModuleChunk(response, clientReference);
if (__DEV__) {
resolveChunkDebugInfo(streamState, newChunk);
resolveChunkDebugInfo(response, streamState, newChunk);
}
chunks.set(id, newChunk);
} else {
if (__DEV__) {
resolveChunkDebugInfo(streamState, chunk);
resolveChunkDebugInfo(response, streamState, chunk);
}
// This can't actually happen because we don't have any forward
// references to modules.
@@ -2978,13 +3000,13 @@ function resolveStream<T: ReadableStream | $AsyncIterable<any, any, void>>(
if (!chunk) {
const newChunk = createInitializedStreamChunk(response, stream, controller);
if (__DEV__) {
resolveChunkDebugInfo(streamState, newChunk);
resolveChunkDebugInfo(response, streamState, newChunk);
}
chunks.set(id, newChunk);
return;
}
if (__DEV__) {
resolveChunkDebugInfo(streamState, chunk);
resolveChunkDebugInfo(response, streamState, chunk);
}
if (chunk.status !== PENDING) {
// We already resolved. We didn't expect to see this.
@@ -3034,6 +3056,10 @@ function resolveStream<T: ReadableStream | $AsyncIterable<any, any, void>>(
resolvedChunk.reason = controller;
if (resolveListeners !== null) {
wakeChunk(resolveListeners, chunk.value, (chunk: any));
} else {
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(resolvedChunk, stream);
}
}
}
@@ -3433,12 +3459,12 @@ function resolvePostponeDev(
postponeInstance,
);
if (__DEV__) {
resolveChunkDebugInfo(streamState, newChunk);
resolveChunkDebugInfo(response, streamState, newChunk);
}
chunks.set(id, newChunk);
} else {
if (__DEV__) {
resolveChunkDebugInfo(streamState, chunk);
resolveChunkDebugInfo(response, streamState, chunk);
}
triggerErrorOnChunk(response, chunk, postponeInstance);
}
@@ -3467,12 +3493,12 @@ function resolveErrorModel(
errorWithDigest,
);
if (__DEV__) {
resolveChunkDebugInfo(streamState, newChunk);
resolveChunkDebugInfo(response, streamState, newChunk);
}
chunks.set(id, newChunk);
} else {
if (__DEV__) {
resolveChunkDebugInfo(streamState, chunk);
resolveChunkDebugInfo(response, streamState, chunk);
}
triggerErrorOnChunk(response, chunk, errorWithDigest);
}
@@ -4318,7 +4344,26 @@ function flushComponentPerformance(
// First find the start time of the first component to know if it was running
// in parallel with the previous.
const debugInfo = __DEV__ && root._debugInfo;
let debugInfo = null;
if (__DEV__) {
debugInfo = root._debugInfo;
if (debugInfo.length === 0 && root.status === 'fulfilled') {
const resolvedValue = resolveLazy(root.value);
if (
typeof resolvedValue === 'object' &&
resolvedValue !== null &&
(isArray(resolvedValue) ||
typeof resolvedValue[ASYNC_ITERATOR] === 'function' ||
resolvedValue.$$typeof === REACT_ELEMENT_TYPE ||
resolvedValue.$$typeof === REACT_LAZY_TYPE) &&
isArray(resolvedValue._debugInfo)
) {
// It's possible that the value has been given the debug info.
// In that case we need to look for it on the resolved value.
debugInfo = resolvedValue._debugInfo;
}
}
}
if (debugInfo) {
let startTime = 0;
for (let i = 0; i < debugInfo.length; i++) {

View File

@@ -102,6 +102,7 @@ export function logComponentRender(
const entryName =
isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']';
const debugTask = componentInfo.debugTask;
const measureName = '\u200b' + entryName;
if (__DEV__ && debugTask) {
const properties: Array<[string, string]> = [];
if (componentInfo.key != null) {
@@ -110,9 +111,10 @@ export function logComponentRender(
if (componentInfo.props != null) {
addObjectToProperties(componentInfo.props, properties, 0, '');
}
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, '\u200b' + entryName, {
performance.measure.bind(performance, measureName, {
start: startTime < 0 ? 0 : startTime,
end: childrenEndTime,
detail: {
@@ -125,9 +127,10 @@ export function logComponentRender(
},
}),
);
performance.clearMeasures(measureName);
} else {
console.timeStamp(
'\u200b' + entryName,
measureName,
startTime < 0 ? 0 : startTime,
childrenEndTime,
trackNames[trackIdx],
@@ -152,6 +155,7 @@ export function logComponentAborted(
const isPrimaryEnv = env === rootEnv;
const entryName =
isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']';
const measureName = '\u200b' + entryName;
if (__DEV__) {
const properties: Array<[string, string]> = [
[
@@ -165,7 +169,8 @@ export function logComponentAborted(
if (componentInfo.props != null) {
addObjectToProperties(componentInfo.props, properties, 0, '');
}
performance.measure('\u200b' + entryName, {
performance.measure(measureName, {
start: startTime < 0 ? 0 : startTime,
end: childrenEndTime,
detail: {
@@ -178,9 +183,10 @@ export function logComponentAborted(
},
},
});
performance.clearMeasures(measureName);
} else {
console.timeStamp(
entryName,
measureName,
startTime < 0 ? 0 : startTime,
childrenEndTime,
trackNames[trackIdx],
@@ -206,6 +212,7 @@ export function logComponentErrored(
const isPrimaryEnv = env === rootEnv;
const entryName =
isPrimaryEnv || env === undefined ? name : name + ' [' + env + ']';
const measureName = '\u200b' + entryName;
if (__DEV__) {
const message =
typeof error === 'object' &&
@@ -222,7 +229,8 @@ export function logComponentErrored(
if (componentInfo.props != null) {
addObjectToProperties(componentInfo.props, properties, 0, '');
}
performance.measure('\u200b' + entryName, {
performance.measure(measureName, {
start: startTime < 0 ? 0 : startTime,
end: childrenEndTime,
detail: {
@@ -235,9 +243,10 @@ export function logComponentErrored(
},
},
});
performance.clearMeasures(measureName);
} else {
console.timeStamp(
entryName,
measureName,
startTime < 0 ? 0 : startTime,
childrenEndTime,
trackNames[trackIdx],
@@ -397,6 +406,7 @@ export function logComponentAwaitAborted(
},
}),
);
performance.clearMeasures(entryName);
} else {
console.timeStamp(
entryName,
@@ -453,6 +463,7 @@ export function logComponentAwaitErrored(
},
}),
);
performance.clearMeasures(entryName);
} else {
console.timeStamp(
entryName,
@@ -514,6 +525,7 @@ export function logComponentAwait(
},
}),
);
performance.clearMeasures(entryName);
} else {
console.timeStamp(
entryName,
@@ -538,6 +550,7 @@ export function logIOInfoErrored(
const description = getIODescription(error);
const entryName = getIOShortName(ioInfo, description, ioInfo.env, rootEnv);
const debugTask = ioInfo.debugTask;
const measureName = '\u200b' + entryName;
if (__DEV__ && debugTask) {
const message =
typeof error === 'object' &&
@@ -550,9 +563,10 @@ export function logIOInfoErrored(
const properties = [['rejected with', message]];
const tooltipText =
getIOLongName(ioInfo, description, ioInfo.env, rootEnv) + ' Rejected';
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, '\u200b' + entryName, {
performance.measure.bind(performance, measureName, {
start: startTime < 0 ? 0 : startTime,
end: endTime,
detail: {
@@ -565,9 +579,10 @@ export function logIOInfoErrored(
},
}),
);
performance.clearMeasures(measureName);
} else {
console.timeStamp(
entryName,
measureName,
startTime < 0 ? 0 : startTime,
endTime,
IO_TRACK,
@@ -590,6 +605,7 @@ export function logIOInfo(
const entryName = getIOShortName(ioInfo, description, ioInfo.env, rootEnv);
const color = getIOColor(entryName);
const debugTask = ioInfo.debugTask;
const measureName = '\u200b' + entryName;
if (__DEV__ && debugTask) {
const properties: Array<[string, string]> = [];
if (typeof value === 'object' && value !== null) {
@@ -605,7 +621,7 @@ export function logIOInfo(
);
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, '\u200b' + entryName, {
performance.measure.bind(performance, measureName, {
start: startTime < 0 ? 0 : startTime,
end: endTime,
detail: {
@@ -618,9 +634,10 @@ export function logIOInfo(
},
}),
);
performance.clearMeasures(measureName);
} else {
console.timeStamp(
entryName,
measureName,
startTime < 0 ? 0 : startTime,
endTime,
IO_TRACK,

View File

@@ -1,6 +1,6 @@
{
"name": "react-devtools-core",
"version": "6.1.5",
"version": "7.0.0",
"description": "Use react-devtools outside of the browser",
"license": "MIT",
"main": "./dist/backend.js",

View File

@@ -2,8 +2,8 @@
"manifest_version": 3,
"name": "React Developer Tools",
"description": "Adds React debugging tools to the Chrome Developer Tools.",
"version": "6.1.5",
"version_name": "6.1.5",
"version": "7.0.0",
"version_name": "7.0.0",
"minimum_chrome_version": "114",
"icons": {
"16": "icons/16-production.png",

View File

@@ -2,8 +2,8 @@
"manifest_version": 3,
"name": "React Developer Tools",
"description": "Adds React debugging tools to the Microsoft Edge Developer Tools.",
"version": "6.1.5",
"version_name": "6.1.5",
"version": "7.0.0",
"version_name": "7.0.0",
"minimum_chrome_version": "114",
"icons": {
"16": "icons/16-production.png",

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "React Developer Tools",
"description": "Adds React debugging tools to the Firefox Developer Tools.",
"version": "6.1.5",
"version": "7.0.0",
"browser_specific_settings": {
"gecko": {
"id": "@react-devtools",

View File

@@ -1,6 +1,6 @@
{
"name": "react-devtools-inline",
"version": "6.1.5",
"version": "7.0.0",
"description": "Embed react-devtools within a website",
"license": "MIT",
"main": "./dist/backend.js",

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(`
@@ -3142,4 +3142,105 @@ describe('Store', () => {
await actAsync(() => render(null));
expect(store).toMatchInlineSnapshot(``);
});
// @reactVersion >= 19
it('should keep suspended boundaries in the Suspense tree but not hidden Activity', async () => {
const Activity = React.Activity || React.unstable_Activity;
const never = new Promise(() => {});
function Never() {
readValue(never);
return null;
}
function Component({children}) {
return <div>{children}</div>;
}
function App({hidden}) {
return (
<>
<Activity mode={hidden ? 'hidden' : 'visible'}>
<React.Suspense name="inside-activity">
<Component key="inside-activity">inside Activity</Component>
</React.Suspense>
</Activity>
<React.Suspense name="outer-suspense">
<React.Suspense name="inner-suspense">
<Component key="inside-suspense">inside Suspense</Component>
</React.Suspense>
{hidden ? <Never /> : null}
</React.Suspense>
</>
);
}
await actAsync(() => {
render(<App hidden={true} />);
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Activity>
<Suspense name="outer-suspense">
[suspense-root] rects={[{x:1,y:2,width:15,height:1}]}
<Suspense name="outer-suspense" rects={null}>
`);
// mount as visible
await actAsync(() => {
render(null);
});
await actAsync(() => {
render(<App hidden={false} />);
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Activity>
▾ <Suspense name="inside-activity">
<Component key="inside-activity">
▾ <Suspense name="outer-suspense">
▾ <Suspense name="inner-suspense">
<Component key="inside-suspense">
[suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
<Suspense name="inside-activity" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="outer-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="inner-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
`);
await actAsync(() => {
render(<App hidden={true} />);
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Activity>
<Suspense name="outer-suspense">
[suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
<Suspense name="outer-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="inner-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
`);
await actAsync(() => {
render(<App hidden={false} />);
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Activity>
▾ <Suspense name="inside-activity">
<Component key="inside-activity">
▾ <Suspense name="outer-suspense">
▾ <Suspense name="inner-suspense">
<Component key="inside-suspense">
[suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:15,height:1}]}
<Suspense name="inside-activity" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="outer-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
<Suspense name="inner-suspense" rects={[{x:1,y:2,width:15,height:1}]}>
`);
});
});

View File

@@ -251,7 +251,8 @@ describe('Store component filters', () => {
});
it('should filter ViewTransition', async () => {
const ViewTransition = React.unstable_ViewTransition;
const ViewTransition =
React.ViewTransition || React.unstable_ViewTransition;
if (ViewTransition != null) {
await actAsync(async () =>

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));
}
}
}
@@ -3144,12 +3176,30 @@ export function attach(
}
}
/**
* Offscreen of suspended Suspense
*/
function isSuspendedOffscreen(fiber: Fiber): boolean {
switch (fiber.tag) {
case LegacyHiddenComponent:
// fallthrough since all published implementations currently implement the same state as Offscreen.
case OffscreenComponent:
return (
fiber.memoizedState !== null &&
fiber.return !== null &&
fiber.return.tag === SuspenseComponent
);
default:
return false;
}
}
function unmountRemainingChildren() {
if (
reconcilingParent !== null &&
(reconcilingParent.kind === FIBER_INSTANCE ||
reconcilingParent.kind === FILTERED_FIBER_INSTANCE) &&
isHiddenOffscreen(reconcilingParent.data) &&
isSuspendedOffscreen(reconcilingParent.data) &&
!isInDisconnectedSubtree
) {
// This is a hidden offscreen, we need to execute this in the context of a disconnected subtree.
@@ -3244,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 ||
@@ -3267,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.
@@ -4026,7 +4091,7 @@ export function attach(
trackDebugInfoFromHostComponent(nearestInstance, fiber);
}
if (isHiddenOffscreen(fiber)) {
if (isSuspendedOffscreen(fiber)) {
// If an Offscreen component is hidden, mount its children as disconnected.
const stashedDisconnected = isInDisconnectedSubtree;
isInDisconnectedSubtree = true;
@@ -4037,6 +4102,9 @@ export function attach(
} finally {
isInDisconnectedSubtree = stashedDisconnected;
}
} else if (isHiddenOffscreen(fiber)) {
// hidden Activity is noisy.
// Including it may show overlapping Suspense rects
} else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) {
// Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the
// Offscreen wrapper itself specially.
@@ -4981,17 +5049,28 @@ export function attach(
const prevWasHidden = isHiddenOffscreen(prevFiber);
const nextIsHidden = isHiddenOffscreen(nextFiber);
const prevWasSuspended = isSuspendedOffscreen(prevFiber);
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()
@@ -5058,8 +5137,8 @@ export function attach(
);
updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
}
} else if (nextIsHidden) {
if (!prevWasHidden) {
} else if (nextIsSuspended) {
if (!prevWasSuspended) {
// We're hiding the children. Disconnect them from the front end but keep state.
if (fiberInstance !== null && !isInDisconnectedSubtree) {
disconnectChildrenRecursively(remainingReconcilingChildren);
@@ -5077,7 +5156,7 @@ export function attach(
} finally {
isInDisconnectedSubtree = stashedDisconnected;
}
} else if (prevWasHidden && !nextIsHidden) {
} else if (prevWasSuspended && !nextIsSuspended) {
// We're revealing the hidden children. We now need to update them to the latest state.
// We do this while still in the disconnected state and then we reconnect the new ones.
// This avoids reconnecting things that are about to be removed anyway.
@@ -5103,6 +5182,13 @@ export function attach(
// Children may have reordered while they were hidden.
updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren;
}
} else if (nextIsHidden) {
if (prevWasHidden) {
// still hidden. Nothing to do.
} else {
// We're hiding the children. Remove them from the Frontend
unmountRemainingChildren();
}
} else if (
nextFiber.tag === SuspenseComponent &&
OffscreenComponent !== -1 &&
@@ -5132,6 +5218,14 @@ export function attach(
);
}
if (
(prevFiber.memoizedState === null) !==
(nextFiber.memoizedState === null)
) {
// Toggle suspended state.
recordSuspenseSuspenders(suspenseNode);
}
shouldMeasureSuspenseNode = false;
updateFlags |= updateSuspenseChildrenRecursively(
nextContentFiber,
@@ -5158,6 +5252,8 @@ export function attach(
}
trackThrownPromisesFromRetryCache(suspenseNode, nextFiber.stateNode);
// Toggle suspended state.
recordSuspenseSuspenders(suspenseNode);
mountSuspenseChildrenRecursively(
nextContentFiber,
@@ -5259,7 +5355,7 @@ export function attach(
// We need to crawl the subtree for closest non-filtered Fibers
// so that we can display them in a flat children set.
if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) {
if (!nextIsHidden && !isInDisconnectedSubtree) {
if (!nextIsSuspended && !isInDisconnectedSubtree) {
recordResetChildren(fiberInstance);
}
@@ -5335,7 +5431,7 @@ export function attach(
if (
(child.kind === FIBER_INSTANCE ||
child.kind === FILTERED_FIBER_INSTANCE) &&
isHiddenOffscreen(child.data)
isSuspendedOffscreen(child.data)
) {
// This instance's children are already disconnected.
} else {
@@ -5697,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 || '';
}
@@ -5738,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>,
@@ -8534,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

@@ -51,6 +51,7 @@ import type {
ComponentFilter,
ElementType,
SuspenseNode,
Rect,
} from 'react-devtools-shared/src/frontend/types';
import type {
FrontendBridge,
@@ -99,6 +100,10 @@ export type Capabilities = {
supportsAdvancedProfiling: AdvancedProfiling,
};
function isNonZeroRect(rect: Rect) {
return rect.width > 0 || rect.height > 0 || rect.x > 0 || rect.y > 0;
}
/**
* The store is the single source of truth for updates from the backend.
* ContextProviders can subscribe to the Store for specific things they want to provide.
@@ -918,7 +923,15 @@ export default class Store extends EventEmitter<{
if (current === undefined) {
continue;
}
// Ignore any suspense boundaries that has no visual representation as this is not
// part of the visible loading sequence.
// TODO: Consider making visible meta data and other side-effects get virtual rects.
const hasRects =
current.rects !== null &&
current.rects.length > 0 &&
current.rects.some(isNonZeroRect);
if (
hasRects &&
(!uniqueSuspendersOnly || current.hasUniqueSuspenders) &&
// Roots are already included as part of the Screen
current.id !== rootID
@@ -1539,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)) {
@@ -1566,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;
}
@@ -1612,6 +1626,7 @@ export default class Store extends EventEmitter<{
name,
rects,
hasUniqueSuspenders: false,
isSuspended: isSuspended,
});
hasSuspenseTreeChanged = true;
@@ -1748,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});
@@ -1788,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 (
@@ -1819,6 +1835,7 @@ export default class Store extends EventEmitter<{
}
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
suspense.isSuspended = isSuspended;
// TODO: Recompute the environment names.
}

View File

@@ -181,9 +181,7 @@ export default function Element({data, index, style}: Props): React.Node {
className={styles.KeyValue}
title={key}
onDoubleClick={handleKeyDoubleClick}>
<pre>
<IndexableDisplayName displayName={key} id={id} />
</pre>
<IndexableDisplayName displayName={key} id={id} />
</span>
"
</Fragment>
@@ -196,9 +194,7 @@ export default function Element({data, index, style}: Props): React.Node {
className={styles.KeyValue}
title={nameProp}
onDoubleClick={handleKeyDoubleClick}>
<pre>
<IndexableDisplayName displayName={nameProp} id={id} />
</pre>
<IndexableDisplayName displayName={nameProp} id={id} />
</span>
"
</Fragment>

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

@@ -194,7 +194,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
}
let strictModeBadge = null;
if (element.isStrictModeNonCompliant) {
if (element.isStrictModeNonCompliant && element.parentID !== 0) {
strictModeBadge = (
<Tooltip label="This component is not running in StrictMode. Click to learn more.">
<a
@@ -237,7 +237,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
<div className={styles.SelectedComponentName}>
<div
className={
element.isStrictModeNonCompliant
element.isStrictModeNonCompliant && element.parentID !== 0
? `${styles.ComponentName} ${styles.StrictModeNonCompliantComponentName}`
: styles.ComponentName
}

View File

@@ -13,7 +13,7 @@ import {useState, useTransition} from 'react';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import KeyValue from './KeyValue';
import {serializeDataForCopy} from '../utils';
import {serializeDataForCopy, pluralize} from '../utils';
import Store from '../../store';
import styles from './InspectedElementSharedStyles.css';
import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
@@ -44,6 +44,7 @@ type RowProps = {
index: number,
minTime: number,
maxTime: number,
skipName?: boolean,
};
function getShortDescription(name: string, description: string): string {
@@ -99,6 +100,7 @@ function SuspendedByRow({
index,
minTime,
maxTime,
skipName,
}: RowProps) {
const [isOpen, setIsOpen] = useState(false);
const [openIsPending, startOpenTransition] = useTransition();
@@ -166,8 +168,10 @@ function SuspendedByRow({
className={styles.CollapsableHeaderIcon}
type={isOpen ? 'expanded' : 'collapsed'}
/>
<span className={styles.CollapsableHeaderTitle}>{name}</span>
{shortDescription === '' ? null : (
<span className={styles.CollapsableHeaderTitle}>
{skipName ? shortDescription : name}
</span>
{skipName || shortDescription === '' ? null : (
<>
<span className={styles.CollapsableHeaderSeparator}>{' ('}</span>
<span className={styles.CollapsableHeaderTitle}>
@@ -300,15 +304,141 @@ type Props = {
store: Store,
};
function compareTime(a: SerializedAsyncInfo, b: SerializedAsyncInfo): number {
const ioA = a.awaited;
const ioB = b.awaited;
function withIndex(
value: SerializedAsyncInfo,
index: number,
): {
index: number,
value: SerializedAsyncInfo,
} {
return {
index,
value,
};
}
function compareTime(
a: {
index: number,
value: SerializedAsyncInfo,
},
b: {
index: number,
value: SerializedAsyncInfo,
},
): number {
const ioA = a.value.awaited;
const ioB = b.value.awaited;
if (ioA.start === ioB.start) {
return ioA.end - ioB.end;
}
return ioA.start - ioB.start;
}
type GroupProps = {
bridge: FrontendBridge,
element: Element,
inspectedElement: InspectedElement,
store: Store,
name: string,
suspendedBy: Array<{
index: number,
value: SerializedAsyncInfo,
}>,
minTime: number,
maxTime: number,
};
function SuspendedByGroup({
bridge,
element,
inspectedElement,
store,
name,
suspendedBy,
minTime,
maxTime,
}: GroupProps) {
const [isOpen, setIsOpen] = useState(false);
let start = Infinity;
let end = -Infinity;
let isRejected = false;
for (let i = 0; i < suspendedBy.length; i++) {
const asyncInfo: SerializedAsyncInfo = suspendedBy[i].value;
const ioInfo = asyncInfo.awaited;
if (ioInfo.start < start) {
start = ioInfo.start;
}
if (ioInfo.end > end) {
end = ioInfo.end;
}
const value: any = ioInfo.value;
if (
value !== null &&
typeof value === 'object' &&
value[meta.name] === 'rejected Thenable'
) {
isRejected = true;
}
}
const timeScale = 100 / (maxTime - minTime);
let left = (start - minTime) * timeScale;
let width = (end - start) * timeScale;
if (width < 5) {
// Use at least a 5% width to avoid showing too small indicators.
width = 5;
if (left > 95) {
left = 95;
}
}
const pluralizedName = pluralize(name);
return (
<div className={styles.CollapsableRow}>
<Button
className={styles.CollapsableHeader}
onClick={() => {
setIsOpen(prevIsOpen => !prevIsOpen);
}}
title={pluralizedName}>
<ButtonIcon
className={styles.CollapsableHeaderIcon}
type={isOpen ? 'expanded' : 'collapsed'}
/>
<span className={styles.CollapsableHeaderTitle}>{pluralizedName}</span>
<div className={styles.CollapsableHeaderFiller} />
{isOpen ? null : (
<div className={styles.TimeBarContainer}>
<div
className={
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
}
style={{
left: left.toFixed(2) + '%',
width: width.toFixed(2) + '%',
}}
/>
</div>
)}
</Button>
{isOpen &&
suspendedBy.map(({value, index}) => (
<SuspendedByRow
key={index}
index={index}
asyncInfo={value}
bridge={bridge}
element={element}
inspectedElement={inspectedElement}
store={store}
minTime={minTime}
maxTime={maxTime}
skipName={true}
/>
))}
</div>
);
}
export default function InspectedElementSuspendedBy({
bridge,
element,
@@ -364,9 +494,31 @@ export default function InspectedElementSuspendedBy({
minTime = maxTime - 25;
}
const sortedSuspendedBy = suspendedBy === null ? [] : suspendedBy.slice(0);
const sortedSuspendedBy =
suspendedBy === null ? [] : suspendedBy.map(withIndex);
sortedSuspendedBy.sort(compareTime);
// Organize into groups of consecutive entries with the same name.
const groups = [];
let currentGroup = null;
let currentGroupName = null;
for (let i = 0; i < sortedSuspendedBy.length; i++) {
const entry = sortedSuspendedBy[i];
const name = entry.value.awaited.name;
if (
currentGroupName !== name ||
!name ||
name === 'Promise' ||
currentGroup === null
) {
// Create a new group.
currentGroupName = name;
currentGroup = [];
groups.push(currentGroup);
}
currentGroup.push(entry);
}
let unknownSuspenders = null;
switch (inspectedElement.unknownSuspenders) {
case UNKNOWN_SUSPENDERS_REASON_PRODUCTION:
@@ -407,19 +559,48 @@ export default function InspectedElementSuspendedBy({
<ButtonIcon type="copy" />
</Button>
</div>
{sortedSuspendedBy.map((asyncInfo, index) => (
<SuspendedByRow
key={index}
index={index}
asyncInfo={asyncInfo}
bridge={bridge}
element={element}
inspectedElement={inspectedElement}
store={store}
minTime={minTime}
maxTime={maxTime}
/>
))}
{groups.length === 1
? // If it's only one type of suspender we can flatten it.
groups[0].map(entry => (
<SuspendedByRow
key={entry.index}
index={entry.index}
asyncInfo={entry.value}
bridge={bridge}
element={element}
inspectedElement={inspectedElement}
store={store}
minTime={minTime}
maxTime={maxTime}
/>
))
: groups.map((entries, index) =>
entries.length === 1 ? (
<SuspendedByRow
key={entries[0].index}
index={entries[0].index}
asyncInfo={entries[0].value}
bridge={bridge}
element={element}
inspectedElement={inspectedElement}
store={store}
minTime={minTime}
maxTime={maxTime}
/>
) : (
<SuspendedByGroup
key={entries[0].index}
name={entries[0].value.awaited.name}
suspendedBy={entries}
bridge={bridge}
element={element}
inspectedElement={inspectedElement}
store={store}
minTime={minTime}
maxTime={maxTime}
/>
),
)}
{unknownSuspenders}
</div>
);

View File

@@ -2,13 +2,17 @@
padding: 0.25rem;
}
.CallSite, .IgnoredCallSite {
.CallSite {
display: block;
padding-left: 1rem;
}
.IgnoredCallSite {
opacity: 0.5;
.IgnoredCallSite, .BuiltInCallSite {
display: none;
}
.CallSite + .BuiltInCallSite {
display: block;
}
.Link {

View File

@@ -60,16 +60,22 @@ export function CallSiteView({
symbolicatedCallSite !== null ? symbolicatedCallSite.location : callSite;
const ignored =
symbolicatedCallSite !== null ? symbolicatedCallSite.ignored : false;
if (ignored) {
// TODO: Make an option to be able to toggle the display of ignore listed rows.
// Ideally this UI should be higher than a single Stack Trace so that there's not
// multiple buttons in a single inspection taking up space.
return null;
}
// TODO: Make an option to be able to toggle the display of ignore listed rows.
// Ideally this UI should be higher than a single Stack Trace so that there's not
// multiple buttons in a single inspection taking up space.
const isBuiltIn = url === '' || url.startsWith('<anonymous>'); // This looks like a fake anonymous through eval.
return (
<div className={ignored ? styles.IgnoredCallSite : styles.CallSite}>
<div
className={
ignored
? styles.IgnoredCallSite
: isBuiltIn
? styles.BuiltInCallSite
: styles.CallSite
}>
{functionName || virtualFunctionName}
{url !== '' && (
{!isBuiltIn && (
<>
{' @ '}
<span

View File

@@ -361,15 +361,17 @@ export default function DevTools({
}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'suspense'}>
<SuspenseTab
portalContainer={
suspensePortalContainer
}
/>
</div>
{enableSuspenseTab && (
<div
className={styles.TabContent}
hidden={tab !== 'suspense'}>
<SuspenseTab
portalContainer={
suspensePortalContainer
}
/>
</div>
)}
</div>
{editorPortalContainer ? (
<EditorPane

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

@@ -64,8 +64,9 @@ export default function SearchInput({
const handleKeyDown = (event: KeyboardEvent) => {
const {key, metaKey} = event;
if (key === 'f' && metaKey) {
if (inputRef.current !== null) {
inputRef.current.focus();
const inputElement = inputRef.current;
if (inputElement !== null) {
inputElement.focus();
event.preventDefault();
event.stopPropagation();
}
@@ -75,10 +76,14 @@ export default function SearchInput({
// It's important to listen to the ownerDocument to support the browser extension.
// Here we use portals to render individual tabs (e.g. Profiler),
// and the root document might belong to a different window.
const ownerDocument = inputRef.current.ownerDocument;
ownerDocument.addEventListener('keydown', handleKeyDown);
const ownerDocumentElement = inputRef.current.ownerDocument.documentElement;
if (ownerDocumentElement === null) {
return;
}
ownerDocumentElement.addEventListener('keydown', handleKeyDown);
return () => ownerDocument.removeEventListener('keydown', handleKeyDown);
return () =>
ownerDocumentElement.removeEventListener('keydown', handleKeyDown);
}, []);
return (

Some files were not shown because too many files have changed in this diff Show More