Compare commits

..

60 Commits

Author SHA1 Message Date
Jorge Cabiedes Acosta
cd1d0dc2dc [Compiler] Don't count a setState in the dependency array of the effect it is called on as a usage
Summary:
The validation only allows setState declaration as a usage outside of the effect.

Another edge case is that if you add the setState being validated in the dependency array you also make the validation opt out since it counts as a usage outside of the effect.

Added a bit of logic to consider the effect's deps when creating the cache for setState usages within the effect

Test Plan:
Added a fixture
2025-11-13 16:03:11 -08:00
Hendrik Liebau
93fc57400b [Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})`
instances passed through React Server Components (RSC) would stall after
reading only the first chunk or the first few chunks in the client. This
issue was masked by using `web-streams-polyfill` in tests, but manifests
with native Web Streams implementations.

The root cause is that when a chunk is enqueued to a
`ReadableByteStreamController`, the spec requires the underlying
ArrayBuffer to be synchronously transferred/detached. In the React
Flight Client's chunk parsing, embedded byte stream chunks are created
as views into the incoming RSC stream chunk buffer using `new
Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream
chunks are enqueued, they can detach the shared buffer, leaving the RSC
stream parsing in a broken state.

The fix is to copy embedded byte stream chunks before enqueueing them,
preventing buffer detachment from affecting subsequent parsing. To not
affect performance too much, we use a zero-copy optimization: when a
chunk ends exactly at the end of the RSC stream chunk, or when the row
spans into the next RSC chunk, no further parsing will access that
buffer, so we can safely enqueue the view directly without copying.

We now also enqueue embedded byte stream chunks immediately as they are
parsed, without waiting for the full row to complete.

To simplify the logic in the client, we introduce a new `'b'` protocol
tag specifically for byte stream chunks. The server now emits `'b'`
instead of `'o'` for `Uint8Array` chunks from byte streams (detected via
`supportsBYOB`). This allows the client to recognize byte stream chunks
without needing to track stream IDs.

