Compare commits

..

71 Commits

Author SHA1 Message Date
Joe Savona
2d357eb9be [compiler] enablePreserveMemo treats manual deps as non-nullable
The `@enablePreserveExistingMemoizationGuarantees` mode can still fail to preserve manual memoization due to mismtached dependencies. Specifically, where the user's dependencies are more precise than the compiler infers bc the compiler is being conservative about what might be nullable. In this mode though we're intentionally using information from the manual memoization and can also rely on the deps as a signal for what's non-nullable.

The idea of the PR is that we treat manual memo deps just like other inferred-as-non-nullable objects during PropagateScopeDeps. We're careful to not treat the full path as non-nullable, only up to the last property index. So `x.y.z` as a manual dep treats `x` and `x.y` as non-nullable, allowing us to preserve a conditional dependency on `x.y.z`.

Optionals within manual dependencies are a bit trickier and aren't handled yet, but hopefully that's less common and something we can improve in a follow-up. Not handling them just means that developers may hit false positives on validating existing memoization if they use optional chains in manual dependencies.
2025-10-02 09:19:19 -07:00
Sebastian "Sebbie" Silbermann
a757cb7667 Fix DevTools fixture crash due to usage of renamed APIs (#34682) 2025-10-02 14:43:02 +02:00
Sebastian Markbåge
d74f061b69 [Fiber] Clean up ViewTransition when it fails to start (#34676)
The View Transition docs were unclear about this but apparently the
`finished` promise never settles if the animation never started. So if
there's an error that rejects the `ready` promise, we'll never run the
clean up which can cause it to stall.

Fixes #34662.

However, ultimately that is caused by Chrome stalling our default
`onDefaultTransitionIndicator` but it should be unblocked after 10
seconds, not a minute.
2025-10-01 21:58:13 -04:00
Eugene Choi
f7254efc5c [playground] Persist open tabs on compiler error (#34673)
This change allows it so that tabs that were open before a compiler
error are automatically opened again when the error is resolved. Quality
of life change for those especially working with the advanced view of
the playground.


https://github.com/user-attachments/assets/cd2dc117-e6fc-4f57-a08f-259757c4f5e8
2025-10-01 21:26:16 -04:00
Sebastian "Sebbie" Silbermann
79ca5ae855 Bump next prerelease version numbers (#34674) 2025-10-02 00:31:55 +02:00
lauren
ae74234eae [eprh] Allow compiler rules to be opted-in but not in the preset (#34672)
Follow up to #34649. This adds the compiler rules back so they can be
opted-in 6.1.0, but aren't included in the presets as that would be a
breaking change.
2025-10-01 17:05:42 -04:00
Sebastian "Sebbie" Silbermann
861811347b Bump scheduler version (#34671)
The canaries have been published depending on 0.27-canary. Bumping
scheduler just in case to be sure.
2025-10-01 22:45:31 +02:00
Ricky
7f9d99749c Land enableHiddenSubtreeInsertionEffectCleanup (#34372)
Fixes a bug where insertion effects were not cleaned up if a hidden
Activity is unmounted.
2025-10-01 16:31:30 -04:00
Sebastian "Sebbie" Silbermann
aef8b1b562 19.2 changelog (#34655)
Co-authored-by: Jack Pope <jackpope1@gmail.com>
Co-authored-by: Rick Hanlon <rickhanlonii@meta.com>
2025-10-01 22:11:02 +02:00
Jack Pope
67e24bc527 Improve lint error messages for useEffectEvent (#34669)
Called Before:

> `logEvent` is a function created with React Hook "useEffectEvent", and
can only be called from the same component.

Called After:

> `logEvent` is a function created with React Hook "useEffectEvent", and
can only be called from Effects and Effect Events in the same component.

Referenced Before:

> `logEvent` is a function created with React Hook "useEffectEvent", and
can only be called from the same component. They cannot be assigned to
variables or passed down.

Referenced After:

> `logEvent` 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.
2025-10-01 15:17:08 -04:00
Sebastian Markbåge
bbc2d596fa Traverse down an updated tree even if it has no passive effects in profiling mode (#34667)
We need this to be able to log the renders that happened inside.

This is the same thing we do here but for the offscreen special cases:


https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberCommitWork.js#L3452-L3457
2025-10-01 13:45:37 -04:00
Sebastian "Sebbie" Silbermann
1bd1f01f2a Ship partial-prerendering APIs to Canary (#34633) 2025-10-01 18:22:30 +02:00
Sebastian "Sebbie" Silbermann
548235db10 Enable React performance tracks in Canary (#34665)
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
2025-10-01 18:13:15 +02:00
Sebastian "Sebbie" Silbermann
1f460f31ee [DevTools] Fix host instance highlighting (#34661) 2025-10-01 17:32:34 +02:00
Sebastian "Sebbie" Silbermann
2f0649a0b2 [Fizz] Remove nonce option from resume-and-prerender APIs (#34664) 2025-10-01 17:32:26 +02:00
Sebastian Markbåge
7bccdbd765 Fix "Consecutive" Event Logs in Performance Track (#34659)
Reset EventTime when clearing timers. We need to track repeat updates
separately.

Typically we always reset all timers when we've logged an update. The
same update shouldn't be logged again.

I was trying to be clever and not reset the XEventTime because we also
need the timestamp to know if it's a repeat event. However, because of
this it looked like we had an event schedule an update even after we had
reset them.

This always resets the XEventTime to -1.1 and then stashes the old time
on EventRepeatTime which is our indication whether the next update was a
repeat of the old event.

---------

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
Co-authored-by: Ricky <rickhanlonii@gmail.com>
2025-10-01 10:53:08 -04:00
Sebastian "Sebbie" Silbermann
5667a41fe4 Bump next prerelease version numbers (#34639) 2025-10-01 15:15:24 +02:00
lauren
cf884083e0 [eprh] Temporarily disable compiler rules (#34649)
Temporarily disables the compiler rules in eslint-plugin-react-hooks.
Will revert this later.
2025-09-30 18:45:33 -04:00
Jack Pope
57b16e3788 [lint] Remove experimental gating useEffectEvent rules (#34660)
Stacked on https://github.com/facebook/react/pull/34637

`useEffectEvent` is now in canary so we need to remove this
`__EXPERIMENTAL__` gating on the rules and tests
2025-09-30 16:55:56 -04:00
Jordan Brown
2a04bae651 [lint] Use settings for additional hooks in exhaustive deps (#34637)
Like in the diff below, we can read from the shared configuration to
check exhaustive deps.

I allow the classic additionalHooks configuration to override it so that
this change
is backwards compatible.


--

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34637).
* __->__ #34637
* #34497
2025-09-30 16:44:43 -04:00
Jordan Brown
92cfdc3a4e [lint] Enable custom hooks configuration for useEffectEvent calling rules (#34497)
We need to be able to specify additional effect hooks for the
RulesOfHooks lint rule
in order to allow useEffectEvent to be called by custom effects.
ExhaustiveDeps
does this with a regex suppplied to the rule, but that regex is not
accessible from
other rules.

This diff introduces a `react-hooks` entry you can put in the eslint
settings that
allows you to specify custom effect hooks and share them across all
rules.

This works like:
```
{
  settings: {
    'react-hooks': {
      additionalEffectHooks: string,
    },
  },
}
```

The next diff allows useEffect to read from the same configuration.


----

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34497).
* #34637
* __->__ #34497
2025-09-30 16:44:22 -04:00
Eugene Choi
a55e98f738 [playground] ViewTransition on internals toggle & tab expansion (#34597)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

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

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

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

## Summary

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

Added `<ViewTransition>` for when the "Show Internals" button is toggled
for a basic fade transition. Additionally added a transition for when
tabs are expanded in the advanced view of the Compiler Playground to
display a smoother show/hide animation.

## How did you test this change?

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


https://github.com/user-attachments/assets/c706b337-289e-488d-8cd7-45ff1d27788d
2025-09-30 15:25:10 -04:00
Ruslan Lesiutin
063394cf82 [Perf Tracks]: Always log effect that spawned blocking update (#34648)
We've observed some scenarios, where cascading update happens in an
effect that was shorter than 0.05ms. In this case, this effect won't be
displayed on a timeline, because of the threshold that we are using, but
it would be shown in entry properties or in a stack trace.

To avoid confusion, we should always log such effects.

Validated via manually changing the threshold to 100ms+ and observing
that only effects that triggered an update are visible on a timeline.
2025-09-30 20:05:44 +01:00
Sebastian Markbåge
d8a15c49a4 [Fiber] Reset remaining child lanes after propagating context inside Offscreen (#34658)
Otherwise, when a context is propagated into an Activity (or Suspense)
this will leave work behind on the Offscreen component itself. Which
will cause an extra unnecessary render and commit pass just to figure
out that we're still defering it to idle.

This is because lazy context propagation, when calling to schedule some
work walks back up the tree all the way to the root. This is usually
fine for other nodes since they'll recompute their remaining child lanes
on the way up. However, for the Offscreen component we'll have already
computed it. We need to set it after propagation to ensure it gets
reset.
2025-09-30 14:51:48 -04:00
Sebastian Markbåge
0d8ff4d8c7 [DevTools] Show "Initial Paint" in the breadcrumbs when root is selected (#34652)
We selected the root. This means that we're currently viewing the
Transition that rendered the whole screen. In laymans terms this is
really "Initial Paint". Once we add subtree selection, then the
equivalent should be called "Transition" since in that case it's really
about a Transition within the page. So if you've selected an Activity
tree this should be called "Transition".

Once we add the environment support to the timeline. The first entry on
the timeline should also be called "Initial Paint" when you haven't
selected an Activity and "Transition" when you have.

Technically they're both meant to be "Transition" but nobody thinks of
initial load as a "Transition" from the previous MPA page.

<img width="1214" height="419" alt="Screenshot 2025-09-29 at 5 18 58 PM"
src="https://github.com/user-attachments/assets/cae263e3-133c-4fa9-9587-a7b2344199f4"
/>
2025-09-30 14:40:33 -04:00
Sebastian Markbåge
554a373d7e [DevTools] Use the scrollWidth/Height for the root when the root is the documentElement (#34651)
If I can scroll the document due to it overflowing, I should be able to
scroll the suspense tab as much. The real rect for the root when it's
the document is really the full scroll height.

This doesn't fully eliminate the need to do recursive bounding boxes for
the root since it's still possible to have the rects overflow. E.g. if
they're currently resuspended or inside nested scrolls.

~However, maybe we should have the actual paintable root rect just be
this rectangle instead of including the recursive ones.~ Actually never
mind. The root really represents the Transition so it doesn't make sense
to give it any specific rectangle. It's rather the whole background.
2025-09-30 14:37:40 -04:00
Sebastian Markbåge
5dd163b49e [DevTools] Auto-scroll when stepping through the timeline (#34653)
This brings the Suspense boundary that's switching into view so that
when you play the loading sequence you can see how it plays out.
Otherwise it's really hard to find where things are changing.

This assumes we'll also scroll synchronize the suspense tab which will
bring it into view there too.
2025-09-30 14:37:14 -04:00
Pieter De Baets
ef8894452b Rollout enablePersistedModeClonedFlag (#34520)
## Summary

Experimentation has completed for this at Meta and we've observed
positive impact on key React Native surfaces.

## How did you test this change?

yarn flow fabric
2025-09-30 12:34:13 +01:00
Sebastian "Sebbie" Silbermann
e6f2a8a376 Allow running yarn lint on subset of paths (#34646) 2025-09-30 12:30:40 +02:00
Jack Pope
ba2214e571 Apply build script changes for RN to main (#34640)
This was merged into the 19.1.1 patch release branch in
https://github.com/facebook/react/pull/33972 but we never upstreamed it
to main. This should merge to main to make it easier to sync versions to
RN after future releases.

---------

Co-authored-by: Riccardo Cipolleschi <cipolleschi@meta.com>
2025-09-29 20:40:24 -04:00
Sebastian "Sebbie" Silbermann
ecb2ce6c5f [Flight] Compute better I/O description for exotic types (#34650) 2025-09-29 21:03:07 +02:00
Eugene Choi
3580584ba2 [playground] ViewTransition on tab switch (#34596)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

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

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

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

## Summary

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

Utilized `<ViewTransition>` to introduce a sliding animation upon
switching between the Output and SourceMap tabs in the default
playground view.

## How did you test this change?

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


https://github.com/user-attachments/assets/1ac93482-8104-4f9a-887e-6adca3537dca
2025-09-29 14:40:33 -04:00
Eugene Choi
319a7867d0 [playground] ViewTransition on config expand (#34595)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

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

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

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

## Summary

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

Introduced `<ViewTransition>` to the React Compiler Playground. Added an
initial animation on the config panel opening/closing to allow for a
smoother visual experience. Previously, the panel would flash in and out
of the screen upon open/close.

## How did you test this change?

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




https://github.com/user-attachments/assets/9dc77a6b-d4a5-4a7a-9d81-007ebb55e8d2
2025-09-29 14:09:37 -04:00
Sebastian Markbåge
d15d7fd79e [DevTools] Double click a Suspense Rect to jump to its position in the timeline (#34642)
When you double click it will hide or show by jumping to the selected
index or one step before the selected.

Let's you go from a suspense boundary into the timeline to find its
position. I also highlight the step in the timeline when you hover the
rect.

This only works if it's in the selected root but all of those should be
merged into one single timeline.

One thing that's weird about the SuspenseNodes now is that they
sometimes gets deleted but not always when they're resupended. Nested
ones maybe? This means that if you double click to hide it, you can't
double click again to show it. This seems like an unrelated bug that we
should fix.

We could potentially repurpose the existing "Suspend" button in the
toolbar to do this too, or maybe add another icon there.
2025-09-29 10:43:01 -04:00
Sebastian "Sebbie" Silbermann
8674c3ba28 [DevTools] Enable Suspense tab for Canary releases (#34591) 2025-09-29 16:23:30 +02:00
Sebastian "Sebbie" Silbermann
24e260d35b Enable rules-of-hooks for DevTools (#34645) 2025-09-29 15:31:06 +02:00
Sebastian "Sebbie" Silbermann
2bbb7be0e1 [DevTools] Don't call Hooks conditionally (#34644) 2025-09-29 15:15:09 +02:00
Sebastian Markbåge
dce1f6cd5d [DevTools] Custom Scrubber Design (#34627)
Stacked on #34620.

This will let us use different color for different segments of the
timeline.

Since we're modeling discrete steps (sometimes just a couple), a
scrubber with a handle that you have to move is quite annoying and
misleading. Doesn't show you how many steps there are. Therefore I went
with a design that highlights each segment as its own step and you can
click to jump to a step.

This is still backed by an input range for accessibility and keyboard
controls.

<img width="1213" height="434" alt="Screenshot 2025-09-27 at 4 50 21 PM"
src="https://github.com/user-attachments/assets/2c81753d-1b66-4434-8b1d-0a163fa22ab3"
/>
<img width="1213" height="430" alt="Screenshot 2025-09-27 at 4 50 45 PM"
src="https://github.com/user-attachments/assets/07983978-a8f6-46ed-8c51-6ec96487af66"
/>


https://github.com/user-attachments/assets/bc725f01-f0b5-40a8-bbb5-24cc4e84e86d
2025-09-28 20:00:09 -04:00
Sebastian Markbåge
7c0fff6f2b [DevTools] Add Play/Pause and Skip Controls to the Timeline (#34620)
Stacked on #34625.

This is a nice way to step through the timeline and simulate the visuals
on screen as you do it. It's also convenient to step through one at a
time, especially with the forwards button.

However, the secondary purpose of this is that it helps anchor the UI
visually as something like a timeline like in a video so that the
timeline itself becomes more identifiable.


https://github.com/user-attachments/assets/cb367c8e-9efb-4a00-a58e-4579be20beb8
2025-09-28 19:14:28 -04:00
Sebastian Markbåge
e2d19bf6a9 [DevTools] Use pretty icon with icon for unique suspenders toggle (#34625)
Stacked on #34624.

<img width="638" height="170" alt="Screenshot 2025-09-27 at 12 57 10 PM"
src="https://github.com/user-attachments/assets/f67023b1-e7be-4252-93ab-6302bc63ac26"
/>
<img width="641" height="250" alt="Screenshot 2025-09-27 at 12 57 21 PM"
src="https://github.com/user-attachments/assets/f96a9b48-c6f4-406f-a0ea-b3da288411b5"
/>
2025-09-28 19:13:15 -04:00
Sebastian Markbåge
a7d8dddaf3 [DevTools] Add Settings button on Suspense Tab (#34624)
The settings dialog appears on all tabs and should be reachable from
Suspense tab too. It's a bit weird because it's not contextual to the
tab and it shows you whatever your last settings tab was opened. Maybe
it should default to opening to the current tab's settings?

There aren't any Suspense specific settings yet but there definitely
will be. We could move the "Show all" into settings but it might be
frequently that you want to check why something isn't suspending a
Suspense boundary or test SSR streaming.

However, the general settings still apply to the Suspense tab. E.g.
switching dark/light mode.

<img width="857" height="233" alt="Screenshot 2025-09-27 at 12 35 05 PM"
src="https://github.com/user-attachments/assets/4a38e94f-2074-4dce-906b-9a1c40bccb9b"
/>
2025-09-28 19:09:52 -04:00
Sebastian Markbåge
8309724cb4 [Fiber][DevTools] Add scheduleRetry to DevTools Hook (#34635)
When forcing suspense/error we're doing that by scheduling a sync update
on the fiber. Resuspending a Suspense boundary can only happen sync
update so that makes sense. Erroring also forces a sync commit. This
means that no View Transitions fire.

However, unsuspending (and dismissing an error dialog) can be async so
the reveal should be able to be async.

This adds another hook for scheduling using the Retry lane. That way
when you play through a reveal sequence of Suspense boundaries (like
playing through the timeline), it'll run the animations that would've
ran during a loading sequence.
2025-09-28 13:51:35 -04:00
Sebastian Markbåge
09d3cd8fb5 [DevTools] Larger panel buttons and center (#34619)
The panel icons are quite small. Especially compared to the equivalent
buttons elsewhere in Chrome DevTools that otherwise use the same icons.
This makes them a little bigger to make them similar size to our other
button icons.

They were also a bit off center. This centers them as well.

Before:

<img width="409" height="426" alt="Screenshot 2025-09-26 at 4 23 15 PM"
src="https://github.com/user-attachments/assets/4a5de032-e316-44ed-9424-8bccce00f0cd"
/>

After:

<img width="519" height="388" alt="Screenshot 2025-09-26 at 4 22 57 PM"
src="https://github.com/user-attachments/assets/1763e522-5683-4fac-a913-27910a30a039"
/>
2025-09-28 12:09:08 -04:00
Sebastian Markbåge
f78b2343cc [DevTools] Recursively compute the bounding rect of the roots (#34629)
It's possible for the children to overflow the bounding rect of the root
in general when they overflow in the DOM. However even when it doesn't
overflow in the DOM, the bounding rect of the root can shrink while the
content is suspended. In fact, it's very likely.

Originally I thought we didn't need to consider this recursively because
document scrolling takes absolute positioned content into account but
because we're using nested overflow scrolling, we have to manually
compute this.
2025-09-28 10:15:31 -04:00
Sebastian "Sebbie" Silbermann
e08f53b182 Match react-dom/static test entrypoints and published entrypoints (#34599) 2025-09-28 13:26:31 +02:00
Sebastian Markbåge
2622487a74 [DevTools] Move Timeline to footer instead of header (#34617)
One thing that always bothered me is that the collapse buttons on either
side of the toolbar looks like left/right buttons which would conflict
with some steps buttons I plan to add. Another issue is that we'll need
to add more tool buttons to the top and probably eventually a Search
field. Ideally this whole section should line up vertically with the
height of the title row.

I also realized that all UIs that have some kind of timeline control
(and play/pause/skip) do that in the bottom below the content. E.g.
music players and video players all do that. We're better off playing
into that structure since that's the UI analogy we're going for here.
Makes it clearer what the weird timeline is for.

By moving it to the bottom it also frees up the top for the collapse
buttons and more controls.

__Horizontal__

<img width="794" height="809" alt="Screenshot 2025-09-26 at 3 40 35 PM"
src="https://github.com/user-attachments/assets/dacad9c4-d52f-4b66-9585-5cc74f230e6f"
/>

__Vertical__

<img width="570" height="812" alt="Screenshot 2025-09-26 at 3 40 53 PM"
src="https://github.com/user-attachments/assets/db225413-849e-46f1-b764-8fbd08b395c4"
/>
2025-09-26 16:27:49 -04:00
Sebastian "Sebbie" Silbermann
8a24ef3e75 [DevTools] Show Transition indicator when "suspended by" rows are expanded (#34565) 2025-09-26 22:27:22 +02:00
Ruslan Lesiutin
c552618a82 flags: make enableAsyncDebugInfo dynamic for www (#34430)
As titled. This adds dev-only debugging information to Fizz / Flight
that could be used for tracking Promise's stack traces in "suspended by"
section of DevTools.
2025-09-26 11:43:03 -07:00
Sebastian "Sebbie" Silbermann
df38ac9a3b Ensure useEffectEvent implementation is available in Canary (#34614) 2025-09-26 18:53:12 +02:00
Jack Pope
8bb7241f4c Bump useEffectEvent to Canary (#34610)
Bumps `useEffectEvent` from `@experimental` to `@canary`. Removes the
`experimental_` prefix from the export.

## TODO
- [ ] Update useEffectEvent reference page and Canary badging in docs:
https://github.com/reactjs/react.dev/pull/8025
2025-09-26 11:51:30 -04:00
Sebastian "Sebbie" Silbermann
8d557a638e [DevTools] Only show Suspense rects matching "unique-suspenders-only" filter (#34607) 2025-09-26 17:29:15 +02:00
Sebastian Markbåge
6a51a9fea6 [DevTools] Track Server Environment Names of Each SuspenseNode (#34605)
Tracks the environment names of the I/O in each SuspenseNode and sent it
to the front end when the suspenders change.

In the front end, every child boundary should really be treated as it
has all environment names of the parents too since they're blocked by
the parent too. We could do this tracking on backend but if there's ever
one added on the root would need to be send for every child.

This lets us highlight which subtrees are blocked by content on the
server.

---------

Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>
2025-09-26 09:43:38 -04:00
Sebastian Markbåge
1fd291d3c5 [DevTools] Disable the tree list for now (#34606)
When there are no named Activities we should hide the tree side panel
(and the button to show it). Since it's not implemented yet there are
never any ones so it's always hidden.
2025-09-26 09:43:00 -04:00
Sebastian Markbåge
047715c4ba [Flight] Preload <img> and <link> using hints before they're rendered (#34604)
In Fizz and Fiber we emit hints for suspensey images and CSS as soon as
we discover them during render. At the beginning of the stream. This
adds a similar capability when a Host Component is known to be a Host
Component during the Flight render.

The client doesn't know that these resources are in the payload until it
parses that particular component which is lazy. So they need to be
hoisted with hints. We detect when these are rendered during Flight and
add them as hints. That allows you to consume a Flight payload to
preload prefetched content without having to render it.

`<link rel="preload">` can be hoisted more or less as is.

`<link rel="stylesheet">` we preload but we don't actually insert them
anywhere until they're rendered. We do these even for non-suspensey
stylesheets since we know that when they're rendered they're going to
start loading even if they're not immediately used. They're never lazy.

`<img src>` we only preload if they follow the suspensey image pattern
since otherwise they may be more lazy e.g. by if they're in the
viewport. We also skip if they're known to be inside `<picture>`. Same
as Fizz. Ideally this would preload the other `<source>` but it's
tricky.

The downside of this is that you might conditionally render something in
only one branch given a client component. However, in that case you're
already eagerly fetching the server component's data in that branch so
it's not too much of a stretch that you want to eagerly fetch the
corresponding resources as well. If you wanted it to be lazy, you
should've done a lazy fetch of the RSC.

We don't collect hints when any of these are wrapped in a Client
Component. In those cases you might want to add your own preload to a
wrapper Shared Component.

Everything is skipped if it's known to be inside `<noscript>`.

Note that the format context is approximate (see #34601) so it's
possible for these hints to overfetch or underfetch if you try to trick
it. E.g. by rendering Server Components inside a Client Component that
renders `<noscript>`.

---------

Co-authored-by: Josh Story <josh.c.story@gmail.com>
2025-09-25 23:44:14 -04:00
Eugene Choi
250f1b20e0 [playground] Fix useEffect on tabify (#34594)
There was a bug in the Compiler Playground related to the "Show
Internals" toggle due to a useEffect that was causing the tab names to
flicker from a rerender. Rewritten instead with a `<Suspense>` boundary
+ `use`.
2025-09-25 14:56:41 -04:00
Sebastian Markbåge
b0c1dc01ec [Flight] Add approximate parent context for FormatContext (#34601)
Flight doesn't have any semantically sound notion of a parent context.
That's why we removed Server Context. Each root can really start
anywhere in the tree when you refetch subtrees. Additionally when you
dedupe elements they can end up in multiple different parent contexts.

However, we do have a DEV only version of this with debugTask being
tracked for the nearest parent element to track the context of
properties inside of it.

To apply certain DOM specific hints and optimizations when you render
host components we need some information of the context. This is usually
very local so doesn't suffer from the likelihood that you refetch in the
middle. We'll also only use this information for optimistic hints and
not hard semantics so getting it wrong isn't terrible.

```
<picture>
  <img />
</picture>
<noscript>
  <p>
    <img />
  </p>
</noscript>
```

For example, in these cases we should exclude preloading the image but
we have to know if that's the scope we're in.

We can easily get this wrong if they're split or even if they're wrapped
in client components that we don't know about like:

```
<NoScript>
  <p>
    <img />
  </p>
</NoScript>
```

However, getting it wrong in either direction is not the end of the
world. It's about covering the common cases well.
2025-09-25 12:05:47 -04:00
Sebastian Markbåge
6eb5d67e9c [Fizz] Outline a Suspense Boundary if it has Suspensey CSS or Images (#34552)
We should favor outlining a boundary if it contains Suspensey CSS or
Suspensey Images since then we can load that content separately and not
block the main content. This also allows us to animate the reveal.

For example this should be able to animate the reveal even though the
actual HTML content isn't large in this case it's worth outlining so
that the JS runtime can choose to animate this reveal.

```js
<ViewTransition>
  <Suspense>
    <img src="..." />
  </Suspense>
</ViewTransition>
```

For Suspensey Images, in Fizz, we currently only implement the suspensey
semantics when a View Transition is running. Therefore the outlining
only applies if it appears inside a Suspense boundary which might
animate. Otherwise there's no point in outlining. It is also only if the
Suspense boundary itself might animate its appear and not just any
ViewTransition. So the effect is very conservative.

For CSS it applies even without ViewTransition though, since it can help
unblock the main content faster.
2025-09-25 09:38:41 -04:00
Hendrik Liebau
ac2c1a5a58 [Flight] Ensure blocked debug info is handled properly (#34524)
This PR ensures that server components are reliably included in the
DevTools component tree, even if debug info is received delayed, e.g.
when using a debug channel. The fix consists of three parts:

- We must not unset the debug chunk before all debug info entries are
resolved.
- We must ensure that the "RSC Stream" IO debug info entry is pushed
last, after all other entries were resolved.
- We need to transfer the debug info from blocked element chunks onto
the lazy node and the element.

Ideally, we wouldn't even create a lazy node for blocked elements that
are at the root of the JSON payload, because that would basically wrap a
lazy in a lazy. This optimization that ensures that everything around
the blocked element can proceed is only needed for nested elements.
However, we also need it for resolving deduped references in blocked
root elements, unless we adapt that logic, which would be a bigger lift.

When reloading the Flight fixture, the component tree is now displayed
deterministically. Previously, it would sometimes omit synchronous
server components.

<img width="306" height="565" alt="complete"
src="https://github.com/user-attachments/assets/db61aa10-1816-43e6-9903-0e585190cdf1"
/>

---------

Co-authored-by: Sebastian Markbage <sebastian@calyptus.eu>
2025-09-25 15:13:15 +02:00
Sebastian "Sebbie" Silbermann
c44fbf43b1 [DevTools] Fix instrumentation error when reconciling promise-as-a-child (#34587) 2025-09-24 22:50:12 +02:00
Joseph Savona
8ad773b1f3 [compiler] Add support for commonjs (#34589)
We previously always generated import statements for any modules that
had to be required, notably the `import {c} from
'react/compiler-runtime'` for the memo cache function. However, this
obviously doesn't work when the source is using commonjs. Now we check
the sourceType of the module and generate require() statements if the
source type is 'script'.

I initially explored using
https://babeljs.io/docs/babel-helper-module-imports, but the API design
was unfortunately not flexible enough for our use-case. Specifically,
our pipeline is as follows:
* Compile individual functions. Generate candidate imports,
pre-allocating the local names for those imports.
* If the file is compiled successfully, actually add the imports to the
program.

Ie we need to pre-allocate identifier names for the imports before we
add them to the program — but that isn't supported by
babel-helper-module-imports. So instead we generate our own require()
calls if the sourceType is script.
2025-09-24 11:17:42 -07:00
Sebastian "Sebbie" Silbermann
58d17912e8 Fix failing React DevTools regression tests (#34585) 2025-09-24 19:08:13 +02:00
Joseph Savona
2c6d92fd80 [compiler] Name anonymous functions from inlined useCallbacks (#34586)
@eps1lon flagged this case. Inlined useCallback has an extra LoadLocal
indirection which caused us not to add a name. While I was there I added
some extra checks to make sure we don't generate names for a given node
twice (just in case).
2025-09-24 09:18:16 -07:00
Sebastian Markbåge
e233218359 Track "Animating" Entry for Gestures while the Gesture is Still On-going (#34548)
Stacked on #34546.

Same as #34538 but for gestures.

Includes various fixes.

This shows how it ends with a Transition when you release in the
committed state. Note how the Animation of the Gesture continues until
the Transition is done so that the handoff is seamless.

<img width="853" height="134" alt="Screenshot 2025-09-20 at 7 37 29 PM"
src="https://github.com/user-attachments/assets/6192a033-4bec-43b9-884b-77e3a6f00da6"
/>
2025-09-24 11:26:03 -04:00
Sebastian Markbåge
05b61f812a Add Gesture Track in Performance Tab (#34546) 2025-09-24 17:20:14 +02:00
Sebastian Markbåge
e0c421ab71 Include SyncLane in includesBlockingLane helper (#34543)
This helper weirdly doesn't include the sync lane.

Everywhere we use it we have to check the sync lane separately. We can
simplify things by simply including the sync lane.

This fixes a lack of optimization because we should not check the store
consistency for a `flushSync` render.


d91d28c8ba/packages/react-reconciler/src/ReactFiberHooks.js (L1691-L1693)
2025-09-24 09:34:35 -04:00
Ruslan Lesiutin
2ee6147510 [DevTools] Switch sourcemap-codec dependency (#34569)
[sourcemap-codec](https://www.npmjs.com/package/sourcemap-codec)
(deprecated) ->
[@jridgewell/sourcemap-codec](https://www.npmjs.com/package/@jridgewell/sourcemap-codec)

Validated that symbolication still works.
2025-09-24 06:11:53 -07:00
Jordan Brown
e02c173fa5 [lint] Allow useEffectEvent in useLayoutEffect and useInsertionEffect (#34492)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34492).
* #34497
* __->__ #34492
2025-09-23 16:56:09 -04:00
Ruslan Lesiutin
24a2ba03fb [DevTools] fix: dedupe file fetch requests and define a timeout (#34566)
If there is a large owner stack, we could potentially spam multiple
fetch requests for the same source map. This adds a simple deduplication
logic, based on URL.

Also, this adds a timeout of 60 seconds to all fetch requests initiated
by fileFetcher content script.
2025-09-23 11:38:07 -07:00
Sebastian "Sebbie" Silbermann
012b371cde [DevTools] Handle LegacyHidden Fibers like Offscreen Fibers. (#34564) 2025-09-23 20:14:53 +02:00
Jack Pope
83c88ad470 Handle fabric root level fragment with compareDocumentPosition (#34533)
The root instance doesn't have a canonical property so we were not
returning a public instance that we can call compareDocumentPosition on
when a Fragment had no other host parent in Fabric. In this case we need
to get the ReactNativeElement from the ReactNativeDocument.

I've also added test coverage for this case in DOM for consistency,
though it was already working there because we use DOM elements as root.
This same test will be copied to RN using Fantom.
2025-09-23 10:56:43 -04:00
Sebastian "Sebbie" Silbermann
cad813ac1e Fix CI from stale merge (#34555) 2025-09-23 08:49:16 +02:00
246 changed files with 5329 additions and 2867 deletions

View File

@@ -517,6 +517,14 @@ module.exports = {
__IS_INTERNAL_VERSION__: 'readonly',
},
},
{
files: ['packages/react-devtools-*/**/*.js'],
excludedFiles: '**/__tests__/**/*.js',
plugins: ['eslint-plugin-react-hooks-published'],
rules: {
'react-hooks-published/rules-of-hooks': ERROR,
},
},
{
files: ['packages/eslint-plugin-react-hooks/src/**/*'],
extends: ['plugin:@typescript-eslint/recommended'],

View File

@@ -1,3 +1,76 @@
## 19.2.0 (October 1st, 2025)
Below is a list of all new features, APIs, and bug fixes.
Read the [React 19.2 release post](https://react.dev/blog/2025/10/01/react-19-2) for more information.
### New React Features
- [`<Activity>`](https://react.dev/reference/react/Activity): A new API to hide and restore the UI and internal state of its children.
- [`useEffectEvent`](https://react.dev/reference/react/useEffectEvent) is a React Hook that lets you extract non-reactive logic into an [Effect Event](https://react.dev/learn/separating-events-from-effects#declaring-an-effect-event).
- [`cacheSignal`](https://react.dev/reference/react/cacheSignal) (for RSCs) lets your know when the `cache()` lifetime is over.
- [React Performance tracks](https://react.dev/reference/developer-tooling/react-performance-tracks) appear on the Performance panels timeline in your browser developer tools
### New React DOM Features
- Added resume APIs for partial pre-rendering with Web Streams:
- [`resume`](https://react.dev/reference/react-dom/server/resume): to resume a prerender to a stream.
- [`resumeAndPrerender`](https://react.dev/reference/react-dom/static/resumeAndPrerender): to resume a prerender to HTML.
- Added resume APIs for partial pre-rendering with Node Streams:
- [`resumeToPipeableStream`](https://react.dev/reference/react-dom/server/resumeToPipeableStream): to resume a prerender to a stream.
- [`resumeAndPrerenderToNodeStream`](https://react.dev/reference/react-dom/static/resumeAndPrerenderToNodeStream): to resume a prerender to HTML.
- Updated [`prerender`](https://react.dev/reference/react-dom/static/prerender) APIs to return a `postponed` state that can be passed to the `resume` APIs.
### Notable changes
- React DOM now batches suspense boundary reveals, matching the behavior of client side rendering. This change is especially noticeable when animating the reveal of Suspense boundaries e.g. with the upcoming `<ViewTransition>` Component. React will batch as much reveals as possible before the first paint while trying to hit popular first-contentful paint metrics.
- Add Node Web Streams (`prerender`, `renderToReadableStream`) to server-side-rendering APIs for Node.js
- Use underscore instead of `:` IDs generated by useId
### All Changes
#### React
- `<Activity />` was developed over many years, starting before `ClassComponent.setState` (@acdlite @sebmarkbage and many others)
- Stringify context as "SomeContext" instead of "SomeContext.Provider" (@kassens [#33507](https://github.com/facebook/react/pull/33507))
- Include stack of cause of React instrumentation errors with `%o` placeholder (@eps1lon [#34198](https://github.com/facebook/react/pull/34198))
- Fix infinite `useDeferredValue` loop in popstate event (@acdlite [#32821](https://github.com/facebook/react/pull/32821))
- Fix a bug when an initial value was passed to `useDeferredValue` (@acdlite [#34376](https://github.com/facebook/react/pull/34376))
- Fix a crash when submitting forms with Client Actions (@sebmarkbage [#33055](https://github.com/facebook/react/pull/33055))
- Hide/unhide the content of dehydrated suspense boundaries if they resuspend (@sebmarkbage [#32900](https://github.com/facebook/react/pull/32900))
- Avoid stack overflow on wide trees during Hot Reload (@sophiebits [#34145](https://github.com/facebook/react/pull/34145))
- Improve Owner and Component stacks in various places (@sebmarkbage, @eps1lon: [#33629](https://github.com/facebook/react/pull/33629), [#33724](https://github.com/facebook/react/pull/33724), [#32735](https://github.com/facebook/react/pull/32735), [#33723](https://github.com/facebook/react/pull/33723))
- Add `cacheSignal` (@sebmarkbage [#33557](https://github.com/facebook/react/pull/33557))
#### React DOM
- Block on Suspensey Fonts during reveal of server-side-rendered content (@sebmarkbage [#33342](https://github.com/facebook/react/pull/33342))
- Use underscore instead of `:` for IDs generated by `useId` (@sebmarkbage, @eps1lon: [#32001](https://github.com/facebook/react/pull/32001), [https://github.com/facebook/react/pull/33342](https://github.com/facebook/react/pull/33342)[#33099](https://github.com/facebook/react/pull/33099), [#33422](https://github.com/facebook/react/pull/33422))
- Stop warning when ARIA 1.3 attributes are used (@Abdul-Omira [#34264](https://github.com/facebook/react/pull/34264))
- Allow `nonce` to be used on hoistable styles (@Andarist [#32461](https://github.com/facebook/react/pull/32461))
- Warn for using a React owned node as a Container if it also has text content (@sebmarkbage [#32774](https://github.com/facebook/react/pull/32774))
- s/HTML/text for for error messages if text hydration mismatches (@rickhanlonii [#32763](https://github.com/facebook/react/pull/32763))
- Fix a bug with `React.use` inside `React.lazy`\-ed Component (@hi-ogawa [#33941](https://github.com/facebook/react/pull/33941))
- Enable the `progressiveChunkSize` option for server-side-rendering APIs (@sebmarkbage [#33027](https://github.com/facebook/react/pull/33027))
- Fix a bug with deeply nested Suspense inside Suspense fallback when server-side-rendering (@gnoff [#33467](https://github.com/facebook/react/pull/33467))
- Avoid hanging when suspending after aborting while rendering (@gnoff [#34192](https://github.com/facebook/react/pull/34192))
- Add Node Web Streams to server-side-rendering APIs for Node.js (@sebmarkbage [#33475](https://github.com/facebook/react/pull/33475))
#### React Server Components
- Preload `<img>` and `<link>` using hints before they're rendered (@sebmarkbage [#34604](https://github.com/facebook/react/pull/34604))
- Log error if production elements are rendered during development (@eps1lon [#34189](https://github.com/facebook/react/pull/34189))
- Fix a bug when returning a Temporary reference (e.g. a Client Reference) from Server Functions (@sebmarkbage [#34084](https://github.com/facebook/react/pull/34084), @denk0403 [#33761](https://github.com/facebook/react/pull/33761))
- Pass line/column to `filterStackFrame` (@eps1lon [#33707](https://github.com/facebook/react/pull/33707))
- Support Async Modules in Turbopack Server References (@lubieowoce [#34531](https://github.com/facebook/react/pull/34531))
- Add support for .mjs file extension in Webpack (@jennyscript [#33028](https://github.com/facebook/react/pull/33028))
- Fix a wrong missing key warning (@unstubbable [#34350](https://github.com/facebook/react/pull/34350))
- Make console log resolve in predictable order (@sebmarkbage [#33665](https://github.com/facebook/react/pull/33665))
#### React Reconciler
- [createContainer](https://github.com/facebook/react/blob/v19.2.0/packages/react-reconciler/src/ReactFiberReconciler.js#L255-L261) and [createHydrationContainer](https://github.com/facebook/react/blob/v19.2.0/packages/react-reconciler/src/ReactFiberReconciler.js#L305-L312) had their parameter order adjusted after `on*` handlers to account for upcoming experimental APIs
## 19.1.1 (July 28, 2025)
### React

View File

@@ -7,18 +7,18 @@
//
// The @latest channel uses the version as-is, e.g.:
//
// 19.1.0
// 19.3.0
//
// The @canary channel appends additional information, with the scheme
// <version>-<label>-<commit_sha>, e.g.:
//
// 19.1.0-canary-a1c2d3e4
// 19.3.0-canary-a1c2d3e4
//
// The @experimental channel doesn't include a version, only a date and a sha, e.g.:
//
// 0.0.0-experimental-241c4467e-20200129
const ReactVersion = '19.2.0';
const ReactVersion = '19.3.0';
// The label used by the @canary channel. Represents the upcoming release's
// stability. Most of the time, this will be "canary", but we may temporarily
@@ -33,8 +33,8 @@ const canaryChannelLabel = 'canary';
const rcNumber = 0;
const stablePackages = {
'eslint-plugin-react-hooks': '6.1.0',
'jest-react': '0.17.0',
'eslint-plugin-react-hooks': '6.2.0',
'jest-react': '0.18.0',
react: ReactVersion,
'react-art': ReactVersion,
'react-dom': ReactVersion,
@@ -42,12 +42,12 @@ const stablePackages = {
'react-server-dom-turbopack': ReactVersion,
'react-server-dom-parcel': ReactVersion,
'react-is': ReactVersion,
'react-reconciler': '0.33.0',
'react-refresh': '0.18.0',
'react-reconciler': '0.34.0',
'react-refresh': '0.19.0',
'react-test-renderer': ReactVersion,
'use-subscription': '1.12.0',
'use-sync-external-store': '1.6.0',
scheduler: '0.27.0',
'use-subscription': '1.13.0',
'use-sync-external-store': '1.7.0',
scheduler: '0.28.0',
};
// These packages do not exist in the @canary or @latest channel, only

View File

@@ -2,4 +2,4 @@ import type { PluginOptions } from 
'babel-plugin-react-compiler/dist';
({
  //compilationMode: "all"
} satisfies Partial<PluginOptions>);
} satisfies PluginOptions);

View File

@@ -23,7 +23,8 @@ function formatPrint(data: Array<string>): Promise<string> {
async function expandConfigs(page: Page): Promise<void> {
const expandButton = page.locator('[title="Expand config editor"]');
expandButton.click();
await expandButton.click();
await page.waitForSelector('.monaco-editor-config', {state: 'visible'});
}
const TEST_SOURCE = `export default function TestComponent({ x }) {
@@ -263,7 +264,7 @@ test('error is displayed when config has validation error', async ({page}) => {
({
compilationMode: "123"
} satisfies Partial<PluginOptions>);`,
} satisfies PluginOptions);`,
showInternals: false,
};
const hash = encodeStore(store);
@@ -293,7 +294,7 @@ test('disableMemoizationForDebugging flag works as expected', async ({
environment: {
disableMemoizationForDebugging: true
}
} satisfies Partial<PluginOptions>);`,
} satisfies PluginOptions);`,
showInternals: false,
};
const hash = encodeStore(store);

View File

@@ -6,7 +6,14 @@
*/
import {Resizable} from 're-resizable';
import React, {useCallback} from 'react';
import React, {
useCallback,
useId,
unstable_ViewTransition as ViewTransition,
unstable_addTransitionType as addTransitionType,
startTransition,
} from 'react';
import {EXPAND_ACCORDION_TRANSITION} from '../lib/transitionTypes';
type TabsRecord = Map<string, React.ReactNode>;
@@ -16,21 +23,25 @@ export default function AccordionWindow(props: {
tabsOpen: Set<string>;
setTabsOpen: (newTab: Set<string>) => void;
changedPasses: Set<string>;
isFailure: boolean;
}): React.ReactElement {
return (
<div className="flex flex-row h-full">
{Array.from(props.tabs.keys()).map(name => {
return (
<AccordionWindowItem
name={name}
key={name}
tabs={props.tabs}
tabsOpen={props.tabsOpen}
setTabsOpen={props.setTabsOpen}
hasChanged={props.changedPasses.has(name)}
/>
);
})}
<div className="flex-1 min-w-[550px] sm:min-w-0">
<div className="flex flex-row h-full">
{Array.from(props.tabs.keys()).map(name => {
return (
<AccordionWindowItem
name={name}
key={name}
tabs={props.tabs}
tabsOpen={props.tabsOpen}
setTabsOpen={props.setTabsOpen}
hasChanged={props.changedPasses.has(name)}
isFailure={props.isFailure}
/>
);
})}
</div>
</div>
);
}
@@ -41,24 +52,32 @@ function AccordionWindowItem({
tabsOpen,
setTabsOpen,
hasChanged,
isFailure,
}: {
name: string;
tabs: TabsRecord;
tabsOpen: Set<string>;
setTabsOpen: (newTab: Set<string>) => void;
hasChanged: boolean;
isFailure: boolean;
}): React.ReactElement {
const isShow = tabsOpen.has(name);
const id = useId();
const isShow = isFailure ? name === 'Output' : tabsOpen.has(name);
const toggleTabs = useCallback(() => {
const nextState = new Set(tabsOpen);
if (nextState.has(name)) {
nextState.delete(name);
} else {
nextState.add(name);
}
setTabsOpen(nextState);
}, [tabsOpen, name, setTabsOpen]);
const transitionName = `accordion-window-item-${id}`;
const toggleTabs = () => {
startTransition(() => {
addTransitionType(EXPAND_ACCORDION_TRANSITION);
const nextState = new Set(tabsOpen);
if (nextState.has(name)) {
nextState.delete(name);
} else {
nextState.add(name);
}
setTabsOpen(nextState);
});
};
// Replace spaces with non-breaking spaces
const displayName = name.replace(/ /g, '\u00A0');
@@ -66,31 +85,45 @@ function AccordionWindowItem({
return (
<div key={name} className="flex flex-row">
{isShow ? (
<Resizable className="border-r" minWidth={550} enable={{right: true}}>
<h2
title="Minimize tab"
aria-label="Minimize tab"
onClick={toggleTabs}
className={`p-4 duration-150 ease-in border-b cursor-pointer border-grey-200 ${
hasChanged ? 'font-bold' : 'font-light'
} text-secondary hover:text-link`}>
- {displayName}
</h2>
{tabs.get(name) ?? <div>No output for {name}</div>}
</Resizable>
<ViewTransition
name={transitionName}
update={{
[EXPAND_ACCORDION_TRANSITION]: 'expand-accordion',
default: 'none',
}}>
<Resizable className="border-r" minWidth={550} enable={{right: true}}>
<h2
title="Minimize tab"
aria-label="Minimize tab"
onClick={toggleTabs}
className={`p-4 duration-150 ease-in border-b cursor-pointer border-grey-200 ${
hasChanged ? 'font-bold' : 'font-light'
} text-secondary hover:text-link`}>
- {displayName}
</h2>
{tabs.get(name) ?? <div>No output for {name}</div>}
</Resizable>
</ViewTransition>
) : (
<div className="relative items-center h-full px-1 py-6 align-middle border-r border-grey-200">
<button
title={`Expand compiler tab: ${name}`}
aria-label={`Expand compiler tab: ${name}`}
style={{transform: 'rotate(90deg) translate(-50%)'}}
onClick={toggleTabs}
className={`flex-grow-0 w-5 transition-colors duration-150 ease-in ${
hasChanged ? 'font-bold' : 'font-light'
} text-secondary hover:text-link`}>
{displayName}
</button>
</div>
<ViewTransition
name={transitionName}
update={{
[EXPAND_ACCORDION_TRANSITION]: 'expand-accordion',
default: 'none',
}}>
<div className="relative items-center h-full px-1 py-6 align-middle border-r border-grey-200">
<button
title={`Expand compiler tab: ${name}`}
aria-label={`Expand compiler tab: ${name}`}
style={{transform: 'rotate(90deg) translate(-50%)'}}
onClick={toggleTabs}
className={`flex-grow-0 w-5 transition-colors duration-150 ease-in ${
hasChanged ? 'font-bold' : 'font-light'
} text-secondary hover:text-link`}>
{displayName}
</button>
</div>
</ViewTransition>
)}
</div>
);

View File

@@ -9,12 +9,19 @@ import MonacoEditor, {loader, type Monaco} from '@monaco-editor/react';
import {PluginOptions} from 'babel-plugin-react-compiler';
import type {editor} from 'monaco-editor';
import * as monaco from 'monaco-editor';
import React, {useState, useRef} from 'react';
import React, {
useState,
useRef,
unstable_ViewTransition as ViewTransition,
unstable_addTransitionType as addTransitionType,
startTransition,
} from 'react';
import {Resizable} from 're-resizable';
import {useStore, useStoreDispatch} from '../StoreContext';
import {monacoOptions} from './monacoOptions';
import {IconChevron} from '../Icons/IconChevron';
import prettyFormat from 'pretty-format';
import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes';
// @ts-expect-error - webpack asset/source loader handles .d.ts files as strings
import compilerTypeDefs from 'babel-plugin-react-compiler/dist/index.d.ts';
@@ -36,7 +43,12 @@ export default function ConfigEditor({
display: isExpanded ? 'block' : 'none',
}}>
<ExpandedEditor
onToggle={setIsExpanded}
onToggle={() => {
startTransition(() => {
addTransitionType(CONFIG_PANEL_TRANSITION);
setIsExpanded(false);
});
}}
appliedOptions={appliedOptions}
/>
</div>
@@ -44,7 +56,14 @@ export default function ConfigEditor({
style={{
display: !isExpanded ? 'block' : 'none',
}}>
<CollapsedEditor onToggle={setIsExpanded} />
<CollapsedEditor
onToggle={() => {
startTransition(() => {
addTransitionType(CONFIG_PANEL_TRANSITION);
setIsExpanded(true);
});
}}
/>
</div>
</>
);
@@ -54,7 +73,7 @@ function ExpandedEditor({
onToggle,
appliedOptions,
}: {
onToggle: (expanded: boolean) => void;
onToggle: () => void;
appliedOptions: PluginOptions | null;
}): React.ReactElement {
const store = useStore();
@@ -111,90 +130,93 @@ function ExpandedEditor({
: 'Invalid configs';
return (
<Resizable
minWidth={300}
maxWidth={600}
defaultSize={{width: 350}}
enable={{right: true, bottom: false}}>
<div className="bg-blue-10 relative h-full flex flex-col !h-[calc(100vh_-_3.5rem)] border border-gray-300">
<div
className="absolute w-8 h-16 bg-blue-10 rounded-r-full flex items-center justify-center z-[2] cursor-pointer border border-l-0 border-gray-300"
title="Minimize config editor"
onClick={() => onToggle(false)}
style={{
top: '50%',
marginTop: '-32px',
right: '-32px',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}>
<IconChevron displayDirection="left" className="text-blue-50" />
</div>
<ViewTransition
update={{[CONFIG_PANEL_TRANSITION]: 'slide-in', default: 'none'}}>
<Resizable
minWidth={300}
maxWidth={600}
defaultSize={{width: 350}}
enable={{right: true, bottom: false}}>
<div className="bg-blue-10 relative h-full flex flex-col !h-[calc(100vh_-_3.5rem)] border border-gray-300">
<div
className="absolute w-8 h-16 bg-blue-10 rounded-r-full flex items-center justify-center z-[2] cursor-pointer border border-l-0 border-gray-300"
title="Minimize config editor"
onClick={onToggle}
style={{
top: '50%',
marginTop: '-32px',
right: '-32px',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}>
<IconChevron displayDirection="left" className="text-blue-50" />
</div>
<div className="flex-1 flex flex-col m-2 mb-2">
<div className="pb-2">
<h2 className="inline-block text-blue-50 py-1.5 px-1.5 xs:px-3 sm:px-4 text-sm">
Config Overrides
</h2>
<div className="flex-1 flex flex-col m-2 mb-2">
<div className="pb-2">
<h2 className="inline-block text-blue-50 py-1.5 px-1.5 xs:px-3 sm:px-4 text-sm">
Config Overrides
</h2>
</div>
<div className="flex-1 rounded-lg overflow-hidden border border-gray-300">
<MonacoEditor
path={'config.ts'}
language={'typescript'}
value={store.config}
onMount={handleMount}
onChange={handleChange}
loading={''}
className="monaco-editor-config"
options={{
...monacoOptions,
lineNumbers: 'off',
renderLineHighlight: 'none',
overviewRulerBorder: false,
overviewRulerLanes: 0,
fontSize: 12,
scrollBeyondLastLine: false,
glyphMargin: false,
}}
/>
</div>
</div>
<div className="flex-1 rounded-lg overflow-hidden border border-gray-300">
<MonacoEditor
path={'config.ts'}
language={'typescript'}
value={store.config}
onMount={handleMount}
onChange={handleChange}
loading={''}
className="monaco-editor-config"
options={{
...monacoOptions,
lineNumbers: 'off',
renderLineHighlight: 'none',
overviewRulerBorder: false,
overviewRulerLanes: 0,
fontSize: 12,
scrollBeyondLastLine: false,
glyphMargin: false,
}}
/>
<div className="flex-1 flex flex-col m-2">
<div className="pb-2">
<h2 className="inline-block text-blue-50 py-1.5 px-1.5 xs:px-3 sm:px-4 text-sm">
Applied Configs
</h2>
</div>
<div className="flex-1 rounded-lg overflow-hidden border border-gray-300">
<MonacoEditor
path={'applied-config.js'}
language={'javascript'}
value={formattedAppliedOptions}
loading={''}
className="monaco-editor-applied-config"
options={{
...monacoOptions,
lineNumbers: 'off',
renderLineHighlight: 'none',
overviewRulerBorder: false,
overviewRulerLanes: 0,
fontSize: 12,
scrollBeyondLastLine: false,
readOnly: true,
glyphMargin: false,
}}
/>
</div>
</div>
</div>
<div className="flex-1 flex flex-col m-2">
<div className="pb-2">
<h2 className="inline-block text-blue-50 py-1.5 px-1.5 xs:px-3 sm:px-4 text-sm">
Applied Configs
</h2>
</div>
<div className="flex-1 rounded-lg overflow-hidden border border-gray-300">
<MonacoEditor
path={'applied-config.js'}
language={'javascript'}
value={formattedAppliedOptions}
loading={''}
className="monaco-editor-applied-config"
options={{
...monacoOptions,
lineNumbers: 'off',
renderLineHighlight: 'none',
overviewRulerBorder: false,
overviewRulerLanes: 0,
fontSize: 12,
scrollBeyondLastLine: false,
readOnly: true,
glyphMargin: false,
}}
/>
</div>
</div>
</div>
</Resizable>
</Resizable>
</ViewTransition>
);
}
function CollapsedEditor({
onToggle,
}: {
onToggle: (expanded: boolean) => void;
onToggle: () => void;
}): React.ReactElement {
return (
<div
@@ -203,7 +225,7 @@ function CollapsedEditor({
<div
className="absolute w-10 h-16 bg-blue-10 hover:translate-x-2 transition-transform rounded-r-full flex items-center justify-center z-[2] cursor-pointer border border-gray-300"
title="Expand config editor"
onClick={() => onToggle(true)}
onClick={onToggle}
style={{
top: '50%',
marginTop: '-32px',

View File

@@ -24,7 +24,11 @@ import BabelPluginReactCompiler, {
printFunctionWithOutlined,
type LoggerEvent,
} from 'babel-plugin-react-compiler';
import {useDeferredValue, useMemo} from 'react';
import {
useDeferredValue,
useMemo,
unstable_ViewTransition as ViewTransition,
} from 'react';
import {useStore} from '../StoreContext';
import ConfigEditor from './ConfigEditor';
import Input from './Input';
@@ -343,12 +347,8 @@ export default function Editor(): JSX.Element {
<ConfigEditor appliedOptions={appliedOptions} />
</div>
<div className="flex flex-1 min-w-0">
<div className="flex-1 min-w-[550px] sm:min-w-0">
<Input language={language} errors={errors} />
</div>
<div className="flex-1 min-w-[550px] sm:min-w-0">
<Output store={deferredStore} compilerOutput={mergedOutput} />
</div>
<Input language={language} errors={errors} />
<Output store={deferredStore} compilerOutput={mergedOutput} />
</div>
</div>
</>

View File

@@ -13,11 +13,17 @@ import {
import invariant from 'invariant';
import type {editor} from 'monaco-editor';
import * as monaco from 'monaco-editor';
import {useEffect, useState} from 'react';
import {
useEffect,
useState,
unstable_ViewTransition as ViewTransition,
} from 'react';
import {renderReactCompilerMarkers} from '../../lib/reactCompilerMonacoDiagnostics';
import {useStore, useStoreDispatch} from '../StoreContext';
import TabbedWindow from '../TabbedWindow';
import {monacoOptions} from './monacoOptions';
import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes';
// @ts-expect-error TODO: Make TS recognize .d.ts files, in addition to loading them with webpack.
import React$Types from '../../node_modules/@types/react/index.d.ts';
@@ -155,9 +161,13 @@ export default function Input({errors, language}: Props): JSX.Element {
const [activeTab, setActiveTab] = useState('Input');
return (
<div className="relative flex flex-col flex-none border-r border-gray-200">
<div className="!h-[calc(100vh_-_3.5rem)]">
<div className="flex flex-col h-full">
<ViewTransition
update={{
[CONFIG_PANEL_TRANSITION]: 'container',
default: 'none',
}}>
<div className="flex-1 min-w-[550px] sm:min-w-0">
<div className="flex flex-col h-full !h-[calc(100vh_-_3.5rem)] border-r border-gray-200">
<TabbedWindow
tabs={tabs}
activeTab={activeTab}
@@ -165,6 +175,6 @@ export default function Input({errors, language}: Props): JSX.Element {
/>
</div>
</div>
</div>
</ViewTransition>
);
}

View File

@@ -19,12 +19,24 @@ import {
import parserBabel from 'prettier/plugins/babel';
import * as prettierPluginEstree from 'prettier/plugins/estree';
import * as prettier from 'prettier/standalone';
import {memo, ReactNode, useEffect, useState} from 'react';
import {type Store} from '../../lib/stores';
import {
memo,
ReactNode,
use,
useState,
Suspense,
unstable_ViewTransition as ViewTransition,
} from 'react';
import AccordionWindow from '../AccordionWindow';
import TabbedWindow from '../TabbedWindow';
import {monacoOptions} from './monacoOptions';
import {BabelFileResult} from '@babel/core';
import {
CONFIG_PANEL_TRANSITION,
TOGGLE_INTERNALS_TRANSITION,
} from '../../lib/transitionTypes';
import {LRUCache} from 'lru-cache';
const MemoizedOutput = memo(Output);
@@ -32,6 +44,10 @@ export default MemoizedOutput;
export const BASIC_OUTPUT_TAB_NAMES = ['Output', 'SourceMap'];
const tabifyCache = new LRUCache<Store, Promise<Map<string, ReactNode>>>({
max: 5,
});
export type PrintedCompilerPipelineValue =
| {
kind: 'hir';
@@ -200,6 +216,25 @@ ${code}
return reorderedTabs;
}
function tabifyCached(
store: Store,
compilerOutput: CompilerOutput,
): Promise<Map<string, ReactNode>> {
const cached = tabifyCache.get(store);
if (cached) return cached;
const result = tabify(store.source, compilerOutput, store.showInternals);
tabifyCache.set(store, result);
return result;
}
function Fallback(): JSX.Element {
return (
<div className="w-full h-monaco_small sm:h-monaco flex items-center justify-center">
Loading...
</div>
);
}
function utf16ToUTF8(s: string): string {
return unescape(encodeURIComponent(s));
}
@@ -213,33 +248,25 @@ function getSourceMapUrl(code: string, map: string): string | null {
}
function Output({store, compilerOutput}: Props): JSX.Element {
return (
<Suspense fallback={<Fallback />}>
<OutputContent store={store} compilerOutput={compilerOutput} />
</Suspense>
);
}
function OutputContent({store, compilerOutput}: Props): JSX.Element {
const [tabsOpen, setTabsOpen] = useState<Set<string>>(
() => new Set(['Output']),
);
const [tabs, setTabs] = useState<Map<string, React.ReactNode>>(
() => new Map(),
);
const [activeTab, setActiveTab] = useState<string>('Output');
/*
* Update the active tab back to the output or errors tab when the compilation state
* changes between success/failure.
*/
const [previousOutputKind, setPreviousOutputKind] = useState(
compilerOutput.kind,
);
if (compilerOutput.kind !== previousOutputKind) {
setPreviousOutputKind(compilerOutput.kind);
setTabsOpen(new Set(['Output']));
setActiveTab('Output');
}
useEffect(() => {
tabify(store.source, compilerOutput, store.showInternals).then(tabs => {
setTabs(tabs);
});
}, [store.source, compilerOutput, store.showInternals]);
const isFailure = compilerOutput.kind !== 'ok';
const changedPasses: Set<string> = new Set(['Output', 'HIR']); // Initial and final passes should always be bold
let lastResult: string = '';
for (const [passName, results] of compilerOutput.results) {
@@ -254,25 +281,43 @@ function Output({store, compilerOutput}: Props): JSX.Element {
lastResult = currResult;
}
}
const tabs = use(tabifyCached(store, compilerOutput));
if (!store.showInternals) {
return (
<TabbedWindow
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<ViewTransition
update={{
[CONFIG_PANEL_TRANSITION]: 'container',
[TOGGLE_INTERNALS_TRANSITION]: '',
default: 'none',
}}>
<TabbedWindow
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
// Display the Output tab on compilation failure
activeTabOverride={isFailure ? 'Output' : undefined}
/>
</ViewTransition>
);
}
return (
<AccordionWindow
defaultTab={store.showInternals ? 'HIR' : 'Output'}
setTabsOpen={setTabsOpen}
tabsOpen={tabsOpen}
tabs={tabs}
changedPasses={changedPasses}
/>
<ViewTransition
update={{
[CONFIG_PANEL_TRANSITION]: 'accordion-container',
[TOGGLE_INTERNALS_TRANSITION]: '',
default: 'none',
}}>
<AccordionWindow
defaultTab={store.showInternals ? 'HIR' : 'Output'}
setTabsOpen={setTabsOpen}
tabsOpen={tabsOpen}
tabs={tabs}
changedPasses={changedPasses}
isFailure={isFailure}
/>
</ViewTransition>
);
}

View File

@@ -10,11 +10,16 @@ import {CheckIcon} from '@heroicons/react/solid';
import clsx from 'clsx';
import Link from 'next/link';
import {useSnackbar} from 'notistack';
import {useState} from 'react';
import {
useState,
startTransition,
unstable_addTransitionType as addTransitionType,
} from 'react';
import {defaultStore} from '../lib/defaultStore';
import {IconGitHub} from './Icons/IconGitHub';
import Logo from './Logo';
import {useStore, useStoreDispatch} from './StoreContext';
import {TOGGLE_INTERNALS_TRANSITION} from '../lib/transitionTypes';
export default function Header(): JSX.Element {
const [showCheck, setShowCheck] = useState(false);
@@ -62,7 +67,12 @@ export default function Header(): JSX.Element {
<input
type="checkbox"
checked={store.showInternals}
onChange={() => dispatchStore({type: 'toggleInternals'})}
onChange={() =>
startTransition(() => {
addTransitionType(TOGGLE_INTERNALS_TRANSITION);
dispatchStore({type: 'toggleInternals'});
})
}
className="absolute opacity-0 cursor-pointer h-full w-full m-0"
/>
<span

View File

@@ -4,39 +4,81 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import React, {
startTransition,
useId,
unstable_ViewTransition as ViewTransition,
unstable_addTransitionType as addTransitionType,
} from 'react';
import clsx from 'clsx';
import {TOGGLE_TAB_TRANSITION} from '../lib/transitionTypes';
export default function TabbedWindow({
tabs,
activeTab,
onTabChange,
activeTabOverride,
}: {
tabs: Map<string, React.ReactNode>;
activeTab: string;
onTabChange: (tab: string) => void;
activeTabOverride?: string;
}): React.ReactElement {
const currentActiveTab = activeTabOverride ? activeTabOverride : activeTab;
const id = useId();
const transitionName = `tab-highlight-${id}`;
const handleTabChange = (tab: string): void => {
startTransition(() => {
addTransitionType(TOGGLE_TAB_TRANSITION);
onTabChange(tab);
});
};
return (
<div className="flex flex-col h-full max-w-full">
<div className="flex p-2 flex-shrink-0">
{Array.from(tabs.keys()).map(tab => {
const isActive = activeTab === tab;
return (
<button
key={tab}
onClick={() => onTabChange(tab)}
className={clsx(
'active:scale-95 transition-transform py-1.5 px-1.5 xs:px-3 sm:px-4 rounded-full text-sm',
!isActive && 'hover:bg-primary/5',
isActive && 'bg-highlight text-link',
)}>
{tab}
</button>
);
})}
</div>
<div className="flex-1 overflow-hidden w-full h-full">
{tabs.get(activeTab)}
<div className="flex-1 min-w-[550px] sm:min-w-0">
<div className="flex flex-col h-full max-w-full">
<div className="flex p-2 flex-shrink-0">
{Array.from(tabs.keys()).map(tab => {
const isActive = currentActiveTab === tab;
return (
<button
key={tab}
onClick={() => handleTabChange(tab)}
className={clsx(
'transition-transform py-1.5 px-1.5 xs:px-3 sm:px-4 rounded-full text-sm relative',
isActive ? 'text-link' : 'hover:bg-primary/5',
)}>
{isActive && (
<ViewTransition
name={transitionName}
enter={{default: 'none'}}
exit={{default: 'none'}}
share={{
[TOGGLE_TAB_TRANSITION]: 'tab-highlight',
default: 'none',
}}
update={{default: 'none'}}>
<div className="absolute inset-0 bg-highlight rounded-full" />
</ViewTransition>
)}
<ViewTransition
enter={{default: 'none'}}
exit={{default: 'none'}}
update={{
[TOGGLE_TAB_TRANSITION]: 'tab-text',
default: 'none',
}}>
<span className="relative z-1">{tab}</span>
</ViewTransition>
</button>
);
})}
</div>
<div className="flex-1 overflow-hidden w-full h-full">
{tabs.get(currentActiveTab)}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,11 @@
/**
* 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.
*/
export const CONFIG_PANEL_TRANSITION = 'config-panel';
export const TOGGLE_TAB_TRANSITION = 'toggle-tab';
export const TOGGLE_INTERNALS_TRANSITION = 'toggle-internals';
export const EXPAND_ACCORDION_TRANSITION = 'open-accordion';

View File

@@ -11,6 +11,7 @@ const path = require('path');
const nextConfig = {
experimental: {
reactCompiler: true,
viewTransition: true,
},
reactStrictMode: true,
webpack: (config, options) => {

View File

@@ -32,6 +32,7 @@
"hermes-eslint": "^0.25.0",
"hermes-parser": "^0.25.0",
"invariant": "^2.2.4",
"lru-cache": "^11.2.2",
"lz-string": "^1.5.0",
"monaco-editor": "^0.52.0",
"next": "15.6.0-canary.7",

View File

@@ -69,3 +69,58 @@
scrollbar-width: none; /* Firefox */
}
}
::view-transition-old(.slide-in) {
animation-name: slideOutLeft;
}
::view-transition-new(.slide-in) {
animation-name: slideInLeft;
}
::view-transition-group(.slide-in) {
z-index: 1;
}
@keyframes slideOutLeft {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
::view-transition-old(.container),
::view-transition-new(.container) {
height: 100%;
}
::view-transition-old(.accordion-container),
::view-transition-new(.accordion-container) {
height: 100%;
object-fit: none;
object-position: left;
}
::view-transition-old(.tab-highlight),
::view-transition-new(.tab-highlight) {
height: 100%;
}
::view-transition-group(.tab-text) {
z-index: 1;
}
::view-transition-old(.expand-accordion),
::view-transition-new(.expand-accordion) {
width: auto;
}
::view-transition-group(.expand-accordion) {
overflow: clip;
}

View File

@@ -3104,6 +3104,11 @@ lru-cache@^10.2.0:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
lru-cache@^11.2.2:
version "11.2.2"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.2.tgz#40fd37edffcfae4b2940379c0722dc6eeaa75f24"
integrity sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"

View File

@@ -240,7 +240,7 @@ export function addImportsToProgram(
programContext: ProgramContext,
): void {
const existingImports = getExistingImports(path);
const stmts: Array<t.ImportDeclaration> = [];
const stmts: Array<t.ImportDeclaration | t.VariableDeclaration> = [];
const sortedModules = [...programContext.imports.entries()].sort(([a], [b]) =>
a.localeCompare(b),
);
@@ -303,9 +303,29 @@ export function addImportsToProgram(
if (maybeExistingImports != null) {
maybeExistingImports.pushContainer('specifiers', importSpecifiers);
} else {
stmts.push(
t.importDeclaration(importSpecifiers, t.stringLiteral(moduleName)),
);
if (path.node.sourceType === 'module') {
stmts.push(
t.importDeclaration(importSpecifiers, t.stringLiteral(moduleName)),
);
} else {
stmts.push(
t.variableDeclaration('const', [
t.variableDeclarator(
t.objectPattern(
sortedImport.map(specifier => {
return t.objectProperty(
t.identifier(specifier.imported),
t.identifier(specifier.name),
);
}),
),
t.callExpression(t.identifier('require'), [
t.stringLiteral(moduleName),
]),
),
]),
);
}
}
}
path.unshiftContainer('body', stmts);

View File

@@ -454,6 +454,32 @@ function collectNonNullsInBlocks(
assumedNonNullObjects.add(entry);
}
}
} else if (
fn.env.config.enablePreserveExistingMemoizationGuarantees &&
instr.value.kind === 'StartMemoize' &&
instr.value.deps != null
) {
for (const dep of instr.value.deps) {
if (dep.root.kind === 'NamedLocal') {
if (
!isImmutableAtInstr(dep.root.value.identifier, instr.id, context)
) {
continue;
}
for (let i = 0; i < dep.path.length; i++) {
const pathEntry = dep.path[i]!;
if (pathEntry.optional) {
break;
}
const depNode = context.registry.getOrCreateProperty({
identifier: dep.root.value.identifier,
path: dep.path.slice(0, i),
reactive: dep.root.value.reactive,
});
assumedNonNullObjects.add(depNode);
}
}
}
}
}

View File

@@ -19,7 +19,7 @@ export function nameAnonymousFunctions(fn: HIRFunction): void {
const parentName = fn.id;
const functions = nameAnonymousFunctionsImpl(fn);
function visit(node: Node, prefix: string): void {
if (node.generatedName != null) {
if (node.generatedName != null && node.fn.nameHint == null) {
/**
* Note that we don't generate a name for functions that already had one,
* so we'll only add the prefix to anonymous functions regardless of
@@ -70,6 +70,10 @@ function nameAnonymousFunctionsImpl(fn: HIRFunction): Array<Node> {
if (name != null && name.kind === 'named') {
names.set(lvalue.identifier.id, name.value);
}
const func = functions.get(value.place.identifier.id);
if (func != null) {
functions.set(lvalue.identifier.id, func);
}
break;
}
case 'PropertyLoad': {
@@ -106,6 +110,7 @@ function nameAnonymousFunctionsImpl(fn: HIRFunction): Array<Node> {
const variableName = value.lvalue.place.identifier.name;
if (
node != null &&
node.generatedName == null &&
variableName != null &&
variableName.kind === 'named'
) {
@@ -137,7 +142,7 @@ function nameAnonymousFunctionsImpl(fn: HIRFunction): Array<Node> {
continue;
}
const node = functions.get(arg.identifier.id);
if (node != null) {
if (node != null && node.generatedName == null) {
const generatedName =
fnArgCount > 1 ? `${calleeName}(arg${i})` : `${calleeName}()`;
node.generatedName = generatedName;
@@ -152,7 +157,7 @@ function nameAnonymousFunctionsImpl(fn: HIRFunction): Array<Node> {
continue;
}
const node = functions.get(attr.place.identifier.id);
if (node != null) {
if (node != null && node.generatedName == null) {
const elementName =
value.tag.kind === 'BuiltinTag'
? value.tag.name

View File

@@ -5,33 +5,21 @@
* LICENSE file in the root directory of this source tree.
*/
import {CompilerError, Effect} from '..';
import {CompilerError, SourceLocation} from '..';
import {ErrorCategory} from '../CompilerError';
import {
ArrayExpression,
BlockId,
FunctionExpression,
HIRFunction,
IdentifierId,
isSetStateType,
isUseEffectHookType,
Place,
CallExpression,
Instruction,
isUseStateType,
isUseRefType,
} from '../HIR';
import {printInstruction} from '../HIR/PrintHIR';
import {eachInstructionLValue, eachInstructionOperand} from '../HIR/visitors';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
import {assertExhaustive} from '../Utils/utils';
type TypeOfValue = 'ignored' | 'fromProps' | 'fromState' | 'fromPropsAndState';
type DerivationMetadata = {
typeOfValue: TypeOfValue;
place: Place;
sourcesIds: Set<IdentifierId>;
};
import {
eachInstructionValueOperand,
eachTerminalOperand,
} from '../HIR/visitors';
/**
* Validates that useEffect is not used for derived computations which could/should
@@ -57,227 +45,102 @@ type DerivationMetadata = {
* ```
*/
export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
const candidateDependencies: Map<IdentifierId, ArrayExpression> = new Map();
const functions: Map<IdentifierId, FunctionExpression> = new Map();
const derivationCache: Map<IdentifierId, DerivationMetadata> = new Map();
if (fn.fnType === 'Hook') {
for (const param of fn.params) {
if (param.kind === 'Identifier') {
derivationCache.set(param.identifier.id, {
place: param,
sourcesIds: new Set([param.identifier.id]),
typeOfValue: 'fromProps',
});
}
}
} else if (fn.fnType === 'Component') {
const props = fn.params[0];
if (props != null && props.kind === 'Identifier') {
derivationCache.set(props.identifier.id, {
place: props,
sourcesIds: new Set([props.identifier.id]),
typeOfValue: 'fromProps',
});
}
}
const locals: Map<IdentifierId, IdentifierId> = new Map();
const errors = new CompilerError();
for (const block of fn.body.blocks.values()) {
for (const phi of block.phis) {
let typeOfValue: TypeOfValue = 'ignored';
let sourcesIds: Set<IdentifierId> = new Set();
for (const operand of phi.operands.values()) {
const operandMetadata = derivationCache.get(operand.identifier.id);
if (operandMetadata === undefined) {
continue;
}
typeOfValue = joinValue(typeOfValue, operandMetadata.typeOfValue);
sourcesIds.add(operand.identifier.id);
}
if (typeOfValue !== 'ignored') {
addDerivationEntry(phi.place, sourcesIds, typeOfValue, derivationCache);
}
}
for (const i of block.instructions) {
function recordInstructiorDerivations(instr: Instruction): void {
let typeOfValue: TypeOfValue = 'ignored';
const sources: Set<IdentifierId> = new Set();
const {lvalue, value} = instr;
if (value.kind === 'FunctionExpression') {
functions.set(lvalue.identifier.id, value);
for (const [, block] of value.loweredFunc.func.body.blocks) {
for (const instr of block.instructions) {
recordInstructiorDerivations(instr);
}
}
} else if (
value.kind === 'CallExpression' ||
value.kind === 'MethodCall'
for (const instr of block.instructions) {
const {lvalue, value} = instr;
if (value.kind === 'LoadLocal') {
locals.set(lvalue.identifier.id, value.place.identifier.id);
} else if (value.kind === 'ArrayExpression') {
candidateDependencies.set(lvalue.identifier.id, value);
} else if (value.kind === 'FunctionExpression') {
functions.set(lvalue.identifier.id, value);
} else if (
value.kind === 'CallExpression' ||
value.kind === 'MethodCall'
) {
const callee =
value.kind === 'CallExpression' ? value.callee : value.property;
if (
isUseEffectHookType(callee.identifier) &&
value.args.length === 2 &&
value.args[0].kind === 'Identifier' &&
value.args[1].kind === 'Identifier'
) {
const callee =
value.kind === 'CallExpression' ? value.callee : value.property;
const effectFunction = functions.get(value.args[0].identifier.id);
const deps = candidateDependencies.get(value.args[1].identifier.id);
if (
isUseEffectHookType(callee.identifier) &&
value.args.length === 2 &&
value.args[0].kind === 'Identifier' &&
value.args[1].kind === 'Identifier'
effectFunction != null &&
deps != null &&
deps.elements.length !== 0 &&
deps.elements.every(element => element.kind === 'Identifier')
) {
const effectFunction = functions.get(value.args[0].identifier.id);
if (effectFunction != null) {
validateEffect(
effectFunction.loweredFunc.func,
errors,
derivationCache,
);
}
} else if (isUseStateType(lvalue.identifier)) {
const stateValueSource = value.args[0];
if (stateValueSource.kind === 'Identifier') {
sources.add(stateValueSource.identifier.id);
}
typeOfValue = joinValue(typeOfValue, 'fromState');
}
}
for (const operand of eachInstructionOperand(instr)) {
const operandMetadata = derivationCache.get(operand.identifier.id);
if (operandMetadata === undefined) {
continue;
}
typeOfValue = joinValue(typeOfValue, operandMetadata.typeOfValue);
for (const id of operandMetadata.sourcesIds) {
sources.add(id);
}
}
if (typeOfValue === 'ignored') {
return;
}
for (const lvalue of eachInstructionLValue(instr)) {
addDerivationEntry(lvalue, sources, typeOfValue, derivationCache);
}
for (const operand of eachInstructionOperand(instr)) {
switch (operand.effect) {
case Effect.Capture:
case Effect.Store:
case Effect.ConditionallyMutate:
case Effect.ConditionallyMutateIterator:
case Effect.Mutate: {
if (isMutable(instr, operand)) {
addDerivationEntry(
operand,
sources,
typeOfValue,
derivationCache,
);
}
break;
}
case Effect.Freeze:
case Effect.Read: {
// no-op
break;
}
case Effect.Unknown: {
CompilerError.invariant(false, {
reason: 'Unexpected unknown effect',
const dependencies: Array<IdentifierId> = deps.elements.map(dep => {
CompilerError.invariant(dep.kind === 'Identifier', {
reason: `Dependency is checked as a place above`,
description: null,
details: [
{
kind: 'error',
loc: operand.loc,
message: 'Unexpected unknown effect',
loc: value.loc,
message: 'this is checked as a place above',
},
],
});
}
default: {
assertExhaustive(
operand.effect,
`Unexpected effect kind \`${operand.effect}\``,
);
}
return locals.get(dep.identifier.id) ?? dep.identifier.id;
});
validateEffect(
effectFunction.loweredFunc.func,
dependencies,
errors,
);
}
}
}
recordInstructiorDerivations(i);
}
}
if (errors.hasAnyErrors()) {
throw errors;
}
}
function joinValue(
lvalueType: TypeOfValue,
valueType: TypeOfValue,
): TypeOfValue {
if (lvalueType === 'ignored') return valueType;
if (valueType === 'ignored') return lvalueType;
if (lvalueType === valueType) return lvalueType;
return 'fromPropsAndState';
}
function addDerivationEntry(
derivedVar: Place,
sourcesIds: Set<IdentifierId>,
typeOfValue: TypeOfValue,
derivationCache: Map<IdentifierId, DerivationMetadata>,
function validateEffect(
effectFunction: HIRFunction,
effectDeps: Array<IdentifierId>,
errors: CompilerError,
): void {
let newValue: DerivationMetadata = {
place: derivedVar,
sourcesIds: new Set(),
typeOfValue: typeOfValue ?? 'ignored',
};
if (sourcesIds !== undefined) {
for (const id of sourcesIds) {
const sourcePlace = derivationCache.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.
*/
if (
sourcePlace.identifier.name === null ||
sourcePlace.identifier.name?.kind === 'promoted'
) {
newValue.sourcesIds.add(derivedVar.identifier.id);
} else {
newValue.sourcesIds.add(sourcePlace.identifier.id);
}
for (const operand of effectFunction.context) {
if (isSetStateType(operand.identifier)) {
continue;
} else if (effectDeps.find(dep => dep === operand.identifier.id) != null) {
continue;
} else {
// Captured something other than the effect dep or setState
return;
}
}
for (const dep of effectDeps) {
if (
effectFunction.context.find(operand => operand.identifier.id === dep) ==
null
) {
// effect dep wasn't actually used in the function
return;
}
}
derivationCache.set(derivedVar.identifier.id, newValue);
}
function validateEffect(
effectFunction: HIRFunction,
errors: CompilerError,
derivationCache: Map<IdentifierId, DerivationMetadata>,
): void {
const seenBlocks: Set<BlockId> = new Set();
const values: Map<IdentifierId, Array<IdentifierId>> = new Map();
for (const dep of effectDeps) {
values.set(dep, [dep]);
}
const effectDerivedSetStateCalls: Array<{
value: CallExpression;
sourceIds: Set<IdentifierId>;
}> = [];
const globals: Set<IdentifierId> = new Set();
const setStateLocations: Array<SourceLocation> = [];
for (const block of effectFunction.body.blocks.values()) {
for (const pred of block.preds) {
if (!seenBlocks.has(pred)) {
@@ -285,64 +148,90 @@ function validateEffect(
return;
}
}
for (const instr of block.instructions) {
// Early return if any instruction is deriving a value from a ref
if (isUseRefType(instr.lvalue.identifier)) {
return;
for (const phi of block.phis) {
const aggregateDeps: Set<IdentifierId> = new Set();
for (const operand of phi.operands.values()) {
const deps = values.get(operand.identifier.id);
if (deps != null) {
for (const dep of deps) {
aggregateDeps.add(dep);
}
}
}
if (
instr.value.kind === 'CallExpression' &&
isSetStateType(instr.value.callee.identifier) &&
instr.value.args.length === 1 &&
instr.value.args[0].kind === 'Identifier'
) {
const argMetadata = derivationCache.get(
instr.value.args[0].identifier.id,
);
if (argMetadata !== undefined) {
effectDerivedSetStateCalls.push({
value: instr.value,
sourceIds: argMetadata.sourcesIds,
});
if (aggregateDeps.size !== 0) {
values.set(phi.place.identifier.id, Array.from(aggregateDeps));
}
}
for (const instr of block.instructions) {
switch (instr.value.kind) {
case 'Primitive':
case 'JSXText':
case 'LoadGlobal': {
break;
}
} else if (instr.value.kind === 'CallExpression') {
const calleeMetadata = derivationCache.get(
instr.value.callee.identifier.id,
);
case 'LoadLocal': {
const deps = values.get(instr.value.place.identifier.id);
if (deps != null) {
values.set(instr.lvalue.identifier.id, deps);
}
break;
}
case 'ComputedLoad':
case 'PropertyLoad':
case 'BinaryExpression':
case 'TemplateLiteral':
case 'CallExpression':
case 'MethodCall': {
const aggregateDeps: Set<IdentifierId> = new Set();
for (const operand of eachInstructionValueOperand(instr.value)) {
const deps = values.get(operand.identifier.id);
if (deps != null) {
for (const dep of deps) {
aggregateDeps.add(dep);
}
}
}
if (aggregateDeps.size !== 0) {
values.set(instr.lvalue.identifier.id, Array.from(aggregateDeps));
}
if (
calleeMetadata !== undefined &&
(calleeMetadata.typeOfValue === 'fromProps' ||
calleeMetadata.typeOfValue === 'fromPropsAndState')
) {
// If the callee is a prop we can't confidently say that it should be derived in render
if (
instr.value.kind === 'CallExpression' &&
isSetStateType(instr.value.callee.identifier) &&
instr.value.args.length === 1 &&
instr.value.args[0].kind === 'Identifier'
) {
const deps = values.get(instr.value.args[0].identifier.id);
if (deps != null && new Set(deps).size === effectDeps.length) {
setStateLocations.push(instr.value.callee.loc);
} else {
// doesn't depend on any deps
return;
}
}
break;
}
default: {
return;
}
if (globals.has(instr.value.callee.identifier.id)) {
// If the callee is a global we can't confidently say that it should be derived in render
return;
}
} else if (instr.value.kind === 'LoadGlobal') {
globals.add(instr.lvalue.identifier.id);
for (const operand of eachInstructionOperand(instr)) {
globals.add(operand.identifier.id);
}
}
}
for (const operand of eachTerminalOperand(block.terminal)) {
if (values.has(operand.identifier.id)) {
//
return;
}
}
seenBlocks.add(block.id);
}
for (const derivedSetStateCall of effectDerivedSetStateCalls) {
for (const loc of setStateLocations) {
errors.push({
category: ErrorCategory.EffectDerivationsOfState,
reason:
'Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)',
description: null,
loc: derivedSetStateCall.value.callee.loc,
loc,
suggestions: null,
});
}

View File

@@ -1,73 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState, useRef} from 'react';
export default function Component({test}) {
const [local, setLocal] = useState('');
const myRef = useRef(null);
useEffect(() => {
setLocal(myRef.current + test);
}, [test]);
return <>{local}</>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{test: 'testString'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects
import { useEffect, useState, useRef } from "react";
export default function Component(t0) {
const $ = _c(5);
const { test } = t0;
const [local, setLocal] = useState("");
const myRef = useRef(null);
let t1;
let t2;
if ($[0] !== test) {
t1 = () => {
setLocal(myRef.current + test);
};
t2 = [test];
$[0] = test;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== local) {
t3 = <>{local}</>;
$[3] = local;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ test: "testString" }],
};
```
### Eval output
(kind: ok) nulltestString

View File

@@ -1,19 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState, useRef} from 'react';
export default function Component({test}) {
const [local, setLocal] = useState('');
const myRef = useRef(null);
useEffect(() => {
setLocal(myRef.current + test);
}, [test]);
return <>{local}</>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{test: 'testString'}],
};

View File

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

View File

@@ -1,17 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({propValue, onChange}) {
const [value, setValue] = useState(null);
useEffect(() => {
setValue(propValue);
onChange();
}, [propValue]);
return <div>{value}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{propValue: 'test', onChange: () => {}}],
};

View File

@@ -1,70 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({propValue}) {
const [value, setValue] = useState(null);
useEffect(() => {
setValue(propValue);
globalCall();
}, [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
import { useEffect, useState } from "react";
function Component(t0) {
const $ = _c(5);
const { propValue } = t0;
const [value, setValue] = useState(null);
let t1;
let t2;
if ($[0] !== propValue) {
t1 = () => {
setValue(propValue);
globalCall();
};
t2 = [propValue];
$[0] = propValue;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== value) {
t3 = <div>{value}</div>;
$[3] = value;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ propValue: "test" }],
};
```
### Eval output
(kind: exception) globalCall is not defined

View File

@@ -1,17 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({propValue}) {
const [value, setValue] = useState(null);
useEffect(() => {
setValue(propValue);
globalCall();
}, [propValue]);
return <div>{value}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{propValue: 'test'}],
};

View File

@@ -1,47 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({value, enabled}) {
const [localValue, setLocalValue] = useState('');
useEffect(() => {
if (enabled) {
setLocalValue(value);
} else {
setLocalValue('disabled');
}
}, [value, enabled]);
return <div>{localValue}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 'test', enabled: true}],
};
```
## Error
```
Found 1 error:
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.derived-state-conditionally-in-effect.ts:9:6
7 | useEffect(() => {
8 | if (enabled) {
> 9 | setLocalValue(value);
| ^^^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
10 | } else {
11 | setLocalValue('disabled');
12 | }
```

View File

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

View File

@@ -1,44 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
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: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.derived-state-from-default-props.ts:9:4
7 |
8 | useEffect(() => {
> 9 | setCurrInput(input + localConst);
| ^^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
10 | }, [input, localConst]);
11 |
12 | return <div>{currInput}</div>;
```

View File

@@ -1,18 +0,0 @@
// @validateNoDerivedComputationsInEffects
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'}],
};

View File

@@ -1,51 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
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: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.derived-state-from-prop-local-state-and-component-scope.ts:11:4
9 |
10 | useEffect(() => {
> 11 | setFullName(firstName + ' ' + middleName + ' ' + lastName);
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
12 | }, [firstName, middleName, lastName]);
13 |
14 | return (
```

View File

@@ -1,25 +0,0 @@
// @validateNoDerivedComputationsInEffects
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'}],
};

View File

@@ -1,47 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function Component({initialName}) {
const [name, setName] = useState('');
useEffect(() => {
setName(initialName);
}, [initialName]);
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{initialName: 'John'}],
};
```
## Error
```
Found 1 error:
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.derived-state-from-prop-setter-call-outside-effect-no-error.ts:8:4
6 |
7 | useEffect(() => {
> 8 | setName(initialName);
| ^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
9 | }, [initialName]);
10 |
11 | return (
```

View File

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

View File

@@ -1,46 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function MockComponent({onSet}) {
return <div onClick={() => onSet('clicked')}>Mock Component</div>;
}
function Component({propValue}) {
const [value, setValue] = useState(null);
useEffect(() => {
setValue(propValue);
}, [propValue]);
return <MockComponent onSet={setValue} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{propValue: 'test'}],
};
```
## Error
```
Found 1 error:
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.derived-state-from-prop-setter-used-outside-effect-no-error.ts:11:4
9 | const [value, setValue] = useState(null);
10 | useEffect(() => {
> 11 | setValue(propValue);
| ^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
12 | }, [propValue]);
13 |
14 | return <MockComponent onSet={setValue} />;
```

View File

@@ -1,20 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function MockComponent({onSet}) {
return <div onClick={() => onSet('clicked')}>Mock Component</div>;
}
function Component({propValue}) {
const [value, setValue] = useState(null);
useEffect(() => {
setValue(propValue);
}, [propValue]);
return <MockComponent onSet={setValue} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{propValue: 'test'}],
};

View File

@@ -1,44 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
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: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.derived-state-from-prop-with-side-effect.ts:8:4
6 |
7 | useEffect(() => {
> 8 | setLocalValue(value);
| ^^^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
9 | document.title = `Value: ${value}`;
10 | }, [value]);
11 |
```

View File

@@ -1,18 +0,0 @@
// @validateNoDerivedComputationsInEffects
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'}],
};

View File

@@ -1,48 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
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: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.effect-contains-local-function-call.ts:12:4
10 |
11 | useEffect(() => {
> 12 | setValue(propValue);
| ^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
13 | localFunction();
14 | }, [propValue]);
15 |
```

View File

@@ -1,22 +0,0 @@
// @validateNoDerivedComputationsInEffects
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'}],
};

View File

@@ -1,46 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
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: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.invalid-derived-computation-in-effect.ts:11:4
9 | const [fullName, setFullName] = useState('');
10 | useEffect(() => {
> 11 | setFullName(firstName + ' ' + lastName);
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
12 | }, [firstName, lastName]);
13 |
14 | return <div>{fullName}</div>;
```

View File

@@ -1,20 +0,0 @@
// @validateNoDerivedComputationsInEffects
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: [],
};

View File

@@ -1,44 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
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: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.invalid-derived-state-from-computed-props.ts:9:4
7 | useEffect(() => {
8 | const computed = props.prefix + props.value + props.suffix;
> 9 | setDisplayValue(computed);
| ^^^^^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
10 | }, [props.prefix, props.value, props.suffix]);
11 |
12 | return <div>{displayValue}</div>;
```

View File

@@ -1,18 +0,0 @@
// @validateNoDerivedComputationsInEffects
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: ']'}],
};

View File

@@ -1,45 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
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: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.invalid-derived-state-from-destructured-props.ts:10:4
8 |
9 | useEffect(() => {
> 10 | setFullName(props.firstName + ' ' + props.lastName);
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
11 | }, [props.firstName, props.lastName]);
12 |
13 | return <div>{fullName}</div>;
```

View File

@@ -1,19 +0,0 @@
// @validateNoDerivedComputationsInEffects
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'}}],
};

View File

@@ -1,82 +0,0 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState, useRef} from 'react';
export default function Component({test}) {
const [local, setLocal] = useState(0);
const myRef = useRef(null);
useEffect(() => {
if (myRef.current) {
setLocal(test);
} else {
setLocal(test + test);
}
}, [test]);
return <>{local}</>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{test: 4}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects
import { useEffect, useState, useRef } from "react";
export default function Component(t0) {
const $ = _c(5);
const { test } = t0;
const [local, setLocal] = useState(0);
const myRef = useRef(null);
let t1;
let t2;
if ($[0] !== test) {
t1 = () => {
if (myRef.current) {
setLocal(test);
} else {
setLocal(test + test);
}
};
t2 = [test];
$[0] = test;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useEffect(t1, t2);
let t3;
if ($[3] !== local) {
t3 = <>{local}</>;
$[3] = local;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ test: 4 }],
};
```
### Eval output
(kind: ok) 8

View File

@@ -1,23 +0,0 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState, useRef} from 'react';
export default function Component({test}) {
const [local, setLocal] = useState(0);
const myRef = useRef(null);
useEffect(() => {
if (myRef.current) {
setLocal(test);
} else {
setLocal(test + test);
}
}, [test]);
return <>{local}</>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{test: 4}],
};

View File

@@ -3,8 +3,6 @@
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function BadExample() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
@@ -12,7 +10,7 @@ function BadExample() {
// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
setFullName(capitalize(firstName + ' ' + lastName));
}, [firstName, lastName]);
return <div>{fullName}</div>;
@@ -28,14 +26,14 @@ Found 1 error:
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.invalid-derived-computation-in-effect.ts:11:4
9 | const [fullName, setFullName] = useState('');
10 | useEffect(() => {
> 11 | setFullName(firstName + ' ' + lastName);
error.invalid-derived-computation-in-effect.ts:9:4
7 | const [fullName, setFullName] = useState('');
8 | useEffect(() => {
> 9 | setFullName(capitalize(firstName + ' ' + lastName));
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
12 | }, [firstName, lastName]);
13 |
14 | return <div>{fullName}</div>;
10 | }, [firstName, lastName]);
11 |
12 | return <div>{fullName}</div>;
```

View File

@@ -1,6 +1,4 @@
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function BadExample() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
@@ -8,7 +6,7 @@ function BadExample() {
// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
setFullName(capitalize(firstName + ' ' + lastName));
}, [firstName, lastName]);
return <div>{fullName}</div>;

View File

@@ -0,0 +1,91 @@
## Input
```javascript
// @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import {useMemo} from 'react';
import {identity, ValidateMemoization} from 'shared-runtime';
function Component({x}) {
const object = useMemo(() => {
return identity({
callback: () => {
// This is a bug in our dependency inference: we stop capturing dependencies
// after x.a.b?.c. But what this dependency is telling us is that if `x.a.b`
// was non-nullish, then we can access `.c.d?.e`. Thus we should take the
// full property chain, exactly as-is with optionals/non-optionals, as a
// dependency
return identity(x.a.b?.c.d?.e);
},
});
}, [x.a.b?.c.d?.e]);
const result = useMemo(() => {
return [object.callback()];
}, [object]);
return <Inner x={x} result={result} />;
}
function Inner({x, result}) {
'use no memo';
return <ValidateMemoization inputs={[x.y.z]} output={result} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: {y: {z: 42}}}],
sequentialRenders: [
{x: {y: {z: 42}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
],
};
```
## Error
```
Found 1 error:
Compilation Skipped: Existing memoization could not be preserved
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `x.a.b?.c`, but the source dependencies were [x.a.b?.c.d?.e]. Inferred less specific property than source.
error.todo-preserve-memo-deps-mixed-optional-nonoptional-property-chain.ts:7:25
5 |
6 | function Component({x}) {
> 7 | const object = useMemo(() => {
| ^^^^^^^
> 8 | return identity({
| ^^^^^^^^^^^^^^^^^^^^^
> 9 | callback: () => {
| ^^^^^^^^^^^^^^^^^^^^^
> 10 | // This is a bug in our dependency inference: we stop capturing dependencies
| ^^^^^^^^^^^^^^^^^^^^^
> 11 | // after x.a.b?.c. But what this dependency is telling us is that if `x.a.b`
| ^^^^^^^^^^^^^^^^^^^^^
> 12 | // was non-nullish, then we can access `.c.d?.e`. Thus we should take the
| ^^^^^^^^^^^^^^^^^^^^^
> 13 | // full property chain, exactly as-is with optionals/non-optionals, as a
| ^^^^^^^^^^^^^^^^^^^^^
> 14 | // dependency
| ^^^^^^^^^^^^^^^^^^^^^
> 15 | return identity(x.a.b?.c.d?.e);
| ^^^^^^^^^^^^^^^^^^^^^
> 16 | },
| ^^^^^^^^^^^^^^^^^^^^^
> 17 | });
| ^^^^^^^^^^^^^^^^^^^^^
> 18 | }, [x.a.b?.c.d?.e]);
| ^^^^ Could not preserve existing manual memoization
19 | const result = useMemo(() => {
20 | return [object.callback()];
21 | }, [object]);
```

View File

@@ -0,0 +1,41 @@
// @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import {useMemo} from 'react';
import {identity, ValidateMemoization} from 'shared-runtime';
function Component({x}) {
const object = useMemo(() => {
return identity({
callback: () => {
// This is a bug in our dependency inference: we stop capturing dependencies
// after x.a.b?.c. But what this dependency is telling us is that if `x.a.b`
// was non-nullish, then we can access `.c.d?.e`. Thus we should take the
// full property chain, exactly as-is with optionals/non-optionals, as a
// dependency
return identity(x.a.b?.c.d?.e);
},
});
}, [x.a.b?.c.d?.e]);
const result = useMemo(() => {
return [object.callback()];
}, [object]);
return <Inner x={x} result={result} />;
}
function Inner({x, result}) {
'use no memo';
return <ValidateMemoization inputs={[x.y.z]} output={result} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: {y: {z: 42}}}],
sequentialRenders: [
{x: {y: {z: 42}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
],
};

View File

@@ -4,15 +4,19 @@
```javascript
// @enableNameAnonymousFunctions
import {useEffect} from 'react';
import {useCallback, useEffect} from 'react';
import {identity, Stringify, useIdentity} from 'shared-runtime';
import * as SharedRuntime from 'shared-runtime';
function Component(props) {
function named() {
const inner = () => props.named;
return inner();
const innerIdentity = identity(() => props.named);
return inner(innerIdentity());
}
const callback = useCallback(() => {
return 'ok';
}, []);
const namedVariable = function () {
return props.namedVariable;
};
@@ -30,6 +34,7 @@ function Component(props) {
return (
<>
{named()}
{callback()}
{namedVariable()}
{methodCall()}
{call()}
@@ -63,7 +68,7 @@ export const TODO_FIXTURE_ENTRYPOINT = {
```javascript
import { c as _c } from "react/compiler-runtime"; // @enableNameAnonymousFunctions
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import { identity, Stringify, useIdentity } from "shared-runtime";
import * as SharedRuntime from "shared-runtime";
@@ -75,7 +80,12 @@ function Component(props) {
const inner = { "Component[named > inner]": () => props.named }[
"Component[named > inner]"
];
return inner();
const innerIdentity = identity(
{ "Component[named > identity()]": () => props.named }[
"Component[named > identity()]"
],
);
return inner(innerIdentity());
};
$[0] = props.named;
$[1] = t0;
@@ -83,6 +93,8 @@ function Component(props) {
t0 = $[1];
}
const named = t0;
const callback = _ComponentCallback;
let t1;
if ($[2] !== props.namedVariable) {
t1 = {
@@ -197,57 +209,62 @@ function Component(props) {
} else {
t9 = $[18];
}
let t10;
const t10 = callback();
let t11;
if ($[19] !== namedVariable) {
t10 = namedVariable();
t11 = namedVariable();
$[19] = namedVariable;
$[20] = t10;
$[20] = t11;
} else {
t10 = $[20];
}
const t11 = methodCall();
const t12 = call();
let t13;
if ($[21] !== hookArgument) {
t13 = hookArgument();
$[21] = hookArgument;
$[22] = t13;
} else {
t13 = $[22];
t11 = $[20];
}
const t12 = methodCall();
const t13 = call();
let t14;
if ($[21] !== hookArgument) {
t14 = hookArgument();
$[21] = hookArgument;
$[22] = t14;
} else {
t14 = $[22];
}
let t15;
if (
$[23] !== builtinElementAttr ||
$[24] !== namedElementAttr ||
$[25] !== t10 ||
$[26] !== t11 ||
$[27] !== t12 ||
$[28] !== t13 ||
$[25] !== t11 ||
$[26] !== t12 ||
$[27] !== t13 ||
$[28] !== t14 ||
$[29] !== t9
) {
t14 = (
t15 = (
<>
{t9}
{t10}
{t11}
{t12}
{t13}
{builtinElementAttr}
{namedElementAttr}
{t13}
{t14}
</>
);
$[23] = builtinElementAttr;
$[24] = namedElementAttr;
$[25] = t10;
$[26] = t11;
$[27] = t12;
$[28] = t13;
$[25] = t11;
$[26] = t12;
$[27] = t13;
$[28] = t14;
$[29] = t9;
$[30] = t14;
$[30] = t15;
} else {
t14 = $[30];
t15 = $[30];
}
return t14;
return t15;
}
function _ComponentCallback() {
return "ok";
}
export const TODO_FIXTURE_ENTRYPOINT = {

View File

@@ -1,14 +1,18 @@
// @enableNameAnonymousFunctions
import {useEffect} from 'react';
import {useCallback, useEffect} from 'react';
import {identity, Stringify, useIdentity} from 'shared-runtime';
import * as SharedRuntime from 'shared-runtime';
function Component(props) {
function named() {
const inner = () => props.named;
return inner();
const innerIdentity = identity(() => props.named);
return inner(innerIdentity());
}
const callback = useCallback(() => {
return 'ok';
}, []);
const namedVariable = function () {
return props.namedVariable;
};
@@ -26,6 +30,7 @@ function Component(props) {
return (
<>
{named()}
{callback()}
{namedVariable()}
{methodCall()}
{call()}

View File

@@ -0,0 +1,122 @@
## Input
```javascript
// @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import {useMemo} from 'react';
import {identity, ValidateMemoization} from 'shared-runtime';
function Component({x}) {
const object = useMemo(() => {
return identity({
callback: () => {
return identity(x.y.z); // accesses more levels of properties than the manual memo
},
});
// x.y as a manual dep only tells us that x is non-nullable, not that x.y is non-nullable
// we can only take a dep on x.y, not x.y.z
}, [x.y]);
const result = useMemo(() => {
return [object.callback()];
}, [object]);
return <ValidateMemoization inputs={[x.y]} output={result} />;
}
const input1 = {x: {y: {z: 42}}};
const input1b = {x: {y: {z: 42}}};
const input2 = {x: {y: {z: 3.14}}};
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [input1],
sequentialRenders: [
input1,
input1,
input1b, // should reset even though .z didn't change
input1,
input2,
],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import { useMemo } from "react";
import { identity, ValidateMemoization } from "shared-runtime";
function Component(t0) {
const $ = _c(11);
const { x } = t0;
let t1;
if ($[0] !== x.y) {
t1 = identity({ callback: () => identity(x.y.z) });
$[0] = x.y;
$[1] = t1;
} else {
t1 = $[1];
}
const object = t1;
let t2;
if ($[2] !== object) {
t2 = object.callback();
$[2] = object;
$[3] = t2;
} else {
t2 = $[3];
}
let t3;
if ($[4] !== t2) {
t3 = [t2];
$[4] = t2;
$[5] = t3;
} else {
t3 = $[5];
}
const result = t3;
let t4;
if ($[6] !== x.y) {
t4 = [x.y];
$[6] = x.y;
$[7] = t4;
} else {
t4 = $[7];
}
let t5;
if ($[8] !== result || $[9] !== t4) {
t5 = <ValidateMemoization inputs={t4} output={result} />;
$[8] = result;
$[9] = t4;
$[10] = t5;
} else {
t5 = $[10];
}
return t5;
}
const input1 = { x: { y: { z: 42 } } };
const input1b = { x: { y: { z: 42 } } };
const input2 = { x: { y: { z: 3.14 } } };
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [input1],
sequentialRenders: [
input1,
input1,
input1b, // should reset even though .z didn't change
input1,
input2,
],
};
```
### Eval output
(kind: ok) <div>{"inputs":[{"z":42}],"output":[42]}</div>
<div>{"inputs":[{"z":42}],"output":[42]}</div>
<div>{"inputs":[{"z":42}],"output":[42]}</div>
<div>{"inputs":[{"z":42}],"output":[42]}</div>
<div>{"inputs":[{"z":3.14}],"output":[3.14]}</div>

View File

@@ -0,0 +1,35 @@
// @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import {useMemo} from 'react';
import {identity, ValidateMemoization} from 'shared-runtime';
function Component({x}) {
const object = useMemo(() => {
return identity({
callback: () => {
return identity(x.y.z); // accesses more levels of properties than the manual memo
},
});
// x.y as a manual dep only tells us that x is non-nullable, not that x.y is non-nullable
// we can only take a dep on x.y, not x.y.z
}, [x.y]);
const result = useMemo(() => {
return [object.callback()];
}, [object]);
return <ValidateMemoization inputs={[x.y]} output={result} />;
}
const input1 = {x: {y: {z: 42}}};
const input1b = {x: {y: {z: 42}}};
const input2 = {x: {y: {z: 3.14}}};
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [input1],
sequentialRenders: [
input1,
input1,
input1b, // should reset even though .z didn't change
input1,
input2,
],
};

View File

@@ -0,0 +1,117 @@
## Input
```javascript
// @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import {useMemo} from 'react';
import {identity, ValidateMemoization} from 'shared-runtime';
function Component({x}) {
const object = useMemo(() => {
return identity({
callback: () => {
return identity(x.y.z);
},
});
}, [x.y.z]);
const result = useMemo(() => {
return [object.callback()];
}, [object]);
return <ValidateMemoization inputs={[x.y.z]} output={result} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: {y: {z: 42}}}],
sequentialRenders: [
{x: {y: {z: 42}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import { useMemo } from "react";
import { identity, ValidateMemoization } from "shared-runtime";
function Component(t0) {
const $ = _c(11);
const { x } = t0;
let t1;
if ($[0] !== x.y.z) {
t1 = identity({ callback: () => identity(x.y.z) });
$[0] = x.y.z;
$[1] = t1;
} else {
t1 = $[1];
}
const object = t1;
let t2;
if ($[2] !== object) {
t2 = object.callback();
$[2] = object;
$[3] = t2;
} else {
t2 = $[3];
}
let t3;
if ($[4] !== t2) {
t3 = [t2];
$[4] = t2;
$[5] = t3;
} else {
t3 = $[5];
}
const result = t3;
let t4;
if ($[6] !== x.y.z) {
t4 = [x.y.z];
$[6] = x.y.z;
$[7] = t4;
} else {
t4 = $[7];
}
let t5;
if ($[8] !== result || $[9] !== t4) {
t5 = <ValidateMemoization inputs={t4} output={result} />;
$[8] = result;
$[9] = t4;
$[10] = t5;
} else {
t5 = $[10];
}
return t5;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: { y: { z: 42 } } }],
sequentialRenders: [
{ x: { y: { z: 42 } } },
{ x: { y: { z: 42 } } },
{ x: { y: { z: 3.14 } } },
{ x: { y: { z: 42 } } },
{ x: { y: { z: 3.14 } } },
{ x: { y: { z: 42 } } },
],
};
```
### Eval output
(kind: ok) <div>{"inputs":[42],"output":[42]}</div>
<div>{"inputs":[42],"output":[42]}</div>
<div>{"inputs":[3.14],"output":[3.14]}</div>
<div>{"inputs":[42],"output":[42]}</div>
<div>{"inputs":[3.14],"output":[3.14]}</div>
<div>{"inputs":[42],"output":[42]}</div>

View File

@@ -0,0 +1,31 @@
// @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import {useMemo} from 'react';
import {identity, ValidateMemoization} from 'shared-runtime';
function Component({x}) {
const object = useMemo(() => {
return identity({
callback: () => {
return identity(x.y.z);
},
});
}, [x.y.z]);
const result = useMemo(() => {
return [object.callback()];
}, [object]);
return <ValidateMemoization inputs={[x.y.z]} output={result} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: {y: {z: 42}}}],
sequentialRenders: [
{x: {y: {z: 42}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
],
};

View File

@@ -0,0 +1,125 @@
## Input
```javascript
// @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import {useMemo} from 'react';
import {identity, ValidateMemoization} from 'shared-runtime';
function Component({x, y, z}) {
const object = useMemo(() => {
return identity({
callback: () => {
return identity(x?.y?.z, y.a?.b, z.a.b?.c);
},
});
}, [x?.y?.z, y.a?.b, z.a.b?.c]);
const result = useMemo(() => {
return [object.callback()];
}, [object]);
return <Inner x={x} result={result} />;
}
function Inner({x, result}) {
'use no memo';
return <ValidateMemoization inputs={[x.y.z]} output={result} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: {y: {z: 42}}}],
sequentialRenders: [
{x: {y: {z: 42}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import { useMemo } from "react";
import { identity, ValidateMemoization } from "shared-runtime";
function Component(t0) {
const $ = _c(11);
const { x, y, z } = t0;
x?.y?.z;
y.a?.b;
z.a.b?.c;
let t1;
if ($[0] !== x?.y?.z || $[1] !== y.a?.b || $[2] !== z.a.b?.c) {
t1 = identity({ callback: () => identity(x?.y?.z, y.a?.b, z.a.b?.c) });
$[0] = x?.y?.z;
$[1] = y.a?.b;
$[2] = z.a.b?.c;
$[3] = t1;
} else {
t1 = $[3];
}
const object = t1;
let t2;
if ($[4] !== object) {
t2 = object.callback();
$[4] = object;
$[5] = t2;
} else {
t2 = $[5];
}
let t3;
if ($[6] !== t2) {
t3 = [t2];
$[6] = t2;
$[7] = t3;
} else {
t3 = $[7];
}
const result = t3;
let t4;
if ($[8] !== result || $[9] !== x) {
t4 = <Inner x={x} result={result} />;
$[8] = result;
$[9] = x;
$[10] = t4;
} else {
t4 = $[10];
}
return t4;
}
function Inner({ x, result }) {
"use no memo";
return <ValidateMemoization inputs={[x.y.z]} output={result} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: { y: { z: 42 } } }],
sequentialRenders: [
{ x: { y: { z: 42 } } },
{ x: { y: { z: 42 } } },
{ x: { y: { z: 3.14 } } },
{ x: { y: { z: 42 } } },
{ x: { y: { z: 3.14 } } },
{ x: { y: { z: 42 } } },
],
};
```
### Eval output
(kind: ok) [[ (exception in render) TypeError: Cannot read properties of undefined (reading 'a') ]]
[[ (exception in render) TypeError: Cannot read properties of undefined (reading 'a') ]]
[[ (exception in render) TypeError: Cannot read properties of undefined (reading 'a') ]]
[[ (exception in render) TypeError: Cannot read properties of undefined (reading 'a') ]]
[[ (exception in render) TypeError: Cannot read properties of undefined (reading 'a') ]]
[[ (exception in render) TypeError: Cannot read properties of undefined (reading 'a') ]]

View File

@@ -0,0 +1,36 @@
// @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees @enableOptionalDependencies @enableTreatFunctionDepsAsConditional:false
import {useMemo} from 'react';
import {identity, ValidateMemoization} from 'shared-runtime';
function Component({x, y, z}) {
const object = useMemo(() => {
return identity({
callback: () => {
return identity(x?.y?.z, y.a?.b, z.a.b?.c);
},
});
}, [x?.y?.z, y.a?.b, z.a.b?.c]);
const result = useMemo(() => {
return [object.callback()];
}, [object]);
return <Inner x={x} result={result} />;
}
function Inner({x, result}) {
'use no memo';
return <ValidateMemoization inputs={[x.y.z]} output={result} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: {y: {z: 42}}}],
sequentialRenders: [
{x: {y: {z: 42}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
{x: {y: {z: 3.14}}},
{x: {y: {z: 42}}},
],
};

View File

@@ -0,0 +1,52 @@
## Input
```javascript
// @script
const React = require('react');
function Component(props) {
return <div>{props.name}</div>;
}
// To work with snap evaluator
exports = {
FIXTURE_ENTRYPOINT: {
fn: Component,
params: [{name: 'React Compiler'}],
},
};
```
## Code
```javascript
const { c: _c } = require("react/compiler-runtime"); // @script
const React = require("react");
function Component(props) {
const $ = _c(2);
let t0;
if ($[0] !== props.name) {
t0 = <div>{props.name}</div>;
$[0] = props.name;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
// To work with snap evaluator
exports = {
FIXTURE_ENTRYPOINT: {
fn: Component,
params: [{ name: "React Compiler" }],
},
};
```
### Eval output
(kind: ok) <div>React Compiler</div>

View File

@@ -0,0 +1,14 @@
// @script
const React = require('react');
function Component(props) {
return <div>{props.name}</div>;
}
// To work with snap evaluator
exports = {
FIXTURE_ENTRYPOINT: {
fn: Component,
params: [{name: 'React Compiler'}],
},
};

View File

@@ -31,10 +31,15 @@ import prettier from 'prettier';
import SproutTodoFilter from './SproutTodoFilter';
import {isExpectError} from './fixture-utils';
import {makeSharedRuntimeTypeProvider} from './sprout/shared-runtime-type-provider';
export function parseLanguage(source: string): 'flow' | 'typescript' {
return source.indexOf('@flow') !== -1 ? 'flow' : 'typescript';
}
export function parseSourceType(source: string): 'script' | 'module' {
return source.indexOf('@script') !== -1 ? 'script' : 'module';
}
/**
* Parse react compiler plugin + environment options from test fixture. Note
* that although this primarily uses `Environment:parseConfigPragma`, it also
@@ -98,6 +103,7 @@ export function parseInput(
input: string,
filename: string,
language: 'flow' | 'typescript',
sourceType: 'module' | 'script',
): BabelCore.types.File {
// Extract the first line to quickly check for custom test directives
if (language === 'flow') {
@@ -105,14 +111,14 @@ export function parseInput(
babel: true,
flow: 'all',
sourceFilename: filename,
sourceType: 'module',
sourceType,
enableExperimentalComponentSyntax: true,
});
} else {
return BabelParser.parse(input, {
sourceFilename: filename,
plugins: ['typescript', 'jsx'],
sourceType: 'module',
sourceType,
});
}
}
@@ -221,11 +227,12 @@ export async function transformFixtureInput(
const firstLine = input.substring(0, input.indexOf('\n'));
const language = parseLanguage(firstLine);
const sourceType = parseSourceType(firstLine);
// Preserve file extension as it determines typescript's babel transform
// mode (e.g. stripping types, parsing rules for brackets)
const filename =
path.basename(fixturePath) + (language === 'typescript' ? '.ts' : '');
const inputAst = parseInput(input, filename, language);
const inputAst = parseInput(input, filename, language, sourceType);
// Give babel transforms an absolute path as relative paths get prefixed
// with `cwd`, which is different across machines
const virtualFilepath = '/' + filename;

View File

@@ -298,7 +298,10 @@ export function doEval(source: string): EvaluatorResult {
return {
kind: 'UnexpectedError',
value:
'Unexpected error during eval, possible syntax error?\n' + e.message,
'Unexpected error during eval, possible syntax error?\n' +
e.message +
'\n\nsource:\n' +
source,
logs,
};
} finally {

View File

@@ -133,7 +133,7 @@ async function renderApp(res, returnValue, formState, noCache, debugChannel) {
}
async function prerenderApp(res, returnValue, formState, noCache) {
const {unstable_prerenderToNodeStream: prerenderToNodeStream} = await import(
const {prerenderToNodeStream} = await import(
'react-server-dom-webpack/static'
);
// const m = require('../src/App.js');

View File

@@ -238,8 +238,8 @@ export default function Page({url, navigate}) {
<Suspend />
</div>
</ViewTransition>
{show ? <Component /> : null}
</Suspense>
{show ? <Component /> : null}
</div>
</ViewTransition>
</SwipeRecognizer>

View File

@@ -71,6 +71,7 @@
"eslint-plugin-no-for-of-loops": "^1.0.0",
"eslint-plugin-no-function-declare-after-return": "^1.0.0",
"eslint-plugin-react": "^6.7.1",
"eslint-plugin-react-hooks-published": "npm:eslint-plugin-react-hooks@^5.2.0",
"eslint-plugin-react-internal": "link:./scripts/eslint-rules",
"fbjs-scripts": "^3.0.1",
"filesize": "^6.0.1",

View File

@@ -1,3 +1,18 @@
## 6.1.0
**Note:** Version 6.0.0 was mistakenly released and immediately deprecated and untagged on npm. This is the first official 6.x major release and includes breaking changes.
- **Breaking:** Require Node.js 18 or newer. ([@michaelfaith](https://github.com/michaelfaith) in [#32458](https://github.com/facebook/react/pull/32458))
- **Breaking:** Flat config is now the default `recommended` preset. Legacy config moved to `recommended-legacy`. ([@michaelfaith](https://github.com/michaelfaith) in [#32457](https://github.com/facebook/react/pull/32457))
- **New Violations:** Disallow calling `use` within try/catch blocks. ([@poteto](https://github.com/poteto) in [#34040](https://github.com/facebook/react/pull/34040))
- **New Violations:** Disallow calling `useEffectEvent` functions in arbitrary closures. ([@jbrown215](https://github.com/jbrown215) in [#33544](https://github.com/facebook/react/pull/33544))
- Handle `React.useEffect` in addition to `useEffect` in rules-of-hooks. ([@Ayc0](https://github.com/Ayc0) in [#34076](https://github.com/facebook/react/pull/34076))
- Added `react-hooks` settings config option that to accept `additionalEffectHooks` that are used across exhaustive-deps and rules-of-hooks rules. ([@jbrown215](https://github.com/jbrown215)) in [#34497](https://github.com/facebook/react/pull/34497)
## 6.0.0
Accidentally released. See 6.1.0 for the actual changes.
## 5.2.0
- Support flat config ([@michaelfaith](https://github.com/michaelfaith) in [#30774](https://github.com/facebook/react/pull/30774))

View File

@@ -1485,6 +1485,85 @@ const tests = {
}
`,
},
{
// Test settings-based additionalHooks - should work with settings
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
});
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: 'useCustomEffect',
},
},
},
{
// Test settings-based additionalHooks - should work with dependencies
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, [props.foo]);
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: 'useCustomEffect',
},
},
},
{
// Test that rule-level additionalHooks takes precedence over settings
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, []);
}
`,
options: [{additionalHooks: 'useAnotherEffect'}],
settings: {
'react-hooks': {
additionalEffectHooks: 'useCustomEffect',
},
},
},
{
// Test settings with multiple hooks pattern
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, [props.foo]);
useAnotherEffect(() => {
console.log(props.bar);
}, [props.bar]);
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: '(useCustomEffect|useAnotherEffect)',
},
},
},
{
code: normalizeIndent`
function MyComponent({ theme }) {
const onStuff = useEffectEvent(() => {
showNotification(theme);
});
useEffect(() => {
onStuff();
}, []);
React.useEffect(() => {
onStuff();
}, []);
}
`,
},
],
invalid: [
{
@@ -3714,6 +3793,40 @@ const tests = {
},
],
},
{
// Test settings-based additionalHooks - should detect missing dependency
code: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, []);
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: 'useCustomEffect',
},
},
errors: [
{
message:
"React Hook useCustomEffect has a missing dependency: 'props.foo'. " +
'Either include it or remove the dependency array.',
suggestions: [
{
desc: 'Update the dependencies array to be: [props.foo]',
output: normalizeIndent`
function MyComponent(props) {
useCustomEffect(() => {
console.log(props.foo);
}, [props.foo]);
}
`,
},
],
},
],
},
{
code: normalizeIndent`
function MyComponent() {
@@ -7721,31 +7834,6 @@ const tests = {
},
],
},
],
};
if (__EXPERIMENTAL__) {
tests.valid = [
...tests.valid,
{
code: normalizeIndent`
function MyComponent({ theme }) {
const onStuff = useEffectEvent(() => {
showNotification(theme);
});
useEffect(() => {
onStuff();
}, []);
React.useEffect(() => {
onStuff();
}, []);
}
`,
},
];
tests.invalid = [
...tests.invalid,
{
code: normalizeIndent`
function MyComponent({ theme }) {
@@ -7809,8 +7897,8 @@ if (__EXPERIMENTAL__) {
},
],
},
];
}
],
};
// Tests that are only valid/invalid across parsers supporting Flow
const testsFlow = {

View File

@@ -581,6 +581,164 @@ 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)',
},
},
},
{
code: normalizeIndent`
// Valid because functions created with useEffectEvent can be called in a useEffect.
function MyComponent({ theme }) {
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
// and useEffectEvent.
function MyComponent({ theme }) {
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 }) {
useEffect(() => {
onClick();
});
const onClick = useEffectEvent(() => {
showNotification(theme);
});
}
`,
},
{
code: normalizeIndent`
function MyComponent({ theme }) {
// 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.
function MyComponent({ theme }) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
useLayoutEffect(() => {
onClick();
});
React.useLayoutEffect(() => {
onClick();
});
}
`,
},
{
code: normalizeIndent`
// Valid because functions created with useEffectEvent can be called in useInsertionEffect.
function 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
// and useInsertionEffect.
function MyComponent({ theme }) {
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: [
{
@@ -1353,86 +1511,39 @@ const allTests = {
`,
errors: [tryCatchUseError('use')],
},
],
};
if (__EXPERIMENTAL__) {
allTests.valid = [
...allTests.valid,
{
code: normalizeIndent`
// Valid because functions created with useEffectEvent can be called in a useEffect.
// Invalid: useEffectEvent should not be callable in regular custom hooks without additional configuration
function MyComponent({ theme }) {
const onClick = useEffectEvent(() => {
showNotification(theme);
});
useEffect(() => {
onClick();
});
React.useEffect(() => {
useCustomHook(() => {
onClick();
});
}
`,
errors: [useEffectEventError('onClick', true)],
},
{
code: normalizeIndent`
// Valid because functions created with useEffectEvent can be passed by reference in useEffect
// and useEffectEvent.
// Invalid: useEffectEvent should not be callable in hooks not matching the settings regex
function MyComponent({ theme }) {
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 }) {
useEffect(() => {
useWrongHook(() => {
onClick();
});
const onClick = useEffectEvent(() => {
showNotification(theme);
});
}
`,
settings: {
'react-hooks': {
additionalEffectHooks: 'useMyEffect',
},
},
errors: [useEffectEventError('onClick', true)],
},
{
code: normalizeIndent`
function MyComponent({ theme }) {
// Can receive arguments
const onEvent = useEffectEvent((text) => {
console.log(text);
});
useEffect(() => {
onEvent('Hello world');
});
React.useEffect(() => {
onEvent('Hello world');
});
}
`,
},
];
allTests.invalid = [
...allTests.invalid,
{
code: normalizeIndent`
function MyComponent({ theme }) {
@@ -1526,21 +1637,36 @@ if (__EXPERIMENTAL__) {
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: [
useEffectEventError('onClick', true),
useEffectEventError('onClick', true),
`\`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.`,
],
},
];
}
],
};
function conditionalError(hook, hasPreviousFinalizer = false) {
return {
@@ -1603,7 +1729,8 @@ function useEffectEventError(fn, called) {
return {
message:
`\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
`the same component.${called ? '' : ' They cannot be assigned to variables or passed down.'}`,
'Effects and Effect Events in the same component.' +
(called ? '' : ' It cannot be assigned to a variable or passed down.'),
};
}

View File

@@ -7,11 +7,7 @@
import type {Linter, Rule} from 'eslint';
import ExhaustiveDeps from './rules/ExhaustiveDeps';
import {
allRules,
mapErrorSeverityToESlint,
recommendedRules,
} from './shared/ReactCompiler';
import {allRules} from './shared/ReactCompiler';
import RulesOfHooks from './rules/RulesOfHooks';
// All rules
@@ -19,7 +15,7 @@ const rules = {
'exhaustive-deps': ExhaustiveDeps,
'rules-of-hooks': RulesOfHooks,
...Object.fromEntries(
Object.entries(allRules).map(([name, config]) => [name, config.rule])
Object.entries(allRules).map(([name, config]) => [name, config.rule]),
),
} satisfies Record<string, Rule.RuleModule>;
@@ -27,15 +23,6 @@ const rules = {
const ruleConfigs = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// Compiler rules
...Object.fromEntries(
Object.entries(recommendedRules).map(([name, ruleConfig]) => {
return [
'react-hooks/' + name,
mapErrorSeverityToESlint(ruleConfig.severity),
];
}),
),
} satisfies Linter.RulesRecord;
const plugin = {

View File

@@ -21,6 +21,8 @@ import type {
VariableDeclarator,
} from 'estree';
import { getAdditionalEffectHooksFromSettings } from '../shared/Utils';
type DeclaredDependency = {
key: string;
node: Node;
@@ -69,19 +71,22 @@ const rule = {
},
requireExplicitEffectDeps: {
type: 'boolean',
}
},
},
},
],
},
create(context: Rule.RuleContext) {
const rawOptions = context.options && context.options[0];
const settings = context.settings || {};
// Parse the `additionalHooks` regex.
// Use rule-level additionalHooks if provided, otherwise fall back to settings
const additionalHooks =
rawOptions && rawOptions.additionalHooks
? new RegExp(rawOptions.additionalHooks)
: undefined;
: getAdditionalEffectHooksFromSettings(settings);
const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean =
(rawOptions &&
@@ -93,7 +98,8 @@ const rule = {
? rawOptions.experimental_autoDependenciesHooks
: [];
const requireExplicitEffectDeps: boolean = rawOptions && rawOptions.requireExplicitEffectDeps || false;
const requireExplicitEffectDeps: boolean =
(rawOptions && rawOptions.requireExplicitEffectDeps) || false;
const options = {
additionalHooks,
@@ -1351,7 +1357,7 @@ const rule = {
node: reactiveHook,
message:
`React Hook ${reactiveHookName} always requires dependencies. ` +
`Please add a dependency array or an explicit \`undefined\``
`Please add a dependency array or an explicit \`undefined\``,
});
}
@@ -2116,10 +2122,7 @@ function isAncestorNodeOf(a: Node, b: Node): boolean {
}
function isUseEffectEventIdentifier(node: Node): boolean {
if (__EXPERIMENTAL__) {
return node.type === 'Identifier' && node.name === 'useEffectEvent';
}
return false;
return node.type === 'Identifier' && node.name === 'useEffectEvent';
}
function getUnknownDependenciesMessage(reactiveHookName: string): string {

View File

@@ -20,6 +20,7 @@ import type {
// @ts-expect-error untyped module
import CodePathAnalyzer from '../code-path-analysis/code-path-analyzer';
import {getAdditionalEffectHooksFromSettings} from '../shared/Utils';
/**
* Catch all identifiers that begin with "use" followed by an uppercase Latin
@@ -147,16 +148,37 @@ function getNodeWithoutReactNamespace(
return node;
}
function isUseEffectIdentifier(node: Node): boolean {
return node.type === 'Identifier' && node.name === 'useEffect';
}
function isUseEffectEventIdentifier(node: Node): boolean {
if (__EXPERIMENTAL__) {
return node.type === 'Identifier' && node.name === 'useEffectEvent';
function isEffectIdentifier(node: Node, additionalHooks?: RegExp): boolean {
const isBuiltInEffect =
node.type === 'Identifier' &&
(node.name === 'useEffect' ||
node.name === 'useLayoutEffect' ||
node.name === 'useInsertionEffect');
if (isBuiltInEffect) {
return true;
}
// Check if this matches additional hooks configured by the user
if (additionalHooks && node.type === 'Identifier') {
return additionalHooks.test(node.name);
}
return false;
}
function isUseEffectEventIdentifier(node: Node): boolean {
return node.type === 'Identifier' && node.name === 'useEffectEvent';
}
function useEffectEventError(fn: string, called: boolean): string {
return (
`\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
'Effects and Effect Events in the same component.' +
(called ? '' : ' It cannot be assigned to a variable or passed down.')
);
}
function isUseIdentifier(node: Node): boolean {
return isReactFunction(node, 'use');
}
@@ -169,8 +191,24 @@ const rule = {
recommended: true,
url: 'https://react.dev/reference/rules/rules-of-hooks',
},
schema: [
{
type: 'object',
additionalProperties: false,
properties: {
additionalHooks: {
type: 'string',
},
},
},
],
},
create(context: Rule.RuleContext) {
const settings = context.settings || {};
const additionalEffectHooks =
getAdditionalEffectHooksFromSettings(settings);
let lastEffect: CallExpression | null = null;
const codePathReactHooksMapStack: Array<
Map<Rule.CodePathSegment, Array<Node>>
@@ -726,7 +764,7 @@ const rule = {
// Check all `useEffect` and `React.useEffect`, `useEffectEvent`, and `React.useEffectEvent`
const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee);
if (
(isUseEffectIdentifier(nodeWithoutNamespace) ||
(isEffectIdentifier(nodeWithoutNamespace, additionalEffectHooks) ||
isUseEffectEventIdentifier(nodeWithoutNamespace)) &&
node.arguments.length > 0
) {
@@ -740,14 +778,11 @@ const rule = {
// This identifier resolves to a useEffectEvent function, but isn't being referenced in an
// effect or another event function. It isn't being called either.
if (lastEffect == null && useEffectEventFunctions.has(node)) {
const message =
`\`${getSourceCode().getText(
node,
)}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
'the same component.' +
(node.parent.type === 'CallExpression'
? ''
: ' They cannot be assigned to variables or passed down.');
const message = useEffectEventError(
getSourceCode().getText(node),
node.parent.type === 'CallExpression',
);
context.report({
node,
message,

View File

@@ -0,0 +1,22 @@
/**
* 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 { Rule } from 'eslint';
const SETTINGS_KEY = 'react-hooks';
const SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY = 'additionalEffectHooks';
export function getAdditionalEffectHooksFromSettings(
settings: Rule.RuleContext['settings'],
): RegExp | undefined {
const additionalHooks = settings[SETTINGS_KEY]?.[SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY];
if (additionalHooks != null && typeof additionalHooks === 'string') {
return new RegExp(additionalHooks);
}
return undefined;
}

View File

@@ -1,7 +1,7 @@
{
"name": "react-art",
"description": "React ART is a JavaScript library for drawing vector graphics using React. It provides declarative and reactive bindings to the ART library. Using the same declarative API you can render the output to either Canvas, SVG or VML (IE8).",
"version": "19.1.0",
"version": "19.3.0",
"main": "index.js",
"repository": {
"type": "git",
@@ -24,10 +24,10 @@
"dependencies": {
"art": "^0.10.1",
"create-react-class": "^15.6.2",
"scheduler": "^0.26.0"
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^19.1.0"
"react": "^19.3.0"
},
"files": [
"LICENSE",

View File

@@ -499,10 +499,44 @@ function createErrorChunk<T>(
return new ReactPromise(ERRORED, null, error);
}
function moveDebugInfoFromChunkToInnerValue<T>(
chunk: InitializedChunk<T>,
value: T,
): void {
// Remove the debug info from the initialized chunk, and add it to the inner
// value instead. This can be a React element, an array, or an uninitialized
// Lazy.
const resolvedValue = resolveLazy(value);
if (
typeof resolvedValue === 'object' &&
resolvedValue !== null &&
(isArray(resolvedValue) ||
typeof resolvedValue[ASYNC_ITERATOR] === 'function' ||
resolvedValue.$$typeof === REACT_ELEMENT_TYPE ||
resolvedValue.$$typeof === REACT_LAZY_TYPE)
) {
const debugInfo = chunk._debugInfo.splice(0);
if (isArray(resolvedValue._debugInfo)) {
// $FlowFixMe[method-unbinding]
resolvedValue._debugInfo.unshift.apply(
resolvedValue._debugInfo,
debugInfo,
);
} else {
Object.defineProperty((resolvedValue: any), '_debugInfo', {
configurable: false,
enumerable: false,
writable: true,
value: debugInfo,
});
}
}
}
function wakeChunk<T>(
listeners: Array<InitializationReference | (T => mixed)>,
value: T,
chunk: SomeChunk<T>,
chunk: InitializedChunk<T>,
): void {
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
@@ -512,6 +546,10 @@ function wakeChunk<T>(
fulfillReference(listener, value, chunk);
}
}
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(chunk, value);
}
}
function rejectChunk(
@@ -649,7 +687,6 @@ function triggerErrorOnChunk<T>(
}
try {
initializeDebugChunk(response, chunk);
chunk._debugChunk = null;
if (initializingHandler !== null) {
if (initializingHandler.errored) {
// Ignore error parsing debug info, we'll report the original error instead.
@@ -932,9 +969,9 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
}
if (__DEV__) {
// Lazily initialize any debug info and block the initializing chunk on any unresolved entries.
// Initialize any debug info and block the initializing chunk on any
// unresolved entries.
initializeDebugChunk(response, chunk);
chunk._debugChunk = null;
}
try {
@@ -946,7 +983,14 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
if (resolveListeners !== null) {
cyclicChunk.value = null;
cyclicChunk.reason = null;
wakeChunk(resolveListeners, value, cyclicChunk);
for (let i = 0; i < resolveListeners.length; i++) {
const listener = resolveListeners[i];
if (typeof listener === 'function') {
listener(value);
} else {
fulfillReference(listener, value, cyclicChunk);
}
}
}
if (initializingHandler !== null) {
if (initializingHandler.errored) {
@@ -963,6 +1007,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
const initializedChunk: InitializedChunk<T> = (chunk: any);
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
if (__DEV__) {
moveDebugInfoFromChunkToInnerValue(initializedChunk, value);
}
} catch (error) {
const erroredChunk: ErroredChunk<T> = (chunk: any);
erroredChunk.status = ERRORED;
@@ -1079,7 +1127,7 @@ function getTaskName(type: mixed): string {
function initializeElement(
response: Response,
element: any,
lazyType: null | LazyComponent<
lazyNode: null | LazyComponent<
React$Element<any>,
SomeChunk<React$Element<any>>,
>,
@@ -1151,15 +1199,33 @@ function initializeElement(
initializeFakeStack(response, owner);
}
// In case the JSX runtime has validated the lazy type as a static child, we
// need to transfer this information to the element.
if (
lazyType &&
lazyType._store &&
lazyType._store.validated &&
!element._store.validated
) {
element._store.validated = lazyType._store.validated;
if (lazyNode !== null) {
// In case the JSX runtime has validated the lazy type as a static child, we
// need to transfer this information to the element.
if (
lazyNode._store &&
lazyNode._store.validated &&
!element._store.validated
) {
element._store.validated = lazyNode._store.validated;
}
// If the lazy node is initialized, we move its debug info to the inner
// value.
if (lazyNode._payload.status === INITIALIZED && lazyNode._debugInfo) {
const debugInfo = lazyNode._debugInfo.splice(0);
if (element._debugInfo) {
// $FlowFixMe[method-unbinding]
element._debugInfo.unshift.apply(element._debugInfo, debugInfo);
} else {
Object.defineProperty(element, '_debugInfo', {
configurable: false,
enumerable: false,
writable: true,
value: debugInfo,
});
}
}
}
// TODO: We should be freezing the element but currently, we might write into
@@ -1279,13 +1345,13 @@ function createElement(
createBlockedChunk(response);
handler.value = element;
handler.chunk = blockedChunk;
const lazyType = createLazyChunkWrapper(blockedChunk, validated);
const lazyNode = createLazyChunkWrapper(blockedChunk, validated);
if (__DEV__) {
// After we have initialized any blocked references, initialize stack etc.
const init = initializeElement.bind(null, response, element, lazyType);
const init = initializeElement.bind(null, response, element, lazyNode);
blockedChunk.then(init, init);
}
return lazyType;
return lazyNode;
}
}
if (__DEV__) {
@@ -1466,7 +1532,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue);
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
element.props = mappedValue;
break;
case '4':
@@ -1482,11 +1548,11 @@ function fulfillReference(
}
break;
default:
transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue);
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
break;
}
} else if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk, mappedValue);
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
handler.deps--;
@@ -1808,47 +1874,34 @@ function loadServerReference<A: Iterable<any>, T>(
return (null: any);
}
function resolveLazy(value: any): mixed {
while (
typeof value === 'object' &&
value !== null &&
value.$$typeof === REACT_LAZY_TYPE
) {
const payload: SomeChunk<any> = value._payload;
if (payload.status === INITIALIZED) {
value = payload.value;
continue;
}
break;
}
return value;
}
function transferReferencedDebugInfo(
parentChunk: null | SomeChunk<any>,
referencedChunk: SomeChunk<any>,
referencedValue: mixed,
): void {
if (__DEV__) {
const referencedDebugInfo = referencedChunk._debugInfo;
// If we have a direct reference to an object that was rendered by a synchronous
// server component, it might have some debug info about how it was rendered.
// We forward this to the underlying object. This might be a React Element or
// an Array fragment.
// If this was a string / number return value we lose the debug info. We choose
// that tradeoff to allow sync server components to return plain values and not
// use them as React Nodes necessarily. We could otherwise wrap them in a Lazy.
if (
typeof referencedValue === 'object' &&
referencedValue !== null &&
(isArray(referencedValue) ||
typeof referencedValue[ASYNC_ITERATOR] === 'function' ||
referencedValue.$$typeof === REACT_ELEMENT_TYPE)
) {
// We should maybe use a unique symbol for arrays but this is a React owned array.
// $FlowFixMe[prop-missing]: This should be added to elements.
const existingDebugInfo: ?ReactDebugInfo =
(referencedValue._debugInfo: any);
if (existingDebugInfo == null) {
Object.defineProperty((referencedValue: any), '_debugInfo', {
configurable: false,
enumerable: false,
writable: true,
value: referencedDebugInfo.slice(0), // Clone so that pushing later isn't going into the original
});
} else {
// $FlowFixMe[method-unbinding]
existingDebugInfo.push.apply(existingDebugInfo, referencedDebugInfo);
}
}
// We also add the debug info to the initializing chunk since the resolution of that promise is
// also blocked by the referenced debug info. By adding it to both we can track it even if the array/element
// is extracted, or if the root is rendered as is.
// We add the debug info to the initializing chunk since the resolution of
// that promise is also blocked by the referenced debug info. By adding it
// to both we can track it even if the array/element/lazy is extracted, or
// if the root is rendered as is.
if (parentChunk !== null) {
const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
@@ -1999,7 +2052,7 @@ function getOutlinedModel<T>(
// If we're resolving the "owner" or "stack" slot of an Element array, we don't call
// transferReferencedDebugInfo because this reference is to a debug chunk.
} else {
transferReferencedDebugInfo(initializingChunk, chunk, chunkValue);
transferReferencedDebugInfo(initializingChunk, chunk);
}
return chunkValue;
case PENDING:
@@ -2709,14 +2762,47 @@ function incrementChunkDebugInfo(
}
}
function addDebugInfo(chunk: SomeChunk<any>, debugInfo: ReactDebugInfo): void {
const value = resolveLazy(chunk.value);
if (
typeof value === 'object' &&
value !== null &&
(isArray(value) ||
typeof value[ASYNC_ITERATOR] === 'function' ||
value.$$typeof === REACT_ELEMENT_TYPE ||
value.$$typeof === REACT_LAZY_TYPE)
) {
if (isArray(value._debugInfo)) {
// $FlowFixMe[method-unbinding]
value._debugInfo.push.apply(value._debugInfo, debugInfo);
} else {
Object.defineProperty((value: any), '_debugInfo', {
configurable: false,
enumerable: false,
writable: true,
value: debugInfo,
});
}
} else {
// $FlowFixMe[method-unbinding]
chunk._debugInfo.push.apply(chunk._debugInfo, debugInfo);
}
}
function resolveChunkDebugInfo(
streamState: StreamState,
chunk: SomeChunk<any>,
): void {
if (__DEV__ && enableAsyncDebugInfo) {
// Push the currently resolving chunk's debug info representing the stream on the Promise
// that was waiting on the stream.
chunk._debugInfo.push({awaited: streamState._debugInfo});
// Add the currently resolving chunk's debug info representing the stream
// to the Promise that was waiting on the stream, or its underlying value.
const debugInfo: ReactDebugInfo = [{awaited: streamState._debugInfo}];
if (chunk.status === PENDING || chunk.status === BLOCKED) {
const boundAddDebugInfo = addDebugInfo.bind(null, chunk, debugInfo);
chunk.then(boundAddDebugInfo, boundAddDebugInfo);
} else {
addDebugInfo(chunk, debugInfo);
}
}
}
@@ -2909,7 +2995,8 @@ function resolveStream<T: ReadableStream | $AsyncIterable<any, any, void>>(
const resolveListeners = chunk.value;
if (__DEV__) {
// Lazily initialize any debug info and block the initializing chunk on any unresolved entries.
// Initialize any debug info and block the initializing chunk on any
// unresolved entries.
if (chunk._debugChunk != null) {
const prevHandler = initializingHandler;
const prevChunk = initializingChunk;
@@ -2923,7 +3010,6 @@ function resolveStream<T: ReadableStream | $AsyncIterable<any, any, void>>(
}
try {
initializeDebugChunk(response, chunk);
chunk._debugChunk = null;
if (initializingHandler !== null) {
if (initializingHandler.errored) {
// Ignore error parsing debug info, we'll report the original error instead.
@@ -2947,7 +3033,7 @@ function resolveStream<T: ReadableStream | $AsyncIterable<any, any, void>>(
resolvedChunk.value = stream;
resolvedChunk.reason = controller;
if (resolveListeners !== null) {
wakeChunk(resolveListeners, chunk.value, chunk);
wakeChunk(resolveListeners, chunk.value, (chunk: any));
}
}

View File

@@ -327,8 +327,8 @@ describe('ReactFlight', () => {
const transport = ReactNoopFlightServer.render(root);
await act(async () => {
const promise = ReactNoopFlightClient.read(transport);
expect(getDebugInfo(promise)).toEqual(
const result = await ReactNoopFlightClient.read(transport);
expect(getDebugInfo(result)).toEqual(
__DEV__
? [
{time: 12},
@@ -346,7 +346,7 @@ describe('ReactFlight', () => {
]
: undefined,
);
ReactNoop.render(await promise);
ReactNoop.render(result);
});
expect(ReactNoop).toMatchRenderedOutput(<span>Hello, Seb Smith</span>);
@@ -1378,9 +1378,7 @@ describe('ReactFlight', () => {
environmentName: 'Server',
},
],
findSourceMapURLCalls: [
[__filename, 'Server'],
[__filename, 'Server'],
findSourceMapURLCalls: expect.arrayContaining([
// TODO: What should we request here? The outer (<anonymous>) or the inner (inspected-page.html)?
['inspected-page.html:29:11), <anonymous>', 'Server'],
[
@@ -1389,8 +1387,7 @@ describe('ReactFlight', () => {
],
['file:///testing.js', 'Server'],
['', 'Server'],
[__filename, 'Server'],
],
]),
});
} else {
expect(errors.map(getErrorForJestMatcher)).toEqual([
@@ -2785,8 +2782,8 @@ describe('ReactFlight', () => {
);
await act(async () => {
const promise = ReactNoopFlightClient.read(transport);
expect(getDebugInfo(promise)).toEqual(
const result = await ReactNoopFlightClient.read(transport);
expect(getDebugInfo(result)).toEqual(
__DEV__
? [
{time: gate(flags => flags.enableAsyncDebugInfo) ? 22 : 20},
@@ -2803,11 +2800,10 @@ describe('ReactFlight', () => {
]
: undefined,
);
const result = await promise;
const thirdPartyChildren = await result.props.children[1];
// We expect the debug info to be transferred from the inner stream to the outer.
expect(getDebugInfo(thirdPartyChildren[0])).toEqual(
expect(getDebugInfo(await thirdPartyChildren[0])).toEqual(
__DEV__
? [
{time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, // Clamped to the start
@@ -2910,8 +2906,8 @@ describe('ReactFlight', () => {
);
await act(async () => {
const promise = ReactNoopFlightClient.read(transport);
expect(getDebugInfo(promise)).toEqual(
const result = await ReactNoopFlightClient.read(transport);
expect(getDebugInfo(result)).toEqual(
__DEV__
? [
{time: 16},
@@ -2924,17 +2920,10 @@ describe('ReactFlight', () => {
transport: expect.arrayContaining([]),
},
},
{
time: 16,
},
{
time: 16,
},
{time: 31},
]
: undefined,
);
const result = await promise;
const thirdPartyFragment = await result.props.children;
expect(getDebugInfo(thirdPartyFragment)).toEqual(
__DEV__
@@ -2949,15 +2938,7 @@ describe('ReactFlight', () => {
children: {},
},
},
{
time: 33,
},
{
time: 33,
},
{
time: 33,
},
{time: 33},
]
: undefined,
);
@@ -3013,8 +2994,8 @@ describe('ReactFlight', () => {
);
await act(async () => {
const promise = ReactNoopFlightClient.read(transport);
expect(getDebugInfo(promise)).toEqual(
const result = await ReactNoopFlightClient.read(transport);
expect(getDebugInfo(result)).toEqual(
__DEV__
? [
{time: 16},
@@ -3040,7 +3021,6 @@ describe('ReactFlight', () => {
]
: undefined,
);
const result = await promise;
ReactNoop.render(result);
});
@@ -3891,15 +3871,6 @@ describe('ReactFlight', () => {
{
time: 13,
},
{
time: 14,
},
{
time: 15,
},
{
time: 16,
},
]);
} else {
expect(root._debugInfo).toBe(undefined);

View File

@@ -17,6 +17,7 @@ describe('React hooks DevTools integration', () => {
let act;
let overrideHookState;
let scheduleUpdate;
let scheduleRetry;
let setSuspenseHandler;
let waitForAll;
@@ -27,6 +28,7 @@ describe('React hooks DevTools integration', () => {
inject: injected => {
overrideHookState = injected.overrideHookState;
scheduleUpdate = injected.scheduleUpdate;
scheduleRetry = injected.scheduleRetry;
setSuspenseHandler = injected.setSuspenseHandler;
},
supportsFiber: true,
@@ -312,5 +314,17 @@ describe('React hooks DevTools integration', () => {
} else {
expect(renderer.toJSON().children).toEqual(['Done']);
}
if (scheduleRetry) {
// Lock again, synchronously
setSuspenseHandler(() => true);
await act(() => scheduleUpdate(fiber)); // Re-render
expect(renderer.toJSON().children).toEqual(['Loading']);
// Release the lock again but this time using retry lane
setSuspenseHandler(() => false);
await act(() => scheduleRetry(fiber)); // Re-render
expect(renderer.toJSON().children).toEqual(['Done']);
}
});
});

View File

@@ -28,6 +28,7 @@
"@babel/plugin-transform-modules-commonjs": "^7.10.4",
"@babel/plugin-transform-react-jsx-source": "^7.10.5",
"@babel/preset-react": "^7.10.4",
"@jridgewell/sourcemap-codec": "1.5.5",
"acorn-jsx": "^5.2.0",
"archiver": "^3.0.0",
"babel-core": "^7.0.0-bridge",
@@ -60,7 +61,6 @@
"raw-loader": "^3.1.0",
"rimraf": "^5.0.1",
"source-map-js": "^0.6.2",
"sourcemap-codec": "^1.4.8",
"style-loader": "^0.23.1",
"webpack": "^5.82.1",
"webpack-cli": "^5.1.1",

View File

@@ -23,7 +23,7 @@ function fetchResource(url) {
});
};
fetch(url, {cache: 'force-cache'}).then(
fetch(url, {cache: 'force-cache', signal: AbortSignal.timeout(60000)}).then(
response => {
if (response.ok) {
response

View File

@@ -78,6 +78,18 @@ const fetchFromNetworkCache = (url, resolve, reject) => {
});
};
const pendingFetchRequests = new Set();
function pendingFetchRequestsCleanup({payload, source}) {
if (source === 'react-devtools-background') {
switch (payload?.type) {
case 'fetch-file-with-cache-complete':
case 'fetch-file-with-cache-error':
pendingFetchRequests.delete(payload.url);
}
}
}
chrome.runtime.onMessage.addListener(pendingFetchRequestsCleanup);
const fetchFromPage = async (url, resolve, reject) => {
debugLog('[main] fetchFromPage()', url);
@@ -97,7 +109,11 @@ const fetchFromPage = async (url, resolve, reject) => {
}
chrome.runtime.onMessage.addListener(onPortMessage);
if (pendingFetchRequests.has(url)) {
return;
}
pendingFetchRequests.add(url);
chrome.runtime.sendMessage({
source: 'devtools-page',
payload: {

View File

@@ -22,8 +22,8 @@
"test:e2e": "playwright test --config=playwright.config.js"
},
"dependencies": {
"source-map-js": "^0.6.2",
"sourcemap-codec": "^1.4.8"
"@jridgewell/sourcemap-codec": "1.5.5",
"source-map-js": "^0.6.2"
},
"devDependencies": {
"@babel/core": "^7.11.1",

View File

@@ -130,24 +130,28 @@ describe('Timeline profiler', () => {
// @reactVersion <= 18.2
// @reactVersion >= 18.0
it('should mark sync render without suspends or state updates', () => {
utils.act(() => store.profilerStore.startProfiling());
legacyRender(<div />);
utils.act(() => store.profilerStore.stopProfiling());
expect(registeredMarks).toMatchInlineSnapshot(`
[
"--schedule-render-1",
"--render-start-1",
"--render-stop",
"--commit-start-1",
"--react-version-<filtered-version>",
"--profiler-version-1",
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--react-lane-labels-Sync,InputContinuousHydration,InputContinuous,DefaultHydration,Default,TransitionHydration,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Retry,Retry,Retry,Retry,Retry,SelectiveHydration,IdleHydration,Idle,Offscreen",
"--layout-effects-start-1",
"--layout-effects-stop",
"--commit-stop",
]
`);
[
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--schedule-render-1",
"--render-start-1",
"--render-stop",
"--commit-start-1",
"--react-version-<filtered-version>",
"--profiler-version-1",
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--react-lane-labels-Sync,InputContinuousHydration,InputContinuous,DefaultHydration,Default,TransitionHydration,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Retry,Retry,Retry,Retry,Retry,SelectiveHydration,IdleHydration,Idle,Offscreen",
"--layout-effects-start-1",
"--layout-effects-stop",
"--commit-stop",
]
`);
});
// TODO(hoxyq): investigate why running this test with React 18 fails
@@ -260,46 +264,50 @@ describe('Timeline profiler', () => {
throw Error('Expected error');
}
utils.act(() => store.profilerStore.startProfiling());
legacyRender(
<ErrorBoundary>
<ExampleThatThrows />
</ErrorBoundary>,
);
utils.act(() => store.profilerStore.stopProfiling());
expect(registeredMarks).toMatchInlineSnapshot(`
[
"--schedule-render-1",
"--render-start-1",
"--component-render-start-ErrorBoundary",
"--component-render-stop",
"--component-render-start-ExampleThatThrows",
"--component-render-start-ExampleThatThrows",
"--component-render-stop",
"--error-ExampleThatThrows-mount-Expected error",
"--render-stop",
"--commit-start-1",
"--react-version-<filtered-version>",
"--profiler-version-1",
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--react-lane-labels-Sync,InputContinuousHydration,InputContinuous,DefaultHydration,Default,TransitionHydration,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Retry,Retry,Retry,Retry,Retry,SelectiveHydration,IdleHydration,Idle,Offscreen",
"--layout-effects-start-1",
"--schedule-state-update-1-ErrorBoundary",
"--layout-effects-stop",
"--commit-stop",
"--render-start-1",
"--component-render-start-ErrorBoundary",
"--component-render-stop",
"--render-stop",
"--commit-start-1",
"--react-version-<filtered-version>",
"--profiler-version-1",
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--react-lane-labels-Sync,InputContinuousHydration,InputContinuous,DefaultHydration,Default,TransitionHydration,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Retry,Retry,Retry,Retry,Retry,SelectiveHydration,IdleHydration,Idle,Offscreen",
"--commit-stop",
]
`);
[
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--schedule-render-1",
"--render-start-1",
"--component-render-start-ErrorBoundary",
"--component-render-stop",
"--component-render-start-ExampleThatThrows",
"--component-render-start-ExampleThatThrows",
"--component-render-stop",
"--error-ExampleThatThrows-mount-Expected error",
"--render-stop",
"--commit-start-1",
"--react-version-<filtered-version>",
"--profiler-version-1",
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--react-lane-labels-Sync,InputContinuousHydration,InputContinuous,DefaultHydration,Default,TransitionHydration,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Retry,Retry,Retry,Retry,Retry,SelectiveHydration,IdleHydration,Idle,Offscreen",
"--layout-effects-start-1",
"--schedule-state-update-1-ErrorBoundary",
"--layout-effects-stop",
"--commit-stop",
"--render-start-1",
"--component-render-start-ErrorBoundary",
"--component-render-stop",
"--render-stop",
"--commit-start-1",
"--react-version-<filtered-version>",
"--profiler-version-1",
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--react-lane-labels-Sync,InputContinuousHydration,InputContinuous,DefaultHydration,Default,TransitionHydration,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Retry,Retry,Retry,Retry,Retry,SelectiveHydration,IdleHydration,Idle,Offscreen",
"--commit-stop",
]
`);
});
});
@@ -1095,24 +1103,28 @@ describe('Timeline profiler', () => {
// @reactVersion <= 18.2
// @reactVersion >= 18.0
it('regression test SyncLane', () => {
utils.act(() => store.profilerStore.startProfiling());
legacyRender(<div />);
utils.act(() => store.profilerStore.stopProfiling());
expect(registeredMarks).toMatchInlineSnapshot(`
[
"--schedule-render-1",
"--render-start-1",
"--render-stop",
"--commit-start-1",
"--react-version-<filtered-version>",
"--profiler-version-1",
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--react-lane-labels-Sync,InputContinuousHydration,InputContinuous,DefaultHydration,Default,TransitionHydration,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Retry,Retry,Retry,Retry,Retry,SelectiveHydration,IdleHydration,Idle,Offscreen",
"--layout-effects-start-1",
"--layout-effects-stop",
"--commit-stop",
]
`);
[
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--schedule-render-1",
"--render-start-1",
"--render-stop",
"--commit-start-1",
"--react-version-<filtered-version>",
"--profiler-version-1",
"--react-internal-module-start- at filtered (<anonymous>:0:0)",
"--react-internal-module-stop- at filtered (<anonymous>:1:1)",
"--react-lane-labels-Sync,InputContinuousHydration,InputContinuous,DefaultHydration,Default,TransitionHydration,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Transition,Retry,Retry,Retry,Retry,Retry,SelectiveHydration,IdleHydration,Idle,Offscreen",
"--layout-effects-start-1",
"--layout-effects-stop",
"--commit-stop",
]
`);
});
});
@@ -1432,19 +1444,19 @@ describe('Timeline profiler', () => {
expect(timelineData.suspenseEvents).toHaveLength(1);
const suspenseEvent = timelineData.suspenseEvents[0];
expect(suspenseEvent).toMatchInlineSnapshot(`
{
"componentName": "Example",
"depth": 0,
"duration": 10,
"id": "0",
"phase": "mount",
"promiseName": "",
"resolution": "resolved",
"timestamp": 10,
"type": "suspense",
"warning": null,
}
`);
{
"componentName": "Example",
"depth": 0,
"duration": 0,
"id": "0",
"phase": "mount",
"promiseName": "",
"resolution": "unresolved",
"timestamp": 10,
"type": "suspense",
"warning": null,
}
`);
// There should be two batches of renders: Suspeneded and resolved.
expect(timelineData.batchUIDToMeasuresMap.size).toBe(2);
@@ -1490,19 +1502,19 @@ describe('Timeline profiler', () => {
expect(timelineData.suspenseEvents).toHaveLength(1);
const suspenseEvent = timelineData.suspenseEvents[0];
expect(suspenseEvent).toMatchInlineSnapshot(`
{
"componentName": "Example",
"depth": 0,
"duration": 10,
"id": "0",
"phase": "mount",
"promiseName": "",
"resolution": "rejected",
"timestamp": 10,
"type": "suspense",
"warning": null,
}
`);
{
"componentName": "Example",
"depth": 0,
"duration": 0,
"id": "0",
"phase": "mount",
"promiseName": "",
"resolution": "unresolved",
"timestamp": 10,
"type": "suspense",
"warning": null,
}
`);
// There should be two batches of renders: Suspeneded and resolved.
expect(timelineData.batchUIDToMeasuresMap.size).toBe(2);

View File

@@ -24,21 +24,8 @@ let utils;
let assertLog;
let waitFor;
// This flag is on experimental which disables timeline profiler.
const enableComponentPerformanceTrack =
React.version.startsWith('19') && React.version.includes('experimental');
describe('Timeline profiler', () => {
if (enableComponentPerformanceTrack) {
test('no tests', () => {});
// Ignore all tests.
return;
}
describe('User Timing API', () => {
if (enableComponentPerformanceTrack) {
return;
}
let currentlyNotClearedMarks;
let registeredMarks;
let featureDetectionMarkName = null;
@@ -111,9 +98,31 @@ describe('Timeline profiler', () => {
ReactDOMClient = require('react-dom/client');
Scheduler = require('scheduler');
const InternalTestUtils = require('internal-test-utils');
assertLog = InternalTestUtils.assertLog;
waitFor = InternalTestUtils.waitFor;
if (typeof Scheduler.log !== 'function') {
// backwards compat for older scheduler versions
Scheduler.log = Scheduler.unstable_yieldValue;
Scheduler.unstable_clearLog = Scheduler.unstable_clearYields;
const InternalTestUtils = require('internal-test-utils');
assertLog = InternalTestUtils.assertLog;
// polyfill waitFor as Scheduler.toFlushAndYieldThrough
waitFor = expectedYields => {
let actualYields = Scheduler.unstable_clearYields();
if (actualYields.length !== 0) {
throw new Error(
'Log of yielded values is not empty. ' +
'Call expect(Scheduler).toHaveYielded(...) first.',
);
}
Scheduler.unstable_flushNumberOfYields(expectedYields.length);
actualYields = Scheduler.unstable_clearYields();
expect(actualYields).toEqual(expectedYields);
};
} else {
const InternalTestUtils = require('internal-test-utils');
assertLog = InternalTestUtils.assertLog;
waitFor = InternalTestUtils.waitFor;
}
setPerformanceMock =
require('react-devtools-shared/src/backend/profilingHooks').setPerformanceMock_ONLY_FOR_TESTING;
@@ -146,6 +155,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should return array of lane numbers from bitmask string', () => {
expect(getLanesFromTransportDecimalBitmask('1')).toEqual([0]);
expect(getLanesFromTransportDecimalBitmask('512')).toEqual([9]);
@@ -162,6 +172,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should return empty array if laneBitmaskString is not a bitmask', () => {
expect(getLanesFromTransportDecimalBitmask('')).toEqual([]);
expect(getLanesFromTransportDecimalBitmask('hello')).toEqual([]);
@@ -170,6 +181,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should ignore lanes outside REACT_TOTAL_NUM_LANES', () => {
const REACT_TOTAL_NUM_LANES =
require('react-devtools-timeline/src/constants').REACT_TOTAL_NUM_LANES;
@@ -295,11 +307,13 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should throw given an empty timeline', async () => {
await expect(async () => preprocessData([])).rejects.toThrow();
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should throw given a timeline with no Profile event', async () => {
const randomSample = createUserTimingEntry({
dur: 100,
@@ -316,6 +330,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should throw given a timeline without an explicit profiler version mark nor any other React marks', async () => {
const cpuProfilerSample = creactCpuProfilerSample();
@@ -327,6 +342,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should throw given a timeline with React scheduling marks, but without an explicit profiler version mark', async () => {
const cpuProfilerSample = creactCpuProfilerSample();
const scheduleRenderSample = createUserTimingEntry({
@@ -341,6 +357,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should return empty data given a timeline with no React scheduling profiling marks', async () => {
const cpuProfilerSample = creactCpuProfilerSample();
const randomSample = createUserTimingEntry({
@@ -445,6 +462,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should process legacy data format (before lane labels were added)', async () => {
const cpuProfilerSample = creactCpuProfilerSample();
@@ -832,6 +850,7 @@ describe('Timeline profiler', () => {
`);
});
// @reactVersion < 19.2
it('should process a sample createRoot render sequence', async () => {
function App() {
const [didMount, setDidMount] = React.useState(false);
@@ -1168,6 +1187,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should populate other user timing marks', async () => {
const userTimingData = createUserTimingData([]);
userTimingData.push(
@@ -1218,6 +1238,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('should include a suspended resource "displayName" if one is set', async () => {
let promise = null;
let resolvedValue = null;
@@ -1359,6 +1380,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.2
// @reactVersion < 19.2
it('should not warn when React finishes a previously long (async) update with a short (sync) update inside of an event', async () => {
function Yield({id, value}) {
Scheduler.log(`${id}:${value}`);
@@ -1421,6 +1443,7 @@ describe('Timeline profiler', () => {
describe('nested updates', () => {
// @reactVersion >= 18.2
// @reactVersion < 19.2
it('should not warn about short nested (state) updates during layout effects', async () => {
function Component() {
const [didMount, setDidMount] = React.useState(false);
@@ -1452,6 +1475,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.2
// @reactVersion < 19.2
it('should not warn about short (forced) updates during layout effects', async () => {
class Component extends React.Component {
_didMount: boolean = false;
@@ -1607,6 +1631,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.2
// @reactVersion < 19.2
it('should not warn about transition updates scheduled during commit phase', async () => {
function Component() {
const [value, setValue] = React.useState(0);
@@ -1748,6 +1773,7 @@ describe('Timeline profiler', () => {
describe('errors thrown while rendering', () => {
// @reactVersion >= 18.0
// @reactVersion < 19.2
it('shoult parse Errors thrown during render', async () => {
jest.spyOn(console, 'error');
@@ -1796,6 +1822,7 @@ describe('Timeline profiler', () => {
// This also tests an edge case where a component suspends while profiling
// before the first commit is logged (so the lane-to-labels map will not yet exist).
// @reactVersion >= 18.2
// @reactVersion < 19.2
it('should warn about suspending during an update', async () => {
let promise = null;
let resolvedValue = null;
@@ -1862,6 +1889,7 @@ describe('Timeline profiler', () => {
});
// @reactVersion >= 18.2
// @reactVersion < 19.2
it('should not warn about suspending during an transition', async () => {
let promise = null;
let resolvedValue = null;
@@ -2130,6 +2158,7 @@ describe('Timeline profiler', () => {
`);
});
// @reactVersion < 19.2
it('should process a sample createRoot render sequence', async () => {
function App() {
const [didMount, setDidMount] = React.useState(false);

View File

@@ -725,14 +725,14 @@ describe('ProfilingCache', () => {
const commitData = store.profilerStore.getDataForRoot(rootID).commitData;
expect(commitData).toHaveLength(2);
const isLegacySuspense = React.version.startsWith('17');
if (isLegacySuspense) {
if (React.version.startsWith('17')) {
// React 17 will mount all children until it suspends in a LegacyHidden
// The ID gap is from the Fiber for <Async> that's in the disconnected tree.
expect(commitData[0].fiberActualDurations).toMatchInlineSnapshot(`
Map {
1 => 15,
2 => 15,
3 => 5,
4 => 3,
5 => 2,
}
`);
@@ -741,7 +741,6 @@ describe('ProfilingCache', () => {
1 => 0,
2 => 10,
3 => 3,
4 => 3,
5 => 2,
}
`);

View File

@@ -19,8 +19,6 @@ describe('commit tree', () => {
let Scheduler;
let store: Store;
let utils;
const isLegacySuspense =
React.version.startsWith('16') || React.version.startsWith('17');
beforeEach(() => {
utils = require('./utils');
@@ -186,24 +184,13 @@ describe('commit tree', () => {
utils.act(() => store.profilerStore.startProfiling());
utils.act(() => legacyRender(<App renderChildren={true} />));
await Promise.resolve();
if (isLegacySuspense) {
expect(store).toMatchInlineSnapshot(`
[root]
<App>
▾ <Suspense>
<Lazy>
[suspense-root] rects={null}
<Suspense name="App" rects={null}>
`);
} else {
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Suspense>
[suspense-root] rects={null}
<Suspense name="App" rects={null}>
`);
}
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Suspense>
[suspense-root] rects={null}
<Suspense name="App" rects={null}>
`);
utils.act(() => legacyRender(<App renderChildren={true} />));
expect(store).toMatchInlineSnapshot(`
[root]
@@ -231,13 +218,7 @@ describe('commit tree', () => {
);
}
expect(commitTrees[0].nodes.size).toBe(
isLegacySuspense
? // <Root> + <App> + <Suspense> + <Lazy>
4
: // <Root> + <App> + <Suspense>
3,
);
expect(commitTrees[0].nodes.size).toBe(3);
expect(commitTrees[1].nodes.size).toBe(4); // <Root> + <App> + <Suspense> + <LazyInnerComponent>
expect(commitTrees[2].nodes.size).toBe(2); // <Root> + <App>
});
@@ -291,24 +272,13 @@ describe('commit tree', () => {
it('should support Lazy components that are unmounted before resolving (legacy render)', async () => {
utils.act(() => store.profilerStore.startProfiling());
utils.act(() => legacyRender(<App renderChildren={true} />));
if (isLegacySuspense) {
expect(store).toMatchInlineSnapshot(`
[root]
<App>
▾ <Suspense>
<Lazy>
[suspense-root] rects={null}
<Suspense name="App" rects={null}>
`);
} else {
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Suspense>
[suspense-root] rects={null}
<Suspense name="App" rects={null}>
`);
}
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<Suspense>
[suspense-root] rects={null}
<Suspense name="App" rects={null}>
`);
utils.act(() => legacyRender(<App renderChildren={false} />));
expect(store).toMatchInlineSnapshot(`
[root]
@@ -327,13 +297,7 @@ describe('commit tree', () => {
);
}
expect(commitTrees[0].nodes.size).toBe(
isLegacySuspense
? // <Root> + <App> + <Suspense> + <Lazy>
4
: // <Root> + <App> + <Suspense>
3,
);
expect(commitTrees[0].nodes.size).toBe(3);
expect(commitTrees[1].nodes.size).toBe(2); // <Root> + <App>
});

View File

@@ -838,7 +838,7 @@ describe('Store', () => {
<Suspense name="two" rects={null}>
<Suspense name="three" rects={null}>
`);
await act(() =>
await actAsync(() =>
agent.overrideSuspense({
id: store.getElementIDAtIndex(2),
rendererID,
@@ -2828,7 +2828,7 @@ describe('Store', () => {
`);
});
// @reactVersion >= 18.0
// @reactVersion >= 17.0
it('can reconcile Suspense in fallback positions', async () => {
let resolveFallback;
const fallbackPromise = new Promise(resolve => {
@@ -2907,7 +2907,7 @@ describe('Store', () => {
`);
});
// @reactVersion >= 18.0
// @reactVersion >= 17.0
it('can reconcile resuspended Suspense with Suspense in fallback positions', async () => {
let resolveHeadFallback;
let resolveHeadContent;
@@ -3107,4 +3107,45 @@ describe('Store', () => {
await actAsync(() => render(<span />));
expect(store).toMatchInlineSnapshot(`[root]`);
});
// @reactVersion >= 19.0
it('should reconcile promise-as-a-child', async () => {
function Component({children}) {
return <div>{children}</div>;
}
await actAsync(() =>
render(
<React.Suspense>
{Promise.resolve(<Component key="A">A</Component>)}
</React.Suspense>,
),
);
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense>
<Component key="A">
[suspense-root] rects={[{x:1,y:2,width:1,height:1}]}
<Suspense name="Unknown" rects={[{x:1,y:2,width:1,height:1}]}>
`);
await actAsync(() =>
render(
<React.Suspense>
{Promise.resolve(<Component key="not-A">not A</Component>)}
</React.Suspense>,
),
);
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense>
<Component key="not-A">
[suspense-root] rects={[{x:1,y:2,width:5,height:1}]}
<Suspense name="Unknown" rects={[{x:1,y:2,width:5,height:1}]}>
`);
await actAsync(() => render(null));
expect(store).toMatchInlineSnapshot(``);
});
});

View File

@@ -29,7 +29,7 @@ import type {
} from './types';
import type {ComponentFilter} from 'react-devtools-shared/src/frontend/types';
import type {GroupItem} from './views/TraceUpdates/canvas';
import {isReactNativeEnvironment} from './utils';
import {gte, isReactNativeEnvironment} from './utils';
import {
sessionStorageGetItem,
sessionStorageRemoveItem,
@@ -739,7 +739,7 @@ export default class Agent extends EventEmitter<{
if (renderer !== null) {
const devRenderer = renderer.bundleType === 1;
const enableSuspenseTab =
devRenderer && renderer.version.includes('-experimental-');
devRenderer && gte(renderer.version, '19.2.0-canary');
if (enableSuspenseTab) {
this._bridge.send('enableSuspenseTab');
}

View File

@@ -299,6 +299,7 @@ type SuspenseNode = {
nextSibling: null | 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.
// 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,
@@ -327,6 +328,7 @@ function createSuspenseNode(
nextSibling: null,
rects: null,
suspendedBy: new Map(),
environments: new Map(),
hasUniqueSuspenders: false,
hasUnknownSuspenders: false,
});
@@ -460,10 +462,10 @@ export function getInternalReactConstants(version: string): {
IncompleteFunctionComponent: 28,
IndeterminateComponent: 2, // removed in 19.0.0
LazyComponent: 16,
LegacyHiddenComponent: 23,
LegacyHiddenComponent: 23, // Does not exist in 18+ OSS but exists in fb builds
MemoComponent: 14,
Mode: 8,
OffscreenComponent: 22, // Experimental
OffscreenComponent: 22, // Experimental in 17. Stable in 18+
Profiler: 12,
ScopeComponent: 21, // Experimental
SimpleMemoComponent: 15,
@@ -1063,6 +1065,7 @@ export function attach(
setErrorHandler,
setSuspenseHandler,
scheduleUpdate,
scheduleRetry,
getCurrentFiber,
} = renderer;
const supportsTogglingError =
@@ -2220,6 +2223,10 @@ export function attach(
}
operations[i++] = fiberIdWithChanges;
operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
operations[i++] = suspense.environments.size;
suspense.environments.forEach((count, env) => {
operations[i++] = getStringID(env);
});
});
}
@@ -2244,8 +2251,23 @@ export function attach(
}
if (typeof instance.getClientRects === 'function') {
// DOM
const result: Array<Rect> = [];
const doc = instance.ownerDocument;
if (instance === doc.documentElement) {
// This is the document element. The size of this element is not actually
// what determines the whole scrollable area of the screen. Because any
// thing that overflows the document will also contribute to the scrollable.
// This is unlike overflow: scroll which clips those.
// Therefore, we use the scrollable size for this rect instead.
return [
{
x: 0,
y: 0,
width: instance.scrollWidth,
height: instance.scrollHeight,
},
];
}
const result: Array<Rect> = [];
const win = doc && doc.defaultView;
const scrollX = win ? win.scrollX : 0;
const scrollY = win ? win.scrollY : 0;
@@ -2725,6 +2747,13 @@ export function attach(
return;
}
// TODO: Just enqueue the operations here instead of stashing by id.
// Ensure each environment gets recorded in the string table since it is emitted
// before we loop it over again later during flush.
suspenseNode.environments.forEach((count, env) => {
getStringID(env);
});
pendingSuspenderChanges.add(fiberInstance.id);
}
@@ -2807,7 +2836,20 @@ export function attach(
let suspendedBySet = suspenseNodeSuspendedBy.get(ioInfo);
if (suspendedBySet === undefined) {
suspendedBySet = new Set();
suspenseNodeSuspendedBy.set(asyncInfo.awaited, suspendedBySet);
suspenseNodeSuspendedBy.set(ioInfo, suspendedBySet);
// We've added a dependency. We must increment the ref count of the environment.
const env = ioInfo.env;
if (env != null) {
const environmentCounts = parentSuspenseNode.environments;
const count = environmentCounts.get(env);
if (count === undefined || count === 0) {
environmentCounts.set(env, 1);
// We've discovered a new environment for this SuspenseNode. We'll to update the node.
recordSuspenseSuspenders(parentSuspenseNode);
} else {
environmentCounts.set(env, count + 1);
}
}
}
// The child of the Suspense boundary that was suspended on this, or null if suspended at the root.
// This is used to keep track of how many dependents are still alive and also to get information
@@ -2886,10 +2928,18 @@ export function attach(
previousSuspendedBy: null | Array<ReactAsyncInfo>,
parentSuspenseNode: null | SuspenseNode,
): void {
// Remove any async info from the parent, if they were in the previous set but
// Remove any async info if they were in the previous set but
// is no longer in the new set.
if (previousSuspendedBy !== null && parentSuspenseNode !== null) {
// If we just reconciled a SuspenseNode, we need to remove from that node instead of the parent.
// This is different from inserting because inserting is done during reconiliation
// whereas removal is done after we're done reconciling.
const suspenseNode =
instance.suspenseNode === null
? parentSuspenseNode
: instance.suspenseNode;
if (previousSuspendedBy !== null && suspenseNode !== null) {
const nextSuspendedBy = instance.suspendedBy;
let changedEnvironment = false;
for (let i = 0; i < previousSuspendedBy.length; i++) {
const asyncInfo = previousSuspendedBy[i];
if (
@@ -2901,7 +2951,7 @@ export function attach(
// This IO entry is no longer blocking the current tree.
// Let's remove it from the parent SuspenseNode.
const ioInfo = asyncInfo.awaited;
const suspendedBySet = parentSuspenseNode.suspendedBy.get(ioInfo);
const suspendedBySet = suspenseNode.suspendedBy.get(ioInfo);
if (
suspendedBySet === undefined ||
@@ -2928,19 +2978,41 @@ export function attach(
}
}
if (suspendedBySet !== undefined && suspendedBySet.size === 0) {
parentSuspenseNode.suspendedBy.delete(asyncInfo.awaited);
suspenseNode.suspendedBy.delete(ioInfo);
// Successfully removed all dependencies. We can decrement the ref count of the environment.
const env = ioInfo.env;
if (env != null) {
const environmentCounts = suspenseNode.environments;
const count = environmentCounts.get(env);
if (count === undefined || count === 0) {
throw new Error(
'We are removing an environment but it was not in the set. ' +
'This is a bug in React.',
);
}
if (count === 1) {
environmentCounts.delete(env);
// Last one. We've now change the set of environments. We'll need to update the node.
changedEnvironment = true;
} else {
environmentCounts.set(env, count - 1);
}
}
}
if (
parentSuspenseNode.hasUniqueSuspenders &&
!ioExistsInSuspenseAncestor(parentSuspenseNode, ioInfo)
suspenseNode.hasUniqueSuspenders &&
!ioExistsInSuspenseAncestor(suspenseNode, ioInfo)
) {
// This entry wasn't in any ancestor and is no longer in this suspense boundary.
// This means that a child might now be the unique suspender for this IO.
// Search the child boundaries to see if we can reveal any of them.
unblockSuspendedBy(parentSuspenseNode, ioInfo);
unblockSuspendedBy(suspenseNode, ioInfo);
}
}
}
if (changedEnvironment) {
recordSuspenseSuspenders(suspenseNode);
}
}
}
@@ -3057,13 +3129,23 @@ export function attach(
}
}
function isHiddenOffscreen(fiber: Fiber): boolean {
switch (fiber.tag) {
case LegacyHiddenComponent:
// fallthrough since all published implementations currently implement the same state as Offscreen.
case OffscreenComponent:
return fiber.memoizedState !== null;
default:
return false;
}
}
function unmountRemainingChildren() {
if (
reconcilingParent !== null &&
(reconcilingParent.kind === FIBER_INSTANCE ||
reconcilingParent.kind === FILTERED_FIBER_INSTANCE) &&
reconcilingParent.data.tag === OffscreenComponent &&
reconcilingParent.data.memoizedState !== null &&
isHiddenOffscreen(reconcilingParent.data) &&
!isInDisconnectedSubtree
) {
// This is a hidden offscreen, we need to execute this in the context of a disconnected subtree.
@@ -3170,8 +3252,7 @@ export function attach(
if (
(parent.kind === FIBER_INSTANCE ||
parent.kind === FILTERED_FIBER_INSTANCE) &&
parent.data.tag === OffscreenComponent &&
parent.data.memoizedState !== null
isHiddenOffscreen(parent.data)
) {
// We're inside a hidden offscreen Fiber. We're in a disconnected tree.
return;
@@ -3819,7 +3900,9 @@ export function attach(
(reconcilingParent !== null &&
reconcilingParent.kind === VIRTUAL_INSTANCE) ||
fiber.tag === SuspenseComponent ||
fiber.tag === OffscreenComponent // Use to keep resuspended instances alive inside a SuspenseComponent.
// Use to keep resuspended instances alive inside a SuspenseComponent.
fiber.tag === OffscreenComponent ||
fiber.tag === LegacyHiddenComponent
) {
// If the parent is a Virtual Instance and we filtered this Fiber we include a
// hidden node. We also include this if it's a Suspense boundary so we can track those
@@ -3939,7 +4022,7 @@ export function attach(
trackDebugInfoFromHostComponent(nearestInstance, fiber);
}
if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
if (isHiddenOffscreen(fiber)) {
// If an Offscreen component is hidden, mount its children as disconnected.
const stashedDisconnected = isInDisconnectedSubtree;
isInDisconnectedSubtree = true;
@@ -4261,7 +4344,7 @@ export function attach(
while (child !== null) {
if (child.kind === FILTERED_FIBER_INSTANCE) {
const fiber = child.data;
if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
if (isHiddenOffscreen(fiber)) {
// The children of this Offscreen are hidden so they don't get added.
} else {
addUnfilteredChildrenIDs(child, nextChildren);
@@ -4888,9 +4971,8 @@ export function attach(
const nextDidTimeOut =
isLegacySuspense && nextFiber.memoizedState !== null;
const isOffscreen = nextFiber.tag === OffscreenComponent;
const prevWasHidden = isOffscreen && prevFiber.memoizedState !== null;
const nextIsHidden = isOffscreen && nextFiber.memoizedState !== null;
const prevWasHidden = isHiddenOffscreen(prevFiber);
const nextIsHidden = isHiddenOffscreen(nextFiber);
if (isLegacySuspense) {
if (
@@ -5245,8 +5327,7 @@ export function attach(
if (
(child.kind === FIBER_INSTANCE ||
child.kind === FILTERED_FIBER_INSTANCE) &&
child.data.tag === OffscreenComponent &&
child.data.memoizedState !== null
isHiddenOffscreen(child.data)
) {
// This instance's children are already disconnected.
} else {
@@ -5275,8 +5356,7 @@ export function attach(
if (
(child.kind === FIBER_INSTANCE ||
child.kind === FILTERED_FIBER_INSTANCE) &&
child.data.tag === OffscreenComponent &&
child.data.memoizedState !== null
isHiddenOffscreen(child.data)
) {
// This instance's children should remain disconnected.
} else {
@@ -5586,6 +5666,23 @@ export function attach(
}
}
function findLastKnownRectsForID(id: number): null | Array<Rect> {
try {
const devtoolsInstance = idToDevToolsInstanceMap.get(id);
if (devtoolsInstance === undefined) {
console.warn(`Could not find DevToolsInstance with id "${id}"`);
return null;
}
if (devtoolsInstance.suspenseNode === null) {
return null;
}
return devtoolsInstance.suspenseNode.rects;
} catch (err) {
// The fiber might have unmounted by now.
return null;
}
}
function getDisplayNameForElementID(id: number): null | string {
const devtoolsInstance = idToDevToolsInstanceMap.get(id);
if (devtoolsInstance === undefined) {
@@ -7690,7 +7787,13 @@ export function attach(
// First override is added. Switch React to slower path.
setErrorHandler(shouldErrorFiberAccordingToMap);
}
scheduleUpdate(fiber);
if (!forceError && typeof scheduleRetry === 'function') {
// If we're dismissing an error and the renderer supports it, use a Retry instead of Sync
// This would allow View Transitions to proceed as if the error was dismissed using a Transition.
scheduleRetry(fiber);
} else {
scheduleUpdate(fiber);
}
}
function shouldSuspendFiberAlwaysFalse() {
@@ -7748,7 +7851,13 @@ export function attach(
setSuspenseHandler(shouldSuspendFiberAlwaysFalse);
}
}
scheduleUpdate(fiber);
if (!forceFallback && typeof scheduleRetry === 'function') {
// If we're unsuspending and the renderer supports it, use a Retry instead of Sync
// to allow for things like View Transitions to proceed the way they would for real.
scheduleRetry(fiber);
} else {
scheduleUpdate(fiber);
}
}
/**
@@ -7770,11 +7879,10 @@ export function attach(
}
// TODO: Allow overriding the timeline for the specified root.
forceFallbackForFibers.forEach(fiber => {
scheduleUpdate(fiber);
});
forceFallbackForFibers.clear();
const unsuspendedSet: Set<Fiber> = new Set(forceFallbackForFibers);
let resuspended = false;
for (let i = 0; i < suspendedSet.length; ++i) {
const instance = idToDevToolsInstanceMap.get(suspendedSet[i]);
if (instance === undefined) {
@@ -7786,15 +7894,41 @@ export function attach(
if (instance.kind === FIBER_INSTANCE) {
const fiber = instance.data;
forceFallbackForFibers.add(fiber);
// We could find a minimal set that covers all the Fibers in this suspended set.
// For now we rely on React's batching of updates.
scheduleUpdate(fiber);
if (
forceFallbackForFibers.has(fiber) ||
(fiber.alternate !== null &&
forceFallbackForFibers.has(fiber.alternate))
) {
// We're already forcing fallback for this fiber. Mark it as not unsuspended.
unsuspendedSet.delete(fiber);
if (fiber.alternate !== null) {
unsuspendedSet.delete(fiber.alternate);
}
} else {
forceFallbackForFibers.add(fiber);
// We could find a minimal set that covers all the Fibers in this suspended set.
// For now we rely on React's batching of updates.
scheduleUpdate(fiber);
resuspended = true;
}
} else {
console.warn(`Cannot not suspend ID '${suspendedSet[i]}'.`);
}
}
// Unsuspend any existing forced fallbacks if they're not in the new set.
unsuspendedSet.forEach(fiber => {
forceFallbackForFibers.delete(fiber);
if (!resuspended && typeof scheduleRetry === 'function') {
// If nothing new resuspended we don't need this to be sync. If we're only
// unsuspending then we can schedule this as a Retry if the renderer supports it.
// That way we can trigger animations.
scheduleRetry(fiber);
} else {
scheduleUpdate(fiber);
}
});
if (forceFallbackForFibers.size > 0) {
// First override is added. Switch React to slower path.
// TODO: Semantics for suspending a timeline are different. We want a suspended
@@ -8285,6 +8419,7 @@ export function attach(
getSerializedElementValueByPath,
deletePath,
findHostInstancesForElementID,
findLastKnownRectsForID,
flushInitialOperations,
getBestMatchForTrackedPath,
getDisplayNameForElementID,

View File

@@ -152,6 +152,9 @@ export function attach(
findHostInstancesForElementID() {
return null;
},
findLastKnownRectsForID() {
return null;
},
flushInitialOperations() {},
getBestMatchForTrackedPath() {
return null;

View File

@@ -1168,6 +1168,9 @@ export function attach(
const hostInstance = findHostInstanceForInternalID(id);
return hostInstance == null ? null : [hostInstance];
},
findLastKnownRectsForID() {
return null;
},
getOwnersList,
getPathForElement,
getProfilingData,

View File

@@ -101,6 +101,16 @@ export type FindHostInstancesForElementID = (
id: number,
) => null | $ReadOnlyArray<HostInstance>;
type Rect = {
x: number,
y: number,
width: number,
height: number,
...
};
export type FindLastKnownRectsForID = (
id: number,
) => null | $ReadOnlyArray<Rect>;
export type ReactProviderType<T> = {
$$typeof: symbol | number,
_context: ReactContext<T>,
@@ -155,6 +165,8 @@ export type ReactRenderer = {
) => void,
// 16.9+
scheduleUpdate?: ?(fiber: Object) => void,
// 19.2+
scheduleRetry?: ?(fiber: Object) => void,
setSuspenseHandler?: ?(shouldSuspend: (fiber: Object) => boolean) => void,
// Only injected by React v16.8+ in order to support hooks inspection.
currentDispatcherRef?: LegacyDispatcherRef | CurrentDispatcherRef,
@@ -409,6 +421,7 @@ export type RendererInterface = {
path: Array<string | number>,
) => void,
findHostInstancesForElementID: FindHostInstancesForElementID,
findLastKnownRectsForID: FindLastKnownRectsForID,
flushInitialOperations: () => void,
getBestMatchForTrackedPath: () => PathMatch | null,
getComponentStack?: GetComponentStack,

View File

@@ -11,6 +11,7 @@ import Agent from 'react-devtools-shared/src/backend/agent';
import {hideOverlay, showOverlay} from './Highlighter';
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
import type {RendererInterface} from '../../types';
// This plug-in provides in-page highlighting of the selected element.
// It is used by the browser extension and the standalone DevTools shell (when connected to a browser).
@@ -25,6 +26,7 @@ export default function setupHighlighter(
): void {
bridge.addListener('clearHostInstanceHighlight', clearHostInstanceHighlight);
bridge.addListener('highlightHostInstance', highlightHostInstance);
bridge.addListener('scrollToHostInstance', scrollToHostInstance);
bridge.addListener('shutdown', stopInspectingHost);
bridge.addListener('startInspectingHost', startInspectingHost);
bridge.addListener('stopInspectingHost', stopInspectingHost);
@@ -111,24 +113,162 @@ export default function setupHighlighter(
}
const nodes = renderer.findHostInstancesForElementID(id);
if (nodes != null) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node === null) {
continue;
}
const nodeRects =
// $FlowFixMe[method-unbinding]
typeof node.getClientRects === 'function'
? node.getClientRects()
: [];
// If this is currently display: none, then try another node.
// This can happen when one of the host instances is a hoistable.
if (
nodeRects.length > 0 &&
(nodeRects.length > 2 ||
nodeRects[0].width > 0 ||
nodeRects[0].height > 0)
) {
// $FlowFixMe[method-unbinding]
if (scrollIntoView && typeof node.scrollIntoView === 'function') {
if (scrollDelayTimer) {
clearTimeout(scrollDelayTimer);
scrollDelayTimer = null;
}
// If the node isn't visible show it before highlighting it.
// We may want to reconsider this; it might be a little disruptive.
node.scrollIntoView({block: 'nearest', inline: 'nearest'});
}
if (nodes != null && nodes[0] != null) {
const node = nodes[0];
// $FlowFixMe[method-unbinding]
if (scrollIntoView && typeof node.scrollIntoView === 'function') {
// If the node isn't visible show it before highlighting it.
// We may want to reconsider this; it might be a little disruptive.
node.scrollIntoView({block: 'nearest', inline: 'nearest'});
showOverlay(nodes, displayName, agent, hideAfterTimeout);
if (openBuiltinElementsPanel) {
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node;
bridge.send('syncSelectionToBuiltinElementsPanel');
}
return;
}
}
}
showOverlay(nodes, displayName, agent, hideAfterTimeout);
hideOverlay(agent);
}
if (openBuiltinElementsPanel) {
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node;
bridge.send('syncSelectionToBuiltinElementsPanel');
function attemptScrollToHostInstance(
renderer: RendererInterface,
id: number,
) {
const nodes = renderer.findHostInstancesForElementID(id);
if (nodes != null) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node === null) {
continue;
}
const nodeRects =
// $FlowFixMe[method-unbinding]
typeof node.getClientRects === 'function'
? node.getClientRects()
: [];
// If this is currently display: none, then try another node.
// This can happen when one of the host instances is a hoistable.
if (
nodeRects.length > 0 &&
(nodeRects.length > 2 ||
nodeRects[0].width > 0 ||
nodeRects[0].height > 0)
) {
// $FlowFixMe[method-unbinding]
if (typeof node.scrollIntoView === 'function') {
node.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
});
return true;
}
}
}
} else {
hideOverlay(agent);
}
return false;
}
let scrollDelayTimer = null;
function scrollToHostInstance({
id,
rendererID,
}: {
id: number,
rendererID: number,
}) {
// Always hide the existing overlay so it doesn't obscure the element.
// If you wanted to show the overlay, highlightHostInstance should be used instead
// with the scrollIntoView option.
hideOverlay(agent);
if (scrollDelayTimer) {
clearTimeout(scrollDelayTimer);
scrollDelayTimer = null;
}
const renderer = agent.rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
return;
}
// In some cases fiber may already be unmounted
if (!renderer.hasElementWithId(id)) {
return;
}
if (attemptScrollToHostInstance(renderer, id)) {
return;
}
// It's possible that the current state of a Suspense boundary doesn't have a position
// in the tree. E.g. because it's not yet mounted in the state we're moving to.
// Such as if it's in a null tree or inside another boundary's hidden state.
// In this case we use the last known position and try to scroll to that.
const rects = renderer.findLastKnownRectsForID(id);
if (rects !== null && rects.length > 0) {
let x = Infinity;
let y = Infinity;
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
if (rect.x < x) {
x = rect.x;
}
if (rect.y < y) {
y = rect.y;
}
}
const element = document.documentElement;
if (!element) {
return;
}
// Check if the target corner is already in the viewport.
if (
x < window.scrollX ||
y < window.scrollY ||
x > window.scrollX + element.clientWidth ||
y > window.scrollY + element.clientHeight
) {
window.scrollTo({
top: y,
left: x,
behavior: 'smooth',
});
}
// It's possible that after mount, we're able to scroll deeper once the new nodes
// have mounted. Let's try again after mount. Ideally we'd know which commit this
// is going to be but for now we just try after 100ms.
scrollDelayTimer = setTimeout(() => {
attemptScrollToHostInstance(renderer, id);
}, 100);
}
}

View File

@@ -93,6 +93,10 @@ type HighlightHostInstance = {
scrollIntoView: boolean,
};
type ScrollToHostInstance = {
...ElementAndRendererID,
};
type OverrideValue = {
...ElementAndRendererID,
path: Array<string | number>,
@@ -254,6 +258,7 @@ type FrontendEvents = {
startInspectingHost: [],
startProfiling: [StartProfilingParams],
stopInspectingHost: [boolean],
scrollToHostInstance: [ScrollToHostInstance],
stopProfiling: [],
storeAsGlobal: [StoreAsGlobalParams],
updateComponentFilters: [Array<ComponentFilter>],

View File

@@ -42,6 +42,7 @@ export type Resource<Input, Key, Value> = {
let readContext;
if (typeof React.use === 'function') {
readContext = function (Context: ReactContext<null>) {
// eslint-disable-next-line react-hooks-published/rules-of-hooks
return React.use(Context);
};
} else if (
@@ -141,6 +142,7 @@ export function createResource<Input, Key, Value>(
const key = hashInput(input);
const result: Thenable<Value> = accessResult(resource, fetch, input, key);
if (typeof React.use === 'function') {
// eslint-disable-next-line react-hooks-published/rules-of-hooks
return React.use(result);
}

View File

@@ -1759,12 +1759,22 @@ export default class Store extends EventEmitter<{
break;
}
case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
const changeLength = operations[i + 1];
i += 2;
i++;
const changeLength = operations[i++];
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const id = operations[i];
const hasUniqueSuspenders = operations[i + 1] === 1;
const id = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const environmentNamesLength = operations[i++];
const environmentNames = [];
for (
let envIndex = 0;
envIndex < environmentNamesLength;
envIndex++
) {
const environmentNameStringID = operations[i++];
environmentNames.push(stringTable[environmentNameStringID]);
}
const suspense = this._idToSuspense.get(id);
if (suspense === undefined) {
@@ -1777,8 +1787,6 @@ export default class Store extends EventEmitter<{
break;
}
i += 2;
if (__DEBUG__) {
const previousHasUniqueSuspenders = suspense.hasUniqueSuspenders;
debug(
@@ -1788,6 +1796,7 @@ export default class Store extends EventEmitter<{
}
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
// TODO: Recompute the environment names.
}
hasSuspenseTreeChanged = true;

View File

@@ -40,6 +40,12 @@ export type IconType =
| 'panel-right-open'
| 'panel-bottom-open'
| 'panel-bottom-close'
| 'filter-on'
| 'filter-off'
| 'play'
| 'pause'
| 'skip-previous'
| 'skip-next'
| 'error'
| 'suspend'
| 'undo'
@@ -52,7 +58,7 @@ type Props = {
type: IconType,
};
const panelIcons = '0 -960 960 820';
const panelIcons = '96 -864 768 768';
export default function ButtonIcon({className = '', type}: Props): React.Node {
let pathData = null;
let viewBox = '0 0 24 24';
@@ -153,6 +159,30 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
pathData = PATH_MATERIAL_PANEL_BOTTOM_CLOSE;
viewBox = panelIcons;
break;
case 'filter-on':
pathData = PATH_MATERIAL_FILTER_ALT;
viewBox = panelIcons;
break;
case 'filter-off':
pathData = PATH_MATERIAL_FILTER_ALT_OFF;
viewBox = panelIcons;
break;
case 'play':
pathData = PATH_MATERIAL_PLAY_ARROW;
viewBox = panelIcons;
break;
case 'pause':
pathData = PATH_MATERIAL_PAUSE;
viewBox = panelIcons;
break;
case 'skip-previous':
pathData = PATH_MATERIAL_SKIP_PREVIOUS_ARROW;
viewBox = panelIcons;
break;
case 'skip-next':
pathData = PATH_MATERIAL_SKIP_NEXT_ARROW;
viewBox = panelIcons;
break;
case 'suspend':
pathData = PATH_SUSPEND;
break;
@@ -338,3 +368,33 @@ const PATH_MATERIAL_PANEL_BOTTOM_OPEN = `
const PATH_MATERIAL_PANEL_BOTTOM_CLOSE = `
m506-508 102-110q8-8.82 3.5-19.41T595-648H365q-12.25 0-16.62 10.5Q344-627 352-618l102 110q11.18 11 26.09 11T506-508Zm243-308q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538ZM216-336v120h528v-120H216Zm528-72v-336H216v336h528Zm-528 72v120-120Z
`;
// Source: Material Design Icons filter_alt
const PATH_MATERIAL_FILTER_ALT = `
M440-160q-17 0-28.5-11.5T400-200v-240L168-736q-15-20-4.5-42t36.5-22h560q26 0 36.5 22t-4.5 42L560-440v240q0 17-11.5 28.5T520-160h-80Zm40-308 198-252H282l198 252Zm0 0Z
`;
// Source: Material Design Icons filter_alt_off
const PATH_MATERIAL_FILTER_ALT_OFF = `
m592-481-57-57 143-182H353l-80-80h487q25 0 36 22t-4 42L592-481ZM791-56 560-287v87q0 17-11.5 28.5T520-160h-80q-17 0-28.5-11.5T400-200v-247L56-791l56-57 736 736-57 56ZM535-538Z
`;
// Source: Material Design Icons play_arrow
const PATH_MATERIAL_PLAY_ARROW = `
M320-200v-560l440 280-440 280Zm80-280Zm0 134 210-134-210-134v268Z
`;
// Source: Material Design Icons pause
const PATH_MATERIAL_PAUSE = `
M520-200v-560h240v560H520Zm-320 0v-560h240v560H200Zm400-80h80v-400h-80v400Zm-320 0h80v-400h-80v400Zm0-400v400-400Zm320 0v400-400Z
`;
// Source: Material Design Icons skip_previous
const PATH_MATERIAL_SKIP_PREVIOUS_ARROW = `
M220-240v-480h80v480h-80Zm520 0L380-480l360-240v480Zm-80-240Zm0 90v-180l-136 90 136 90Z
`;
// Source: Material Design Icons skip_next
const PATH_MATERIAL_SKIP_NEXT_ARROW = `
M660-240v-480h80v480h-80Zm-440 0v-480l360 240-360 240Zm80-240Zm0 90 136-90-136-90v180Z
`;

View File

@@ -17,7 +17,10 @@ import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import Icon from '../Icon';
import Toggle from '../Toggle';
import {ElementTypeSuspense} from 'react-devtools-shared/src/frontend/types';
import {
ElementTypeSuspense,
ElementTypeRoot,
} from 'react-devtools-shared/src/frontend/types';
import InspectedElementView from './InspectedElementView';
import {InspectedElementContext} from './InspectedElementContext';
import {getAlwaysOpenInEditor} from '../../../utils';
@@ -205,6 +208,16 @@ export default function InspectedElementWrapper(_: Props): React.Node {
);
}
let fullName = element.displayName || '';
if (element.nameProp !== null) {
fullName += ' "' + element.nameProp + '"';
}
if (element.type === ElementTypeRoot) {
// The root only has "suspended by" and it represents the things that block
// Initial Paint.
fullName = 'Initial Paint';
}
return (
<div
className={styles.InspectedElement}
@@ -228,8 +241,8 @@ export default function InspectedElementWrapper(_: Props): React.Node {
? `${styles.ComponentName} ${styles.StrictModeNonCompliantComponentName}`
: styles.ComponentName
}
title={element.displayName}>
{element.displayName}
title={fullName}>
{fullName}
</div>
</div>

View File

@@ -75,6 +75,10 @@
display: flex;
}
.CollapsableHeader[data-pending="true"] {
cursor: progress;
}
.CollapsableHeaderIcon {
flex: 0 0 1rem;
margin-left: -0.25rem;

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