Tests now use the proper Jest environment with native Web Streams
instead of polyfills, exposing and validating the fix for this issue.
2025-11-13 21:23:02 +01:00
Sebastian "Sebbie" Silbermann
093b3246e1 [react-dom] Batch updates from resize until next frame (#35117) 2025-11-13 13:30:21 +01:00
Nathan
3a495ae722 [compiler] source location validator (#35109)
@josephsavona this was briefly discussed in an old thread, lmk your
thoughts on the approach. I have some fixes ready as well but wanted to
get this test case in first... there's some things I don't _love_ about
this approach, but end of the day it's just a tool for the test suite
rather than something for end user folks so even if it does a 70% good
enough job that's fine.

### refresher on the problem
when we generate coverage reports with jest (istanbul), our coverage
ends up completely out of whack due to the AST missing a ton of (let's
call them "important") source locations after the compiler pipeline has
run.

At the moment to get around this, we've been doing something a bit
unorthodox and also running our test suite with istanbul running before
the compiler -- which results in its own set of issues (for eg, things
being memoized differently, or the compiler completely bailing out on
the instrumented code, etc).

before getting in fixes, I wanted to set up a test case to start
chipping away on as you had recommended.

### how it works

The validator basically:
1. Traverses the original AST and collects the source locations for some
"important" node types
- (excludes useMemo/useCallback calls, as those are stripped out by the
compiler)
3. Traverses the generated AST and looks for nodes with matching source
locations.
4. Generates errors for source locations missing nodes in the generated
AST

### caveats/drawbacks

There are some things that don't work super well with this approach. A
more natural test fit I think would be just having some explicit
assertions made against an AST in a test file, as you can just bake all
of the assumptions/nuance in there that are difficult to handle in a
generic manner. However, this is maybe "good enough" for now.

1. Have to be careful what you put into the test fixture. If you put in
some code that the compiler just removes (for eg, a variable assignment
that is unused), you're creating a failure case that's impossible to
fix. I added a skip for useMemo/useCallback.
2. "Important" locations must exactly match for validation to pass.
- Might get tricky making sure things are mapped correctly when a node
type is completely changed, for eg, when a block statement arrow
function body gets turned into an implicit return via the body just
being an expression/identifier.
- This can/could result in scenarios where more changes are needed to
shuttle the locations through due to HIR not having a 1:1 mapping all
the babel nuances, even if some combination of other data might be good
enough even if not 10000% accurate. This might be the _right_ thing
anyways so we don't end up with edge cases having incorrect source
locations.
2025-11-12 19:02:46 -08:00
Ricky
bbe3f4d322 [flags] disableLegacyMode in native-fb (#35120)
this is failing test too because we need the legacy mode in the react
package until we fix the tests
2025-11-12 15:38:58 -05:00
Sebastian "Sebbie" Silbermann
1ea46df8ba [DevTools] Batch updates when updating component filters (#35093) 2025-11-11 23:20:22 +01:00
Sebastian "Sebbie" Silbermann
8c15edd57c [DevTools] Send root unmount as a regular removal operation (#35107) 2025-11-11 23:08:54 +01:00
Jorge Cabiedes
5e94655cbb [compiler] _exp version of ValidateNoDerivedComputationsInEffects take precedence over stable version when enabled (#35099)
Summary:
We should only run one version of the validation. I think it makes sense
that if the exp version is enable it takes precedence over the stable
one

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35099).
* __->__ #35099
* #35100
2025-11-11 10:16:20 -08:00
Jorge Cabiedes
db8273c12f [compiler] Update test snap to include fixture comment (#35100)
Summary:
I missed this test case failing and now having @loggerTestOnly after
landing some other PRs good to know they're not land blocking

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35100).
* #35099
* __->__ #35100
2025-11-11 10:16:04 -08:00
Ricky
04ee54cd12 [tests] add more portal activity tests (#35095)
I copied some tests from
[`Activity-test.js`](1d68bce19c/packages/react-reconciler/src/__tests__/Activity-test.js)
and made them portal specific just to confirm my understanding of how
Portals + Activity interact is correct. Seems good to include them.
2025-11-11 12:47:56 -05:00
Jorge Cabiedes
100fc4a8cf [compiler] Prevent local state source variables from depending on other state (#35044)
Summary:
When a local state is created sometimes it uses a `prop` or even other
local state for its initial value.

This value is only relevant on first render so we shouldn't consider it
part of our data flow

Test Plan:
Added tests
2025-11-10 12:29:34 -08:00
Jorge Cabiedes
92ac4e8b80 [compiler] Don't validate when effect cleanup function depends on effect localized setState state derived values (#35020)
Summary:
If we are using a clean up function in an effect and that clean up
function depends on a value that is used to set the state we are
validating for we shouldn't throw an error since it is a valid use case
for an effect.

Test Plan:
added test

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35020).
* #35044
* __->__ #35020
2025-11-10 12:28:19 -08:00
Jorge Cabiedes
f76c3617e0 [compiler] Switch to track setStates by aliasing and id instead of identifier names (#34973)
Summary:
This makes the setState usage logic much more robust. We no longer rely
on identifierName.

Now we track when a setState is loaded into a new promoted identifier
variable and track this in a map `setStateLoaded` map.

For other types of instructions we consider the setState to be being
used. In this case we record its usage into the `setStateUsages` map.



Test Plan:
We expect no changes in behavior for the current tests

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34973).
* #35044
* #35020
* __->__ #34973
* #34972
2025-11-10 12:16:27 -08:00
Jorge Cabiedes
7296120396 [compiler] Update ValidateNoDerivedComputationsInEffects_exp to log the error instead of throwing (#34972)
Summary:
TSIA

Simple change to log errors in Pipeline.ts instead of throwing in the
validation

Test Plan:
updated snap tests

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34972).
* #35044
* #35020
* #34973
* __->__ #34972
2025-11-10 12:16:13 -08:00
Jorge Cabiedes
6347c6d373 [compiler] Fix false negatives and add data flow tree to compiler error for no-deriving-state-in-effects (#34995)
Summary:
Revamped the derivationCache graph.

This fixes a bunch of bugs where sometimes we fail to track from which
props/state we derived values from.

Also, it is more intuitive and allows us to easily implement a Data Flow
Tree.

We can print this tree which gives insight on how the data is derived
and should facilitate error resolution in complicated components

Test Plan:
Added a test case where we were failing to track derivations. Also
updated the test cases with the new error containing the data flow tree

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34995).
* #35044
* #35020
* #34973
* #34972
* __->__ #34995
* #34967
2025-11-10 12:09:13 -08:00
Jorge Cabiedes
01fb328632 [compiler] Prevent overriding a derivationEntry on effect mutation and instead update typeOfValue and fix infinite loops (#34967)
Summary:
With this we are now comparing a snapshot of the derivationCache with
the new changes every time we are done recording the derivations
happening in the HIR.

We have to do this after recording everything since we still do some
mutations on the cache when recording mutations.



Test Plan:
Test the following in playground:
```
// @validateNoDerivedComputationsInEffects_exp

function Component({ value }) {
  const [checked, setChecked] = useState('');

  useEffect(() => {
    setChecked(value === '' ? [] : value.split(','));
  }, [value]);

  return (
    <div>{checked}</div>
  )
}
```

This no longer causes an infinite loop.

Added a test case in the next PR in the stack

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34967).
* #35044
* #35020
* #34973
* #34972
* #34995
* __->__ #34967
2025-11-10 12:08:05 -08:00
Sebastian "Sebbie" Silbermann
ce4054ebdd [DevTools] Measure when reconnecting Suspense (#35098) 2025-11-10 20:55:31 +01:00
Sebastian "Sebbie" Silbermann
21c1d51acb [DevTools] Don't attempt to draw bounding box if inspected element is not a Suspense (#35097) 2025-11-10 20:01:59 +01:00
Facebook Community Bot
be48396dbd Remove Dead Code in WWW JS
Differential Revision: D86593830

Pull Request resolved: https://github.com/facebook/react/pull/35085
2025-11-10 16:34:01 +00:00
Andrew Clark
5268492536 Fix: Activity should hide portal contents (#35091)
This PR updates the behavior of Activity so that when it is hidden, it
hides the contents of any portals contained within it.

Previously we had intentionally chosen not to implement this behavior,
because it was thought that this concern should be left to the userspace
code that manages the portal, e.g. by adding or removing the portal
container from the DOM. Depending on the use case for the portal, this
is often desirable anyway because the portal container itself is not
controlled by React.

However, React does own the _contents_ of the portal, and we can hide
those elements regardless of what the user chooses to do with the
container. This makes the hiding/unhiding behavior of portals with
Activity automatic in the majority of cases, and also benefits from
aligning the DOM mutations with the rest of the React's commit phase
lifecycle.

The reason we have to special case this at all is because usually we
only hide the direct DOM children of the Activity boundary. There's no
reason to go deeper than that, because hiding a parent DOM element
effectively hides everything inside of it. Portals are the exception,
because they don't exist in the normal DOM hierarchy; we can't assume
that just because a portal has a parent in the React tree that it will
also have that parent in the actual DOM.

So, whenever an Activity boundary is hidden, we must search for and hide
_any_ portal that is contained within it, and recursively hide its
direct children, too.

To optimize this search, we use a new subtree flag, PortalStatic, that
is set only on fiber paths that contain a HostPortal. This lets us skip
over any subtree that does not contain a portal.
2025-11-10 10:42:26 -05:00
Sebastian Markbåge
c83be7da9f [Fizz] Simplify createSuspenseBoundary path (#35087)
Small follow up to #35068.

Since this is now a single argument we can simplify the creation
branching a bit and make sure it's const.
2025-11-09 15:19:43 -05:00
Sebastian Markbåge
6362b5c711 [DevTools] Special case the selected root outline (#35071)
When I moved the outline to above all other rects, I thought it was
clever to unify with the root so that the outline was also used for the
root selection. But the root outline is not drawn like the other rects.
It's outside the padding and doesn't have the 1px adjustment which leads
the overlay to be slightly inside the other rect instead of above it.

This goes back to just having the selected root be drawn by the root
element.

Before:

<img width="652" height="253" alt="Screenshot 2025-11-07 at 11 39 28 AM"
src="https://github.com/user-attachments/assets/334237d1-f190-4995-94cc-9690ec0f7ce1"
/>

After:

<img width="674" height="220" alt="Screenshot 2025-11-07 at 11 44 01 AM"
src="https://github.com/user-attachments/assets/afaa86d8-942a-44d8-a1a5-67c7fb642c0d"
/>
2025-11-09 15:03:31 -05:00
Sebastian "Sebbie" Silbermann
5a9921b839 [DevTools] Apply Activity slice filter when double clicking Activity (#34908) 2025-11-08 18:09:44 +01:00
Andrew Clark
717e70843e Fix: Errors should not escape a hidden Activity (#35074)
If an error is thrown inside a hidden Activity, it should not escape
into the visible part of the UI. Conceptually, a hidden Activity
boundary is not part of the current UI; it's the same as an unmounted
tree, except for the fact that the state will be restored if it's later
revealed.

Fixes:
- https://github.com/facebook/react/issues/35073
2025-11-07 18:18:24 -05:00
Stian Jensen
a10ff9c857 Upgrade devtools dependency update-notifier to 5.x (#31655)
## Summary

This PR upgrades the dependency on update-notifier, used in
react-devtools, to 5.x
This is the latest non-ESM version, so upgrading to it should be
unproblematic (while updating to 6.x and beyond will have to wait).

Upgrading means we avoid installing a lot of outdated dependencies (as
can be seen from the diff in yarn.lock), and resolves part of
https://github.com/facebook/react/issues/28058

Changelog:
https://github.com/yeoman/update-notifier/releases

The most relevant breaking change seems to be that the minimum support
node version is increased from v6 to v10, but I couldn't find what is
currently React's official node version support.

## How did you test this change?

I ran the test-suite locally (`yarn test` in root folder), but I'm not
sure if that one actually covers devtools?

I also built and tested this version of devtools with some internal
company projects (both react and react-native based) – following
guidelines from
https://github.com/facebook/react/issues/28058#issuecomment-1943619292.
2025-11-07 18:43:42 +00:00
Sebastian Markbåge
fa50caf5f8 [Fizz] Unify preamble only fields to save a field (#35068)
Stacked on #35067.

Same idea of saving a field on the SuspenseBoundary in the common case.
The case where they can have a preamble is rare.
2025-11-07 09:19:19 -05:00
Sebastian Markbåge
1e986f514f [Fizz] Unify prerender only fields to save a field (#35067)
I need to regain a field because the SuspenseBoundary type is already at
16 fields in prod, after which it deopts v8.

There are two fields that are only used in prerender to track postpones.
These are ripe to be split into an optional object so that they only
take up one field when they're not used.
2025-11-07 09:18:28 -05:00
Sebastian Markbåge
38bdda1ca6 Don't skip content in visible offscreen trees for Gesture View Transitions (#35066)
Follow up to #35063.

I forgot there's another variant of this in the ApplyGesture path.
2025-11-06 20:59:08 -05:00
Jack Pope
a44e750e87 Store instance handles in an internal map behind flag (#35053)
We already append `randomKey` to each handle name to prevent external
libraries from accessing and relying on these internals. But more
libraries recently have been getting around this by simply iterating
over the element properties and using a `startsWith` check.

This flag allows us to experiment with moving these handles to an
internal map.

This PR starts with the two most common internals, the props object and
the fiber. We can consider moving additional properties such as the
container root and others depending on perf results.
2025-11-06 18:17:53 -05:00
Sebastian Markbåge
37b089a59c Don't skip content in visible offscreen trees for View Transitions (#35063)
Also, don't not skip hidden trees.

Memoized state is null when an Offscreen boundary (Suspense or Activity)
is visible.

This logic was inversed in a couple of View Transition checks which
caused pairs to be discovered or not discovered incorrectly for
insertion and deletion of Suspense or Activity boundaries.
2025-11-06 16:03:02 -05:00
Sebastian Markbåge
1a31a814f1 Escape View Transition Name Strings as base64 (#35060)
This is an alternative to #35059.

If the name needs escaping, then instead of escaping it, we just use a
base64 name. This wouldn't allow you to match on an escaped name in your
own CSS like you should be able to if browsers worked properly. But at
least it would provide matching name in current browsers which is
probably sufficient if you're using auto-generated names.

This also covers some cases where `CSS.escape()` isn't sufficient anyway
like when the name ends in a dot.
2025-11-06 16:02:06 -05:00
Alexander Kachkaev
5a2205ba28 Update bug report template for eslint plugin label (#34959)
## Summary

When creating https://github.com/facebook/react/issues/34957, I noticed
a reference to `eslint-plugin-react-compiler` instead of
`eslint-plugin-react-hooks`. Since the former is merged into the latter
(https://github.com/facebook/react/pull/32416,
https://github.com/facebook/react/pull/34228), I have decided to update
the issue template to avoid confusion.
2025-11-05 16:57:26 -05:00
Sebastian Markbåge
fa767dade6 Remove unstable_expectedLoadTime option (#35051)
Follow up to #35022.

It's now replaced by the `defer` option.

Sounds like nobody is actually using this option, including Meta, so we
can just delete it.
2025-11-05 15:52:21 -05:00
Sebastian Markbåge
0ba2f01f74 Rename <Suspense unstable_expectedLoadTime> to <Suspense defer> and implement in SSR (#35022)
We've long had the CPU suspense feature behind a flag under the terrible
API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a
long time we want it to just be `defer={true}` (or just `<Suspense
defer>` in the short hand syntax). So this adds the new name and warns
for the old name.

For only the new name, I also implemented SSR semantics in Fizz. It has
two effects here.
1) It renders the fallback before the content (similar to prerender)
allowing siblings to complete quicker.
2) It always outlines the result. When streaming this should really
happen naturally but if you defer a prerendered content it also implies
that it's expensive and should be outlined. It gives you a opt-in to
outlining similar to suspensey images and css but let you control it
manually.
2025-11-05 14:12:09 -05:00
Sebastian Markbåge
dd048c3b2d Clean up enablePostpone Experiment (#35048)
We're not shipping this and it's a lot of code to maintain that is
blocking my refactor of Fizz for SuspenseList.
2025-11-05 00:05:59 -05:00
Sebastian Markbåge
c308cb5905 Disable enablePostpone flag in experimental (#31042)
I don't think we're ready to land this yet since we're using it to run
other experiments and our tests. I'm opening this PR to indicate intent
to disable and to ensure tests in other combinations still work. Such as
enableHalt without enablePostpone. I think we'll also need to rewrite
some tests that depend on enablePostpone to preserve some coverage.

The conclusion after this experiment is that try/catch around these are
too likely to block these signals and consider them error. Throwing
works for Hooks and `use()` because the lint rule can ensure that
they're not wrapped in try/catch. Throwing in arbitrary functions not
quite ecosystem compatible. It's also why there's `use()` and not just
throwing a Promise. This might also affect the Catch proposal.

The "prerender" for SSR that's supporting "Partial Prerendering" is
still there. This just disables the `React.postpone()` API for creating
the holes.
2025-11-04 23:23:25 -05:00
Sebastian Markbåge
986323f8c6 [Fiber] SuspenseList with "hidden" tail row should "catch" suspense (#35042)
Normally if you suspend in a SuspenseList row above a Suspense boundary
in that row, it'll suspend the parent. Which can itself delay the commit
or resuspend a parent boundary. That's because SuspenseList mostly just
coordinates the state of the inner boundaries and isn't a boundary
itself.

However, for tail "hidden" and "collapsed" this is not quite the case
because the rows themselves can avoid being rendered.

In the case of "collapsed" we require at least one Suspense boundary
above to have successfully rendered before committing the list because
the idea of this mode is that you should at least always show some
indicator that things are still loading. Since we'd never try the next
one after that at all, this just works. Expect there was an unrelated
bug that meant that "suspend with delay" on a Retry didn't suspend the
commit. This caused a scenario were it'd allow a commit proceed when it
shouldn't. So I fixed that too. The counter intuitive thing here is that
we won't actually show a previous completed row if the loading state of
the next row is still loading.

For tail "hidden" it's a little different because we don't actually
require any loading indicator at all to be shown while it's loading. If
we attempt a row and it suspends, we can just hide it (and the rest) and
move to commit. Therefore this implements a path where if all the rest
of the tail are new mounts (we wouldn't be required to unmount any
existing boundaries) then we can treat the SuspenseList boundary itself
as "catching" the suspense. This is more coherent semantics since any
future row that we didn't attempt also wouldn't resuspend the parent.

This allows simple cases like `<SuspenseList>{list}</SuspenseList>` to
stream in each row without any indicator and no need for Suspense
boundaries.
2025-11-04 22:11:33 -05:00
Jordan Brown
8f8b336734 [eslint] Fix useEffectEvent checks in component syntax (#35041)
We were not recording uEE calls in component/hook syntax. Easy fix.

Added tests matching function component syntax for component syntax +
added one for hooks
2025-11-04 14:59:29 -05:00
Alex Hunt
d000261eef [Tracks] Annotate devtools.performanceIssue for Cascading Updates in DEV (#34961) 2025-11-04 17:07:31 +00:00
Hendrik Liebau
f646e8ffd8 [Flight] Fix hasReadable flag in Node.js clients' debug channel (#35039)
For Edge Flight servers, that use Web Streams, we're defining the
`debugChannel` option as:

```
debugChannel?: {readable?: ReadableStream, writable?: WritableStream, ...}
```

Whereas for Node.js Flight servers, that use Node.js Streams, we're
defining it as:

```
debugChannel?: Readable | Writable | Duplex | WebSocket
```

For the Edge Flight clients, there is currently only one direction of
the debug channel supported, so we define the option as:

```
debugChannel?: {readable?: ReadableStream, ...}
```

Consequently, for the Node.js Flight clients, we define the option as:

```
debugChannel?: Readable
```

The presence of a readable debug channel is passed to the Flight client
internally via the `hasReadable` flag on the internal `debugChannel`
option. For the Node.js clients, that flag was accidentally derived from
the public option `debugChannel.readable`, which is conceptually
incorrect, because `debugChannel` is a `Readable` stream, not an options
object with a `readable` property. However, a `Readable` also has a
`readable` property, which is a boolean that indicates whether the
stream is in a readable state. This meant that the `hasReadable` flag
was incidentally still set correctly. Regardless, this was confusing and
unintentional, so we're now fixing it to always set `hasReadable` to
`true` when a `debugChannel` is provided to the Node.js clients. We'll
revisit this in case we ever add support for writable debug channels in
Node.js (and Edge) clients.
2025-11-04 16:30:08 +01:00
Jack Pope
edd05f181b Add fragment handles to children of FragmentInstances (#34935)
This PR adds a `unstable_reactFragments?: Set<FragmentInstance>`
property to DOM nodes that belong to a Fragment with a ref (top level
host components). This allows you to access a FragmentInstance from a
DOM node.

This is flagged behind `enableFragmentRefsInstanceHandles`.

The primary use case to unblock is reusing IntersectionObserver
instances. A fairly common practice is to cache and reuse
IntersectionObservers that share the same config, with a map of
node->callbacks to run for each entry in the IO callback. Currently this
is not possible with Fragment Ref `observeUsing` because the key in the
cache would have to be the `FragmentInstance` and you can't find it
without a handle from the node. This works now by accessing
`entry.target.fragments`.

This also opens up possibilities to use `FragmentInstance` operations in
other places, such as events. We can do
`event.target.unstable_reactFragments`, then access
`fragmentInstance.getClientRects` for example. In a future PR, we can
assign an event's `currentTarget` as the Fragment Ref for a more direct
handle when the event has been dispatched by the Fragment itself.

The first commit here implemented a handle only on observed elements.
This is awkward because there isn't a good way to document or expose
this temporary property. `element.fragments` is closer to what we would
expect from a DOM API if a standard was implemented here. And by
assigning it to all top-level nodes of a Fragment, it can be used beyond
the cached IntersectionObserver callback.

One tradeoff here is adding extra work during the creation of
FragmentInstances as well as keeping track of adding/removing nodes.
Previously we only track the Fiber on creation but here we add a
traversal which could apply to a large set of top-level host children.
The `element.unstable_reactFragments` Set can also be randomly ordered.
2025-11-03 17:51:00 -05:00
Hendrik Liebau
67f7d47a9b [Flight] Fix debug info filtering to include later resolved I/O (#35036)
In #35019, we excluded debug I/O info from being considered for
enhancing the owner stack if it resolved after the defined `endTime`
option that can be passed to the Flight client. However, we should
include any I/O that was awaited before that end time, even if it
resolved later.
2025-11-03 22:59:40 +01:00
Hendrik Liebau
561ee24d4a [Fizz] Push halted await to the owner stack for late-arriving I/O info (#35019) 2025-11-01 16:03:09 +01:00
Sebastian Markbåge
488d88b018 Render children passed to "backwards" SuspenseList in reverse mount order (#35021)
Stacked on #35018.

This mounts the children of SuspenseList backwards. Meaning the first
child is mounted last in the DOM (and effect list). It's like calling
reverse() on the children.

This is meant to set us up for allowing AsyncIterable children where the
unknown number of children streams in at the end (which is the beginning
in a backwards SuspenseList). For consistency we do that with other
children too.

`unstable_legacy-backwards` still exists for the old mode but is meant
to be deprecated.

<img width="100" alt="image"
src="https://github.com/user-attachments/assets/5c2a95d7-34c4-4a4e-b602-3646a834d779"
/>
2025-10-31 13:33:23 -04:00
Sebastian Markbåge
26cf280480 Switch the default revealOrder to "forwards" and tail "hidden" on SuspenseList (#35018)
We have warned about this for a while now so we can make the switch.

Often when you reach for SuspenseList, you mean forwards. It doesn't
make sense to have the default to just be a noop. While "together" is
another useful mode that's more like a Group so isn't so associated with
the default as List. So we're switching it.

However, tail=hidden isn't as obvious of a default it does allow for a
convenient pattern for streaming in list of items by default.

This doesn't yet switch the rendering order of "backwards". That's
coming in a follow up.
2025-10-31 12:58:18 -04:00
Sebastian "Sebbie" Silbermann
c9ddee7e36 [DevTools] Reset forced states when changing component filters (#34929) 2025-10-31 12:57:11 +01:00
Sebastian Markbåge
6fb7754494 [DevTools] Render selected outline on top of every other rect (#35012)
When rects are close together (or overlapping) the outline can end up
being covered up by sibling rects or deeper rects. This renders the
selected outline on top of everything so it's always visible.

<img width="275" height="730" alt="Screenshot 2025-10-29 at 8 43 28 PM"
src="https://github.com/user-attachments/assets/69224883-f548-45ec-ada1-1a04ec17eaf5"
/>
<img width="266" height="737" alt="Screenshot 2025-10-29 at 8 58 53 PM"
src="https://github.com/user-attachments/assets/946f7dde-450d-49fd-9fbd-57487f67f461"
/>

Additionally, this makes it so that it's not part of the translucent
tree when things are hidden by the timeline. That way it's easier to see
what is selected inside a hidden tree.

<img width="498" height="196" alt="Screenshot 2025-10-29 at 8 45 24 PM"
src="https://github.com/user-attachments/assets/723107ab-a92c-42c2-8a7d-a548ac3332d0"
/>
<img width="571" height="735" alt="Screenshot 2025-10-29 at 8 59 06 PM"
src="https://github.com/user-attachments/assets/d653f1a7-4096-45c3-b92a-ef155d4742e6"
/>
2025-10-30 15:26:49 -04:00
Sebastian Markbåge
3a0ab8a7ee [DevTools] Synchronize Scroll Position Between Suspense Tab and Main Document (#34641)
It's annoying to have to try to find where it lines up with no hints.

This way when you hover over something it should be on screen.

The strategy I went with is that it scrolls to a percentage along the
scrollable axis but the two might not be exactly the same. Partially
because they have different aspect ratios but also because suspended
boundaries can shrink the document while the suspense tab needs to still
be able to show the boundaries that are currently invisible.
2025-10-29 21:49:35 -04:00
Sebastian Markbåge
0a5fb67ddf [DevTools] Sort suspense timeline by end time instead of just document order (#35011)
Right now it's possible for things like server environments to appear
before other content in the timeline just because it's in a different
document order.

Ofc the order in production is not guaranteed but we can at least use
the timing information we have as a hint towards the actual order.

Unfortunately since the end time of the RSC stream itself is always
after the content that resolved to produce it, it becomes kind of
determined by the chunking. Similarly since for a clean refresh, the
scripts and styles will typically load after the server content they
appear later. Similarly SSR typically finishes after the RSC parts.
Therefore a hack here is that I artificially delay everything with a
non-null environment (RSC) so that RSC always comes after client-side
(Suspense). This is also consistent with how we color things that have
an environment even if children are just Suspense.

To ensure that we never show a child before a parent, in the timeline,
each child has a minimum time of its parent.
2025-10-29 15:05:04 -04:00
Sebastian Markbåge
4f93170066 [Flight] Cache the value if we visit the same I/O or Promise multiple times along different paths (#35005)
We avoid visiting the same async node twice but if we see it again we
returned "null" indicating that there's no I/O there.

This means that if you have two different Promises both resolving from
the same I/O node then we only show one of them. However, in general we
treat that as two different I/O entries to allow for things like
batching to still show up separately.

This fixes that by caching the return value for multiple visits. So if
we found I/O (but no user space await) in one path and then we visit
that path through a different Promise chain, then we'll still emit it
twice.

However, if we visit the same exact Promise that we emitted an await on
then we skip it. Because there's no need to emit two awaits on the same
thing. It only matters when the path ends up informing whether it has
I/O or not.
2025-10-29 10:55:43 -04:00
Sebastian Markbåge
0fa32506da [Flight] Clone subsequent I/O nodes if it's resolved more than once (#35003)
IO tasks can execute more than once. E.g. a connection may fire each
time a new message or chunk comes in or a setInterval every time it
executes.

We used to treat these all as one I/O node and just updated the end time
as we go. Most of the time this was fine because typically you would
have a Promise instance whose end time is really the one that gets used
as the I/O anyway.

However, in a pattern like this it could be problematic:

```js
setTimeout(() => {
  function App() {
    return Promise.resolve(123);
  }
  renderToReadableStream(<App />);
});
```

Because the I/O's end time is before the render started so it should be
excluded from being considered I/O as part of the render. It happened
outside of render. But because the `Promise.resolve()` is inside render
its end time is after the render start so the promise is considered part
of the render. This is usually not a problem because the end time of the
I/O is still before the start of the render so even though the Promise
is valid it has no I/O source so it's properly excluded.

However, if the I/O's end time updates before we observe this then the
I/O can be considered part of the render. E.g. if this was a setInterval
it would be clearly wrong. But it turns out that even setTimeout can
sometimes execute more than once in the async_hooks because each run of
"process.nextTick" and microtasks respectively are ran in their own
before/after. When a micro task executes after this main body it'll
update the end time which can then turn the whole I/O as being inside
the render.

To solve this properly I create a new I/O node each time before() is
invoked so that each one gets to observe a different end time. This has
a potential CPU and memory allocation cost when there's a lot of them
like in a quick stream.
2025-10-28 13:27:35 -04:00
Ricky
fb0d96073c [tests] disableLegacyMode in test-renderer (#35002)
500 tests failed from not using async act. Will fix the tests and then
re-land this.
2025-10-28 12:53:30 -04:00
Michael H
b4455a6ee6 [react-dom] Include all Node.js APIs in Bun entrypoint for /server (#34193) 2025-10-27 23:06:45 +01:00
lauren
17b3765244 [generate-changelog] Refactor (#34993)
Just a light reorganization.
2025-10-27 18:04:48 -04:00
lauren
69f3e9d034 [generate-changelog] Add --format option (#34992)
Adds a new `--format` option which can be `text` (default), `csv`, or
`json`.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34992).
* #34993
* __->__ #34992
2025-10-27 17:54:09 -04:00
Ricky
dd53a946ec [rn] enabled disableLegacyMode everywhere (#34947)
Stacked on https://github.com/facebook/react/pull/34946

This should be a noop, now that the legacy renderers are not being
sync'd.
2025-10-27 17:48:33 -04:00
Ricky
90817f8810 [rn] delete the legacy renderers from the sync (#34946)
Now that RN is only on the New Architecture, we can stop stop syncing
the legacy React Native renderers.

In this diff, I just stop syncing them. In a follow up I'll delete the
code for them so only Fabric is left.

This will also allow us to remove the `enableLegacyMode` feature flag.
2025-10-27 17:38:56 -04:00
Hendrik Liebau
0d721b60c2 [Flight] Don't hang after resolving cyclic references (#34988) 2025-10-27 22:06:28 +01:00
lauren
d3d0ce329e [script] Add yarn generate-changelog (#34962)
(disclaimer: I used codex to write this script)

Adds a new `yarn generate-changelog` script to simplify the process of
writing changelogs. You can use it as follows:

```
$ yarn generate-changelog --help

Usage: yarn generate-changelog [--codex|--claude] [--debug] [<pkg@version> ...]

Options:
  --codex     Use Codex for commit summarization.                      [boolean]
  --claude    Use Claude for commit summarization.                     [boolean]
  --debug     Enable verbose debug logging.           [boolean] [default: false]
  -h, --help  Show help                                                [boolean]

Examples:
  generate-changelog --codex                Generate changelog for a single
  eslint-plugin-react-hooks@7.0.1           package using Codex.
  generate-changelog --claude react@19.3    Generate changelog entries for
  react-dom@19.3                            multiple packages using Claude.
  generate-changelog --codex                Generate changelog for all stable
                                            packages using recorded versions.
```

For example, if no args are passed, the script will print find all the
relevant commits affecting packages (defaults to `stablePackages` in
`ReactVersions.js`) and format them as a simple markdown list.

```
$ yarn generate-changelog

## eslint-plugin-react-hooks@7.0.0
* [compiler] improve zod v3 backwards compat (#34877) ([#34877](https://github.com/facebook/react/pull/34877) by [@henryqdineen](https://github.com/henryqdineen))
* [ESLint] Disallow passing effect event down when inlined as a prop (#34820) ([#34820](https://github.com/facebook/react/pull/34820) by [@jf-eirinha](https://github.com/jf-eirinha))
* Switch to `export =` to fix eslint-plugin-react-hooks types (#34949) ([#34949](https://github.com/facebook/react/pull/34949) by [@karlhorky](https://github.com/karlhorky))
* [eprh] Type `configs.flat` more strictly (#34950) ([#34950](https://github.com/facebook/react/pull/34950) by [@poteto](https://github.com/poteto))
* Add hint for Node.js cjs-module-lexer for eslint-plugin-react-hook types (#34951) ([#34951](https://github.com/facebook/react/pull/34951) by [@karlhorky](https://github.com/karlhorky))
* Add hint for Node.js cjs-module-lexer for eslint-plugin-react-hook types (#34953) ([#34953](https://github.com/facebook/react/pull/34953) by [@karlhorky](https://github.com/karlhorky))

// etc etc...
```

If `--codex` or `--claude` is passed, the script will attempt to use
them to summarize the commit(s) in the same style as our existing
CHANGELOG.md.

And finally, for debugging the script you can add `--debug` to see
what's going on.
2025-10-27 15:48:36 -04:00
Eugene Choi
ba0590f306 [playground] Upgrade playwright (#34991)
Some vulnerabilities were detected in older versions of Playwright,
upgrading for the playground.
2025-10-27 13:42:02 -04:00
202 changed files with 7979 additions and 5707 deletions

View File

@@ -11,7 +11,7 @@ body:
options:
- label: React Compiler core (the JS output is incorrect, or your app works incorrectly after optimization)
- label: babel-plugin-react-compiler (build issue installing or using the Babel plugin)
- label: eslint-plugin-react-compiler (build issue installing or using the eslint plugin)
- label: eslint-plugin-react-hooks (build issue installing or using the eslint plugin)
- label: react-compiler-healthcheck (build issue installing or using the healthcheck script)
- type: input
attributes:

View File

@@ -162,10 +162,13 @@ jobs:
mv build/facebook-react-native/react-is/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/react-is/
mv build/facebook-react-native/react-test-renderer/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/react-test-renderer/
# Delete OSS renderer. OSS renderer is synced through internal script.
# Delete the OSS renderers, these are sync'd to RN separately.
RENDERER_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/implementations/
rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js
rm $RENDERER_FOLDER/ReactNativeRenderer-{dev,prod,profiling}.js
# Delete the legacy renderer shim, this is not sync'd and will get deleted in the future.
SHIM_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/shims/
rm $SHIM_FOLDER/ReactNative.js
# Copy eslint-plugin-react-hooks
# NOTE: This is different from www, here we include the full package

View File

@@ -27,7 +27,7 @@
"@babel/types": "7.26.3",
"@heroicons/react": "^1.0.6",
"@monaco-editor/react": "^4.8.0-rc.2",
"@playwright/test": "^1.51.1",
"@playwright/test": "^1.56.1",
"@use-gesture/react": "^10.2.22",
"hermes-eslint": "^0.25.0",
"hermes-parser": "^0.25.0",

View File

@@ -798,12 +798,12 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@playwright/test@^1.51.1":
version "1.51.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.51.1.tgz#75357d513221a7be0baad75f01e966baf9c41a2e"
integrity sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==
"@playwright/test@^1.56.1":
version "1.56.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.56.1.tgz#6e3bf3d0c90c5cf94bf64bdb56fd15a805c8bd3f"
integrity sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==
dependencies:
playwright "1.51.1"
playwright "1.56.1"
"@rtsao/scc@^1.1.0":
version "1.1.0"
@@ -854,23 +854,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
"@types/react-dom@19.1.9":
version "19.1.9"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.9.tgz#5ab695fce1e804184767932365ae6569c11b4b4b"
integrity sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==
"@types/react-dom@19.2":
version "19.2.2"
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz#a4cc874797b7ddc9cb180ef0d5dc23f596fc2332"
integrity sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==
"@types/react@19.1.12":
version "19.1.12"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.12.tgz#7bfaa76aabbb0b4fe0493c21a3a7a93d33e8937b"
integrity sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==
dependencies:
csstype "^3.0.2"
"@types/react@19.2":
version "19.2.2"
resolved "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz#ba123a75d4c2a51158697160a4ea2ff70aa6bf36"
@@ -3465,17 +3453,17 @@ pirates@^4.0.1:
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9"
integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==
playwright-core@1.51.1:
version "1.51.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.51.1.tgz#d57f0393e02416f32a47cf82b27533656a8acce1"
integrity sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==
playwright-core@1.56.1:
version "1.56.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.1.tgz#24a66481e5cd33a045632230aa2c4f0cb6b1db3d"
integrity sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==
playwright@1.51.1:
version "1.51.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.51.1.tgz#ae1467ee318083968ad28d6990db59f47a55390f"
integrity sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==
playwright@1.56.1:
version "1.56.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.56.1.tgz#62e3b99ddebed0d475e5936a152c88e68be55fbf"
integrity sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==
dependencies:
playwright-core "1.51.1"
playwright-core "1.56.1"
optionalDependencies:
fsevents "2.3.2"

View File

@@ -105,6 +105,7 @@ import {inferMutationAliasingRanges} from '../Inference/InferMutationAliasingRan
import {validateNoDerivedComputationsInEffects} from '../Validation/ValidateNoDerivedComputationsInEffects';
import {validateNoDerivedComputationsInEffects_exp} from '../Validation/ValidateNoDerivedComputationsInEffects_exp';
import {nameAnonymousFunctions} from '../Transform/NameAnonymousFunctions';
import {validateSourceLocations} from '../Validation/ValidateSourceLocations';
export type CompilerPipelineValue =
| {kind: 'ast'; name: string; value: CodegenFunction}
@@ -272,12 +273,10 @@ function runWithEnvironment(
validateNoSetStateInRender(hir).unwrap();
}
if (env.config.validateNoDerivedComputationsInEffects) {
validateNoDerivedComputationsInEffects(hir);
}
if (env.config.validateNoDerivedComputationsInEffects_exp) {
validateNoDerivedComputationsInEffects_exp(hir);
env.logErrors(validateNoDerivedComputationsInEffects_exp(hir));
} else if (env.config.validateNoDerivedComputationsInEffects) {
validateNoDerivedComputationsInEffects(hir);
}
if (env.config.validateNoSetStateInEffects) {
@@ -559,6 +558,10 @@ function runWithEnvironment(
log({kind: 'ast', name: 'Codegen (outlined)', value: outlined.fn});
}
if (env.config.validateSourceLocations) {
validateSourceLocations(func, ast).unwrap();
}
/**
* This flag should be only set for unit / fixture tests to check
* that Forget correctly handles unexpected errors (e.g. exceptions

View File

@@ -364,6 +364,13 @@ export const EnvironmentConfigSchema = z.object({
validateNoCapitalizedCalls: z.nullable(z.array(z.string())).default(null),
validateBlocklistedImports: z.nullable(z.array(z.string())).default(null),
/**
* Validates that AST nodes generated during codegen have proper source locations.
* This is useful for debugging issues with source maps and Istanbul coverage.
* When enabled, the compiler will error if important source locations are missing in the generated AST.
*/
validateSourceLocations: z.boolean().default(false),
/**
* Validate against impure functions called during render
*/

View File

@@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {Result} from '../Utils/Result';
import {CompilerDiagnostic, CompilerError, Effect} from '..';
import {ErrorCategory} from '../CompilerError';
import {
@@ -20,8 +21,8 @@ import {
isUseStateType,
BasicBlock,
isUseRefType,
GeneratedSource,
SourceLocation,
ArrayExpression,
} from '../HIR';
import {eachInstructionLValue, eachInstructionOperand} from '../HIR/visitors';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
@@ -33,15 +34,22 @@ type DerivationMetadata = {
typeOfValue: TypeOfValue;
place: Place;
sourcesIds: Set<IdentifierId>;
isStateSource: boolean;
};
type EffectMetadata = {
effect: HIRFunction;
dependencies: ArrayExpression;
};
type ValidationContext = {
readonly functions: Map<IdentifierId, FunctionExpression>;
readonly candidateDependencies: Map<IdentifierId, ArrayExpression>;
readonly errors: CompilerError;
readonly derivationCache: DerivationCache;
readonly effects: Set<HIRFunction>;
readonly setStateCache: Map<string | undefined | null, Array<Place>>;
readonly effectSetStateCache: Map<string | undefined | null, Array<Place>>;
readonly effectsCache: Map<IdentifierId, EffectMetadata>;
readonly setStateLoads: Map<IdentifierId, IdentifierId | null>;
readonly setStateUsages: Map<IdentifierId, Set<SourceLocation>>;
};
class DerivationCache {
@@ -56,6 +64,7 @@ class DerivationCache {
place: value.place,
sourcesIds: new Set(value.sourcesIds),
typeOfValue: value.typeOfValue,
isStateSource: value.isStateSource,
});
}
}
@@ -95,41 +104,28 @@ class DerivationCache {
derivedVar: Place,
sourcesIds: Set<IdentifierId>,
typeOfValue: TypeOfValue,
isStateSource: boolean,
): void {
let newValue: DerivationMetadata = {
place: derivedVar,
sourcesIds: new Set(),
typeOfValue: typeOfValue ?? 'ignored',
};
if (sourcesIds !== undefined) {
for (const id of sourcesIds) {
const sourcePlace = this.cache.get(id)?.place;
if (sourcePlace === undefined) {
continue;
}
/*
* If the identifier of the source is a promoted identifier, then
* we should set the target as the source.
*/
let finalIsSource = isStateSource;
if (!finalIsSource) {
for (const sourceId of sourcesIds) {
const sourceMetadata = this.cache.get(sourceId);
if (
sourcePlace.identifier.name === null ||
sourcePlace.identifier.name?.kind === 'promoted'
sourceMetadata?.isStateSource &&
sourceMetadata.place.identifier.name?.kind !== 'named'
) {
newValue.sourcesIds.add(derivedVar.identifier.id);
} else {
newValue.sourcesIds.add(sourcePlace.identifier.id);
finalIsSource = true;
break;
}
}
}
if (newValue.sourcesIds.size === 0) {
newValue.sourcesIds.add(derivedVar.identifier.id);
}
this.cache.set(derivedVar.identifier.id, newValue);
this.cache.set(derivedVar.identifier.id, {
place: derivedVar,
sourcesIds: sourcesIds,
typeOfValue: typeOfValue ?? 'ignored',
isStateSource: finalIsSource,
});
}
private isDerivationEqual(
@@ -151,6 +147,14 @@ class DerivationCache {
}
}
function isNamedIdentifier(place: Place): place is Place & {
identifier: {name: NonNullable<Place['identifier']['name']>};
} {
return (
place.identifier.name !== null && place.identifier.name.kind === 'named'
);
}
/**
* Validates that useEffect is not used for derived computations which could/should
* be performed in render.
@@ -176,25 +180,24 @@ class DerivationCache {
*/
export function validateNoDerivedComputationsInEffects_exp(
fn: HIRFunction,
): void {
): Result<void, CompilerError> {
const functions: Map<IdentifierId, FunctionExpression> = new Map();
const candidateDependencies: Map<IdentifierId, ArrayExpression> = new Map();
const derivationCache = new DerivationCache();
const errors = new CompilerError();
const effects: Set<HIRFunction> = new Set();
const effectsCache: Map<IdentifierId, EffectMetadata> = new Map();
const setStateCache: Map<string | undefined | null, Array<Place>> = new Map();
const effectSetStateCache: Map<
string | undefined | null,
Array<Place>
> = new Map();
const setStateLoads: Map<IdentifierId, IdentifierId> = new Map();
const setStateUsages: Map<IdentifierId, Set<SourceLocation>> = new Map();
const context: ValidationContext = {
functions,
candidateDependencies,
errors,
derivationCache,
effects,
setStateCache,
effectSetStateCache,
effectsCache,
setStateLoads,
setStateUsages,
};
if (fn.fnType === 'Hook') {
@@ -202,8 +205,9 @@ export function validateNoDerivedComputationsInEffects_exp(
if (param.kind === 'Identifier') {
context.derivationCache.cache.set(param.identifier.id, {
place: param,
sourcesIds: new Set([param.identifier.id]),
sourcesIds: new Set(),
typeOfValue: 'fromProps',
isStateSource: true,
});
}
}
@@ -212,8 +216,9 @@ export function validateNoDerivedComputationsInEffects_exp(
if (props != null && props.kind === 'Identifier') {
context.derivationCache.cache.set(props.identifier.id, {
place: props,
sourcesIds: new Set([props.identifier.id]),
sourcesIds: new Set(),
typeOfValue: 'fromProps',
isStateSource: true,
});
}
}
@@ -233,13 +238,11 @@ export function validateNoDerivedComputationsInEffects_exp(
isFirstPass = false;
} while (context.derivationCache.snapshot());
for (const effect of effects) {
validateEffect(effect, context);
for (const [, effect] of effectsCache) {
validateEffect(effect.effect, effect.dependencies, context);
}
if (errors.hasAnyErrors()) {
throw errors;
}
return errors.asResult();
}
function recordPhiDerivations(
@@ -267,6 +270,7 @@ function recordPhiDerivations(
phi.place,
sourcesIds,
typeOfValue,
false,
);
}
}
@@ -282,17 +286,69 @@ function joinValue(
return 'fromPropsAndState';
}
function getRootSetState(
key: IdentifierId,
loads: Map<IdentifierId, IdentifierId | null>,
visited: Set<IdentifierId> = new Set(),
): IdentifierId | null {
if (visited.has(key)) {
return null;
}
visited.add(key);
const parentId = loads.get(key);
if (parentId === undefined) {
return null;
}
if (parentId === null) {
return key;
}
return getRootSetState(parentId, loads, visited);
}
function maybeRecordSetState(
instr: Instruction,
loads: Map<IdentifierId, IdentifierId | null>,
usages: Map<IdentifierId, Set<SourceLocation>>,
): void {
for (const operand of eachInstructionLValue(instr)) {
if (
instr.value.kind === 'LoadLocal' &&
loads.has(instr.value.place.identifier.id)
) {
loads.set(operand.identifier.id, instr.value.place.identifier.id);
} else {
if (isSetStateType(operand.identifier)) {
// this is a root setState
loads.set(operand.identifier.id, null);
}
}
const rootSetState = getRootSetState(operand.identifier.id, loads);
if (rootSetState !== null && usages.get(rootSetState) === undefined) {
usages.set(rootSetState, new Set([operand.loc]));
}
}
}
function recordInstructionDerivations(
instr: Instruction,
context: ValidationContext,
isFirstPass: boolean,
): void {
maybeRecordSetState(instr, context.setStateLoads, context.setStateUsages);
let typeOfValue: TypeOfValue = 'ignored';
let isSource: boolean = false;
const sources: Set<IdentifierId> = new Set();
const {lvalue, value} = instr;
if (value.kind === 'FunctionExpression') {
context.functions.set(lvalue.identifier.id, value);
for (const [, block] of value.loweredFunc.func.body.blocks) {
recordPhiDerivations(block, context);
for (const instr of block.instructions) {
recordInstructionDerivations(instr, context, isFirstPass);
}
@@ -307,28 +363,37 @@ function recordInstructionDerivations(
value.args[1].kind === 'Identifier'
) {
const effectFunction = context.functions.get(value.args[0].identifier.id);
if (effectFunction != null) {
context.effects.add(effectFunction.loweredFunc.func);
const deps = context.candidateDependencies.get(
value.args[1].identifier.id,
);
if (effectFunction != null && deps != null) {
context.effectsCache.set(value.args[0].identifier.id, {
effect: effectFunction.loweredFunc.func,
dependencies: deps,
});
}
} else if (isUseStateType(lvalue.identifier) && value.args.length > 0) {
const stateValueSource = value.args[0];
if (stateValueSource.kind === 'Identifier') {
sources.add(stateValueSource.identifier.id);
}
typeOfValue = joinValue(typeOfValue, 'fromState');
typeOfValue = 'fromState';
context.derivationCache.addDerivationEntry(
lvalue,
new Set(),
typeOfValue,
true,
);
return;
}
} else if (value.kind === 'ArrayExpression') {
context.candidateDependencies.set(lvalue.identifier.id, value);
}
for (const operand of eachInstructionOperand(instr)) {
if (
isSetStateType(operand.identifier) &&
operand.loc !== GeneratedSource &&
isFirstPass
) {
if (context.setStateCache.has(operand.loc.identifierName)) {
context.setStateCache.get(operand.loc.identifierName)!.push(operand);
} else {
context.setStateCache.set(operand.loc.identifierName, [operand]);
if (context.setStateLoads.has(operand.identifier.id)) {
const rootSetStateId = getRootSetState(
operand.identifier.id,
context.setStateLoads,
);
if (rootSetStateId !== null) {
context.setStateUsages.get(rootSetStateId)?.add(operand.loc);
}
}
@@ -341,9 +406,7 @@ function recordInstructionDerivations(
}
typeOfValue = joinValue(typeOfValue, operandMetadata.typeOfValue);
for (const id of operandMetadata.sourcesIds) {
sources.add(id);
}
sources.add(operand.identifier.id);
}
if (typeOfValue === 'ignored') {
@@ -351,7 +414,12 @@ function recordInstructionDerivations(
}
for (const lvalue of eachInstructionLValue(instr)) {
context.derivationCache.addDerivationEntry(lvalue, sources, typeOfValue);
context.derivationCache.addDerivationEntry(
lvalue,
sources,
typeOfValue,
isSource,
);
}
for (const operand of eachInstructionOperand(instr)) {
@@ -378,6 +446,7 @@ function recordInstructionDerivations(
operand,
sources,
typeOfValue,
false,
);
}
}
@@ -411,21 +480,183 @@ function recordInstructionDerivations(
}
}
type TreeNode = {
name: string;
typeOfValue: TypeOfValue;
isSource: boolean;
children: Array<TreeNode>;
};
function buildTreeNode(
sourceId: IdentifierId,
context: ValidationContext,
visited: Set<string> = new Set(),
): Array<TreeNode> {
const sourceMetadata = context.derivationCache.cache.get(sourceId);
if (!sourceMetadata) {
return [];
}
if (sourceMetadata.isStateSource && isNamedIdentifier(sourceMetadata.place)) {
return [
{
name: sourceMetadata.place.identifier.name.value,
typeOfValue: sourceMetadata.typeOfValue,
isSource: sourceMetadata.isStateSource,
children: [],
},
];
}
const children: Array<TreeNode> = [];
const namedSiblings: Set<string> = new Set();
for (const childId of sourceMetadata.sourcesIds) {
const childNodes = buildTreeNode(
childId,
context,
new Set([
...visited,
...(isNamedIdentifier(sourceMetadata.place)
? [sourceMetadata.place.identifier.name.value]
: []),
]),
);
if (childNodes) {
for (const childNode of childNodes) {
if (!namedSiblings.has(childNode.name)) {
children.push(childNode);
namedSiblings.add(childNode.name);
}
}
}
}
if (
isNamedIdentifier(sourceMetadata.place) &&
!visited.has(sourceMetadata.place.identifier.name.value)
) {
return [
{
name: sourceMetadata.place.identifier.name.value,
typeOfValue: sourceMetadata.typeOfValue,
isSource: sourceMetadata.isStateSource,
children: children,
},
];
}
return children;
}
function renderTree(
node: TreeNode,
indent: string = '',
isLast: boolean = true,
propsSet: Set<string>,
stateSet: Set<string>,
): string {
const prefix = indent + (isLast ? '└── ' : '├── ');
const childIndent = indent + (isLast ? ' ' : '│ ');
let result = `${prefix}${node.name}`;
if (node.isSource) {
let typeLabel: string;
if (node.typeOfValue === 'fromProps') {
propsSet.add(node.name);
typeLabel = 'Prop';
} else if (node.typeOfValue === 'fromState') {
stateSet.add(node.name);
typeLabel = 'State';
} else {
propsSet.add(node.name);
stateSet.add(node.name);
typeLabel = 'Prop and State';
}
result += ` (${typeLabel})`;
}
if (node.children.length > 0) {
result += '\n';
node.children.forEach((child, index) => {
const isLastChild = index === node.children.length - 1;
result += renderTree(child, childIndent, isLastChild, propsSet, stateSet);
if (index < node.children.length - 1) {
result += '\n';
}
});
}
return result;
}
function getFnLocalDeps(
fn: FunctionExpression | undefined,
): Set<IdentifierId> | undefined {
if (!fn) {
return undefined;
}
const deps: Set<IdentifierId> = new Set();
for (const [, block] of fn.loweredFunc.func.body.blocks) {
for (const instr of block.instructions) {
if (instr.value.kind === 'LoadLocal') {
deps.add(instr.value.place.identifier.id);
}
}
}
return deps;
}
function validateEffect(
effectFunction: HIRFunction,
dependencies: ArrayExpression,
context: ValidationContext,
): void {
const seenBlocks: Set<BlockId> = new Set();
const effectDerivedSetStateCalls: Array<{
value: CallExpression;
loc: SourceLocation;
id: IdentifierId;
sourceIds: Set<IdentifierId>;
typeOfValue: TypeOfValue;
}> = [];
const effectSetStateUsages: Map<
IdentifierId,
Set<SourceLocation>
> = new Map();
// Consider setStates in the effect's dependency array as being part of effectSetStateUsages
for (const dep of dependencies.elements) {
if (dep.kind === 'Identifier') {
const root = getRootSetState(dep.identifier.id, context.setStateLoads);
if (root !== null) {
effectSetStateUsages.set(root, new Set([dep.loc]));
}
}
}
let cleanUpFunctionDeps: Set<IdentifierId> | undefined;
const globals: Set<IdentifierId> = new Set();
for (const block of effectFunction.body.blocks.values()) {
/*
* if the block is in an effect and is of type return then its an effect's cleanup function
* if the cleanup function depends on a value from which effect-set state is derived then
* we can't validate
*/
if (
block.terminal.kind === 'return' &&
block.terminal.returnVariant === 'Explicit'
) {
cleanUpFunctionDeps = getFnLocalDeps(
context.functions.get(block.terminal.value.identifier.id),
);
}
for (const pred of block.preds) {
if (!seenBlocks.has(pred)) {
// skip if block has a back edge
@@ -439,19 +670,16 @@ function validateEffect(
return;
}
maybeRecordSetState(instr, context.setStateLoads, effectSetStateUsages);
for (const operand of eachInstructionOperand(instr)) {
if (
isSetStateType(operand.identifier) &&
operand.loc !== GeneratedSource
) {
if (context.effectSetStateCache.has(operand.loc.identifierName)) {
context.effectSetStateCache
.get(operand.loc.identifierName)!
.push(operand);
} else {
context.effectSetStateCache.set(operand.loc.identifierName, [
operand,
]);
if (context.setStateLoads.has(operand.identifier.id)) {
const rootSetStateId = getRootSetState(
operand.identifier.id,
context.setStateLoads,
);
if (rootSetStateId !== null) {
effectSetStateUsages.get(rootSetStateId)?.add(operand.loc);
}
}
}
@@ -469,7 +697,7 @@ function validateEffect(
if (argMetadata !== undefined) {
effectDerivedSetStateCalls.push({
value: instr.value,
loc: instr.value.callee.loc,
id: instr.value.callee.identifier.id,
sourceIds: argMetadata.sourcesIds,
typeOfValue: argMetadata.typeOfValue,
});
@@ -503,37 +731,74 @@ function validateEffect(
}
for (const derivedSetStateCall of effectDerivedSetStateCalls) {
const rootSetStateCall = getRootSetState(
derivedSetStateCall.id,
context.setStateLoads,
);
if (
derivedSetStateCall.loc !== GeneratedSource &&
context.effectSetStateCache.has(derivedSetStateCall.loc.identifierName) &&
context.setStateCache.has(derivedSetStateCall.loc.identifierName) &&
context.effectSetStateCache.get(derivedSetStateCall.loc.identifierName)!
.length ===
context.setStateCache.get(derivedSetStateCall.loc.identifierName)!
.length -
1
rootSetStateCall !== null &&
effectSetStateUsages.has(rootSetStateCall) &&
context.setStateUsages.has(rootSetStateCall) &&
effectSetStateUsages.get(rootSetStateCall)!.size ===
context.setStateUsages.get(rootSetStateCall)!.size - 1
) {
const derivedDepsStr = Array.from(derivedSetStateCall.sourceIds)
.map(sourceId => {
const sourceMetadata = context.derivationCache.cache.get(sourceId);
return sourceMetadata?.place.identifier.name?.value;
})
.filter(Boolean)
.join(', ');
const propsSet = new Set<string>();
const stateSet = new Set<string>();
let description;
if (derivedSetStateCall.typeOfValue === 'fromProps') {
description = `From props: [${derivedDepsStr}]`;
} else if (derivedSetStateCall.typeOfValue === 'fromState') {
description = `From local state: [${derivedDepsStr}]`;
} else {
description = `From props and local state: [${derivedDepsStr}]`;
const rootNodesMap = new Map<string, TreeNode>();
for (const id of derivedSetStateCall.sourceIds) {
const nodes = buildTreeNode(id, context);
for (const node of nodes) {
if (!rootNodesMap.has(node.name)) {
rootNodesMap.set(node.name, node);
}
}
}
const rootNodes = Array.from(rootNodesMap.values());
const trees = rootNodes.map((node, index) =>
renderTree(
node,
'',
index === rootNodes.length - 1,
propsSet,
stateSet,
),
);
for (const dep of derivedSetStateCall.sourceIds) {
if (cleanUpFunctionDeps !== undefined && cleanUpFunctionDeps.has(dep)) {
return;
}
}
const propsArr = Array.from(propsSet);
const stateArr = Array.from(stateSet);
let rootSources = '';
if (propsArr.length > 0) {
rootSources += `Props: [${propsArr.join(', ')}]`;
}
if (stateArr.length > 0) {
if (rootSources) rootSources += '\n';
rootSources += `State: [${stateArr.join(', ')}]`;
}
const description = `Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
This setState call is setting a derived value that depends on the following reactive sources:
${rootSources}
Data Flow Tree:
${trees.join('\n')}
See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state`;
context.errors.pushDiagnostic(
CompilerDiagnostic.create({
description: `Derived values (${description}) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user`,
description: description,
category: ErrorCategory.EffectDerivationsOfState,
reason:
'You might not need an effect. Derive values in render, not effects.',

View File

@@ -0,0 +1,206 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {NodePath} from '@babel/traverse';
import * as t from '@babel/types';
import {CompilerDiagnostic, CompilerError, ErrorCategory} from '..';
import {CodegenFunction} from '../ReactiveScopes';
import {Result} from '../Utils/Result';
/**
* IMPORTANT: This validation is only intended for use in unit tests.
* It is not intended for use in production.
*
* This validation is used to ensure that the generated AST has proper source locations
* for "important" original nodes.
*
* There's one big gotcha with this validation: it only works if the "important" original nodes
* are not optimized away by the compiler.
*
* When that scenario happens, we should just update the fixture to not include a node that has no
* corresponding node in the generated AST due to being completely removed during compilation.
*/
/**
* Some common node types that are important for coverage tracking.
* Based on istanbul-lib-instrument
*/
const IMPORTANT_INSTRUMENTED_TYPES = new Set([
'ArrowFunctionExpression',
'AssignmentPattern',
'ObjectMethod',
'ExpressionStatement',
'BreakStatement',
'ContinueStatement',
'ReturnStatement',
'ThrowStatement',
'TryStatement',
'VariableDeclarator',
'IfStatement',
'ForStatement',
'ForInStatement',
'ForOfStatement',
'WhileStatement',
'DoWhileStatement',
'SwitchStatement',
'SwitchCase',
'WithStatement',
'FunctionDeclaration',
'FunctionExpression',
'LabeledStatement',
'ConditionalExpression',
'LogicalExpression',
]);
/**
* Check if a node is a manual memoization call that the compiler optimizes away.
* These include useMemo and useCallback calls, which are intentionally removed
* by the DropManualMemoization pass.
*/
function isManualMemoization(node: t.Node): boolean {
// Check if this is a useMemo/useCallback call expression
if (t.isCallExpression(node)) {
const callee = node.callee;
if (t.isIdentifier(callee)) {
return callee.name === 'useMemo' || callee.name === 'useCallback';
}
if (
t.isMemberExpression(callee) &&
t.isIdentifier(callee.property) &&
t.isIdentifier(callee.object)
) {
return (
callee.object.name === 'React' &&
(callee.property.name === 'useMemo' ||
callee.property.name === 'useCallback')
);
}
}
return false;
}
/**
* Create a location key for comparison. We compare by line/column/source,
* not by object identity.
*/
function locationKey(loc: t.SourceLocation): string {
return `${loc.start.line}:${loc.start.column}-${loc.end.line}:${loc.end.column}`;
}
/**
* Validates that important source locations from the original code are preserved
* in the generated AST. This ensures that Istanbul coverage instrumentation can
* properly map back to the original source code.
*
* The validator:
* 1. Collects locations from "important" nodes in the original AST (those that
* Istanbul instruments for coverage tracking)
* 2. Exempts known compiler optimizations (useMemo/useCallback removal)
* 3. Verifies that all important locations appear somewhere in the generated AST
*
* Missing locations can cause Istanbul to fail to track coverage for certain
* code paths, leading to inaccurate coverage reports.
*/
export function validateSourceLocations(
func: NodePath<
t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
>,
generatedAst: CodegenFunction,
): Result<void, CompilerError> {
const errors = new CompilerError();
// Step 1: Collect important locations from the original source
const importantOriginalLocations = new Map<
string,
{loc: t.SourceLocation; nodeType: string}
>();
func.traverse({
enter(path) {
const node = path.node;
// Only track node types that Istanbul instruments
if (!IMPORTANT_INSTRUMENTED_TYPES.has(node.type)) {
return;
}
// Skip manual memoization that the compiler intentionally removes
if (isManualMemoization(node)) {
return;
}
// Collect the location if it exists
if (node.loc) {
const key = locationKey(node.loc);
importantOriginalLocations.set(key, {
loc: node.loc,
nodeType: node.type,
});
}
},
});
// Step 2: Collect all locations from the generated AST
const generatedLocations = new Set<string>();
function collectGeneratedLocations(node: t.Node): void {
if (node.loc) {
generatedLocations.add(locationKey(node.loc));
}
// Use Babel's VISITOR_KEYS to traverse only actual node properties
const keys = t.VISITOR_KEYS[node.type as keyof typeof t.VISITOR_KEYS];
if (!keys) {
return;
}
for (const key of keys) {
const value = (node as any)[key];
if (Array.isArray(value)) {
for (const item of value) {
if (t.isNode(item)) {
collectGeneratedLocations(item);
}
}
} else if (t.isNode(value)) {
collectGeneratedLocations(value);
}
}
}
// Collect from main function body
collectGeneratedLocations(generatedAst.body);
// Collect from outlined functions
for (const outlined of generatedAst.outlined) {
collectGeneratedLocations(outlined.fn.body);
}
// Step 3: Validate that all important locations are preserved
for (const [key, {loc, nodeType}] of importantOriginalLocations) {
if (!generatedLocations.has(key)) {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.Todo,
reason: 'Important source location missing in generated code',
description:
`Source location for ${nodeType} is missing in the generated output. This can cause coverage instrumentation ` +
`to fail to track this code properly, resulting in inaccurate coverage reports.`,
}).withDetails({
kind: 'error',
loc,
message: null,
}),
);
}
}
return errors.asResult();
}

View File

@@ -12,4 +12,5 @@ export {validateNoCapitalizedCalls} from './ValidateNoCapitalizedCalls';
export {validateNoRefAccessInRender} from './ValidateNoRefAccessInRender';
export {validateNoSetStateInRender} from './ValidateNoSetStateInRender';
export {validatePreservedManualMemoization} from './ValidatePreservedManualMemoization';
export {validateSourceLocations} from './ValidateSourceLocations';
export {validateUseMemo} from './ValidateUseMemo';

View File

@@ -0,0 +1,86 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({value, enabled}) {
const [localValue, setLocalValue] = useState('');
useEffect(() => {
if (enabled) {
setLocalValue(value);
} else {
setLocalValue('disabled');
}
}, [value, enabled]);
return <div>{localValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 'test', enabled: true}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component(t0) {
const $ = _c(6);
const { value, enabled } = t0;
const [localValue, setLocalValue] = useState("");
let t1;
let t2;
if ($[0] !== enabled || $[1] !== value) {
t1 = () => {
if (enabled) {
setLocalValue(value);
} else {
setLocalValue("disabled");
}
};
t2 = [value, enabled];
$[0] = enabled;
$[1] = value;
$[2] = t1;
$[3] = t2;
} else {
t1 = $[2];
t2 = $[3];
}
useEffect(t1, t2);
let t3;
if ($[4] !== localValue) {
t3 = <div>{localValue}</div>;
$[4] = localValue;
$[5] = t3;
} else {
t3 = $[5];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ value: "test", enabled: true }],
};
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nProps: [value]\n\nData Flow Tree:\n└── value (Prop)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":9,"column":6,"index":244},"end":{"line":9,"column":19,"index":257},"filename":"derived-state-conditionally-in-effect.ts","identifierName":"setLocalValue"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":107},"end":{"line":16,"column":1,"index":378},"filename":"derived-state-conditionally-in-effect.ts"},"fnName":"Component","memoSlots":6,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div>test</div>

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({value, enabled}) {

View File

@@ -0,0 +1,78 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
export default function Component({input = 'empty'}) {
const [currInput, setCurrInput] = useState(input);
const localConst = 'local const';
useEffect(() => {
setCurrInput(input + localConst);
}, [input, localConst]);
return <div>{currInput}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{input: 'test'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
export default function Component(t0) {
const $ = _c(5);
const { input: t1 } = t0;
const input = t1 === undefined ? "empty" : t1;
const [currInput, setCurrInput] = useState(input);
let t2;
let t3;
if ($[0] !== input) {
t2 = () => {
setCurrInput(input + "local const");
};
t3 = [input, "local const"];
$[0] = input;
$[1] = t2;
$[2] = t3;
} else {
t2 = $[1];
t3 = $[2];
}
useEffect(t2, t3);
let t4;
if ($[3] !== currInput) {
t4 = <div>{currInput}</div>;
$[3] = currInput;
$[4] = t4;
} else {
t4 = $[4];
}
return t4;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ input: "test" }],
};
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nProps: [input]\n\nData Flow Tree:\n└── input (Prop)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":9,"column":4,"index":276},"end":{"line":9,"column":16,"index":288},"filename":"derived-state-from-default-props.ts","identifierName":"setCurrInput"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":15,"index":122},"end":{"line":13,"column":1,"index":372},"filename":"derived-state-from-default-props.ts"},"fnName":"Component","memoSlots":5,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div>testlocal const</div>

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
export default function Component({input = 'empty'}) {

View File

@@ -0,0 +1,77 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({shouldChange}) {
const [count, setCount] = useState(0);
useEffect(() => {
if (shouldChange) {
setCount(count + 1);
}
}, [count]);
return <div>{count}</div>;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component(t0) {
const $ = _c(7);
const { shouldChange } = t0;
const [count, setCount] = useState(0);
let t1;
if ($[0] !== count || $[1] !== shouldChange) {
t1 = () => {
if (shouldChange) {
setCount(count + 1);
}
};
$[0] = count;
$[1] = shouldChange;
$[2] = t1;
} else {
t1 = $[2];
}
let t2;
if ($[3] !== count) {
t2 = [count];
$[3] = count;
$[4] = t2;
} else {
t2 = $[4];
}
useEffect(t1, t2);
let t3;
if ($[5] !== count) {
t3 = <div>{count}</div>;
$[5] = count;
$[6] = t3;
} else {
t3 = $[6];
}
return t3;
}
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nState: [count]\n\nData Flow Tree:\n└── count (State)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":10,"column":6,"index":237},"end":{"line":10,"column":14,"index":245},"filename":"derived-state-from-local-state-in-effect.ts","identifierName":"setCount"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":5,"column":0,"index":108},"end":{"line":15,"column":1,"index":310},"filename":"derived-state-from-local-state-in-effect.ts"},"fnName":"Component","memoSlots":7,"memoBlocks":3,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,115 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({firstName}) {
const [lastName, setLastName] = useState('Doe');
const [fullName, setFullName] = useState('John');
const middleName = 'D.';
useEffect(() => {
setFullName(firstName + ' ' + middleName + ' ' + lastName);
}, [firstName, middleName, lastName]);
return (
<div>
<input value={lastName} onChange={e => setLastName(e.target.value)} />
<div>{fullName}</div>
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstName: 'John'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component(t0) {
const $ = _c(12);
const { firstName } = t0;
const [lastName, setLastName] = useState("Doe");
const [fullName, setFullName] = useState("John");
let t1;
let t2;
if ($[0] !== firstName || $[1] !== lastName) {
t1 = () => {
setFullName(firstName + " " + "D." + " " + lastName);
};
t2 = [firstName, "D.", lastName];
$[0] = firstName;
$[1] = lastName;
$[2] = t1;
$[3] = t2;
} else {
t1 = $[2];
t2 = $[3];
}
useEffect(t1, t2);
let t3;
if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
t3 = (e) => setLastName(e.target.value);
$[4] = t3;
} else {
t3 = $[4];
}
let t4;
if ($[5] !== lastName) {
t4 = <input value={lastName} onChange={t3} />;
$[5] = lastName;
$[6] = t4;
} else {
t4 = $[6];
}
let t5;
if ($[7] !== fullName) {
t5 = <div>{fullName}</div>;
$[7] = fullName;
$[8] = t5;
} else {
t5 = $[8];
}
let t6;
if ($[9] !== t4 || $[10] !== t5) {
t6 = (
<div>
{t4}
{t5}
</div>
);
$[9] = t4;
$[10] = t5;
$[11] = t6;
} else {
t6 = $[11];
}
return t6;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ firstName: "John" }],
};
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nProps: [firstName]\nState: [lastName]\n\nData Flow Tree:\n├── firstName (Prop)\n└── lastName (State)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":11,"column":4,"index":297},"end":{"line":11,"column":15,"index":308},"filename":"derived-state-from-prop-local-state-and-component-scope.ts","identifierName":"setFullName"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":107},"end":{"line":20,"column":1,"index":542},"filename":"derived-state-from-prop-local-state-and-component-scope.ts"},"fnName":"Component","memoSlots":12,"memoBlocks":5,"memoValues":6,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div><input value="Doe"><div>John D. Doe</div></div>

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({initialName}) {
@@ -29,7 +29,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component(t0) {
@@ -79,6 +79,12 @@ export const FIXTURE_ENTRYPOINT = {
};
```
## Logs
```
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":107},"end":{"line":16,"column":1,"index":359},"filename":"derived-state-from-prop-setter-call-outside-effect-no-error.ts"},"fnName":"Component","memoSlots":6,"memoBlocks":3,"memoValues":4,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div><input value="John"></div>

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({initialName}) {

View File

@@ -0,0 +1,57 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
function Component({value}) {
const [checked, setChecked] = useState('');
useEffect(() => {
setChecked(value === '' ? [] : value.split(','));
}, [value]);
return <div>{checked}</div>;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp
function Component(t0) {
const $ = _c(5);
const { value } = t0;
const [checked, setChecked] = useState("");
let t1;
let t2;
if ($[0] !== value) {
t1 = () => {
setChecked(value === "" ? [] : value.split(","));
};
t2 = [value];
$[0] = value;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== checked) {
t3 = <div>{checked}</div>;
$[3] = checked;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,11 @@
// @validateNoDerivedComputationsInEffects_exp
function Component({value}) {
const [checked, setChecked] = useState('');
useEffect(() => {
setChecked(value === '' ? [] : value.split(','));
}, [value]);
return <div>{checked}</div>;
}

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function MockComponent({onSet}) {
@@ -28,7 +28,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function MockComponent(t0) {
@@ -80,6 +80,13 @@ export const FIXTURE_ENTRYPOINT = {
};
```
## Logs
```
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":107},"end":{"line":6,"column":1,"index":211},"filename":"derived-state-from-prop-setter-used-outside-effect-no-error.ts"},"fnName":"MockComponent","memoSlots":2,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":0,"prunedMemoValues":0}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":8,"column":0,"index":213},"end":{"line":15,"column":1,"index":402},"filename":"derived-state-from-prop-setter-used-outside-effect-no-error.ts"},"fnName":"Component","memoSlots":4,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div>Mock Component</div>

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function MockComponent({onSet}) {

View File

@@ -0,0 +1,78 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({value}) {
const [localValue, setLocalValue] = useState('');
useEffect(() => {
setLocalValue(value);
document.title = `Value: ${value}`;
}, [value]);
return <div>{localValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 'test'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component(t0) {
const $ = _c(5);
const { value } = t0;
const [localValue, setLocalValue] = useState("");
let t1;
let t2;
if ($[0] !== value) {
t1 = () => {
setLocalValue(value);
document.title = `Value: ${value}`;
};
t2 = [value];
$[0] = value;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== localValue) {
t3 = <div>{localValue}</div>;
$[3] = localValue;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ value: "test" }],
};
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nProps: [value]\n\nData Flow Tree:\n└── value (Prop)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":8,"column":4,"index":214},"end":{"line":8,"column":17,"index":227},"filename":"derived-state-from-prop-with-side-effect.ts","identifierName":"setLocalValue"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":107},"end":{"line":13,"column":1,"index":327},"filename":"derived-state-from-prop-with-side-effect.ts"},"fnName":"Component","memoSlots":5,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div>test</div>

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({value}) {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState, useRef} from 'react';
export default function Component({test}) {
@@ -27,7 +27,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState, useRef } from "react";
export default function Component(t0) {
@@ -68,6 +68,12 @@ export const FIXTURE_ENTRYPOINT = {
};
```
## Logs
```
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":15,"index":130},"end":{"line":14,"column":1,"index":328},"filename":"derived-state-from-ref-and-state-no-error.ts"},"fnName":"Component","memoSlots":5,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) nulltestString

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState, useRef} from 'react';
export default function Component({test}) {

View File

@@ -0,0 +1,93 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({propValue}) {
const [value, setValue] = useState(null);
function localFunction() {
console.log('local function');
}
useEffect(() => {
setValue(propValue);
localFunction();
}, [propValue]);
return <div>{value}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{propValue: 'test'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component(t0) {
const $ = _c(6);
const { propValue } = t0;
const [value, setValue] = useState(null);
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = function localFunction() {
console.log("local function");
};
$[0] = t1;
} else {
t1 = $[0];
}
const localFunction = t1;
let t2;
let t3;
if ($[1] !== propValue) {
t2 = () => {
setValue(propValue);
localFunction();
};
t3 = [propValue];
$[1] = propValue;
$[2] = t2;
$[3] = t3;
} else {
t2 = $[2];
t3 = $[3];
}
useEffect(t2, t3);
let t4;
if ($[4] !== value) {
t4 = <div>{value}</div>;
$[4] = value;
$[5] = t4;
} else {
t4 = $[5];
}
return t4;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ propValue: "test" }],
};
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nProps: [propValue]\n\nData Flow Tree:\n└── propValue (Prop)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":12,"column":4,"index":279},"end":{"line":12,"column":12,"index":287},"filename":"effect-contains-local-function-call.ts","identifierName":"setValue"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":107},"end":{"line":17,"column":1,"index":371},"filename":"effect-contains-local-function-call.ts"},"fnName":"Component","memoSlots":6,"memoBlocks":3,"memoValues":4,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div>test</div>
logs: ['local function']

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({propValue}) {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({propValue, onChange}) {
@@ -25,7 +25,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component(t0) {
@@ -70,6 +70,13 @@ export const FIXTURE_ENTRYPOINT = {
};
```
## Logs
```
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":107},"end":{"line":12,"column":1,"index":306},"filename":"effect-contains-prop-function-call-no-error.ts"},"fnName":"Component","memoSlots":7,"memoBlocks":3,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":16,"column":41,"index":402},"end":{"line":16,"column":49,"index":410},"filename":"effect-contains-prop-function-call-no-error.ts"},"fnName":null,"memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div>test</div>

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({propValue, onChange}) {

View File

@@ -0,0 +1,63 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
function Component({prop}) {
const [s, setS] = useState(0);
useEffect(() => {
setS(prop);
}, [prop, setS]);
return <div>{prop}</div>;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
function Component(t0) {
const $ = _c(5);
const { prop } = t0;
const [, setS] = useState(0);
let t1;
let t2;
if ($[0] !== prop) {
t1 = () => {
setS(prop);
};
t2 = [prop, setS];
$[0] = prop;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== prop) {
t3 = <div>{prop}</div>;
$[3] = prop;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nProps: [prop]\n\nData Flow Tree:\n└── prop (Prop)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":6,"column":4,"index":150},"end":{"line":6,"column":8,"index":154},"filename":"effect-used-in-dep-array-still-errors.ts","identifierName":"setS"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":3,"column":0,"index":64},"end":{"line":10,"column":1,"index":212},"filename":"effect-used-in-dep-array-still-errors.ts"},"fnName":"Component","memoSlots":5,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,10 @@
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
function Component({prop}) {
const [s, setS] = useState(0);
useEffect(() => {
setS(prop);
}, [prop, setS]);
return <div>{prop}</div>;
}

View File

@@ -0,0 +1,76 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component(file: File) {
const [imageUrl, setImageUrl] = useState(null);
/*
* Cleaning up the variable or a source of the variable used to setState
* inside the effect communicates that we always need to clean up something
* which is a valid use case for useEffect. In which case we want to
* avoid an throwing
*/
useEffect(() => {
const imageUrlPrepared = URL.createObjectURL(file);
setImageUrl(imageUrlPrepared);
return () => URL.revokeObjectURL(imageUrlPrepared);
}, [file]);
return <Image src={imageUrl} xstyle={styles.imageSizeLimits} />;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component(file) {
const $ = _c(5);
const [imageUrl, setImageUrl] = useState(null);
let t0;
let t1;
if ($[0] !== file) {
t0 = () => {
const imageUrlPrepared = URL.createObjectURL(file);
setImageUrl(imageUrlPrepared);
return () => URL.revokeObjectURL(imageUrlPrepared);
};
t1 = [file];
$[0] = file;
$[1] = t0;
$[2] = t1;
} else {
t0 = $[1];
t1 = $[2];
}
useEffect(t0, t1);
let t2;
if ($[3] !== imageUrl) {
t2 = <Image src={imageUrl} xstyle={styles.imageSizeLimits} />;
$[3] = imageUrl;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
}
```
## Logs
```
{"kind":"CompileSuccess","fnLoc":{"start":{"line":5,"column":0,"index":108},"end":{"line":21,"column":1,"index":700},"filename":"effect-with-cleanup-function-depending-on-derived-computation-value.ts"},"fnName":"Component","memoSlots":5,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,21 @@
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component(file: File) {
const [imageUrl, setImageUrl] = useState(null);
/*
* Cleaning up the variable or a source of the variable used to setState
* inside the effect communicates that we always need to clean up something
* which is a valid use case for useEffect. In which case we want to
* avoid an throwing
*/
useEffect(() => {
const imageUrlPrepared = URL.createObjectURL(file);
setImageUrl(imageUrlPrepared);
return () => URL.revokeObjectURL(imageUrlPrepared);
}, [file]);
return <Image src={imageUrl} xstyle={styles.imageSizeLimits} />;
}

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({propValue}) {
@@ -25,7 +25,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component(t0) {
@@ -65,6 +65,12 @@ export const FIXTURE_ENTRYPOINT = {
};
```
## Logs
```
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":107},"end":{"line":12,"column":1,"index":298},"filename":"effect-with-global-function-call-no-error.ts"},"fnName":"Component","memoSlots":5,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: exception) globalCall is not defined

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component({propValue}) {

View File

@@ -1,49 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
import {useEffect, useState} from 'react';
function Component({value, enabled}) {
const [localValue, setLocalValue] = useState('');
useEffect(() => {
if (enabled) {
setLocalValue(value);
} else {
setLocalValue('disabled');
}
}, [value, enabled]);
return <div>{localValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 'test', enabled: true}],
};
```
## Error
```
Found 1 error:
Error: You might not need an effect. Derive values in render, not effects.
Derived values (From props: [value]) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
error.derived-state-conditionally-in-effect.ts:9:6
7 | useEffect(() => {
8 | if (enabled) {
> 9 | setLocalValue(value);
| ^^^^^^^^^^^^^ This should be computed during render, not in an effect
10 | } else {
11 | setLocalValue('disabled');
12 | }
```

View File

@@ -1,46 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
import {useEffect, useState} from 'react';
export default function Component({input = 'empty'}) {
const [currInput, setCurrInput] = useState(input);
const localConst = 'local const';
useEffect(() => {
setCurrInput(input + localConst);
}, [input, localConst]);
return <div>{currInput}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{input: 'test'}],
};
```
## Error
```
Found 1 error:
Error: You might not need an effect. Derive values in render, not effects.
Derived values (From props: [input]) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
error.derived-state-from-default-props.ts:9:4
7 |
8 | useEffect(() => {
> 9 | setCurrInput(input + localConst);
| ^^^^^^^^^^^^ This should be computed during render, not in an effect
10 | }, [input, localConst]);
11 |
12 | return <div>{currInput}</div>;
```

View File

@@ -1,43 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
import {useEffect, useState} from 'react';
function Component({shouldChange}) {
const [count, setCount] = useState(0);
useEffect(() => {
if (shouldChange) {
setCount(count + 1);
}
}, [count]);
return <div>{count}</div>;
}
```
## Error
```
Found 1 error:
Error: You might not need an effect. Derive values in render, not effects.
Derived values (From local state: [count]) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
error.derived-state-from-local-state-in-effect.ts:10:6
8 | useEffect(() => {
9 | if (shouldChange) {
> 10 | setCount(count + 1);
| ^^^^^^^^ This should be computed during render, not in an effect
11 | }
12 | }, [count]);
13 |
```

View File

@@ -1,53 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
import {useEffect, useState} from 'react';
function Component({firstName}) {
const [lastName, setLastName] = useState('Doe');
const [fullName, setFullName] = useState('John');
const middleName = 'D.';
useEffect(() => {
setFullName(firstName + ' ' + middleName + ' ' + lastName);
}, [firstName, middleName, lastName]);
return (
<div>
<input value={lastName} onChange={e => setLastName(e.target.value)} />
<div>{fullName}</div>
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstName: 'John'}],
};
```
## Error
```
Found 1 error:
Error: You might not need an effect. Derive values in render, not effects.
Derived values (From props and local state: [firstName, lastName]) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
error.derived-state-from-prop-local-state-and-component-scope.ts:11:4
9 |
10 | useEffect(() => {
> 11 | setFullName(firstName + ' ' + middleName + ' ' + lastName);
| ^^^^^^^^^^^ This should be computed during render, not in an effect
12 | }, [firstName, middleName, lastName]);
13 |
14 | return (
```

View File

@@ -1,46 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
import {useEffect, useState} from 'react';
function Component({value}) {
const [localValue, setLocalValue] = useState('');
useEffect(() => {
setLocalValue(value);
document.title = `Value: ${value}`;
}, [value]);
return <div>{localValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 'test'}],
};
```
## Error
```
Found 1 error:
Error: You might not need an effect. Derive values in render, not effects.
Derived values (From props: [value]) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
error.derived-state-from-prop-with-side-effect.ts:8:4
6 |
7 | useEffect(() => {
> 8 | setLocalValue(value);
| ^^^^^^^^^^^^^ This should be computed during render, not in an effect
9 | document.title = `Value: ${value}`;
10 | }, [value]);
11 |
```

View File

@@ -1,50 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
import {useEffect, useState} from 'react';
function Component({propValue}) {
const [value, setValue] = useState(null);
function localFunction() {
console.log('local function');
}
useEffect(() => {
setValue(propValue);
localFunction();
}, [propValue]);
return <div>{value}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{propValue: 'test'}],
};
```
## Error
```
Found 1 error:
Error: You might not need an effect. Derive values in render, not effects.
Derived values (From props: [propValue]) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
error.effect-contains-local-function-call.ts:12:4
10 |
11 | useEffect(() => {
> 12 | setValue(propValue);
| ^^^^^^^^ This should be computed during render, not in an effect
13 | localFunction();
14 | }, [propValue]);
15 |
```

View File

@@ -1,48 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
import {useEffect, useState} from 'react';
function Component() {
const [firstName, setFirstName] = useState('Taylor');
const lastName = 'Swift';
// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
};
```
## Error
```
Found 1 error:
Error: You might not need an effect. Derive values in render, not effects.
Derived values (From local state: [firstName]) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
error.invalid-derived-computation-in-effect.ts:11:4
9 | const [fullName, setFullName] = useState('');
10 | useEffect(() => {
> 11 | setFullName(firstName + ' ' + lastName);
| ^^^^^^^^^^^ This should be computed during render, not in an effect
12 | }, [firstName, lastName]);
13 |
14 | return <div>{fullName}</div>;
```

View File

@@ -1,46 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
import {useEffect, useState} from 'react';
export default function Component(props) {
const [displayValue, setDisplayValue] = useState('');
useEffect(() => {
const computed = props.prefix + props.value + props.suffix;
setDisplayValue(computed);
}, [props.prefix, props.value, props.suffix]);
return <div>{displayValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{prefix: '[', value: 'test', suffix: ']'}],
};
```
## Error
```
Found 1 error:
Error: You might not need an effect. Derive values in render, not effects.
Derived values (From props: [props]) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
error.invalid-derived-state-from-computed-props.ts:9:4
7 | useEffect(() => {
8 | const computed = props.prefix + props.value + props.suffix;
> 9 | setDisplayValue(computed);
| ^^^^^^^^^^^^^^^ This should be computed during render, not in an effect
10 | }, [props.prefix, props.value, props.suffix]);
11 |
12 | return <div>{displayValue}</div>;
```

View File

@@ -1,47 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
import {useEffect, useState} from 'react';
export default function Component({props}) {
const [fullName, setFullName] = useState(
props.firstName + ' ' + props.lastName
);
useEffect(() => {
setFullName(props.firstName + ' ' + props.lastName);
}, [props.firstName, props.lastName]);
return <div>{fullName}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{props: {firstName: 'John', lastName: 'Doe'}}],
};
```
## Error
```
Found 1 error:
Error: You might not need an effect. Derive values in render, not effects.
Derived values (From props: [props]) should be computed during render, rather than in effects. Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user.
error.invalid-derived-state-from-destructured-props.ts:10:4
8 |
9 | useEffect(() => {
> 10 | setFullName(props.firstName + ' ' + props.lastName);
| ^^^^^^^^^^^ This should be computed during render, not in an effect
11 | }, [props.firstName, props.lastName]);
12 |
13 | return <div>{fullName}</div>;
```

View File

@@ -0,0 +1,80 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component() {
const [firstName, setFirstName] = useState('Taylor');
const lastName = 'Swift';
// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
function Component() {
const $ = _c(5);
const [firstName] = useState("Taylor");
const [fullName, setFullName] = useState("");
let t0;
let t1;
if ($[0] !== firstName) {
t0 = () => {
setFullName(firstName + " " + "Swift");
};
t1 = [firstName, "Swift"];
$[0] = firstName;
$[1] = t0;
$[2] = t1;
} else {
t0 = $[1];
t1 = $[2];
}
useEffect(t0, t1);
let t2;
if ($[3] !== fullName) {
t2 = <div>{fullName}</div>;
$[3] = fullName;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
};
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nState: [firstName]\n\nData Flow Tree:\n└── firstName (State)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":11,"column":4,"index":341},"end":{"line":11,"column":15,"index":352},"filename":"invalid-derived-computation-in-effect.ts","identifierName":"setFullName"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":107},"end":{"line":15,"column":1,"index":445},"filename":"invalid-derived-computation-in-effect.ts"},"fnName":"Component","memoSlots":5,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div>Taylor Swift</div>

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
function Component() {

View File

@@ -0,0 +1,79 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
export default function Component(props) {
const [displayValue, setDisplayValue] = useState('');
useEffect(() => {
const computed = props.prefix + props.value + props.suffix;
setDisplayValue(computed);
}, [props.prefix, props.value, props.suffix]);
return <div>{displayValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{prefix: '[', value: 'test', suffix: ']'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
export default function Component(props) {
const $ = _c(7);
const [displayValue, setDisplayValue] = useState("");
let t0;
let t1;
if ($[0] !== props.prefix || $[1] !== props.suffix || $[2] !== props.value) {
t0 = () => {
const computed = props.prefix + props.value + props.suffix;
setDisplayValue(computed);
};
t1 = [props.prefix, props.value, props.suffix];
$[0] = props.prefix;
$[1] = props.suffix;
$[2] = props.value;
$[3] = t0;
$[4] = t1;
} else {
t0 = $[3];
t1 = $[4];
}
useEffect(t0, t1);
let t2;
if ($[5] !== displayValue) {
t2 = <div>{displayValue}</div>;
$[5] = displayValue;
$[6] = t2;
} else {
t2 = $[6];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ prefix: "[", value: "test", suffix: "]" }],
};
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nProps: [props]\n\nData Flow Tree:\n└── computed\n └── props (Prop)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":9,"column":4,"index":295},"end":{"line":9,"column":19,"index":310},"filename":"invalid-derived-state-from-computed-props.ts","identifierName":"setDisplayValue"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":15,"index":122},"end":{"line":13,"column":1,"index":409},"filename":"invalid-derived-state-from-computed-props.ts"},"fnName":"Component","memoSlots":7,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div>[test]</div>

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
export default function Component(props) {

View File

@@ -0,0 +1,81 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
export default function Component({props}) {
const [fullName, setFullName] = useState(
props.firstName + ' ' + props.lastName
);
useEffect(() => {
setFullName(props.firstName + ' ' + props.lastName);
}, [props.firstName, props.lastName]);
return <div>{fullName}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{props: {firstName: 'John', lastName: 'Doe'}}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState } from "react";
export default function Component(t0) {
const $ = _c(6);
const { props } = t0;
const [fullName, setFullName] = useState(
props.firstName + " " + props.lastName,
);
let t1;
let t2;
if ($[0] !== props.firstName || $[1] !== props.lastName) {
t1 = () => {
setFullName(props.firstName + " " + props.lastName);
};
t2 = [props.firstName, props.lastName];
$[0] = props.firstName;
$[1] = props.lastName;
$[2] = t1;
$[3] = t2;
} else {
t1 = $[2];
t2 = $[3];
}
useEffect(t1, t2);
let t3;
if ($[4] !== fullName) {
t3 = <div>{fullName}</div>;
$[4] = fullName;
$[5] = t3;
} else {
t3 = $[5];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ props: { firstName: "John", lastName: "Doe" } }],
};
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nProps: [props]\n\nData Flow Tree:\n└── props (Prop)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":10,"column":4,"index":269},"end":{"line":10,"column":15,"index":280},"filename":"invalid-derived-state-from-destructured-props.ts","identifierName":"setFullName"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":15,"index":122},"end":{"line":14,"column":1,"index":397},"filename":"invalid-derived-state-from-destructured-props.ts"},"fnName":"Component","memoSlots":6,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) <div>John Doe</div>

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState} from 'react';
export default function Component({props}) {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState, useRef} from 'react';
export default function Component({test}) {
@@ -31,7 +31,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import { useEffect, useState, useRef } from "react";
export default function Component(t0) {
@@ -77,6 +77,12 @@ export const FIXTURE_ENTRYPOINT = {
};
```
## Logs
```
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":15,"index":130},"end":{"line":18,"column":1,"index":386},"filename":"ref-conditional-in-effect-no-error.ts"},"fnName":"Component","memoSlots":5,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) 8

View File

@@ -1,4 +1,4 @@
// @validateNoDerivedComputationsInEffects_exp
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
import {useEffect, useState, useRef} from 'react';
export default function Component({test}) {

View File

@@ -0,0 +1,72 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
function Component({prop}) {
const [s, setS] = useState();
const [second, setSecond] = useState(prop);
/*
* `second` is a source of state. It will inherit the value of `prop` in
* the first render, but after that it will no longer be updated when
* `prop` changes. So we shouldn't consider `second` as being derived from
* `prop`
*/
useEffect(() => {
setS(second);
}, [second]);
return <div>{s}</div>;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
function Component(t0) {
const $ = _c(5);
const { prop } = t0;
const [s, setS] = useState();
const [second] = useState(prop);
let t1;
let t2;
if ($[0] !== second) {
t1 = () => {
setS(second);
};
t2 = [second];
$[0] = second;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== s) {
t3 = <div>{s}</div>;
$[3] = s;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nState: [second]\n\nData Flow Tree:\n└── second (State)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":14,"column":4,"index":443},"end":{"line":14,"column":8,"index":447},"filename":"usestate-derived-from-prop-no-show-in-data-flow-tree.ts","identifierName":"setS"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":3,"column":0,"index":64},"end":{"line":18,"column":1,"index":500},"filename":"usestate-derived-from-prop-no-show-in-data-flow-tree.ts"},"fnName":"Component","memoSlots":5,"memoBlocks":2,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,18 @@
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
function Component({prop}) {
const [s, setS] = useState();
const [second, setSecond] = useState(prop);
/*
* `second` is a source of state. It will inherit the value of `prop` in
* the first render, but after that it will no longer be updated when
* `prop` changes. So we shouldn't consider `second` as being derived from
* `prop`
*/
useEffect(() => {
setS(second);
}, [second]);
return <div>{s}</div>;
}

View File

@@ -0,0 +1,224 @@
## Input
```javascript
// @validateSourceLocations
import {useEffect, useCallback} from 'react';
function Component({prop1, prop2}) {
const x = prop1 + prop2;
const y = x * 2;
const arr = [x, y];
const obj = {x, y};
const [a, b] = arr;
const {x: c, y: d} = obj;
useEffect(() => {
if (a > 10) {
console.log(a);
}
}, [a]);
const foo = useCallback(() => {
return a + b;
}, [a, b]);
function bar() {
return (c + d) * 2;
}
console.log('Hello, world!');
return [y, foo, bar];
}
```
## Error
```
Found 13 errors:
Todo: Important source location missing in generated code
Source location for VariableDeclarator is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:5:8
3 |
4 | function Component({prop1, prop2}) {
> 5 | const x = prop1 + prop2;
| ^^^^^^^^^^^^^^^^^
6 | const y = x * 2;
7 | const arr = [x, y];
8 | const obj = {x, y};
Todo: Important source location missing in generated code
Source location for VariableDeclarator is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:6:8
4 | function Component({prop1, prop2}) {
5 | const x = prop1 + prop2;
> 6 | const y = x * 2;
| ^^^^^^^^^
7 | const arr = [x, y];
8 | const obj = {x, y};
9 | const [a, b] = arr;
Todo: Important source location missing in generated code
Source location for VariableDeclarator is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:7:8
5 | const x = prop1 + prop2;
6 | const y = x * 2;
> 7 | const arr = [x, y];
| ^^^^^^^^^^^^
8 | const obj = {x, y};
9 | const [a, b] = arr;
10 | const {x: c, y: d} = obj;
Todo: Important source location missing in generated code
Source location for VariableDeclarator is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:8:8
6 | const y = x * 2;
7 | const arr = [x, y];
> 8 | const obj = {x, y};
| ^^^^^^^^^^^^
9 | const [a, b] = arr;
10 | const {x: c, y: d} = obj;
11 |
Todo: Important source location missing in generated code
Source location for VariableDeclarator is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:9:8
7 | const arr = [x, y];
8 | const obj = {x, y};
> 9 | const [a, b] = arr;
| ^^^^^^^^^^^^
10 | const {x: c, y: d} = obj;
11 |
12 | useEffect(() => {
Todo: Important source location missing in generated code
Source location for VariableDeclarator is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:10:8
8 | const obj = {x, y};
9 | const [a, b] = arr;
> 10 | const {x: c, y: d} = obj;
| ^^^^^^^^^^^^^^^^^^
11 |
12 | useEffect(() => {
13 | if (a > 10) {
Todo: Important source location missing in generated code
Source location for ExpressionStatement is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:12:2
10 | const {x: c, y: d} = obj;
11 |
> 12 | useEffect(() => {
| ^^^^^^^^^^^^^^^^^
> 13 | if (a > 10) {
| ^^^^^^^^^^^^^^^^^
> 14 | console.log(a);
| ^^^^^^^^^^^^^^^^^
> 15 | }
| ^^^^^^^^^^^^^^^^^
> 16 | }, [a]);
| ^^^^^^^^^^^
17 |
18 | const foo = useCallback(() => {
19 | return a + b;
Todo: Important source location missing in generated code
Source location for ExpressionStatement is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:14:6
12 | useEffect(() => {
13 | if (a > 10) {
> 14 | console.log(a);
| ^^^^^^^^^^^^^^^
15 | }
16 | }, [a]);
17 |
Todo: Important source location missing in generated code
Source location for VariableDeclarator is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:18:8
16 | }, [a]);
17 |
> 18 | const foo = useCallback(() => {
| ^^^^^^^^^^^^^^^^^^^^^^^^^
> 19 | return a + b;
| ^^^^^^^^^^^^^^^^^
> 20 | }, [a, b]);
| ^^^^^^^^^^^^^
21 |
22 | function bar() {
23 | return (c + d) * 2;
Todo: Important source location missing in generated code
Source location for ReturnStatement is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:19:4
17 |
18 | const foo = useCallback(() => {
> 19 | return a + b;
| ^^^^^^^^^^^^^
20 | }, [a, b]);
21 |
22 | function bar() {
Todo: Important source location missing in generated code
Source location for ReturnStatement is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:23:4
21 |
22 | function bar() {
> 23 | return (c + d) * 2;
| ^^^^^^^^^^^^^^^^^^^
24 | }
25 |
26 | console.log('Hello, world!');
Todo: Important source location missing in generated code
Source location for ExpressionStatement is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:26:2
24 | }
25 |
> 26 | console.log('Hello, world!');
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
27 |
28 | return [y, foo, bar];
29 | }
Todo: Important source location missing in generated code
Source location for ReturnStatement is missing in the generated output. This can cause coverage instrumentation to fail to track this code properly, resulting in inaccurate coverage reports..
error.todo-missing-source-locations.ts:28:2
26 | console.log('Hello, world!');
27 |
> 28 | return [y, foo, bar];
| ^^^^^^^^^^^^^^^^^^^^^
29 | }
30 |
```

View File

@@ -0,0 +1,29 @@
// @validateSourceLocations
import {useEffect, useCallback} from 'react';
function Component({prop1, prop2}) {
const x = prop1 + prop2;
const y = x * 2;
const arr = [x, y];
const obj = {x, y};
const [a, b] = arr;
const {x: c, y: d} = obj;
useEffect(() => {
if (a > 10) {
console.log(a);
}
}, [a]);
const foo = useCallback(() => {
return a + b;
}, [a, b]);
function bar() {
return (c + d) * 2;
}
console.log('Hello, world!');
return [y, foo, bar];
}

View File

@@ -152,6 +152,7 @@
"download-build-in-codesandbox-ci": "yarn build --type=node react/index react.react-server react-dom/index react-dom/client react-dom/src/server react-dom/test-utils react-dom.react-server scheduler/index react/jsx-runtime react/jsx-dev-runtime react-server-dom-webpack",
"check-release-dependencies": "node ./scripts/release/check-release-dependencies",
"generate-inline-fizz-runtime": "node ./scripts/rollup/generate-inline-fizz-runtime.js",
"generate-changelog": "node ./scripts/tasks/generate-changelog/index.js",
"flags": "node ./scripts/flags/flags.js"
},
"resolutions": {

View File

@@ -585,6 +585,29 @@ const allTests = {
code: normalizeIndent`
// Valid: useEffectEvent can be called in custom effect hooks configured via ESLint settings
function MyComponent({ theme }) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
useMyEffect(() => {
onClick();
});
useServerEffect(() => {
onClick();
});
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: '(useMyEffect|useServerEffect)',
},
},
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Valid: useEffectEvent can be called in custom effect hooks configured via ESLint settings
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
@@ -618,6 +641,24 @@ const allTests = {
}
`,
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Valid because functions created with useEffectEvent can be called in a useEffect.
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
useEffect(() => {
onClick();
});
React.useEffect(() => {
onClick();
});
}
`,
},
{
code: normalizeIndent`
// Valid because functions created with useEffectEvent can be passed by reference in useEffect
@@ -644,6 +685,34 @@ const allTests = {
}
`,
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Valid because functions created with useEffectEvent can be passed by reference in useEffect
// and useEffectEvent.
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
const onClick2 = useEffectEvent(() => {
debounce(onClick);
debounce(() => onClick());
debounce(() => { onClick() });
deboucne(() => debounce(onClick));
});
useEffect(() => {
let id = setInterval(() => onClick(), 100);
return () => clearInterval(onClick);
}, []);
React.useEffect(() => {
let id = setInterval(() => onClick(), 100);
return () => clearInterval(onClick);
}, []);
return null;
}
`,
},
{
code: normalizeIndent`
function MyComponent({ theme }) {
@@ -656,6 +725,20 @@ const allTests = {
}
`,
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
component MyComponent(theme: any) {
useEffect(() => {
onClick();
});
const onClick = useEffectEvent(() => {
showNotification(theme);
});
}
`,
},
{
code: normalizeIndent`
function MyComponent({ theme }) {
@@ -673,6 +756,25 @@ const allTests = {
}
`,
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
component MyComponent(theme: any) {
// Can receive arguments
const onEvent = useEffectEvent((text) => {
console.log(text);
});
useEffect(() => {
onEvent('Hello world');
});
React.useEffect(() => {
onEvent('Hello world');
});
}
`,
},
{
code: normalizeIndent`
// Valid because functions created with useEffectEvent can be called in useLayoutEffect.
@@ -689,6 +791,24 @@ const allTests = {
}
`,
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Valid because functions created with useEffectEvent can be called in useLayoutEffect.
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
useLayoutEffect(() => {
onClick();
});
React.useLayoutEffect(() => {
onClick();
});
}
`,
},
{
code: normalizeIndent`
// Valid because functions created with useEffectEvent can be called in useInsertionEffect.
@@ -705,6 +825,24 @@ const allTests = {
}
`,
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Valid because functions created with useEffectEvent can be called in useInsertionEffect.
component MyComponent(theme) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
useInsertionEffect(() => {
onClick();
});
React.useInsertionEffect(() => {
onClick();
});
}
`,
},
{
code: normalizeIndent`
// Valid because functions created with useEffectEvent can be passed by reference in useLayoutEffect
@@ -739,6 +877,42 @@ const allTests = {
}
`,
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Valid because functions created with useEffectEvent can be passed by reference in useLayoutEffect.
// and useInsertionEffect.
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
const onClick2 = useEffectEvent(() => {
debounce(onClick);
debounce(() => onClick());
debounce(() => { onClick() });
deboucne(() => debounce(onClick));
});
useLayoutEffect(() => {
let id = setInterval(() => onClick(), 100);
return () => clearInterval(onClick);
}, []);
React.useLayoutEffect(() => {
let id = setInterval(() => onClick(), 100);
return () => clearInterval(onClick);
}, []);
useInsertionEffect(() => {
let id = setInterval(() => onClick(), 100);
return () => clearInterval(onClick);
}, []);
React.useInsertionEffect(() => {
let id = setInterval(() => onClick(), 100);
return () => clearInterval(onClick);
}, []);
return null;
}
`,
},
],
invalid: [
{
@@ -1525,6 +1699,22 @@ const allTests = {
`,
errors: [useEffectEventError('onClick', true)],
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Invalid: useEffectEvent should not be callable in regular custom hooks without additional configuration
component MyComponent() {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
useCustomHook(() => {
onClick();
});
}
`,
errors: [useEffectEventError('onClick', true)],
},
{
code: normalizeIndent`
// Invalid: useEffectEvent should not be callable in hooks not matching the settings regex
@@ -1544,6 +1734,27 @@ const allTests = {
},
errors: [useEffectEventError('onClick', true)],
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Invalid: useEffectEvent should not be callable in hooks not matching the settings regex
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
useWrongHook(() => {
onClick();
});
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: 'useMyEffect',
},
},
errors: [useEffectEventError('onClick', true)],
},
{
code: normalizeIndent`
function MyComponent({ theme }) {
@@ -1555,6 +1766,19 @@ const allTests = {
`,
errors: [useEffectEventError('onClick', false)],
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
return <Child onClick={onClick}></Child>;
}
`,
errors: [useEffectEventError('onClick', false)],
},
{
code: normalizeIndent`
// Invalid because useEffectEvent is being passed down
@@ -1566,6 +1790,19 @@ const allTests = {
`,
errors: [{...useEffectEventError(null, false), line: 4}],
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Invalid because useEffectEvent is being passed down
component MyComponent(theme: any) {
return <Child onClick={useEffectEvent(() => {
showNotification(theme);
})} />;
}
`,
errors: [{...useEffectEventError(null, false), line: 5}],
},
{
code: normalizeIndent`
// This should error even though it shares an identifier name with the below
@@ -1601,6 +1838,43 @@ const allTests = {
{...useEffectEventError('onClick', true), line: 15},
],
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// This should error even though it shares an identifier name with the below
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme)
});
return <Child onClick={onClick} />
}
// The useEffectEvent function shares an identifier name with the above
component MyOtherComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme)
});
return <Child onClick={() => onClick()} />
}
// The useEffectEvent function shares an identifier name with the above
component MyLastComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme)
});
useEffect(() => {
onClick(); // No error here, errors on all other uses
onClick;
})
return <Child />
}
`,
errors: [
{...useEffectEventError('onClick', false), line: 8},
{...useEffectEventError('onClick', true), line: 16},
],
},
{
code: normalizeIndent`
const MyComponent = ({ theme }) => {
@@ -1625,6 +1899,21 @@ const allTests = {
`,
errors: [{...useEffectEventError('onClick', false), line: 7}],
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Invalid because onClick is being aliased to foo but not invoked
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
let foo = onClick;
return <Bar onClick={foo} />
}
`,
errors: [{...useEffectEventError('onClick', false), line: 8}],
},
{
code: normalizeIndent`
// Should error because it's being passed down to JSX, although it's been referenced once
@@ -1641,6 +1930,24 @@ const allTests = {
`,
errors: [useEffectEventError('onClick', false)],
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax version
// Should error because it's being passed down to JSX, although it's been referenced once
// in an effect
component MyComponent(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(them);
});
useEffect(() => {
setTimeout(onClick, 100);
});
return <Child onClick={onClick} />
}
`,
errors: [useEffectEventError('onClick', false)],
},
{
code: normalizeIndent`
// Invalid because functions created with useEffectEvent cannot be called in arbitrary closures.
@@ -1676,6 +1983,43 @@ const allTests = {
`It cannot be assigned to a variable or passed down.`,
],
},
{
syntax: 'flow',
code: normalizeIndent`
// Hook syntax version
// Invalid because functions created with useEffectEvent cannot be called in arbitrary closures.
hook useMyHook(theme: any) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
// error message 1
const onClick2 = () => { onClick() };
// error message 2
const onClick3 = useCallback(() => onClick(), []);
// error message 3
const onClick4 = onClick;
return <>
{/** error message 4 */}
<Child onClick={onClick}></Child>
<Child onClick={onClick2}></Child>
<Child onClick={onClick3}></Child>
</>;
}
`,
// Explicitly test error messages here for various cases
errors: [
`\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
'Effects and Effect Events in the same component.',
`\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
'Effects and Effect Events in the same component.',
`\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
`Effects and Effect Events in the same component. ` +
`It cannot be assigned to a variable or passed down.`,
`\`onClick\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
`Effects and Effect Events in the same component. ` +
`It cannot be assigned to a variable or passed down.`,
],
},
],
};

View File

@@ -833,6 +833,18 @@ const rule = {
recordAllUseEffectEventFunctions(getScope(node));
}
},
// @ts-expect-error parser-hermes produces these node types
ComponentDeclaration(node) {
// component MyComponent() { const onClick = useEffectEvent(...) }
recordAllUseEffectEventFunctions(getScope(node));
},
// @ts-expect-error parser-hermes produces these node types
HookDeclaration(node) {
// hook useMyHook() { const onClick = useEffectEvent(...) }
recordAllUseEffectEventFunctions(getScope(node));
},
};
},
} satisfies Rule.RuleModule;

View File

@@ -39,12 +39,9 @@ import type {
EncodeFormActionCallback,
} from './ReactFlightReplyClient';
import type {Postpone} from 'react/src/ReactPostpone';
import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences';
import {
enablePostpone,
enableProfilerTimer,
enableComponentPerformanceTrack,
enableAsyncDebugInfo,
@@ -89,7 +86,6 @@ import {
import {
REACT_LAZY_TYPE,
REACT_ELEMENT_TYPE,
REACT_POSTPONE_TYPE,
ASYNC_ITERATOR,
REACT_FRAGMENT_TYPE,
} from 'shared/ReactSymbols';
@@ -367,6 +363,7 @@ type Response = {
_debugRootStack?: null | Error, // DEV-only
_debugRootTask?: null | ConsoleTask, // DEV-only
_debugStartTime: number, // DEV-only
_debugEndTime?: number, // DEV-only
_debugIOStarted: boolean, // DEV-only
_debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only
_debugChannel?: void | DebugChannel, // DEV-only
@@ -500,6 +497,33 @@ function createErrorChunk<T>(
return new ReactPromise(ERRORED, null, error);
}
function filterDebugInfo(
response: Response,
value: {_debugInfo: ReactDebugInfo, ...},
) {
if (response._debugEndTime === null) {
// No end time was defined, so we keep all debug info entries.
return;
}
// Remove any debug info entries after the defined end time. For async info
// that means we're including anything that was awaited before the end time,
// but it doesn't need to be resolved before the end time.
const relativeEndTime =
response._debugEndTime -
// $FlowFixMe[prop-missing]
performance.timeOrigin;
const debugInfo = [];
for (let i = 0; i < value._debugInfo.length; i++) {
const info = value._debugInfo[i];
if (typeof info.time === 'number' && info.time > relativeEndTime) {
break;
}
debugInfo.push(info);
}
value._debugInfo = debugInfo;
}
function moveDebugInfoFromChunkToInnerValue<T>(
chunk: InitializedChunk<T> | InitializedStreamChunk<any>,
value: T,
@@ -534,7 +558,17 @@ function moveDebugInfoFromChunkToInnerValue<T>(
}
}
function processChunkDebugInfo<T>(
response: Response,
chunk: InitializedChunk<T> | InitializedStreamChunk<any>,
value: T,
): void {
filterDebugInfo(response, chunk);
moveDebugInfoFromChunkToInnerValue(chunk, value);
}
function wakeChunk<T>(
response: Response,
listeners: Array<InitializationReference | (T => mixed)>,
value: T,
chunk: InitializedChunk<T>,
@@ -544,16 +578,17 @@ function wakeChunk<T>(
if (typeof listener === 'function') {
listener(value);
} else {
fulfillReference(listener, value, chunk);
fulfillReference(response, listener, value, chunk);
}
}
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(chunk, value);
processChunkDebugInfo(response, chunk, value);
}
}
function rejectChunk(
response: Response,
listeners: Array<InitializationReference | (mixed => mixed)>,
error: mixed,
): void {
@@ -562,7 +597,7 @@ function rejectChunk(
if (typeof listener === 'function') {
listener(error);
} else {
rejectReference(listener, error);
rejectReference(response, listener.handler, error);
}
}
}
@@ -595,13 +630,14 @@ function resolveBlockedCycle<T>(
}
function wakeChunkIfInitialized<T>(
response: Response,
chunk: SomeChunk<T>,
resolveListeners: Array<InitializationReference | (T => mixed)>,
rejectListeners: null | Array<InitializationReference | (mixed => mixed)>,
): void {
switch (chunk.status) {
case INITIALIZED:
wakeChunk(resolveListeners, chunk.value, chunk);
wakeChunk(response, resolveListeners, chunk.value, chunk);
break;
case BLOCKED:
// It is possible that we're blocked on our own chunk if it's a cycle.
@@ -615,7 +651,7 @@ function wakeChunkIfInitialized<T>(
if (cyclicHandler !== null) {
// This reference points back to this chunk. We can resolve the cycle by
// using the value from that handler.
fulfillReference(reference, cyclicHandler.value, chunk);
fulfillReference(response, reference, cyclicHandler.value, chunk);
resolveListeners.splice(i, 1);
i--;
if (rejectListeners !== null) {
@@ -624,6 +660,23 @@ function wakeChunkIfInitialized<T>(
rejectListeners.splice(rejectionIdx, 1);
}
}
// The status might have changed after fulfilling the reference.
switch ((chunk: SomeChunk<T>).status) {
case INITIALIZED:
const initializedChunk: InitializedChunk<T> = (chunk: any);
wakeChunk(
response,
resolveListeners,
initializedChunk.value,
initializedChunk,
);
return;
case ERRORED:
if (rejectListeners !== null) {
rejectChunk(response, rejectListeners, chunk.reason);
}
return;
}
}
}
}
@@ -650,7 +703,7 @@ function wakeChunkIfInitialized<T>(
break;
case ERRORED:
if (rejectListeners) {
rejectChunk(rejectListeners, chunk.reason);
rejectChunk(response, rejectListeners, chunk.reason);
}
break;
}
@@ -708,7 +761,7 @@ function triggerErrorOnChunk<T>(
erroredChunk.status = ERRORED;
erroredChunk.reason = error;
if (listeners !== null) {
rejectChunk(listeners, error);
rejectChunk(response, listeners, error);
}
}
@@ -816,7 +869,7 @@ function resolveModelChunk<T>(
// longer be rendered or might not be the highest pri.
initializeModelChunk(resolvedChunk);
// The status might have changed after initialization.
wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners);
wakeChunkIfInitialized(response, chunk, resolveListeners, rejectListeners);
}
}
@@ -845,12 +898,11 @@ function resolveModuleChunk<T>(
}
if (resolveListeners !== null) {
initializeModuleChunk(resolvedChunk);
wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners);
wakeChunkIfInitialized(response, chunk, resolveListeners, rejectListeners);
}
}
type InitializationReference = {
response: Response, // TODO: Remove Response from here and pass it through instead.
handler: InitializationHandler,
parentObject: Object,
key: string,
@@ -989,7 +1041,7 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
if (typeof listener === 'function') {
listener(value);
} else {
fulfillReference(listener, value, cyclicChunk);
fulfillReference(response, listener, value, cyclicChunk);
}
}
}
@@ -1010,7 +1062,7 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
initializedChunk.value = value;
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(initializedChunk, value);
processChunkDebugInfo(response, initializedChunk, value);
}
} catch (error) {
const erroredChunk: ErroredChunk<T> = (chunk: any);
@@ -1397,11 +1449,12 @@ function getChunk(response: Response, id: number): SomeChunk<any> {
}
function fulfillReference(
response: Response,
reference: InitializationReference,
value: any,
fulfilledChunk: SomeChunk<any>,
): void {
const {response, handler, parentObject, key, map, path} = reference;
const {handler, parentObject, key, map, path} = reference;
for (let i = 1; i < path.length; i++) {
while (
@@ -1471,7 +1524,11 @@ function fulfillReference(
return;
}
default: {
rejectReference(reference, referencedChunk.reason);
rejectReference(
response,
reference.handler,
referencedChunk.reason,
);
return;
}
}
@@ -1569,21 +1626,20 @@ function fulfillReference(
initializedChunk.value = handler.value;
initializedChunk.reason = handler.reason; // Used by streaming chunks
if (resolveListeners !== null) {
wakeChunk(resolveListeners, handler.value, initializedChunk);
wakeChunk(response, resolveListeners, handler.value, initializedChunk);
} else {
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(initializedChunk, handler.value);
processChunkDebugInfo(response, initializedChunk, handler.value);
}
}
}
}
function rejectReference(
reference: InitializationReference,
response: Response,
handler: InitializationHandler,
error: mixed,
): void {
const {handler, response} = reference;
if (handler.errored) {
// We've already errored. We could instead build up an AggregateError
// but if there are multiple errors we just take the first one like
@@ -1674,7 +1730,6 @@ function waitForReference<T>(
}
const reference: InitializationReference = {
response,
handler,
parentObject,
key,
@@ -1822,10 +1877,10 @@ function loadServerReference<A: Iterable<any>, T>(
initializedChunk.status = INITIALIZED;
initializedChunk.value = handler.value;
if (resolveListeners !== null) {
wakeChunk(resolveListeners, handler.value, initializedChunk);
wakeChunk(response, resolveListeners, handler.value, initializedChunk);
} else {
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(initializedChunk, handler.value);
processChunkDebugInfo(response, initializedChunk, handler.value);
}
}
}
@@ -2562,6 +2617,7 @@ function ResponseInstance(
replayConsole: boolean, // DEV-only
environmentName: void | string, // DEV-only
debugStartTime: void | number, // DEV-only
debugEndTime: void | number, // DEV-only
debugChannel: void | DebugChannel, // DEV-only
) {
const chunks: Map<number, SomeChunk<any>> = new Map();
@@ -2629,6 +2685,7 @@ function ResponseInstance(
// and is not considered I/O required to load the stream.
setTimeout(markIOStarted.bind(this), 0);
}
this._debugEndTime = debugEndTime == null ? null : debugEndTime;
this._debugFindSourceMapURL = findSourceMapURL;
this._debugChannel = debugChannel;
this._blockedConsole = null;
@@ -2672,6 +2729,7 @@ export function createResponse(
replayConsole: boolean, // DEV-only
environmentName: void | string, // DEV-only
debugStartTime: void | number, // DEV-only
debugEndTime: void | number, // DEV-only
debugChannel: void | DebugChannel, // DEV-only
): WeakResponse {
return getWeakResponse(
@@ -2688,6 +2746,7 @@ export function createResponse(
replayConsole,
environmentName,
debugStartTime,
debugEndTime,
debugChannel,
),
);
@@ -3059,10 +3118,10 @@ function resolveStream<T: ReadableStream | $AsyncIterable<any, any, void>>(
resolvedChunk.value = stream;
resolvedChunk.reason = controller;
if (resolveListeners !== null) {
wakeChunk(resolveListeners, chunk.value, (chunk: any));
wakeChunk(response, resolveListeners, chunk.value, (chunk: any));
} else {
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(resolvedChunk, stream);
processChunkDebugInfo(response, resolvedChunk, stream);
}
}
}
@@ -3202,7 +3261,12 @@ function startAsyncIterable<T>(
initializedChunk.status = INITIALIZED;
initializedChunk.value = {done: false, value: value};
if (resolveListeners !== null) {
wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners);
wakeChunkIfInitialized(
response,
chunk,
resolveListeners,
rejectListeners,
);
}
}
nextWriteIndex++;
@@ -3392,88 +3456,6 @@ function resolveErrorDev(
return error;
}
function resolvePostponeProd(
response: Response,
id: number,
streamState: StreamState,
): void {
if (__DEV__) {
// These errors should never make it into a build so we don't need to encode them in codes.json
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'resolvePostponeProd should never be called in development mode. Use resolvePostponeDev instead. This is a bug in React.',
);
}
const error = new Error(
'A Server Component was postponed. The reason is omitted in production' +
' builds to avoid leaking sensitive details.',
);
const postponeInstance: Postpone = (error: any);
postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
postponeInstance.stack = 'Error: ' + error.message;
const chunks = response._chunks;
const chunk = chunks.get(id);
if (!chunk) {
const newChunk: ErroredChunk<any> = createErrorChunk(
response,
postponeInstance,
);
chunks.set(id, newChunk);
} else {
triggerErrorOnChunk(response, chunk, postponeInstance);
}
}
function resolvePostponeDev(
response: Response,
id: number,
reason: string,
stack: ReactStackTrace,
env: string,
streamState: StreamState,
): void {
if (!__DEV__) {
// These errors should never make it into a build so we don't need to encode them in codes.json
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'resolvePostponeDev should never be called in production mode. Use resolvePostponeProd instead. This is a bug in React.',
);
}
let postponeInstance: Postpone;
const callStack = buildFakeCallStack(
response,
stack,
env,
false,
// $FlowFixMe[incompatible-use]
Error.bind(null, reason || ''),
);
const rootTask = response._debugRootTask;
if (rootTask != null) {
postponeInstance = rootTask.run(callStack);
} else {
postponeInstance = callStack();
}
postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
const chunks = response._chunks;
const chunk = chunks.get(id);
if (!chunk) {
const newChunk: ErroredChunk<any> = createErrorChunk(
response,
postponeInstance,
);
if (__DEV__) {
resolveChunkDebugInfo(response, streamState, newChunk);
}
chunks.set(id, newChunk);
} else {
if (__DEV__) {
resolveChunkDebugInfo(response, streamState, chunk);
}
triggerErrorOnChunk(response, chunk, postponeInstance);
}
}
function resolveErrorModel(
response: Response,
id: number,
@@ -4825,25 +4807,6 @@ function processFullStringRow(
return;
}
// Fallthrough
case 80 /* "P" */: {
if (enablePostpone) {
if (__DEV__) {
const postponeInfo = JSON.parse(row);
resolvePostponeDev(
response,
id,
postponeInfo.reason,
postponeInfo.stack,
postponeInfo.env,
streamState,
);
} else {
resolvePostponeProd(response, id, streamState);
}
return;
}
}
// Fallthrough
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
if (__DEV__ && row === '') {
resolveDebugHalt(response, id);
@@ -4894,6 +4857,7 @@ export function processBinaryChunk(
resolvedRowTag === 65 /* "A" */ ||
resolvedRowTag === 79 /* "O" */ ||
resolvedRowTag === 111 /* "o" */ ||
resolvedRowTag === 98 /* "b" */ ||
resolvedRowTag === 85 /* "U" */ ||
resolvedRowTag === 83 /* "S" */ ||
resolvedRowTag === 115 /* "s" */ ||
@@ -4953,14 +4917,31 @@ export function processBinaryChunk(
// We found the last chunk of the row
const length = lastIdx - i;
const lastChunk = new Uint8Array(chunk.buffer, offset, length);
processFullBinaryRow(
response,
streamState,
rowID,
rowTag,
buffer,
lastChunk,
);
// Check if this is a Uint8Array for a byte stream. We enqueue it
// immediately but need to determine if we can use zero-copy or must copy.
if (rowTag === 98 /* "b" */) {
resolveBuffer(
response,
rowID,
// If we're at the end of the RSC chunk, no more parsing will access
// this buffer and we don't need to copy the chunk to allow detaching
// the buffer, otherwise we need to copy.
lastIdx === chunkLength ? lastChunk : lastChunk.slice(),
streamState,
);
} else {
// Process all other row types.
processFullBinaryRow(
response,
streamState,
rowID,
rowTag,
buffer,
lastChunk,
);
}
// Reset state machine for a new row
i = lastIdx;
if (rowState === ROW_CHUNK_BY_NEWLINE) {
@@ -4973,14 +4954,27 @@ export function processBinaryChunk(
rowLength = 0;
buffer.length = 0;
} else {
// The rest of this row is in a future chunk. We stash the rest of the
// current chunk until we can process the full row.
// The rest of this row is in a future chunk.
const length = chunk.byteLength - i;
const remainingSlice = new Uint8Array(chunk.buffer, offset, length);
buffer.push(remainingSlice);
// Update how many bytes we're still waiting for. If we're looking for
// a newline, this doesn't hurt since we'll just ignore it.
rowLength -= remainingSlice.byteLength;
// For byte streams, we can enqueue the partial row immediately without
// copying since we're at the end of the RSC chunk and no more parsing
// will access this buffer.
if (rowTag === 98 /* "b" */) {
// Update how many bytes we're still waiting for. We need to do this
// before enqueueing, as enqueue will detach the buffer and byteLength
// will become 0.
rowLength -= remainingSlice.byteLength;
resolveBuffer(response, rowID, remainingSlice, streamState);
} else {
// For other row types, stash the rest of the current chunk until we can
// process the full row.
buffer.push(remainingSlice);
// Update how many bytes we're still waiting for. If we're looking for
// a newline, this doesn't hurt since we'll just ignore it.
rowLength -= remainingSlice.byteLength;
}
break;
}
}

View File

@@ -27,7 +27,7 @@ describe('Bridge', () => {
// Check that we're wired up correctly.
bridge.send('reloadAppForProfiling');
jest.runAllTimers();
expect(wall.send).toHaveBeenCalledWith('reloadAppForProfiling');
expect(wall.send).toHaveBeenCalledWith('reloadAppForProfiling', undefined);
// Should flush pending messages and then shut down.
wall.send.mockClear();
@@ -37,7 +37,7 @@ describe('Bridge', () => {
jest.runAllTimers();
expect(wall.send).toHaveBeenCalledWith('update', '1');
expect(wall.send).toHaveBeenCalledWith('update', '2');
expect(wall.send).toHaveBeenCalledWith('shutdown');
expect(wall.send).toHaveBeenCalledWith('shutdown', undefined);
expect(shutdownCallback).toHaveBeenCalledTimes(1);
// Verify that the Bridge doesn't send messages after shutdown.

View File

@@ -116,6 +116,16 @@ function shouldIgnoreConsoleErrorOrWarn(args) {
return false;
}
const maybeError = args[1];
if (
maybeError !== null &&
typeof maybeError === 'object' &&
maybeError.message === 'Simulated error coming from DevTools'
) {
// Error from forcing an error boundary.
return true;
}
return global._ignoredErrorOrWarningMessages.some(errorOrWarningMessage => {
return firstArg.indexOf(errorOrWarningMessage) !== -1;
});

View File

@@ -3283,8 +3283,6 @@ describe('Store', () => {
<Suspense name="Outer" rects={null}>
`);
console.log('...........................');
await actAsync(() => {
resolve('loaded');
});
@@ -3300,4 +3298,100 @@ describe('Store', () => {
<Suspense name="Inner" rects={[{x:1,y:2,width:6,height:1}]}>
`);
});
// @reactVersion >= 19.0
it('measures rects when reconnecting', async () => {
function Component({children, promise}) {
let content = '';
if (promise) {
const value = readValue(promise);
if (typeof value === 'string') {
content += value;
}
}
return (
<div>
{content}
{children}
</div>
);
}
function App({outer, inner}) {
return (
<React.Suspense
name="outer"
fallback={<Component key="outer-fallback">loading outer</Component>}>
<Component key="outer-content" promise={outer}>
outer content
</Component>
<React.Suspense
name="inner"
fallback={
<Component key="inner-fallback">loading inner</Component>
}>
<Component key="inner-content" promise={inner}>
inner content
</Component>
</React.Suspense>
</React.Suspense>
);
}
await actAsync(() => {
render(<App outer={null} inner={null} />);
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Suspense name="outer">
<Component key="outer-content">
▾ <Suspense name="inner">
<Component key="inner-content">
[suspense-root] rects={[{x:1,y:2,width:13,height:1}, {x:1,y:2,width:13,height:1}]}
<Suspense name="outer" rects={[{x:1,y:2,width:13,height:1}, {x:1,y:2,width:13,height:1}]}>
<Suspense name="inner" rects={[{x:1,y:2,width:13,height:1}]}>
`);
let outerResolve;
const outerPromise = new Promise(resolve => {
outerResolve = resolve;
});
let innerResolve;
const innerPromise = new Promise(resolve => {
innerResolve = resolve;
});
await actAsync(() => {
render(<App outer={outerPromise} inner={innerPromise} />);
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Suspense name="outer">
<Component key="outer-fallback">
[suspense-root] rects={[{x:1,y:2,width:13,height:1}, {x:1,y:2,width:13,height:1}, {x:1,y:2,width:13,height:1}]}
<Suspense name="outer" rects={[{x:1,y:2,width:13,height:1}, {x:1,y:2,width:13,height:1}]}>
<Suspense name="inner" rects={[{x:1,y:2,width:13,height:1}]}>
`);
await actAsync(() => {
outerResolve('..');
innerResolve('.');
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Suspense name="outer">
<Component key="outer-content">
▾ <Suspense name="inner">
<Component key="inner-content">
[suspense-root] rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:14,height:1}]}
<Suspense name="outer" rects={[{x:1,y:2,width:15,height:1}, {x:1,y:2,width:14,height:1}]}>
<Suspense name="inner" rects={[{x:1,y:2,width:14,height:1}]}>
`);
});
});

View File

@@ -18,12 +18,24 @@ import {
describe('Store component filters', () => {
let React;
let Types;
let agent;
let bridge: FrontendBridge;
let store: Store;
let utils;
let actAsync;
beforeAll(() => {
// JSDDOM doesn't implement getClientRects so we're just faking one for testing purposes
Element.prototype.getClientRects = function (this: Element) {
const textContent = this.textContent;
return [
new DOMRect(1, 2, textContent.length, textContent.split('\n').length),
];
};
});
beforeEach(() => {
agent = global.agent;
bridge = global.bridge;
store = global.store;
store.collapseNodesByDefault = false;
@@ -156,9 +168,9 @@ describe('Store component filters', () => {
<div>
▾ <Suspense>
<div>
[suspense-root] rects={[]}
<Suspense name="Unknown" rects={[]}>
<Suspense name="Unknown" rects={[]}>
[suspense-root] rects={[{x:1,y:2,width:7,height:1}, {x:1,y:2,width:6,height:1}]}
<Suspense name="Unknown" rects={[{x:1,y:2,width:7,height:1}]}>
<Suspense name="Unknown" rects={[{x:1,y:2,width:6,height:1}]}>
`);
await actAsync(
@@ -174,9 +186,9 @@ describe('Store component filters', () => {
<div>
▾ <Suspense>
<div>
[suspense-root] rects={[]}
<Suspense name="Unknown" rects={[]}>
<Suspense name="Unknown" rects={[]}>
[suspense-root] rects={[{x:1,y:2,width:7,height:1}, {x:1,y:2,width:6,height:1}]}
<Suspense name="Unknown" rects={[{x:1,y:2,width:7,height:1}]}>
<Suspense name="Unknown" rects={[{x:1,y:2,width:6,height:1}]}>
`);
await actAsync(
@@ -192,9 +204,9 @@ describe('Store component filters', () => {
<div>
▾ <Suspense>
<div>
[suspense-root] rects={[]}
<Suspense name="Unknown" rects={[]}>
<Suspense name="Unknown" rects={[]}>
[suspense-root] rects={[{x:1,y:2,width:7,height:1}, {x:1,y:2,width:6,height:1}]}
<Suspense name="Unknown" rects={[{x:1,y:2,width:7,height:1}]}>
<Suspense name="Unknown" rects={[{x:1,y:2,width:6,height:1}]}>
`);
});
@@ -740,4 +752,180 @@ describe('Store component filters', () => {
`);
});
});
// @reactVersion >= 16.6
it('resets forced error and fallback states when filters are changed', async () => {
store.componentFilters = [];
class ErrorBoundary extends React.Component {
state = {hasError: false};
static getDerivedStateFromError() {
return {hasError: true};
}
render() {
if (this.state.hasError) {
return <div key="did-error" />;
}
return this.props.children;
}
}
function App() {
return (
<>
<React.Suspense fallback={<div key="loading" />}>
<div key="suspense-content" />
</React.Suspense>
<ErrorBoundary>
<div key="error-content" />
</ErrorBoundary>
</>
);
}
await actAsync(async () => {
render(<App />);
});
const rendererID = utils.getRendererID();
await actAsync(() => {
agent.overrideSuspense({
id: store.getElementIDAtIndex(2),
rendererID,
forceFallback: true,
});
agent.overrideError({
id: store.getElementIDAtIndex(4),
rendererID,
forceError: true,
});
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Suspense>
<div key="loading">
▾ <ErrorBoundary>
<div key="did-error">
[suspense-root] rects={[{x:1,y:2,width:0,height:1}, {x:1,y:2,width:0,height:1}, {x:1,y:2,width:0,height:1}]}
<Suspense name="App" rects={[{x:1,y:2,width:0,height:1}]}>
`);
await actAsync(() => {
store.componentFilters = [
utils.createElementTypeFilter(Types.ElementTypeFunction, true),
];
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense>
<div key="suspense-content">
▾ <ErrorBoundary>
<div key="error-content">
[suspense-root] rects={[{x:1,y:2,width:0,height:1}, {x:1,y:2,width:0,height:1}]}
<Suspense name="Unknown" rects={[{x:1,y:2,width:0,height:1}]}>
`);
});
// @reactVersion >= 19.2
it('can filter by Activity slices', async () => {
const Activity = React.Activity;
const immediate = Promise.resolve(<div>Immediate</div>);
function Root({children}) {
return (
<Activity name="/" mode="visible">
<React.Suspense fallback="Loading...">
<h1>Root</h1>
<main>{children}</main>
</React.Suspense>
</Activity>
);
}
function Layout({children}) {
return (
<Activity name="/blog" mode="visible">
<h2>Blog</h2>
<section>{children}</section>
</Activity>
);
}
function Page() {
return <React.Suspense fallback="Loading...">{immediate}</React.Suspense>;
}
await actAsync(async () =>
render(
<Root>
<Layout>
<Page />
</Layout>
</Root>,
),
);
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Root>
▾ <Activity name="/">
▾ <Suspense>
<h1>
▾ <main>
▾ <Layout>
▾ <Activity name="/blog">
<h2>
▾ <section>
▾ <Page>
▾ <Suspense>
<div>
[suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}
<Suspense name="Root" rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}>
<Suspense name="Page" rects={[{x:1,y:2,width:9,height:1}]}>
`);
await actAsync(
async () =>
(store.componentFilters = [
utils.createActivitySliceFilter(store.getElementIDAtIndex(1)),
]),
);
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Activity name="/">
▾ <Suspense>
<h1>
▾ <main>
▾ <Layout>
▸ <Activity name="/blog">
[suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}
<Suspense name="Unknown" rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}>
<Suspense name="Page" rects={[{x:1,y:2,width:9,height:1}]}>
`);
await actAsync(async () => (store.componentFilters = []));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Root>
▾ <Activity name="/">
▾ <Suspense>
<h1>
▾ <main>
▾ <Layout>
▾ <Activity name="/blog">
<h2>
▾ <section>
▾ <Page>
▾ <Suspense>
<div>
[suspense-root] rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}
<Suspense name="Root" rects={[{x:1,y:2,width:4,height:1}, {x:1,y:2,width:13,height:1}]}>
<Suspense name="Page" rects={[{x:1,y:2,width:9,height:1}]}>
`);
});
});

View File

@@ -328,6 +328,19 @@ export function createLocationFilter(
};
}
export function createActivitySliceFilter(
activityID: Element['id'],
isEnabled: boolean = true,
) {
const Types = require('react-devtools-shared/src/frontend/types');
return {
type: Types.ComponentFilterActivitySlice,
isEnabled,
isValid: true,
activityID: activityID,
};
}
export function getRendererID(): number {
if (global.agent == null) {
throw Error('Agent unavailable.');

View File

@@ -26,6 +26,7 @@ import {
ComponentFilterHOC,
ComponentFilterLocation,
ComponentFilterEnvironmentName,
ComponentFilterActivitySlice,
ElementTypeClass,
ElementTypeContext,
ElementTypeFunction,
@@ -53,7 +54,7 @@ import {
renamePathInObject,
setInObject,
utfEncodeString,
filterOutLocationComponentFilters,
persistableComponentFilters,
} from 'react-devtools-shared/src/utils';
import {
formatConsoleArgumentsToSingleString,
@@ -85,6 +86,7 @@ import {
TREE_OPERATION_SET_SUBTREE_MODE,
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
SUSPENSE_TREE_OPERATION_ADD,
SUSPENSE_TREE_OPERATION_REMOVE,
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
@@ -170,6 +172,7 @@ import type {
} from '../types';
import type {
ComponentFilter,
ActivitySliceFilter,
ElementType,
Plugins,
} from 'react-devtools-shared/src/frontend/types';
@@ -301,6 +304,7 @@ type SuspenseNode = {
rects: null | Array<Rect>, // The bounding rects of content children.
suspendedBy: Map<ReactIOInfo, Set<DevToolsInstance>>, // Tracks which data we're suspended by and the children that suspend it.
environments: Map<string, number>, // Tracks the Flight environment names that suspended this. I.e. if the server blocked this.
endTime: number, // Track a short cut to the maximum end time value within the suspendedBy set.
// Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all
// also in the parent sets. This determine whether this could contribute in the loading sequence.
hasUniqueSuspenders: boolean,
@@ -330,6 +334,7 @@ function createSuspenseNode(
rects: null,
suspendedBy: new Map(),
environments: new Map(),
endTime: 0,
hasUniqueSuspenders: false,
hasUnknownSuspenders: false,
});
@@ -866,6 +871,9 @@ const idToDevToolsInstanceMap: Map<
FiberInstance | VirtualInstance,
> = new Map();
let focusedActivityID: null | FiberInstance['id'] = null;
let focusedActivity: null | Fiber = null;
const idToSuspenseNodeMap: Map<FiberInstance['id'], SuspenseNode> = new Map();
// Map of canonical HostInstances to the nearest parent DevToolsInstance.
@@ -1433,16 +1441,25 @@ export function attach(
const hideElementsWithPaths: Set<RegExp> = new Set();
const hideElementsWithTypes: Set<ElementType> = new Set();
const hideElementsWithEnvs: Set<string> = new Set();
let isInFocusedActivity: boolean = true;
// Highlight updates
let traceUpdatesEnabled: boolean = false;
const traceUpdatesForNodes: Set<HostInstance> = new Set();
function applyComponentFilters(componentFilters: Array<ComponentFilter>) {
function applyComponentFilters(
componentFilters: Array<ComponentFilter>,
nextActivitySlice: null | Fiber,
) {
hideElementsWithTypes.clear();
hideElementsWithDisplayNames.clear();
hideElementsWithPaths.clear();
hideElementsWithEnvs.clear();
const previousFocusedActivityID = focusedActivityID;
focusedActivityID = null;
focusedActivity = null;
// Consider everything in the slice by default
isInFocusedActivity = true;
componentFilters.forEach(componentFilter => {
if (!componentFilter.isEnabled) {
@@ -1471,6 +1488,25 @@ export function attach(
case ComponentFilterEnvironmentName:
hideElementsWithEnvs.add(componentFilter.value);
break;
case ComponentFilterActivitySlice:
if (
nextActivitySlice !== null &&
nextActivitySlice.tag === ActivityComponent
) {
focusedActivity = nextActivitySlice;
isInFocusedActivity = false;
if (componentFilter.rendererID !== rendererID) {
// We filtered an Activity from another renderer.
// We need to restore the instance ID since we won't be mounting it
// in this renderer.
focusedActivityID = previousFocusedActivityID;
}
} else {
// We're not filtering by activity slice after all.
// Don't mark the filter as disabled here.
// Otherwise updateComponentFilters() will think no enabled filter was changed.
}
break;
default:
console.warn(
`Invalid component filter type "${componentFilter.type}"`,
@@ -1484,11 +1520,9 @@ export function attach(
// because they are stored in localStorage within the context of the extension.
// Instead it relies on the extension to pass filters through.
if (window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ != null) {
const componentFiltersWithoutLocationBasedOnes =
filterOutLocationComponentFilters(
window.__REACT_DEVTOOLS_COMPONENT_FILTERS__,
);
applyComponentFilters(componentFiltersWithoutLocationBasedOnes);
const restoredComponentFilters: Array<ComponentFilter> =
persistableComponentFilters(window.__REACT_DEVTOOLS_COMPONENT_FILTERS__);
applyComponentFilters(restoredComponentFilters, null);
} else {
// Unfortunately this feature is not expected to work for React Native for now.
// It would be annoying for us to spam YellowBox warnings with unactionable stuff,
@@ -1496,7 +1530,7 @@ export function attach(
//console.warn('⚛ DevTools: Could not locate saved component filters');
// Fallback to assuming the default filters in this case.
applyComponentFilters(getDefaultComponentFilters());
applyComponentFilters(getDefaultComponentFilters(), null);
}
// If necessary, we can revisit optimizing this operation.
@@ -1510,6 +1544,27 @@ export function attach(
throw Error('Cannot modify filter preferences while profiling');
}
const previousForcedFallbacks =
forceFallbackForFibers.size > 0 ? new Set(forceFallbackForFibers) : null;
const previousForcedErrors =
forceErrorForFibers.size > 0 ? new Map(forceErrorForFibers) : null;
// The ID will be based on the old tree. We need to find the Fiber based on
// that ID before we unmount everything. We set the activity slice ID once
// we mount it again.
let nextFocusedActivity: null | Fiber = null;
let focusedActivityFilter: null | ActivitySliceFilter = null;
for (let i = 0; i < componentFilters.length; i++) {
const filter = componentFilters[i];
if (filter.type === ComponentFilterActivitySlice && filter.isEnabled) {
focusedActivityFilter = filter;
const instance = idToDevToolsInstanceMap.get(filter.activityID);
if (instance !== undefined && instance.kind === FIBER_INSTANCE) {
nextFocusedActivity = instance.data;
}
}
}
// Recursively unmount all roots.
hook.getFiberRoots(rendererID).forEach(root => {
const rootInstance = rootToFiberInstanceMap.get(root);
@@ -1521,15 +1576,59 @@ export function attach(
currentRoot = rootInstance;
unmountInstanceRecursively(rootInstance);
rootToFiberInstanceMap.delete(root);
flushPendingEvents();
currentRoot = (null: any);
});
applyComponentFilters(componentFilters);
if (
nextFocusedActivity !== focusedActivity &&
(focusedActivityFilter === null ||
focusedActivityFilter.rendererID === rendererID)
) {
// When we find the applied instance during mount we will send the actual ID.
// Otherwise 0 will indicate that we unfocused the activity slice.
pushOperation(TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE);
pushOperation(0);
}
applyComponentFilters(componentFilters, nextFocusedActivity);
// Reset pseudo counters so that new path selections will be persisted.
rootDisplayNameCounter.clear();
// We just cleared all the forced states. Schedule updates on the affected Fibers
// so that we get their initial states again according to the new filters.
if (typeof scheduleUpdate === 'function') {
if (previousForcedFallbacks !== null) {
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const fiber of previousForcedFallbacks) {
if (typeof scheduleRetry === 'function') {
scheduleRetry(fiber);
} else {
scheduleUpdate(fiber);
}
}
}
if (
previousForcedErrors !== null &&
typeof setErrorHandler === 'function'
) {
// Unlike for Suspense, disabling the forced error state requires setting
// the status to false first. `shouldErrorFiberAccordingToMap` will clear
// the Fibers later.
setErrorHandler(shouldErrorFiberAccordingToMap);
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const [fiber, shouldError] of previousForcedErrors) {
forceErrorForFibers.set(fiber, false);
if (shouldError) {
if (typeof scheduleRetry === 'function') {
scheduleRetry(fiber);
} else {
scheduleUpdate(fiber);
}
}
}
}
}
// Recursively re-mount all roots with new filter criteria applied.
hook.getFiberRoots(rendererID).forEach(root => {
const current = root.current;
@@ -1546,10 +1645,16 @@ export function attach(
currentRoot = newRoot;
setRootPseudoKey(currentRoot.id, root.current);
mountFiberRecursively(root.current, false);
flushPendingEvents();
currentRoot = (null: any);
});
// We need to write back the new ID for the focused Fiber.
// Otherwise subsequent filter applications will try to focus based on the old ID.
// This is also relevant to filter across renderers.
if (focusedActivityFilter !== null && focusedActivityID !== null) {
focusedActivityFilter.activityID = focusedActivityID;
}
flushPendingEvents();
needsToFlushComponentLogs = false;
@@ -1579,6 +1684,10 @@ export function attach(
data: ReactComponentInfo,
secondaryEnv: null | string,
): boolean {
if (!isInFocusedActivity) {
return true;
}
// For purposes of filtering Server Components are always Function Components.
// Environment will be used to filter Server vs Client.
// Technically they can be forwardRef and memo too but those filters will go away
@@ -1614,6 +1723,11 @@ export function attach(
function shouldFilterFiber(fiber: Fiber): boolean {
const {tag, type, key} = fiber;
// It is never valid to filter the root element.
if (tag !== HostRoot && !isInFocusedActivity) {
return true;
}
switch (tag) {
case DehydratedSuspenseComponent:
// TODO: ideally we would show dehydrated Suspense immediately.
@@ -2043,7 +2157,6 @@ export function attach(
let pendingOperationsQueue: Array<OperationsArray> | null = [];
const pendingStringTable: Map<string, StringTableEntry> = new Map();
let pendingStringTableLength: number = 0;
let pendingUnmountedRootID: FiberInstance['id'] | null = null;
function pushOperation(op: number): void {
if (__DEV__) {
@@ -2071,8 +2184,7 @@ export function attach(
pendingOperations.length === 0 &&
pendingRealUnmountedIDs.length === 0 &&
pendingRealUnmountedSuspenseIDs.length === 0 &&
pendingSuspenderChanges.size === 0 &&
pendingUnmountedRootID === null
pendingSuspenderChanges.size === 0
);
}
@@ -2134,9 +2246,7 @@ export function attach(
return;
}
const numUnmountIDs =
pendingRealUnmountedIDs.length +
(pendingUnmountedRootID === null ? 0 : 1);
const numUnmountIDs = pendingRealUnmountedIDs.length;
const numUnmountSuspenseIDs = pendingRealUnmountedSuspenseIDs.length;
const numSuspenderChanges = pendingSuspenderChanges.size;
@@ -2156,8 +2266,8 @@ export function attach(
// Regular operations
pendingOperations.length +
// All suspender changes are batched in a single message.
// [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders, isSuspended]]
(numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 3 : 0),
// [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders, endTime, isSuspended]]
(numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 4 : 0),
);
// Identify which renderer this update is coming from.
@@ -2214,11 +2324,6 @@ export function attach(
for (let j = 0; j < pendingRealUnmountedIDs.length; j++) {
operations[i++] = pendingRealUnmountedIDs[j];
}
// The root ID should always be unmounted last.
if (pendingUnmountedRootID !== null) {
operations[i] = pendingUnmountedRootID;
i++;
}
}
// Fill in pending operations.
@@ -2242,6 +2347,7 @@ export function attach(
}
operations[i++] = fiberIdWithChanges;
operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
operations[i++] = Math.round(suspense.endTime * 1000);
const instance = suspense.instance;
const isSuspended =
// TODO: Track if other SuspenseNode like SuspenseList rows are suspended.
@@ -2265,7 +2371,6 @@ export function attach(
pendingRealUnmountedIDs.length = 0;
pendingRealUnmountedSuspenseIDs.length = 0;
pendingSuspenderChanges.clear();
pendingUnmountedRootID = null;
pendingStringTable.clear();
pendingStringTableLength = 0;
}
@@ -2469,6 +2574,17 @@ export function attach(
}
}
} else {
const suspenseNode = fiberInstance.suspenseNode;
if (suspenseNode !== null && fiber.memoizedState === null) {
// We're reconnecting an unsuspended Suspense. Measure to see if anything changed.
const prevRects = suspenseNode.rects;
const nextRects = measureInstance(fiberInstance);
if (!areEqualRects(prevRects, nextRects)) {
suspenseNode.rects = nextRects;
recordSuspenseResize(suspenseNode);
}
}
const {key} = fiber;
const displayName = getDisplayNameForFiber(fiber);
const elementType = getElementTypeForFiber(fiber);
@@ -2740,7 +2856,6 @@ export function attach(
// Already disconnected.
return;
}
const fiber = fiberInstance.data;
if (trackedPathMatchInstance === fiberInstance) {
// We're in the process of trying to restore previous selection.
@@ -2750,17 +2865,7 @@ export function attach(
}
const id = fiberInstance.id;
const isRoot = fiber.tag === HostRoot;
if (isRoot) {
// Roots must be removed only after all children have been removed.
// So we track it separately.
pendingUnmountedRootID = id;
} else {
// To maintain child-first ordering,
// we'll push it into one of these queues,
// and later arrange them in the correct order.
pendingRealUnmountedIDs.push(id);
}
pendingRealUnmountedIDs.push(id);
}
function recordSuspenseResize(suspenseNode: SuspenseNode): void {
@@ -2912,12 +3017,19 @@ export function attach(
// like owner instances to link down into the tree.
if (!suspendedBySet.has(parentInstance)) {
suspendedBySet.add(parentInstance);
const virtualEndTime = getVirtualEndTime(ioInfo);
if (
!parentSuspenseNode.hasUniqueSuspenders &&
!ioExistsInSuspenseAncestor(parentSuspenseNode, ioInfo)
) {
// This didn't exist in the parent before, so let's mark this boundary as having a unique suspender.
parentSuspenseNode.hasUniqueSuspenders = true;
if (parentSuspenseNode.endTime < virtualEndTime) {
parentSuspenseNode.endTime = virtualEndTime;
}
recordSuspenseSuspenders(parentSuspenseNode);
} else if (parentSuspenseNode.endTime < virtualEndTime) {
parentSuspenseNode.endTime = virtualEndTime;
recordSuspenseSuspenders(parentSuspenseNode);
}
}
@@ -2979,6 +3091,26 @@ export function attach(
}
}
function getVirtualEndTime(ioInfo: ReactIOInfo): number {
if (ioInfo.env != null) {
// Sort client side content first so that scripts and streams don't
// cover up the effect of server time.
return ioInfo.end + 1000000;
}
return ioInfo.end;
}
function computeEndTime(suspenseNode: SuspenseNode) {
let maxEndTime = 0;
suspenseNode.suspendedBy.forEach((set, ioInfo) => {
const virtualEndTime = getVirtualEndTime(ioInfo);
if (virtualEndTime > maxEndTime) {
maxEndTime = virtualEndTime;
}
});
return maxEndTime;
}
function removePreviousSuspendedBy(
instance: DevToolsInstance,
previousSuspendedBy: null | Array<ReactAsyncInfo>,
@@ -2996,6 +3128,7 @@ export function attach(
if (previousSuspendedBy !== null && suspenseNode !== null) {
const nextSuspendedBy = instance.suspendedBy;
let changedEnvironment = false;
let mayHaveChangedEndTime = false;
for (let i = 0; i < previousSuspendedBy.length; i++) {
const asyncInfo = previousSuspendedBy[i];
if (
@@ -3009,6 +3142,11 @@ export function attach(
const ioInfo = asyncInfo.awaited;
const suspendedBySet = suspenseNode.suspendedBy.get(ioInfo);
if (suspenseNode.endTime === getVirtualEndTime(ioInfo)) {
// This may be the only remaining entry at this end time. Recompute the end time.
mayHaveChangedEndTime = true;
}
if (
suspendedBySet === undefined ||
!suspendedBySet.delete(instance)
@@ -3066,7 +3204,11 @@ export function attach(
}
}
}
if (changedEnvironment) {
const newEndTime = mayHaveChangedEndTime
? computeEndTime(suspenseNode)
: suspenseNode.endTime;
if (changedEnvironment || newEndTime !== suspenseNode.endTime) {
suspenseNode.endTime = newEndTime;
recordSuspenseSuspenders(suspenseNode);
}
}
@@ -3940,11 +4082,23 @@ export function attach(
fiber: Fiber,
traceNearestHostComponentUpdate: boolean,
): void {
const isFocusedActivityEntry =
focusedActivity !== null &&
(fiber === focusedActivity || fiber.alternate === focusedActivity);
if (isFocusedActivityEntry) {
isInFocusedActivity = true;
}
const shouldIncludeInTree = !shouldFilterFiber(fiber);
let newInstance = null;
let newSuspenseNode = null;
if (shouldIncludeInTree) {
newInstance = recordMount(fiber, reconcilingParent);
if (isFocusedActivityEntry) {
focusedActivityID = newInstance.id;
pushOperation(TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE);
pushOperation(newInstance.id);
}
if (fiber.tag === SuspenseComponent || fiber.tag === HostRoot) {
newSuspenseNode = createSuspenseNode(newInstance);
// Measure this Suspense node. In general we shouldn't do this until we have
@@ -4060,6 +4214,7 @@ export function attach(
const stashedSuspenseParent = reconcilingParentSuspenseNode;
const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode;
const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes;
const stashedIsInActivitySlice = isInFocusedActivity;
if (newInstance !== null) {
// Push a new DevTools instance parent while reconciling this subtree.
reconcilingParent = newInstance;
@@ -4073,6 +4228,17 @@ export function attach(
remainingReconcilingChildrenSuspenseNodes = null;
shouldPopSuspenseNode = true;
}
if (
!isFocusedActivityEntry &&
focusedActivity !== null &&
fiber.tag === ActivityComponent
) {
// We're not filtering how Activity within the focused activity.
// We cut of the bottom in the Frontend if we want to just show the
// Activity slice instead of all Activity descendants.
// The filtering in the backend only happens because filtering out
// everything above the focused Activity is hard to implement in the frontend.
}
try {
if (traceUpdatesEnabled) {
if (traceNearestHostComponentUpdate) {
@@ -4200,6 +4366,7 @@ export function attach(
}
}
} finally {
isInFocusedActivity = stashedIsInActivitySlice;
if (newInstance !== null) {
reconcilingParent = stashedParent;
previouslyReconciledSibling = stashedPrevious;
@@ -4231,6 +4398,7 @@ export function attach(
const stashedSuspenseParent = reconcilingParentSuspenseNode;
const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode;
const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes;
const stashedIsInActivitySlice = isInFocusedActivity;
const previousSuspendedBy = instance.suspendedBy;
// Push a new DevTools instance parent while reconciling this subtree.
reconcilingParent = instance;
@@ -4249,6 +4417,19 @@ export function attach(
shouldPopSuspenseNode = true;
}
if (focusedActivity !== null) {
if (instance.id === focusedActivityID) {
isInFocusedActivity = true;
} else if (
instance.kind === FIBER_INSTANCE &&
instance.data !== null &&
instance.data.tag === ActivityComponent
) {
// Filtering nested Activity components inside the focused activity
// is done in the frontend.
}
}
try {
// Unmount the remaining set.
if (
@@ -4299,6 +4480,7 @@ export function attach(
previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
}
isInFocusedActivity = stashedIsInActivitySlice;
}
if (instance.kind === FIBER_INSTANCE) {
recordUnmount(instance);
@@ -4979,6 +5161,7 @@ export function attach(
const stashedSuspenseParent = reconcilingParentSuspenseNode;
const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode;
const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes;
const stashedIsInActivitySlice = isInFocusedActivity;
let updateFlags = NoUpdate;
let shouldMeasureSuspenseNode = false;
let shouldPopSuspenseNode = false;
@@ -5018,6 +5201,15 @@ export function attach(
shouldMeasureSuspenseNode = true;
shouldPopSuspenseNode = true;
}
if (focusedActivity !== null) {
if (fiberInstance.id === focusedActivityID) {
isInFocusedActivity = true;
} else if (nextFiber.tag === ActivityComponent) {
// Filtering nested Activity components inside the focused activity
// is done in the frontend.
}
}
}
try {
trackDebugInfoFromLazyType(nextFiber);
@@ -5442,6 +5634,7 @@ export function attach(
previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;
}
isInFocusedActivity = stashedIsInActivitySlice;
}
}
}
@@ -5556,11 +5749,12 @@ export function attach(
mountFiberRecursively(root.current, false);
flushPendingEvents();
needsToFlushComponentLogs = false;
currentRoot = (null: any);
});
flushPendingEvents();
needsToFlushComponentLogs = false;
}
}

View File

@@ -33,6 +33,66 @@ export default function setupHighlighter(
bridge.addListener('shutdown', stopInspectingHost);
bridge.addListener('startInspectingHost', startInspectingHost);
bridge.addListener('stopInspectingHost', stopInspectingHost);
bridge.addListener('scrollTo', scrollDocumentTo);
bridge.addListener('requestScrollPosition', sendScroll);
let applyingScroll = false;
function scrollDocumentTo({
left,
top,
right,
bottom,
}: {
left: number,
top: number,
right: number,
bottom: number,
}) {
if (
left === Math.round(window.scrollX) &&
top === Math.round(window.scrollY)
) {
return;
}
applyingScroll = true;
window.scrollTo({
top: top,
left: left,
behavior: 'smooth',
});
}
let scrollTimer = null;
function sendScroll() {
if (scrollTimer) {
clearTimeout(scrollTimer);
scrollTimer = null;
}
if (applyingScroll) {
return;
}
const left = window.scrollX;
const top = window.scrollY;
const right = left + window.innerWidth;
const bottom = top + window.innerHeight;
bridge.send('scrollTo', {left, top, right, bottom});
}
function scrollEnd() {
// Upon scrollend send it immediately.
sendScroll();
applyingScroll = false;
}
document.addEventListener('scroll', () => {
if (!scrollTimer) {
// Periodically synchronize the scroll while scrolling.
scrollTimer = setTimeout(sendScroll, 400);
}
});
document.addEventListener('scrollend', scrollEnd);
function startInspectingHost(onlySuspenseNodes: boolean) {
inspectOnlySuspenseNodes = onlySuspenseNodes;

View File

@@ -217,6 +217,7 @@ export type BackendEvents = {
selectElement: [number],
shutdown: [],
stopInspectingHost: [boolean],
scrollTo: [{left: number, top: number, right: number, bottom: number}],
syncSelectionToBuiltinElementsPanel: [],
unsupportedRendererVersion: [],
@@ -270,6 +271,8 @@ type FrontendEvents = {
startProfiling: [StartProfilingParams],
stopInspectingHost: [],
scrollToHostInstance: [ScrollToHostInstance],
scrollTo: [{left: number, top: number, right: number, bottom: number}],
requestScrollPosition: [],
stopProfiling: [],
storeAsGlobal: [StoreAsGlobalParams],
updateComponentFilters: [Array<ComponentFilter>],
@@ -416,7 +419,8 @@ class Bridge<
try {
if (this._messageQueue.length) {
for (let i = 0; i < this._messageQueue.length; i += 2) {
this._wall.send(this._messageQueue[i], ...this._messageQueue[i + 1]);
// This only supports one argument in practice but the types suggests it should support multiple.
this._wall.send(this._messageQueue[i], this._messageQueue[i + 1][0]);
}
this._messageQueue.length = 0;
}

View File

@@ -29,6 +29,7 @@ export const SUSPENSE_TREE_OPERATION_REMOVE = 9;
export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10;
export const SUSPENSE_TREE_OPERATION_RESIZE = 11;
export const SUSPENSE_TREE_OPERATION_SUSPENDERS = 12;
export const TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE = 13;
export const PROFILING_FLAG_BASIC_SUPPORT /*. */ = 0b001;
export const PROFILING_FLAG_TIMELINE_SUPPORT /* */ = 0b010;

View File

@@ -21,13 +21,18 @@ import {
TREE_OPERATION_SET_SUBTREE_MODE,
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
SUSPENSE_TREE_OPERATION_ADD,
SUSPENSE_TREE_OPERATION_REMOVE,
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
SUSPENSE_TREE_OPERATION_RESIZE,
SUSPENSE_TREE_OPERATION_SUSPENDERS,
} from '../constants';
import {ElementTypeRoot} from '../frontend/types';
import {
ElementTypeRoot,
ElementTypeActivity,
ComponentFilterActivitySlice,
} from '../frontend/types';
import {
getSavedComponentFilters,
setSavedComponentFilters,
@@ -144,7 +149,13 @@ export default class Store extends EventEmitter<{
hookSettings: [$ReadOnly<DevToolsHookSettings>],
hostInstanceSelected: [Element['id']],
settingsUpdated: [$ReadOnly<DevToolsHookSettings>],
mutated: [[Array<Element['id']>, Map<Element['id'], Element['id']>]],
mutated: [
[
Array<Element['id']>,
Map<Element['id'], Element['id']>,
Element['id'] | null,
],
],
recordChangeDescriptions: [],
roots: [],
rootSupportsBasicProfiling: [],
@@ -658,6 +669,10 @@ export default class Store extends EventEmitter<{
return element;
}
containsSuspense(id: SuspenseNode['id']): boolean {
return this._idToSuspense.has(id);
}
getSuspenseByID(id: SuspenseNode['id']): SuspenseNode | null {
const suspense = this._idToSuspense.get(id);
if (suspense === undefined) {
@@ -925,7 +940,7 @@ export default class Store extends EventEmitter<{
*/
getSuspendableDocumentOrderSuspense(
uniqueSuspendersOnly: boolean,
): $ReadOnlyArray<SuspenseTimelineStep> {
): Array<SuspenseTimelineStep> {
const target: Array<SuspenseTimelineStep> = [];
const roots = this.roots;
let rootStep: null | SuspenseTimelineStep = null;
@@ -949,17 +964,25 @@ export default class Store extends EventEmitter<{
rootStep = {
id: suspense.id,
environment: environmentName,
endTime: suspense.endTime,
};
target.push(rootStep);
} else if (rootStep.environment === null) {
// If any root has an environment name, then let's use it.
rootStep.environment = environmentName;
} else {
if (rootStep.environment === null) {
// If any root has an environment name, then let's use it.
rootStep.environment = environmentName;
}
if (suspense.endTime > rootStep.endTime) {
// If any root has a higher end time, let's use that.
rootStep.endTime = suspense.endTime;
}
}
this.pushTimelineStepsInDocumentOrder(
suspense.children,
target,
uniqueSuspendersOnly,
environments,
0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter.
);
}
}
@@ -972,6 +995,7 @@ export default class Store extends EventEmitter<{
target: Array<SuspenseTimelineStep>,
uniqueSuspendersOnly: boolean,
parentEnvironments: Array<string>,
parentEndTime: number,
): void {
for (let i = 0; i < children.length; i++) {
const child = this.getSuspenseByID(children[i]);
@@ -996,10 +1020,15 @@ export default class Store extends EventEmitter<{
unionEnvironments.length > 0
? unionEnvironments[unionEnvironments.length - 1]
: null;
// The end time of a child boundary can in effect never be earlier than its parent even if
// everything unsuspended before that.
const maxEndTime =
parentEndTime > child.endTime ? parentEndTime : child.endTime;
if (hasRects && (!uniqueSuspendersOnly || child.hasUniqueSuspenders)) {
target.push({
id: child.id,
environment: environmentName,
endTime: maxEndTime,
});
}
this.pushTimelineStepsInDocumentOrder(
@@ -1007,10 +1036,28 @@ export default class Store extends EventEmitter<{
target,
uniqueSuspendersOnly,
unionEnvironments,
maxEndTime,
);
}
}
getEndTimeOrDocumentOrderSuspense(
uniqueSuspendersOnly: boolean,
): $ReadOnlyArray<SuspenseTimelineStep> {
const timeline =
this.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
if (timeline.length === 0) {
return timeline;
}
const root = timeline[0];
// We mutate in place since we assume we've got a fresh array.
timeline.sort((a, b) => {
// Root is always first
return a === root ? -1 : b === root ? 1 : a.endTime - b.endTime;
});
return timeline;
}
getRendererIDForElement(id: number): number | null {
let current = this._idToElement.get(id);
while (current !== undefined) {
@@ -1124,7 +1171,7 @@ export default class Store extends EventEmitter<{
// The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed.
// In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden).
// Updating the selected search index later may require auto-expanding a collapsed subtree though.
this.emit('mutated', [[], new Map()]);
this.emit('mutated', [[], new Map(), null]);
}
}
}
@@ -1193,10 +1240,11 @@ export default class Store extends EventEmitter<{
const addedElementIDs: Array<number> = [];
// This is a mapping of removed ID -> parent ID:
// We'll use the parent ID to adjust selection if it gets deleted.
const removedElementIDs: Map<number, number> = new Map();
const removedSuspenseIDs: Map<SuspenseNode['id'], SuspenseNode['id']> =
new Map();
// We'll use the parent ID to adjust selection if it gets deleted.
let nextActivitySliceID = null;
let i = 2;
@@ -1688,6 +1736,7 @@ export default class Store extends EventEmitter<{
hasUniqueSuspenders: false,
isSuspended: isSuspended,
environments: [],
endTime: 0,
});
hasSuspenseTreeChanged = true;
@@ -1884,6 +1933,7 @@ export default class Store extends EventEmitter<{
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const id = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const endTime = operations[i++] / 1000;
const isSuspended = operations[i++] === 1;
const environmentNamesLength = operations[i++];
const environmentNames = [];
@@ -1919,6 +1969,7 @@ export default class Store extends EventEmitter<{
}
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
suspense.endTime = endTime;
suspense.isSuspended = isSuspended;
suspense.environments = environmentNames;
}
@@ -1927,6 +1978,11 @@ export default class Store extends EventEmitter<{
break;
}
case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: {
i++;
nextActivitySliceID = operations[i++];
break;
}
default:
this._throwAndEmitError(
new UnsupportedBridgeOperationError(
@@ -2025,9 +2081,80 @@ export default class Store extends EventEmitter<{
console.groupEnd();
}
this.emit('mutated', [addedElementIDs, removedElementIDs]);
if (nextActivitySliceID !== null && nextActivitySliceID !== 0) {
let didCollapse = false;
// The backend filtered everything above the Activity slice.
// We need to hide everything below the Activity slice by collapsing
// the Activities that are descendants of the next Activity slice.
const nextActivitySlice = this._idToElement.get(nextActivitySliceID);
if (nextActivitySlice === undefined) {
throw new Error('Next Activity slice not found in Store.');
}
for (let j = 0; j < nextActivitySlice.children.length; j++) {
didCollapse ||= this._collapseActivitiesRecursively(
nextActivitySlice.children[j],
);
}
if (didCollapse) {
let weightAcrossRoots = 0;
this._roots.forEach(rootID => {
const {weight} = ((this.getElementByID(rootID): any): Element);
weightAcrossRoots += weight;
});
this._weightAcrossRoots = weightAcrossRoots;
}
}
for (let j = 0; j < this._componentFilters.length; j++) {
const filter = this._componentFilters[j];
// If we're focusing an Activity, IDs may have changed.
if (filter.type === ComponentFilterActivitySlice) {
if (nextActivitySliceID === null || nextActivitySliceID === 0) {
filter.isValid = false;
} else {
filter.activityID = nextActivitySliceID;
}
}
}
this.emit('mutated', [
addedElementIDs,
removedElementIDs,
nextActivitySliceID,
]);
};
_collapseActivitiesRecursively(elementID: number): boolean {
let didMutate = false;
const element = this._idToElement.get(elementID);
if (element === undefined) {
throw new Error('Element not found in Store.');
}
if (element.type === ElementTypeActivity) {
if (!element.isCollapsed) {
element.isCollapsed = true;
const weightDelta = 1 - element.weight;
let parentElement = this._idToElement.get(element.parentID);
while (parentElement !== undefined) {
parentElement.weight += weightDelta;
parentElement = this._idToElement.get(parentElement.parentID);
}
return true;
}
return false;
}
for (let i = 0; i < element.children.length; i++) {
didMutate ||= this._collapseActivitiesRecursively(element.children[i]);
}
return didMutate;
}
// Certain backends save filters on a per-domain basis.
// In order to prevent filter preferences and applied filters from being out of sync,
// this message enables the backend to override the frontend's current ("saved") filters.
@@ -2193,7 +2320,7 @@ export default class Store extends EventEmitter<{
if (previousStatus !== status) {
// Propagate to subscribers, although tree state has not changed
this.emit('mutated', [[], new Map()]);
this.emit('mutated', [[], new Map(), null]);
}
}

View File

@@ -0,0 +1,28 @@
.ActivitySlice {
max-width: 100%;
overflow-x: auto;
flex: 1;
display: flex;
align-items: center;
position: relative;
}
.ActivitySliceButton {
color: var(--color-button-active);
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
}
.Bar {
display: flex;
flex: 1 1 auto;
overflow-x: auto;
}
.VRule {
flex: 0 0 auto;
height: 20px;
width: 1px;
background-color: var(--color-border);
margin: 0 0.5rem;
}

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
import {startTransition, useContext} from 'react';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import {StoreContext} from '../context';
import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList';
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
import styles from './ActivitySlice.css';
export default function ActivitySlice(): React.Node {
const dispatch = useContext(TreeDispatcherContext);
const {activityID} = useContext(TreeStateContext);
const store = useContext(StoreContext);
const activity =
activityID === null ? null : store.getElementByID(activityID);
const name = activity ? activity.nameProp : null;
const changeActivitySliceAction = useChangeActivitySliceAction();
return (
<div className={styles.ActivitySlice}>
<div className={styles.Bar}>
<Button
className={styles.ActivitySliceButton}
onClick={dispatch.bind(null, {
type: 'SELECT_ELEMENT_BY_ID',
payload: activityID,
})}>
"{name || 'Unknown'}"
</Button>
</div>
<div className={styles.VRule} />
<Button
onClick={startTransition.bind(
null,
changeActivitySliceAction.bind(null, null),
)}
title="Back to tree view">
<ButtonIcon type="close" />
</Button>
</div>
);
}

View File

@@ -8,8 +8,9 @@
*/
import * as React from 'react';
import {Fragment, useContext, useMemo, useState} from 'react';
import {Fragment, startTransition, useContext, useMemo, useState} from 'react';
import Store from 'react-devtools-shared/src/devtools/store';
import {ElementTypeActivity} from 'react-devtools-shared/src/frontend/types';
import ButtonIcon from '../ButtonIcon';
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
import {StoreContext} from '../context';
@@ -25,6 +26,7 @@ import styles from './Element.css';
import Icon from '../Icon';
import {useChangeOwnerAction} from './OwnersListContext';
import Tooltip from './reach-ui/tooltip';
import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList';
type Props = {
data: ItemData,
@@ -65,6 +67,7 @@ export default function Element({data, index, style}: Props): React.Node {
}>(errorsAndWarningsSubscription);
const changeOwnerAction = useChangeOwnerAction();
const changeActivitySliceAction = useChangeActivitySliceAction();
// Handle elements that are removed from the tree while an async render is in progress.
if (element == null) {
@@ -75,9 +78,13 @@ export default function Element({data, index, style}: Props): React.Node {
}
const handleDoubleClick = () => {
if (id !== null) {
changeOwnerAction(id);
}
startTransition(() => {
if (element.type === ElementTypeActivity) {
changeActivitySliceAction(element.id);
} else {
changeOwnerAction(element.id);
}
});
};
// $FlowFixMe[missing-local-annot]

View File

@@ -11,6 +11,7 @@ import * as React from 'react';
import {
Fragment,
Suspense,
startTransition,
useCallback,
useContext,
useEffect,
@@ -37,7 +38,10 @@ import ButtonIcon from '../ButtonIcon';
import Button from '../Button';
import {logEvent} from 'react-devtools-shared/src/Logger';
import {useExtensionComponentsPanelVisibility} from 'react-devtools-shared/src/frontend/hooks/useExtensionComponentsPanelVisibility';
import {ElementTypeActivity} from 'react-devtools-shared/src/frontend/types';
import {useChangeOwnerAction} from './OwnersListContext';
import {useChangeActivitySliceAction} from '../SuspenseTab/ActivityList';
import ActivitySlice from './ActivitySlice';
// Indent for each node at level N, compared to node at level N - 1.
const INDENTATION_SIZE = 10;
@@ -72,6 +76,7 @@ function calculateInitialScrollOffset(
export default function Tree(): React.Node {
const dispatch = useContext(TreeDispatcherContext);
const {
activityID,
numElements,
ownerID,
searchIndex,
@@ -302,6 +307,7 @@ export default function Tree(): React.Node {
const handleBlur = useCallback(() => setTreeFocused(false), []);
const handleFocus = useCallback(() => setTreeFocused(true), []);
const changeActivitySliceAction = useChangeActivitySliceAction();
const changeOwnerAction = useChangeOwnerAction();
const handleKeyPress = useCallback(
(event: $FlowFixMe) => {
@@ -309,7 +315,17 @@ export default function Tree(): React.Node {
case 'Enter':
case ' ':
if (inspectedElementID !== null) {
changeOwnerAction(inspectedElementID);
const inspectedElement = store.getElementByID(inspectedElementID);
startTransition(() => {
if (
inspectedElement !== null &&
inspectedElement.type === ElementTypeActivity
) {
changeActivitySliceAction(inspectedElementID);
} else {
changeOwnerAction(inspectedElementID);
}
});
}
break;
default:
@@ -444,7 +460,13 @@ export default function Tree(): React.Node {
</Fragment>
)}
<Suspense fallback={<Loading />}>
{ownerID !== null ? <OwnersStack /> : <ComponentSearchInput />}
{ownerID !== null ? (
<OwnersStack />
) : activityID !== null ? (
<ActivitySlice />
) : (
<ComponentSearchInput />
)}
</Suspense>
{ownerID === null && (errors > 0 || warnings > 0) && (
<React.Fragment>

View File

@@ -57,6 +57,9 @@ export type StateContext = {
ownerID: number | null,
ownerFlatTree: Array<Element> | null,
// Activity slice
activityID: Element['id'] | null,
// Inspection element panel
inspectedElementID: number | null,
inspectedElementIndex: number | null,
@@ -70,7 +73,7 @@ type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = {
};
type ACTION_HANDLE_STORE_MUTATION = {
type: 'HANDLE_STORE_MUTATION',
payload: [Array<number>, Map<number, number>],
payload: [Array<number>, Map<number, number>, null | Element['id']],
};
type ACTION_RESET_OWNER_STACK = {
type: 'RESET_OWNER_STACK',
@@ -167,6 +170,9 @@ type State = {
ownerID: number | null,
ownerFlatTree: Array<Element> | null,
// Activity slice
activityID: Element['id'] | null,
// Inspection element panel
inspectedElementID: number | null,
inspectedElementIndex: number | null,
@@ -794,6 +800,33 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
};
}
function reduceActivityState(
store: Store,
state: State,
action: Action,
): State {
switch (action.type) {
case 'HANDLE_STORE_MUTATION':
let {activityID} = state;
const [, , activitySliceIDChange] = action.payload;
if (activitySliceIDChange === 0 && activityID !== null) {
activityID = null;
} else if (
activitySliceIDChange !== null &&
activitySliceIDChange !== activityID
) {
activityID = activitySliceIDChange;
}
if (activityID !== state.activityID) {
return {
...state,
activityID,
};
}
}
return state;
}
type Props = {
children: React$Node,
@@ -828,6 +861,9 @@ function getInitialState({
ownerID: defaultOwnerID == null ? null : defaultOwnerID,
ownerFlatTree: null,
// Activity slice
activityID: null,
// Inspection element panel
inspectedElementID:
defaultInspectedElementID != null
@@ -882,6 +918,7 @@ function TreeContextController({
state = reduceTreeState(store, state, action);
state = reduceSearchState(store, state, action);
state = reduceOwnersState(store, state, action);
state = reduceActivityState(store, state, action);
// TODO(hoxyq): review
// If the selected ID is in a collapsed subtree, reset the selected index to null.
@@ -950,13 +987,14 @@ function TreeContextController({
// Mutations to the underlying tree may impact this context (e.g. search results, selection state).
useEffect(() => {
const handleStoreMutated = ([addedElementIDs, removedElementIDs]: [
Array<number>,
Map<number, number>,
]) => {
const handleStoreMutated = ([
addedElementIDs,
removedElementIDs,
activitySliceIDChange,
]: [Array<number>, Map<number, number>, null | Element['id']]) => {
dispatch({
type: 'HANDLE_STORE_MUTATION',
payload: [addedElementIDs, removedElementIDs],
payload: [addedElementIDs, removedElementIDs, activitySliceIDChange],
});
};
@@ -967,7 +1005,7 @@ function TreeContextController({
// It would only impact the search state, which is unlikely to exist yet at this point.
dispatch({
type: 'HANDLE_STORE_MUTATION',
payload: [[], new Map()],
payload: [[], new Map(), null],
});
}

View File

@@ -16,6 +16,7 @@ import {
TREE_OPERATION_SET_SUBTREE_MODE,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
SUSPENSE_TREE_OPERATION_ADD,
SUSPENSE_TREE_OPERATION_REMOVE,
SUSPENSE_TREE_OPERATION_REORDER_CHILDREN,
@@ -460,13 +461,14 @@ function updateTree(
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const suspenseNodeId = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const endTime = operations[i++] / 1000;
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)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
`Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} ending at ${String(endTime)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
);
}
}
@@ -474,6 +476,20 @@ function updateTree(
break;
}
case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: {
i++;
const activitySliceIDChange = operations[i++];
if (__DEBUG__) {
debug(
'Applied activity slice change',
activitySliceIDChange === 0
? 'Reset applied activity slice'
: `Changed to activity slice ID ${activitySliceIDChange}`,
);
}
break;
}
default:
throw Error(`Unsupported Bridge operation "${operation}"`);
}

View File

@@ -29,6 +29,7 @@ import {
ComponentFilterHOC,
ComponentFilterLocation,
ComponentFilterEnvironmentName,
ComponentFilterActivitySlice,
ElementTypeClass,
ElementTypeContext,
ElementTypeFunction,
@@ -171,6 +172,8 @@ export default function ComponentsSettings({
isValid: true,
value: 'Client',
};
} else if (type === ComponentFilterActivitySlice) {
// TODO: Allow changing type
}
}
return cloned;
@@ -364,34 +367,39 @@ export default function ComponentsSettings({
{componentFilters.map((componentFilter, index) => (
<tr className={styles.TableRow} key={index}>
<td className={styles.TableCell}>
<Toggle
className={
componentFilter.isValid !== false
? ''
: styles.InvalidRegExp
}
isChecked={componentFilter.isEnabled}
onChange={isEnabled =>
toggleFilterIsEnabled(componentFilter, isEnabled)
}
title={
componentFilter.isValid === false
? 'Filter invalid'
: componentFilter.isEnabled
? 'Filter enabled'
: 'Filter disabled'
}>
<ToggleIcon
isEnabled={componentFilter.isEnabled}
isValid={
componentFilter.isValid == null ||
componentFilter.isValid === true
{componentFilter.type !== ComponentFilterActivitySlice && (
<Toggle
className={
componentFilter.isValid !== false
? ''
: styles.InvalidRegExp
}
/>
</Toggle>
isChecked={componentFilter.isEnabled}
onChange={isEnabled =>
toggleFilterIsEnabled(componentFilter, isEnabled)
}
title={
componentFilter.isValid === false
? 'Filter invalid'
: componentFilter.isEnabled
? 'Filter enabled'
: 'Filter disabled'
}>
<ToggleIcon
isEnabled={componentFilter.isEnabled}
isValid={
componentFilter.isValid == null ||
componentFilter.isValid === true
}
/>
</Toggle>
)}
</td>
<td className={styles.TableCell}>
<select
disabled={
componentFilter.type === ComponentFilterActivitySlice
}
value={componentFilter.type}
onChange={({currentTarget}) =>
changeFilterType(
@@ -413,6 +421,11 @@ export default function ComponentsSettings({
environment
</option>
)}
{componentFilter.type === ComponentFilterActivitySlice && (
<option value={ComponentFilterActivitySlice}>
component
</option>
)}
</select>
</td>
<td className={styles.TableCell}>
@@ -422,6 +435,8 @@ export default function ComponentsSettings({
{(componentFilter.type === ComponentFilterLocation ||
componentFilter.type === ComponentFilterDisplayName) &&
'matches'}
{componentFilter.type === ComponentFilterActivitySlice &&
'within'}
</td>
<td className={styles.TableCell}>
{componentFilter.type === ComponentFilterElementType && (
@@ -487,6 +502,9 @@ export default function ComponentsSettings({
))}
</select>
)}
{componentFilter.type === ComponentFilterActivitySlice && (
<span>Activity Slice</span>
)}
</td>
<td className={styles.TableCell}>
<Button

View File

@@ -0,0 +1,45 @@
.ActivityList {
cursor: default;
list-style-type: none;
margin: 0;
padding: 0;
}
.ActivityList[data-pending-activity-slice-selection="true"] {
cursor: wait;
}
.ActivityList:focus {
outline: none;
}
.ActivityListItem {
color: var(--color-component-name);
padding: 0 0.25rem;
user-select: none;
}
.ActivityListItem:hover {
background-color: var(--color-background-hover);
}
.ActivityListItem[aria-selected="true"] {
background-color: var(--color-background-inactive);
}
.ActivityList:focus .ActivityListItem[aria-selected="true"] {
background-color: var(--color-background-selected);
color: var(--color-text-selected);
/* Invert colors */
--color-component-name: var(--color-component-name-inverted);
--color-text: var(--color-text-selected);
--color-component-badge-background: var(
--color-component-badge-background-inverted
);
--color-forget-badge-background: var(--color-forget-badge-background-inverted);
--color-component-badge-count: var(--color-component-badge-count-inverted);
--color-attribute-name: var(--color-attribute-name-inverted);
--color-attribute-value: var(--color-attribute-value-inverted);
--color-expand-collapse-toggle: var(--color-component-name-inverted);
}

View File

@@ -0,0 +1,173 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {
Element,
ActivitySliceFilter,
ComponentFilter,
} from 'react-devtools-shared/src/frontend/types';
import typeof {
SyntheticMouseEvent,
SyntheticKeyboardEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
import {useContext, useTransition} from 'react';
import {ComponentFilterActivitySlice} from 'react-devtools-shared/src/frontend/types';
import styles from './ActivityList.css';
import {
TreeStateContext,
TreeDispatcherContext,
} from '../Components/TreeContext';
import {useHighlightHostInstance} from '../hooks';
import {StoreContext} from '../context';
export function useChangeActivitySliceAction(): (
id: Element['id'] | null,
) => void {
const store = useContext(StoreContext);
function changeActivitySliceAction(activityID: Element['id'] | null) {
const nextFilters: ComponentFilter[] = [];
// Remove any existing activity slice filter
for (let i = 0; i < store.componentFilters.length; i++) {
const filter = store.componentFilters[i];
if (filter.type !== ComponentFilterActivitySlice) {
nextFilters.push(filter);
}
}
if (activityID !== null) {
const rendererID = store.getRendererIDForElement(activityID);
if (rendererID === null) {
throw new Error('Expected to find renderer.');
}
const activityFilter: ActivitySliceFilter = {
type: ComponentFilterActivitySlice,
activityID,
rendererID,
isValid: true,
isEnabled: true,
};
nextFilters.push(activityFilter);
}
store.componentFilters = nextFilters;
}
return changeActivitySliceAction;
}
export default function ActivityList({
activities,
}: {
activities: $ReadOnlyArray<Element>,
}): React$Node {
const {inspectedElementID} = useContext(TreeStateContext);
const treeDispatch = useContext(TreeDispatcherContext);
// TODO: Derive from inspected element
const selectedActivityID = inspectedElementID;
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
const [isPendingActivitySliceSelection, startActivitySliceSelection] =
useTransition();
const changeActivitySliceAction = useChangeActivitySliceAction();
function handleKeyDown(event: SyntheticKeyboardEvent) {
// TODO: Implement keyboard navigation
switch (event.key) {
case 'Enter':
case ' ':
if (inspectedElementID !== null) {
startActivitySliceSelection(() => {
changeActivitySliceAction(inspectedElementID);
});
}
event.preventDefault();
break;
case 'Home':
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: activities[0].id});
event.preventDefault();
break;
case 'End':
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: activities[activities.length - 1].id,
});
event.preventDefault();
break;
case 'ArrowUp': {
const currentIndex = activities.findIndex(
activity => activity.id === selectedActivityID,
);
if (currentIndex !== undefined) {
const nextIndex =
(currentIndex + activities.length - 1) % activities.length;
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: activities[nextIndex].id,
});
}
event.preventDefault();
break;
}
case 'ArrowDown': {
const currentIndex = activities.findIndex(
activity => activity.id === selectedActivityID,
);
if (currentIndex !== undefined) {
const nextIndex = (currentIndex + 1) % activities.length;
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: activities[nextIndex].id,
});
}
event.preventDefault();
break;
}
default:
break;
}
}
function handleClick(id: Element['id'], event: SyntheticMouseEvent) {
event.preventDefault();
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
}
function handleDoubleClick() {
if (inspectedElementID !== null) {
changeActivitySliceAction(inspectedElementID);
}
}
return (
<ol
role="listbox"
className={styles.ActivityList}
data-pending-activity-slice-selection={isPendingActivitySliceSelection}
tabIndex={0}
onKeyDown={handleKeyDown}>
{activities.map(activity => (
<li
key={activity.id}
role="option"
aria-selected={activity.id === selectedActivityID ? 'true' : 'false'}
className={styles.ActivityListItem}
onClick={handleClick.bind(null, activity.id)}
onDoubleClick={handleDoubleClick}
onPointerOver={highlightHostInstance.bind(null, activity.id, false)}
onPointerLeave={clearHighlightHostInstance}>
{activity.nameProp}
</li>
))}
</ol>
);
}

View File

@@ -6,18 +6,17 @@
border-radius: 0.25rem;
}
.SuspenseRectsContainer[data-highlighted='true'] {
outline-color: var(--color-transition);
outline-style: solid;
outline-width: 4px;
}
.SuspenseRectsRoot {
cursor: pointer;
outline-color: var(--color-transition);
background-color: color-mix(in srgb, var(--color-transition) 5%, transparent);
}
.SuspenseRectsRootOutline {
outline-width: 4px;
border-radius: 0.125rem;
}
.SuspenseRectsRoot[data-hovered='true'] {
background-color: color-mix(in srgb, var(--color-transition) 15%, transparent);
}

View File

@@ -18,7 +18,7 @@ import typeof {
} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
import {createContext, useContext} from 'react';
import {createContext, useContext, useLayoutEffect} from 'react';
import {
TreeDispatcherContext,
TreeStateContext,
@@ -236,13 +236,6 @@ function SuspenseRects({
<span>{suspense.name}</span>
</ScaledRect>
) : null}
{selected && visible ? (
<ScaledRect
className={styles.SuspenseRectOutline}
rect={boundingBox}
adjust={true}
/>
) : null}
</ViewBox.Provider>
</ScaledRect>
);
@@ -435,7 +428,11 @@ function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
const ViewBox = createContext<Rect>((null: any));
function SuspenseRectsContainer(): React$Node {
function SuspenseRectsContainer({
scaleRef,
}: {
scaleRef: {current: number},
}): React$Node {
const store = useContext(StoreContext);
const {inspectedElementID} = useContext(TreeStateContext);
const treeDispatch = useContext(TreeDispatcherContext);
@@ -505,17 +502,47 @@ function SuspenseRectsContainer(): React$Node {
const rootEnvironment =
timeline.length === 0 ? null : timeline[0].environment;
useLayoutEffect(() => {
// 100% of the width represents this many pixels in the real document.
scaleRef.current = boundingBoxWidth;
}, [boundingBoxWidth]);
let selectedBoundingBox = null;
let selectedEnvironment = null;
if (isRootSelected) {
selectedEnvironment = rootEnvironment;
} else if (
inspectedElementID !== null &&
// TODO: Separate inspected element and inspected Suspense and use the inspected Suspense ID here.
store.containsSuspense(inspectedElementID)
) {
const selectedSuspenseNode = store.getSuspenseByID(inspectedElementID);
if (
selectedSuspenseNode !== null &&
(selectedSuspenseNode.hasUniqueSuspenders || !uniqueSuspendersOnly)
) {
selectedBoundingBox = getBoundingBox(selectedSuspenseNode.rects);
for (let i = 0; i < timeline.length; i++) {
const timelineStep = timeline[i];
if (timelineStep.id === inspectedElementID) {
selectedEnvironment = timelineStep.environment;
break;
}
}
}
}
return (
<div
className={
styles.SuspenseRectsContainer +
(hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '') +
(isRootSelected ? ' ' + styles.SuspenseRectsRootOutline : '') +
' ' +
getClassNameForEnvironment(rootEnvironment)
}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
data-highlighted={isRootSelected}
data-hovered={isRootHovered}>
<ViewBox.Provider value={boundingBox}>
<div
@@ -524,6 +551,17 @@ function SuspenseRectsContainer(): React$Node {
{roots.map(rootID => {
return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
})}
{selectedBoundingBox !== null ? (
<ScaledRect
className={
styles.SuspenseRectOutline +
' ' +
getClassNameForEnvironment(selectedEnvironment)
}
rect={selectedBoundingBox}
adjust={true}
/>
) : null}
</div>
</ViewBox.Provider>
</div>

View File

@@ -91,10 +91,9 @@
}
}
.TreeList {
.ActivityList {
flex: 0 0 var(--horizontal-resize-tree-list-percentage);
border-right: 1px solid var(--color-border);
padding: 0.25rem;
overflow: auto;
}
@@ -142,4 +141,4 @@
.SuspenseTreeViewFooterButtons {
padding: 0.25rem;
}
}

View File

@@ -6,12 +6,14 @@
*
* @flow
*/
import type {Element} from 'react-devtools-shared/src/frontend/types';
import * as React from 'react';
import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
Fragment,
@@ -30,12 +32,12 @@ import styles from './SuspenseTab.css';
import SuspenseBreadcrumbs from './SuspenseBreadcrumbs';
import SuspenseRects from './SuspenseRects';
import SuspenseTimeline from './SuspenseTimeline';
import SuspenseTreeList from './SuspenseTreeList';
import ActivityList from './ActivityList';
import {
SuspenseTreeDispatcherContext,
SuspenseTreeStateContext,
} from './SuspenseTreeContext';
import {StoreContext, OptionsContext} from '../context';
import {BridgeContext, StoreContext, OptionsContext} from '../context';
import Button from '../Button';
import Toggle from '../Toggle';
import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
@@ -74,7 +76,7 @@ function ToggleUniqueSuspenders() {
function handleToggleUniqueSuspenders() {
const nextUniqueSuspendersOnly = !uniqueSuspendersOnly;
// TODO: Handle different timeline modes (e.g. random order)
const nextTimeline = store.getSuspendableDocumentOrderSuspense(
const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
nextUniqueSuspendersOnly,
);
suspenseTreeDispatch({
@@ -157,6 +159,130 @@ function ToggleInspectedElement({
);
}
function SynchronizedScrollContainer({
className,
children,
scaleRef,
}: {
className?: string,
children?: React.Node,
scaleRef: {current: number},
}) {
const bridge = useContext(BridgeContext);
const ref = useRef(null);
const applyingScrollRef = useRef(false);
// TODO: useEffectEvent
function scrollContainerTo({
left,
top,
right,
bottom,
}: {
left: number,
top: number,
right: number,
bottom: number,
}): void {
const element = ref.current;
if (element === null) {
return;
}
const scale = scaleRef.current / element.clientWidth;
const targetLeft = Math.round(left / scale);
const targetTop = Math.round(top / scale);
if (
targetLeft !== Math.round(element.scrollLeft) ||
targetTop !== Math.round(element.scrollTop)
) {
// Disable scroll events until we've applied the new scroll position.
applyingScrollRef.current = true;
element.scrollTo({
left: targetLeft,
top: targetTop,
behavior: 'smooth',
});
}
}
useEffect(() => {
const callback = scrollContainerTo;
bridge.addListener('scrollTo', callback);
// Ask for the current scroll position when we mount so we can attach ourselves to it.
bridge.send('requestScrollPosition');
return () => bridge.removeListener('scrollTo', callback);
}, [bridge]);
const scrollTimer = useRef<null | TimeoutID>(null);
// TODO: useEffectEvent
function sendScroll() {
if (scrollTimer.current) {
clearTimeout(scrollTimer.current);
scrollTimer.current = null;
}
if (applyingScrollRef.current) {
return;
}
const element = ref.current;
if (element === null) {
return;
}
const scale = scaleRef.current / element.clientWidth;
const left = element.scrollLeft * scale;
const top = element.scrollTop * scale;
const right = left + element.clientWidth * scale;
const bottom = top + element.clientHeight * scale;
bridge.send('scrollTo', {left, top, right, bottom});
}
// TODO: useEffectEvent
function throttleScroll() {
if (!scrollTimer.current) {
// Periodically synchronize the scroll while scrolling.
scrollTimer.current = setTimeout(sendScroll, 400);
}
}
function scrollEnd() {
// Upon scrollend send it immediately.
sendScroll();
applyingScrollRef.current = false;
}
useEffect(() => {
const element = ref.current;
if (element === null) {
return;
}
const scrollCallback = throttleScroll;
const scrollEndCallback = scrollEnd;
element.addEventListener('scroll', scrollCallback);
element.addEventListener('scrollend', scrollEndCallback);
return () => {
element.removeEventListener('scroll', scrollCallback);
element.removeEventListener('scrollend', scrollEndCallback);
};
}, [ref]);
return (
<div className={className} ref={ref}>
{children}
</div>
);
}
// TODO: Get this from the store directly.
// The backend needs to keep a separate tree so that resuspending keeps Activity around.
function useActivities(): $ReadOnlyArray<Element> {
const activities = useMemo(() => {
const items: Array<Element> = [];
return items;
}, []);
return activities;
}
function SuspenseTab(_: {}) {
const store = useContext(StoreContext);
const {hideSettings} = useContext(OptionsContext);
@@ -166,10 +292,10 @@ function SuspenseTab(_: {}) {
initLayoutState,
);
const activities = useActivities();
// If there are no named Activity boundaries, we don't have any tree list and we should hide
// both the panel and the button to toggle it. Since we currently don't support it yet, it's
// always disabled.
const treeListDisabled = true;
// both the panel and the button to toggle it.
const treeListDisabled = activities.length === 0;
const wrapperTreeRef = useRef<null | HTMLElement>(null);
const resizeTreeRef = useRef<null | HTMLElement>(null);
@@ -341,16 +467,18 @@ function SuspenseTab(_: {}) {
}
};
const scaleRef = useRef(0);
return (
<SettingsModalContextController>
<div className={styles.SuspenseTab} ref={wrapperTreeRef}>
<div className={styles.TreeWrapper} ref={resizeTreeRef}>
{treeListDisabled ? null : (
<div
className={styles.TreeList}
className={styles.ActivityList}
hidden={treeListHidden}
ref={resizeTreeListRef}>
<SuspenseTreeList />
<ActivityList activities={activities} />
</div>
)}
{treeListDisabled ? null : (
@@ -388,9 +516,11 @@ function SuspenseTab(_: {}) {
orientation="horizontal"
/>
</header>
<div className={styles.Rects}>
<SuspenseRects />
</div>
<SynchronizedScrollContainer
className={styles.Rects}
scaleRef={scaleRef}>
<SuspenseRects scaleRef={scaleRef} />
</SynchronizedScrollContainer>
<footer className={styles.SuspenseTreeViewFooter}>
<SuspenseTimeline />
<div className={styles.SuspenseTreeViewFooterButtons}>

View File

@@ -111,7 +111,7 @@ type Props = {
function getInitialState(store: Store): SuspenseTreeState {
const uniqueSuspendersOnly = true;
const timeline =
store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
store.getEndTimeOrDocumentOrderSuspense(uniqueSuspendersOnly);
const timelineIndex = timeline.length - 1;
const selectedSuspenseID =
timelineIndex === -1 ? null : timeline[timelineIndex].id;
@@ -182,7 +182,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
}
// TODO: Handle different timeline modes (e.g. random order)
const nextTimeline = store.getSuspendableDocumentOrderSuspense(
const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
state.uniqueSuspendersOnly,
);

View File

@@ -1,14 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
export default function SuspenseTreeList(_: {}): React$Node {
return <div>Activity slices not implemented yet</div>;
}

View File

@@ -82,8 +82,9 @@ export const ComponentFilterDisplayName = 2;
export const ComponentFilterLocation = 3;
export const ComponentFilterHOC = 4;
export const ComponentFilterEnvironmentName = 5;
export const ComponentFilterActivitySlice = 6;
export type ComponentFilterType = 1 | 2 | 3 | 4 | 5;
export type ComponentFilterType = 1 | 2 | 3 | 4 | 5 | 6;
// Hide all elements of types in this Set.
// We hide host components only by default.
@@ -115,11 +116,20 @@ export type EnvironmentNameComponentFilter = {
value: string,
};
export type ActivitySliceFilter = {
type: 6,
activityID: Element['id'],
rendererID: number,
isValid: boolean,
isEnabled: boolean,
};
export type ComponentFilter =
| BooleanComponentFilter
| ElementTypeComponentFilter
| RegExpComponentFilter
| EnvironmentNameComponentFilter;
| EnvironmentNameComponentFilter
| ActivitySliceFilter;
export type HookName = string | null;
// Map of hook source ("<filename>:<line-number>:<column-number>") to name.
@@ -196,6 +206,7 @@ export type Rect = {
export type SuspenseTimelineStep = {
id: SuspenseNode['id'], // TODO: Will become a group.
environment: null | string,
endTime: number,
};
export type SuspenseNode = {
@@ -207,6 +218,7 @@ export type SuspenseNode = {
hasUniqueSuspenders: boolean,
isSuspended: boolean,
environments: Array<string>,
endTime: number,
};
// Serialized version of ReactIOInfo

View File

@@ -33,6 +33,7 @@ import {
TREE_OPERATION_SET_SUBTREE_MODE,
TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY,
LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET,
@@ -47,6 +48,7 @@ import {
SUSPENSE_TREE_OPERATION_SUSPENDERS,
} from './constants';
import {
ComponentFilterActivitySlice,
ComponentFilterElementType,
ComponentFilterLocation,
ElementTypeHostComponent,
@@ -432,16 +434,27 @@ export function printOperationsArray(operations: Array<number>) {
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const id = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const endTime = operations[i++] / 1000;
const isSuspended = operations[i++] === 1;
const environmentNamesLength = operations[i++];
i += environmentNamesLength;
logs.push(
`Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
`Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} ending at ${String(endTime)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
);
}
break;
}
case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: {
i++;
const activitySliceIDChange = operations[i + 1];
logs.push(
activitySliceIDChange === 0
? 'Reset applied activity slice'
: 'Applied activity slice change to ' + activitySliceIDChange,
);
break;
}
default:
throw Error(`Unsupported Bridge operation "${operation}"`);
}
@@ -467,7 +480,7 @@ export function getSavedComponentFilters(): Array<ComponentFilter> {
);
if (raw != null) {
const parsedFilters: Array<ComponentFilter> = JSON.parse(raw);
return filterOutLocationComponentFilters(parsedFilters);
return persistableComponentFilters(parsedFilters);
}
} catch (error) {}
return getDefaultComponentFilters();
@@ -478,16 +491,11 @@ export function setSavedComponentFilters(
): void {
localStorageSetItem(
LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY,
JSON.stringify(filterOutLocationComponentFilters(componentFilters)),
JSON.stringify(persistableComponentFilters(componentFilters)),
);
}
// Following __debugSource removal from Fiber, the new approach for finding the source location
// of a component, represented by the Fiber, is based on lazily generating and parsing component stack frames
// To find the original location, React DevTools will perform symbolication, source maps are required for that.
// In order to start filtering Fibers, we need to find location for all of them, which can't be done lazily.
// Eager symbolication can become quite expensive for large applications.
export function filterOutLocationComponentFilters(
export function persistableComponentFilters(
componentFilters: Array<ComponentFilter>,
): Array<ComponentFilter> {
// This is just an additional check to preserve the previous state
@@ -496,7 +504,18 @@ export function filterOutLocationComponentFilters(
return componentFilters;
}
return componentFilters.filter(f => f.type !== ComponentFilterLocation);
return componentFilters.filter(f => {
return (
// Following __debugSource removal from Fiber, the new approach for finding the source location
// of a component, represented by the Fiber, is based on lazily generating and parsing component stack frames
// To find the original location, React DevTools will perform symbolication, source maps are required for that.
// In order to start filtering Fibers, we need to find location for all of them, which can't be done lazily.
// Eager symbolication can become quite expensive for large applications.
f.type !== ComponentFilterLocation &&
// Activity slice filters are based on DevTools instance IDs which do not persist across sessions.
f.type !== ComponentFilterActivitySlice
);
});
}
const vscodeFilepath = 'vscode://file/{path}:{line}:{column}';

View File

@@ -0,0 +1,96 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
function deferred<T>(
timeoutMS: number,
resolvedValue: T,
displayName: string,
): Promise<T> {
const promise = new Promise<T>(resolve => {
setTimeout(() => resolve(resolvedValue), timeoutMS);
});
(promise as any).displayName = displayName;
return promise;
}
const title = deferred(100, 'Segmented Page Title', 'title');
const content = deferred(
400,
'This is the content of a segmented page. It loads in multiple parts.',
'content',
);
function Page(): React.Node {
return (
<article>
<h1>{title}</h1>
<p>{content}</p>
</article>
);
}
function InnerSegment({children}: {children: React.Node}): React.Node {
return (
<>
<h3>Inner Segment</h3>
<React.Suspense name="InnerSegment" fallback={<p>Loading...</p>}>
<section>{children}</section>
<p>After inner</p>
</React.Suspense>
</>
);
}
const cookies = deferred(200, 'Cookies: 🍪🍪🍪', 'cookies');
function OuterSegment({children}: {children: React.Node}): React.Node {
return (
<>
<h2>Outer Segment</h2>
<React.Suspense name="OuterSegment" fallback={<p>Loading outer</p>}>
<p>{cookies}</p>
<div>{children}</div>
<p>After outer</p>
</React.Suspense>
</>
);
}
function Root({children}: {children: React.Node}): React.Node {
return (
<>
<h1>Root Segment</h1>
<React.Suspense name="Root" fallback={<p>Loading root</p>}>
<main>{children}</main>
<footer>After root</footer>
</React.Suspense>
</>
);
}
export default function Segments(): React.Node {
return (
<React.Activity name="/" mode="visible">
<Root>
<React.Activity name="/outer/" mode="visible">
<OuterSegment>
<React.Activity name="/outer/inner" mode="visible">
<InnerSegment>
<React.Activity name="/outer/inner/page" mode="visible">
<Page />
</React.Activity>
</InnerSegment>
</React.Activity>
</OuterSegment>
</React.Activity>
</Root>
</React.Activity>
);
}

View File

@@ -18,6 +18,7 @@ import ToDoList from './ToDoList';
import Toggle from './Toggle';
import ErrorBoundaries from './ErrorBoundaries';
import PartiallyStrictApp from './PartiallyStrictApp';
import Segments from './Segments';
import SuspenseTree from './SuspenseTree';
import TraceUpdatesTest from './TraceUpdatesTest';
import {ignoreErrors, ignoreLogs, ignoreWarnings} from './console';
@@ -114,6 +115,7 @@ function mountTestApp() {
mountApp(DeeplyNestedComponents);
mountApp(Iframe);
mountApp(TraceUpdatesTest);
mountApp(Segments);
if (shouldRenderLegacy) {
mountLegacyApp(PartiallyStrictApp);

View File

@@ -27,6 +27,6 @@
"internal-ip": "^6.2.0",
"minimist": "^1.2.3",
"react-devtools-core": "7.0.1",
"update-notifier": "^2.1.0"
"update-notifier": "^5.0.0"
}
}

View File

@@ -38,6 +38,8 @@ import {getParentHydrationBoundary} from './ReactFiberConfigDOM';
import {enableScopeAPI} from 'shared/ReactFeatureFlags';
import {enableInternalInstanceMap} from 'shared/ReactFeatureFlags';
const randomKey = Math.random().toString(36).slice(2);
const internalInstanceKey = '__reactFiber$' + randomKey;
const internalPropsKey = '__reactProps$' + randomKey;
@@ -49,7 +51,32 @@ const internalRootNodeResourcesKey = '__reactResources$' + randomKey;
const internalHoistableMarker = '__reactMarker$' + randomKey;
const internalScrollTimer = '__reactScroll$' + randomKey;
type InstanceUnion =
| Instance
| TextInstance
| SuspenseInstance
| ActivityInstance
| ReactScopeInstance
| Container;
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
const internalInstanceMap:
| WeakMap<InstanceUnion, Fiber>
| Map<InstanceUnion, Fiber> = new PossiblyWeakMap();
const internalPropsMap:
| WeakMap<InstanceUnion, Props>
| Map<InstanceUnion, Props> = new PossiblyWeakMap();
export function detachDeletedInstance(node: Instance): void {
if (enableInternalInstanceMap) {
internalInstanceMap.delete(node);
internalPropsMap.delete(node);
delete (node: any)[internalEventHandlersKey];
delete (node: any)[internalEventHandlerListenersKey];
delete (node: any)[internalEventHandlesSetKey];
delete (node: any)[internalRootNodeResourcesKey];
return;
}
// TODO: This function is only called on host components. I don't think all of
// these fields are relevant.
delete (node: any)[internalInstanceKey];
@@ -68,6 +95,10 @@ export function precacheFiberNode(
| ActivityInstance
| ReactScopeInstance,
): void {
if (enableInternalInstanceMap) {
internalInstanceMap.set(node, hostInst);
return;
}
(node: any)[internalInstanceKey] = hostInst;
}
@@ -95,7 +126,12 @@ export function isContainerMarkedAsRoot(node: Container): boolean {
// HostRoot back. To get to the HostRoot, you need to pass a child of it.
// The same thing applies to Suspense and Activity boundaries.
export function getClosestInstanceFromNode(targetNode: Node): null | Fiber {
let targetInst = (targetNode: any)[internalInstanceKey];
let targetInst: void | Fiber;
if (enableInternalInstanceMap) {
targetInst = internalInstanceMap.get(((targetNode: any): InstanceUnion));
} else {
targetInst = (targetNode: any)[internalInstanceKey];
}
if (targetInst) {
// Don't return HostRoot, SuspenseComponent or ActivityComponent here.
return targetInst;
@@ -112,9 +148,15 @@ export function getClosestInstanceFromNode(targetNode: Node): null | Fiber {
// itself because the fibers are conceptually between the container
// node and the first child. It isn't surrounding the container node.
// If it's not a container, we check if it's an instance.
targetInst =
(parentNode: any)[internalContainerInstanceKey] ||
(parentNode: any)[internalInstanceKey];
if (enableInternalInstanceMap) {
targetInst =
(parentNode: any)[internalContainerInstanceKey] ||
internalInstanceMap.get(((parentNode: any): InstanceUnion));
} else {
targetInst =
(parentNode: any)[internalContainerInstanceKey] ||
(parentNode: any)[internalInstanceKey];
}
if (targetInst) {
// Since this wasn't the direct target of the event, we might have
// stepped past dehydrated DOM nodes to get here. However they could
@@ -147,8 +189,10 @@ export function getClosestInstanceFromNode(targetNode: Node): null | Fiber {
// have had an internalInstanceKey on it.
// Let's get the fiber associated with the SuspenseComponent
// as the deepest instance.
// $FlowFixMe[prop-missing]
const targetFiber = hydrationInstance[internalInstanceKey];
const targetFiber = enableInternalInstanceMap
? internalInstanceMap.get(hydrationInstance)
: // $FlowFixMe[prop-missing]
hydrationInstance[internalInstanceKey];
if (targetFiber) {
return targetFiber;
}
@@ -175,9 +219,16 @@ export function getClosestInstanceFromNode(targetNode: Node): null | Fiber {
* instance, or null if the node was not rendered by this React.
*/
export function getInstanceFromNode(node: Node): Fiber | null {
const inst =
(node: any)[internalInstanceKey] ||
(node: any)[internalContainerInstanceKey];
let inst: void | null | Fiber;
if (enableInternalInstanceMap) {
inst =
internalInstanceMap.get(((node: any): InstanceUnion)) ||
(node: any)[internalContainerInstanceKey];
} else {
inst =
(node: any)[internalInstanceKey] ||
(node: any)[internalContainerInstanceKey];
}
if (inst) {
const tag = inst.tag;
if (
@@ -226,16 +277,25 @@ export function getFiberCurrentPropsFromNode(
| TextInstance
| SuspenseInstance
| ActivityInstance,
): Props {
): Props | null {
if (enableInternalInstanceMap) {
return internalPropsMap.get(node) || null;
}
return (node: any)[internalPropsKey] || null;
}
export function updateFiberProps(node: Instance, props: Props): void {
if (enableInternalInstanceMap) {
internalPropsMap.set(node, props);
return;
}
(node: any)[internalPropsKey] = props;
}
export function getEventListenerSet(node: EventTarget): Set<string> {
let elementListenerSet = (node: any)[internalEventHandlersKey];
let elementListenerSet: Set<string> | void = (node: any)[
internalEventHandlersKey
];
if (elementListenerSet === undefined) {
elementListenerSet = (node: any)[internalEventHandlersKey] = new Set();
}
@@ -246,6 +306,9 @@ export function getFiberFromScopeInstance(
scope: ReactScopeInstance,
): null | Fiber {
if (enableScopeAPI) {
if (enableInternalInstanceMap) {
return internalInstanceMap.get(((scope: any): InstanceUnion)) || null;
}
return (scope: any)[internalInstanceKey] || null;
}
return null;
@@ -318,6 +381,12 @@ export function clearScrollEndTimer(node: EventTarget): void {
}
export function isOwnedInstance(node: Node): boolean {
if (enableInternalInstanceMap) {
return !!(
(node: any)[internalHoistableMarker] ||
internalInstanceMap.has((node: any))
);
}
return !!(
(node: any)[internalHoistableMarker] || (node: any)[internalInstanceKey]
);

View File

@@ -126,6 +126,7 @@ import {
enableHydrationChangeEvent,
enableFragmentRefsScrollIntoView,
enableProfilerTimer,
enableFragmentRefsInstanceHandles,
} from 'shared/ReactFeatureFlags';
import {
HostComponent,
@@ -214,6 +215,10 @@ export type Container =
export type Instance = Element;
export type TextInstance = Text;
type InstanceWithFragmentHandles = Instance & {
unstable_reactFragments?: Set<FragmentInstanceType>,
};
declare class ActivityInterface extends Comment {}
declare class SuspenseInterface extends Comment {
_reactRetry: void | (() => void);
@@ -1430,8 +1435,13 @@ export function applyViewTransitionName(
className: ?string,
): void {
instance = ((instance: any): HTMLElement);
// If the name isn't valid CSS identifier, base64 encode the name instead.
// This doesn't let you select it in custom CSS selectors but it does work in current
// browsers.
const escapedName =
CSS.escape(name) !== name ? 'r-' + btoa(name).replace(/=/g, '') : name;
// $FlowFixMe[prop-missing]
instance.style.viewTransitionName = name;
instance.style.viewTransitionName = escapedName;
if (className != null) {
// $FlowFixMe[prop-missing]
instance.style.viewTransitionClass = className;
@@ -3390,10 +3400,44 @@ if (enableFragmentRefsScrollIntoView) {
};
}
function addFragmentHandleToFiber(
child: Fiber,
fragmentInstance: FragmentInstanceType,
): boolean {
if (enableFragmentRefsInstanceHandles) {
const instance =
getInstanceFromHostFiber<InstanceWithFragmentHandles>(child);
if (instance != null) {
addFragmentHandleToInstance(instance, fragmentInstance);
}
}
return false;
}
function addFragmentHandleToInstance(
instance: InstanceWithFragmentHandles,
fragmentInstance: FragmentInstanceType,
): void {
if (enableFragmentRefsInstanceHandles) {
if (instance.unstable_reactFragments == null) {
instance.unstable_reactFragments = new Set();
}
instance.unstable_reactFragments.add(fragmentInstance);
}
}
export function createFragmentInstance(
fragmentFiber: Fiber,
): FragmentInstanceType {
return new (FragmentInstance: any)(fragmentFiber);
const fragmentInstance = new (FragmentInstance: any)(fragmentFiber);
if (enableFragmentRefsInstanceHandles) {
traverseFragmentInstance(
fragmentFiber,
addFragmentHandleToFiber,
fragmentInstance,
);
}
return fragmentInstance;
}
export function updateFragmentInstanceFiber(
@@ -3404,7 +3448,7 @@ export function updateFragmentInstanceFiber(
}
export function commitNewChildToFragmentInstance(
childInstance: Instance,
childInstance: InstanceWithFragmentHandles,
fragmentInstance: FragmentInstanceType,
): void {
const eventListeners = fragmentInstance._eventListeners;
@@ -3419,17 +3463,25 @@ export function commitNewChildToFragmentInstance(
observer.observe(childInstance);
});
}
if (enableFragmentRefsInstanceHandles) {
addFragmentHandleToInstance(childInstance, fragmentInstance);
}
}
export function deleteChildFromFragmentInstance(
childElement: Instance,
childInstance: InstanceWithFragmentHandles,
fragmentInstance: FragmentInstanceType,
): void {
const eventListeners = fragmentInstance._eventListeners;
if (eventListeners !== null) {
for (let i = 0; i < eventListeners.length; i++) {
const {type, listener, optionsOrUseCapture} = eventListeners[i];
childElement.removeEventListener(type, listener, optionsOrUseCapture);
childInstance.removeEventListener(type, listener, optionsOrUseCapture);
}
}
if (enableFragmentRefsInstanceHandles) {
if (childInstance.unstable_reactFragments != null) {
childInstance.unstable_reactFragments.delete(fragmentInstance);
}
}
}

View File

@@ -341,7 +341,6 @@ export function getEventPriority(domEventName: DOMEventName): EventPriority {
case 'pointerup':
case 'ratechange':
case 'reset':
case 'resize':
case 'seeked':
case 'submit':
case 'toggle':
@@ -380,6 +379,7 @@ export function getEventPriority(domEventName: DOMEventName): EventPriority {
case 'pointermove':
case 'pointerout':
case 'pointerover':
case 'resize':
case 'scroll':
case 'touchmove':
case 'wheel':

View File

@@ -8,7 +8,7 @@ export const clientRenderBoundary =
export const completeBoundary =
'$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;b<a.length;b+=2){var c=a[b],e=a[b+1];null!==e.parentNode&&e.parentNode.removeChild(e);var f=c.parentNode;if(f){var g=c.previousSibling,h=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d||"/&"===d)if(0===h)break;else h--;else"$"!==d&&"$?"!==d&&"$~"!==d&&"$!"!==d&&"&"!==d||h++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;e.firstChild;)f.insertBefore(e.firstChild,c);g.data="$";g._reactRetry&&requestAnimationFrame(g._reactRetry)}}a.length=0};\n$RC=function(a,b){if(b=document.getElementById(b))(a=document.getElementById(a))?(a.previousSibling.data="$~",$RB.push(a,b),2===$RB.length&&("number"!==typeof $RT?requestAnimationFrame($RV.bind(null,$RB)):(a=performance.now(),setTimeout($RV.bind(null,$RB),2300>a&&2E3<a?2300-a:$RT+300-a)))):b.parentNode.removeChild(b)};';
export const completeBoundaryUpgradeToViewTransitions =
'$RV=function(A,g){function k(a,b){var e=a.getAttribute(b);e&&(b=a.style,l.push(a,b.viewTransitionName,b.viewTransitionClass),"auto"!==e&&(b.viewTransitionClass=e),(a=a.getAttribute("vt-name"))||(a="_T_"+K++ +"_"),b.viewTransitionName=a,B=!0)}var B=!1,K=0,l=[];try{var f=document.__reactViewTransition;if(f){f.finished.finally($RV.bind(null,g));return}var m=new Map;for(f=1;f<g.length;f+=2)for(var h=g[f].querySelectorAll("[vt-share]"),d=0;d<h.length;d++){var c=h[d];m.set(c.getAttribute("vt-name"),c)}var u=[];for(h=0;h<g.length;h+=2){var C=g[h],x=C.parentNode;if(x){var v=x.getBoundingClientRect();if(v.left||v.top||v.width||v.height){c=C;for(f=0;c;){if(8===c.nodeType){var r=c.data;if("/$"===r)if(0===f)break;else f--;else"$"!==r&&"$?"!==r&&"$~"!==r&&"$!"!==r||f++}else if(1===c.nodeType){d=c;var D=d.getAttribute("vt-name"),y=m.get(D);k(d,y?"vt-share":"vt-exit");y&&(k(y,"vt-share"),m.set(D,null));var E=d.querySelectorAll("[vt-share]");for(d=0;d<E.length;d++){var F=E[d],G=F.getAttribute("vt-name"),\nH=m.get(G);H&&(k(F,"vt-share"),k(H,"vt-share"),m.set(G,null))}}c=c.nextSibling}for(var I=g[h+1],t=I.firstElementChild;t;)null!==m.get(t.getAttribute("vt-name"))&&k(t,"vt-enter"),t=t.nextElementSibling;c=x;do for(var n=c.firstElementChild;n;){var J=n.getAttribute("vt-update");J&&"none"!==J&&!l.includes(n)&&k(n,"vt-update");n=n.nextElementSibling}while((c=c.parentNode)&&1===c.nodeType&&"none"!==c.getAttribute("vt-update"));u.push.apply(u,I.querySelectorAll(\'img[src]:not([loading="lazy"])\'))}}}if(B){var z=\ndocument.__reactViewTransition=document.startViewTransition({update:function(){A(g);for(var a=[document.documentElement.clientHeight,document.fonts.ready],b={},e=0;e<u.length;b={g:b.g},e++)if(b.g=u[e],!b.g.complete){var p=b.g.getBoundingClientRect();0<p.bottom&&0<p.right&&p.top<window.innerHeight&&p.left<window.innerWidth&&(p=new Promise(function(w){return function(q){w.g.addEventListener("load",q);w.g.addEventListener("error",q)}}(b)),a.push(p))}return Promise.race([Promise.all(a),new Promise(function(w){var q=\nperformance.now();setTimeout(w,2300>q&&2E3<q?2300-q:500)})])},types:[]});z.ready.finally(function(){for(var a=l.length-3;0<=a;a-=3){var b=l[a],e=b.style;e.viewTransitionName=l[a+1];e.viewTransitionClass=l[a+1];""===b.getAttribute("style")&&b.removeAttribute("style")}});z.finished.finally(function(){document.__reactViewTransition===z&&(document.__reactViewTransition=null)});$RB=[];return}}catch(a){}A(g)}.bind(null,$RV);';
'$RV=function(A,g){function k(a,b){var e=a.getAttribute(b);e&&(b=a.style,l.push(a,b.viewTransitionName,b.viewTransitionClass),"auto"!==e&&(b.viewTransitionClass=e),(a=a.getAttribute("vt-name"))||(a="_T_"+K++ +"_"),a=CSS.escape(a)!==a?"r-"+btoa(a).replace(/=/g,""):a,b.viewTransitionName=a,B=!0)}var B=!1,K=0,l=[];try{var f=document.__reactViewTransition;if(f){f.finished.finally($RV.bind(null,g));return}var m=new Map;for(f=1;f<g.length;f+=2)for(var h=g[f].querySelectorAll("[vt-share]"),d=0;d<h.length;d++){var c=h[d];m.set(c.getAttribute("vt-name"),c)}var u=[];for(h=0;h<g.length;h+=2){var C=g[h],x=C.parentNode;if(x){var v=x.getBoundingClientRect();if(v.left||v.top||v.width||v.height){c=C;for(f=0;c;){if(8===c.nodeType){var r=c.data;if("/$"===r)if(0===f)break;else f--;else"$"!==r&&"$?"!==r&&"$~"!==r&&"$!"!==r||f++}else if(1===c.nodeType){d=c;var D=d.getAttribute("vt-name"),y=m.get(D);k(d,y?"vt-share":"vt-exit");y&&(k(y,"vt-share"),m.set(D,null));var E=d.querySelectorAll("[vt-share]");\nfor(d=0;d<E.length;d++){var F=E[d],G=F.getAttribute("vt-name"),H=m.get(G);H&&(k(F,"vt-share"),k(H,"vt-share"),m.set(G,null))}}c=c.nextSibling}for(var I=g[h+1],t=I.firstElementChild;t;)null!==m.get(t.getAttribute("vt-name"))&&k(t,"vt-enter"),t=t.nextElementSibling;c=x;do for(var n=c.firstElementChild;n;){var J=n.getAttribute("vt-update");J&&"none"!==J&&!l.includes(n)&&k(n,"vt-update");n=n.nextElementSibling}while((c=c.parentNode)&&1===c.nodeType&&"none"!==c.getAttribute("vt-update"));u.push.apply(u,\nI.querySelectorAll(\'img[src]:not([loading="lazy"])\'))}}}if(B){var z=document.__reactViewTransition=document.startViewTransition({update:function(){A(g);for(var a=[document.documentElement.clientHeight,document.fonts.ready],b={},e=0;e<u.length;b={g:b.g},e++)if(b.g=u[e],!b.g.complete){var p=b.g.getBoundingClientRect();0<p.bottom&&0<p.right&&p.top<window.innerHeight&&p.left<window.innerWidth&&(p=new Promise(function(w){return function(q){w.g.addEventListener("load",q);w.g.addEventListener("error",q)}}(b)),\na.push(p))}return Promise.race([Promise.all(a),new Promise(function(w){var q=performance.now();setTimeout(w,2300>q&&2E3<q?2300-q:500)})])},types:[]});z.ready.finally(function(){for(var a=l.length-3;0<=a;a-=3){var b=l[a],e=b.style;e.viewTransitionName=l[a+1];e.viewTransitionClass=l[a+1];""===b.getAttribute("style")&&b.removeAttribute("style")}});z.finished.finally(function(){document.__reactViewTransition===z&&(document.__reactViewTransition=null)});$RB=[];return}}catch(a){}A(g)}.bind(null,\n$RV);';
export const completeBoundaryWithStyles =
'$RM=new Map;$RR=function(n,w,p){function u(q){this._p=null;q()}for(var r=new Map,t=document,h,b,e=t.querySelectorAll("link[data-precedence],style[data-precedence]"),v=[],k=0;b=e[k++];)"not all"===b.getAttribute("media")?v.push(b):("LINK"===b.tagName&&$RM.set(b.getAttribute("href"),b),r.set(b.dataset.precedence,h=b));e=0;b=[];var l,a;for(k=!0;;){if(k){var f=p[e++];if(!f){k=!1;e=0;continue}var c=!1,m=0;var d=f[m++];if(a=$RM.get(d)){var g=a._p;c=!0}else{a=t.createElement("link");a.href=d;a.rel=\n"stylesheet";for(a.dataset.precedence=l=f[m++];g=f[m++];)a.setAttribute(g,f[m++]);g=a._p=new Promise(function(q,x){a.onload=u.bind(a,q);a.onerror=u.bind(a,x)});$RM.set(d,a)}d=a.getAttribute("media");!g||d&&!matchMedia(d).matches||b.push(g);if(c)continue}else{a=v[e++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=r.get(l)||h;c===h&&(h=a);r.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=t.head,c.insertBefore(a,c.firstChild))}if(p=document.getElementById(n))p.previousSibling.data=\n"$~";Promise.all(b).then($RC.bind(null,n,w),$RX.bind(null,n,"CSS failed to load"))};';
export const completeSegment =

View File

@@ -130,7 +130,12 @@ export function revealCompletedBoundariesWithViewTransitions(
const idPrefix = '';
name = '_' + idPrefix + 'T_' + autoNameIdx++ + '_';
}
elementStyle['viewTransitionName'] = name;
// If the name isn't valid CSS identifier, base64 encode the name instead.
// This doesn't let you select it in custom CSS selectors but it does work in current
// browsers.
const escapedName =
CSS.escape(name) !== name ? 'r-' + btoa(name).replace(/=/g, '') : name;
elementStyle['viewTransitionName'] = escapedName;
shouldStartViewTransition = true;
}
try {

View File

@@ -12,6 +12,8 @@ if (process.env.NODE_ENV === 'production') {
exports.version = b.version;
exports.renderToReadableStream = b.renderToReadableStream;
exports.renderToPipeableStream = b.renderToPipeableStream;
exports.resumeToPipeableStream = b.resumeToPipeableStream;
exports.resume = b.resume;
exports.renderToString = l.renderToString;
exports.renderToStaticMarkup = l.renderToStaticMarkup;

View File

@@ -38,3 +38,17 @@ export function resume() {
arguments,
);
}
export function renderToPipeableStream() {
return require('./src/server/react-dom-server.bun').renderToPipeableStream.apply(
this,
arguments,
);
}
export function resumeToPipeableStream() {
return require('./src/server/react-dom-server.bun').resumeToPipeableStream.apply(
this,
arguments,
);
}

View File

@@ -0,0 +1,565 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
'use strict';
let React;
let ReactDOM;
let ReactDOMClient;
let Scheduler;
let act;
let Activity;
let useState;
let useLayoutEffect;
let useEffect;
let LegacyHidden;
let assertLog;
let Suspense;
describe('ReactDOMActivity', () => {
let container;
beforeEach(() => {
jest.resetModules();
React = require('react');
Scheduler = require('scheduler/unstable_mock');
Activity = React.Activity;
useState = React.useState;
Suspense = React.Suspense;
useState = React.useState;
LegacyHidden = React.unstable_LegacyHidden;
useLayoutEffect = React.useLayoutEffect;
useEffect = React.useEffect;
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
const InternalTestUtils = require('internal-test-utils');
act = InternalTestUtils.act;
assertLog = InternalTestUtils.assertLog;
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
function Text(props) {
Scheduler.log(props.text);
return <span prop={props.text}>{props.children}</span>;
}
// @gate enableActivity
it(
'hiding an Activity boundary also hides the direct children of any ' +
'portals it contains, regardless of how deeply nested they are',
async () => {
const portalContainer = document.createElement('div');
let setShow;
function Accordion({children}) {
const [shouldShow, _setShow] = useState(true);
setShow = _setShow;
return (
<Activity mode={shouldShow ? 'visible' : 'hidden'}>
{children}
</Activity>
);
}
function App() {
return (
<Accordion>
<div>
{ReactDOM.createPortal(
<div>Portal contents</div>,
portalContainer,
)}
</div>
</Accordion>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
expect(container.innerHTML).toBe('<div></div>');
expect(portalContainer.innerHTML).toBe('<div>Portal contents</div>');
// Hide the Activity boundary. Not only are the nearest DOM elements hidden,
// but also the children of the nested portal contained within it.
await act(() => setShow(false));
expect(container.innerHTML).toBe('<div style="display: none;"></div>');
expect(portalContainer.innerHTML).toBe(
'<div style="display: none;">Portal contents</div>',
);
},
);
// @gate enableActivity
it(
'revealing an Activity boundary inside a portal does not reveal the ' +
'portal contents if has a hidden Activity parent',
async () => {
const portalContainer = document.createElement('div');
let setShow;
function Accordion({children}) {
const [shouldShow, _setShow] = useState(false);
setShow = _setShow;
return (
<Activity mode={shouldShow ? 'visible' : 'hidden'}>
{children}
</Activity>
);
}
function App() {
return (
<Activity mode="hidden">
<div>
{ReactDOM.createPortal(
<Accordion>
<div>Portal contents</div>
</Accordion>,
portalContainer,
)}
</div>
</Activity>
);
}
// Start with both boundaries hidden.
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
expect(container.innerHTML).toBe('<div style="display: none;"></div>');
expect(portalContainer.innerHTML).toBe(
'<div style="display: none;">Portal contents</div>',
);
// Reveal the inner Activity boundary. It should not reveal its children,
// because there's a parent Activity boundary that is still hidden.
await act(() => setShow(true));
expect(container.innerHTML).toBe('<div style="display: none;"></div>');
expect(portalContainer.innerHTML).toBe(
'<div style="display: none;">Portal contents</div>',
);
},
);
// @gate enableActivity
it('hides new portals added to an already hidden tree', async () => {
function Child() {
return <Text text="Child" />;
}
const portalContainer = document.createElement('div');
function Portal({children}) {
return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
}
const root = ReactDOMClient.createRoot(container);
// Mount hidden tree.
await act(() => {
root.render(
<Activity mode="hidden">
<Text text="Parent" />
</Activity>,
);
});
assertLog(['Parent']);
expect(container.innerHTML).toBe(
'<span prop="Parent" style="display: none;"></span>',
);
expect(portalContainer.innerHTML).toBe('');
// Add a portal inside the hidden tree.
await act(() => {
root.render(
<Activity mode="hidden">
<Text text="Parent" />
<Portal>
<Child />
</Portal>
</Activity>,
);
});
assertLog(['Parent', 'Child']);
expect(container.innerHTML).toBe(
'<span prop="Parent" style="display: none;"></span><div style="display: none;"></div>',
);
expect(portalContainer.innerHTML).toBe(
'<span prop="Child" style="display: none;"></span>',
);
// Now reveal it.
await act(() => {
root.render(
<Activity mode="visible">
<Text text="Parent" />
<Portal>
<Child />
</Portal>
</Activity>,
);
});
assertLog(['Parent', 'Child']);
expect(container.innerHTML).toBe(
'<span prop="Parent" style=""></span><div style=""></div>',
);
expect(portalContainer.innerHTML).toBe(
'<span prop="Child" style=""></span>',
);
});
// @gate enableActivity
it('hides new insertions inside an already hidden portal', async () => {
function Child({text}) {
useLayoutEffect(() => {
Scheduler.log(`Mount layout ${text}`);
return () => {
Scheduler.log(`Unmount layout ${text}`);
};
}, [text]);
return <Text text={text} />;
}
const portalContainer = document.createElement('div');
function Portal({children}) {
return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
}
const root = ReactDOMClient.createRoot(container);
// Mount hidden tree.
await act(() => {
root.render(
<Activity mode="hidden">
<Portal>
<Child text="A" />
</Portal>
</Activity>,
);
});
assertLog(['A']);
expect(container.innerHTML).toBe('<div style="display: none;"></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="A" style="display: none;"></span>',
);
// Add a node inside the hidden portal.
await act(() => {
root.render(
<Activity mode="hidden">
<Portal>
<Child text="A" />
<Child text="B" />
</Portal>
</Activity>,
);
});
assertLog(['A', 'B']);
expect(container.innerHTML).toBe('<div style="display: none;"></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="A" style="display: none;"></span><span prop="B" style="display: none;"></span>',
);
// Now reveal it.
await act(() => {
root.render(
<Activity mode="visible">
<Portal>
<Child text="A" />
<Child text="B" />
</Portal>
</Activity>,
);
});
assertLog(['A', 'B', 'Mount layout A', 'Mount layout B']);
expect(container.innerHTML).toBe('<div style=""></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="A" style=""></span><span prop="B" style=""></span>',
);
});
// @gate enableActivity
it('reveal an inner Suspense boundary without revealing an outer Activity on the same host child', async () => {
const promise = new Promise(() => {});
function Child({showInner}) {
useLayoutEffect(() => {
Scheduler.log('Mount layout');
return () => {
Scheduler.log('Unmount layout');
};
}, []);
return (
<>
{showInner ? null : promise}
<Text text="Child" />
</>
);
}
const portalContainer = document.createElement('div');
function Portal({children}) {
return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
}
const root = ReactDOMClient.createRoot(container);
// Prerender the whole tree.
await act(() => {
root.render(
<Activity mode="hidden">
<Portal>
<Suspense name="Inner" fallback={<span>Loading</span>}>
<Child showInner={true} />
</Suspense>
</Portal>
</Activity>,
);
});
assertLog(['Child']);
expect(container.innerHTML).toBe('<div style="display: none;"></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="Child" style="display: none;"></span>',
);
// Re-suspend the inner.
await act(() => {
root.render(
<Activity mode="hidden">
<Portal>
<Suspense name="Inner" fallback={<span>Loading</span>}>
<Child showInner={false} />
</Suspense>
</Portal>
</Activity>,
);
});
assertLog([]);
expect(container.innerHTML).toBe('<div style="display: none;"></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="Child" style="display: none;"></span><span style="display: none;">Loading</span>',
);
// Toggle to visible while suspended.
await act(() => {
root.render(
<Activity mode="visible">
<Portal>
<Suspense name="Inner" fallback={<span>Loading</span>}>
<Child showInner={false} />
</Suspense>
</Portal>
</Activity>,
);
});
assertLog([]);
expect(container.innerHTML).toBe('<div style=""></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="Child" style="display: none;"></span><span style="">Loading</span>',
);
// Now reveal.
await act(() => {
root.render(
<Activity mode="visible">
<Portal>
<Suspense name="Inner" fallback={<span>Loading</span>}>
<Child showInner={true} />
</Suspense>
</Portal>
</Activity>,
);
});
assertLog(['Child', 'Mount layout']);
expect(container.innerHTML).toBe('<div style=""></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="Child" style=""></span>',
);
});
// @gate enableActivity
it('mounts/unmounts layout effects in portal when visibility changes (starting visible)', async () => {
function Child() {
useLayoutEffect(() => {
Scheduler.log('Mount layout');
return () => {
Scheduler.log('Unmount layout');
};
}, []);
return <Text text="Child" />;
}
const portalContainer = document.createElement('div');
function Portal({children}) {
return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
}
const root = ReactDOMClient.createRoot(container);
// Mount visible tree.
await act(() => {
root.render(
<Activity mode="visible">
<Portal>
<Child />
</Portal>
</Activity>,
);
});
assertLog(['Child', 'Mount layout']);
expect(container.innerHTML).toBe('<div></div>');
expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
// Hide the tree. The layout effect is unmounted.
await act(() => {
root.render(
<Activity mode="hidden">
<Portal>
<Child />
</Portal>
</Activity>,
);
});
assertLog(['Unmount layout', 'Child']);
expect(container.innerHTML).toBe('<div style="display: none;"></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="Child" style="display: none;"></span>',
);
});
// @gate enableActivity
it('mounts/unmounts layout effects in portal when visibility changes (starting hidden)', async () => {
function Child() {
useLayoutEffect(() => {
Scheduler.log('Mount layout');
return () => {
Scheduler.log('Unmount layout');
};
}, []);
return <Text text="Child" />;
}
const portalContainer = document.createElement('div');
function Portal({children}) {
return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
}
const root = ReactDOMClient.createRoot(container);
// Mount hidden tree.
await act(() => {
root.render(
<Activity mode="hidden">
<Portal>
<Child />
</Portal>
</Activity>,
);
});
// No layout effect.
assertLog(['Child']);
expect(container.innerHTML).toBe('<div style="display: none;"></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="Child" style="display: none;"></span>',
);
// Unhide the tree. The layout effect is mounted.
await act(() => {
root.render(
<Activity mode="visible">
<Portal>
<Child />
</Portal>
</Activity>,
);
});
assertLog(['Child', 'Mount layout']);
expect(container.innerHTML).toBe('<div style=""></div>');
expect(portalContainer.innerHTML).toBe(
'<span prop="Child" style=""></span>',
);
});
// @gate enableLegacyHidden
it('does not toggle effects or hide nodes for LegacyHidden component inside portal', async () => {
function Child() {
useLayoutEffect(() => {
Scheduler.log('Mount layout');
return () => {
Scheduler.log('Unmount layout');
};
}, []);
useEffect(() => {
Scheduler.log('Mount passive');
return () => {
Scheduler.log('Unmount passive');
};
}, []);
return <Text text="Child" />;
}
const portalContainer = document.createElement('div');
function Portal({children}) {
return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
}
const root = ReactDOMClient.createRoot(container);
// Mount visible tree.
await act(() => {
root.render(
<LegacyHidden mode="visible">
<Portal>
<Child />
</Portal>
</LegacyHidden>,
);
});
assertLog(['Child', 'Mount layout', 'Mount passive']);
expect(container.innerHTML).toBe('<div></div>');
expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
// Hide the tree.
await act(() => {
root.render(
<LegacyHidden mode="hidden">
<Portal>
<Child />
</Portal>
</LegacyHidden>,
);
});
// Effects not unmounted.
assertLog(['Child']);
expect(container.innerHTML).toBe('<div></div>');
expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
// Unhide the tree.
await act(() => {
root.render(
<LegacyHidden mode="visible">
<Portal>
<Child />
</Portal>
</LegacyHidden>,
);
});
// Effects already mounted.
assertLog(['Child']);
expect(container.innerHTML).toBe('<div></div>');
expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
});
});

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