Compare commits

...

43 Commits

Author SHA1 Message Date
Joe Savona
0c10c8f7e4 [compiler] Improve snap workflow for debugging errors
Much nicer workflow for working through errors in the compiler:
* Run `yarn snap -w`, oops there are are errors
* Hit 'p' to select a fixture => the suggestions populate with recent failures, sorted alphabetically. No need to copy/paste the name of the fixture you want to focus on!
* tab/shift-tab to pick one, hit enter to select that one
* ...Focus on fixing that test...
* 'p' to re-enter the picker. Snap tracks the last state of each fixture and continues to show all tests that failed on their last run, so you can easily move on to the next one. The currently selected test is highlighted, making it easy to move to the next one.
* 'a' at any time to run all tests
* 'd' at any time to toggle debug output on/off (while focusing on a single test)
2026-01-23 11:07:42 -08:00
Joseph Savona
a688a3d18c worktree script improvements (#35603)
A few small improvements:
* Use `<root>/.worktrees` as the directory for worktrees so its hidden
by default in finder/ls
* Generate names with a timestamp, and allow auto-generating a name so
that you can just call eg `./scripts/worktree.sh --compiler --claude`
and get a random name
2026-01-23 10:41:17 -08:00
Joseph Savona
2c8725fdfd [compiler] snap fails if nothing compiled, unless @expectNothingCompiled (#35615)
A few times an agent has constructed fixtures that are silently skipped
because the component has no jsx or hook calls. This PR updates snap to
ensure that for each fixture either:

1) There are at least one compile success/failure *and* the
`@expectNothingCompiled` pragma is missing
2) OR there are zero success/failures *and* the `@expectNothingCompiled`
pragma is present

This ensures we are intentional about fixtures that are expected not to
have compilation, and know if that expectation breaks.
2026-01-23 10:38:40 -08:00
Joseph Savona
03613cd68c [compiler] Improve snap usability (#35537)
A whole bunch of changes to snap aimed at making it more usable for
humans and agents. Here's the new CLI interface:

```
node dist/main.js --help
Options:
      --version         Show version number                            [boolean]
      --sync            Run compiler in main thread (instead of using worker
                        threads or subprocesses). Defaults to false.
                                                      [boolean] [default: false]
      --worker-threads  Run compiler in worker threads (instead of
                        subprocesses). Defaults to true.
                                                       [boolean] [default: true]
      --help            Show help                                      [boolean]
  -w, --watch           Run compiler in watch mode, re-running after changes
                                                                       [boolean]
  -u, --update          Update fixtures                                [boolean]
  -p, --pattern         Optional glob pattern to filter fixtures (e.g.,
                        "error.*", "use-memo")                          [string]
  -d, --debug           Enable debug logging to print HIR for each pass[boolean]
```

Key changes:
* Added abbreviations for common arguments
* No more testfilter.txt! Filtering/debugging works more like Jest, see
below.
* The `--debug` flag (`-d`) controls whether to emit debug information.
In watch mode, this flag sets the initial debug value, and it can be
toggled by pressing the 'd' key while watching.
* The `--pattern` flag (`-p`) sets a filter pattern. In watch mode, this
flag sets the initial filter. It can be changed by pressing 'p' and
typing a new pattern, or pressing 'a' to switch to running all tests.
* As before, we only actually enable debugging if debug mode is enabled
_and_ there is only one test selected.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35537).
* #35607
* #35298
* #35596
* #35573
* #35595
* #35539
* __->__ #35537
* #35523
2026-01-23 10:36:55 -08:00
Joseph Savona
2af6822c21 [compiler] Claude file/settings (#35523)
Initializes CLAUDE.md and a settings file for the compiler/ directory to
help use claude with the compiler. Note that some of the commands here
depend on changes to snap from the next PR.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35523).
* #35607
* #35298
* #35596
* #35573
* #35595
* #35539
* #35537
* __->__ #35523
2026-01-23 10:36:43 -08:00
Sebastian "Sebbie" Silbermann
24d8716e36 Gitignore local Claude files (#35610) 2026-01-23 16:30:08 +01:00
Ruslan Lesiutin
94913cbffe [flags] cleanup renameElementSymbol (#35600)
Removed the feature flag completely, enabled by default. Will land once
I have everything ready on xplat side.
2026-01-23 10:46:30 +00:00
Ricky
2d8e7f1ce3 [flags] Remove enableHydrationLaneScheduling (#35549)
This is just a killswitch and has been on for over a year
https://github.com/facebook/react/pull/31751
2026-01-22 13:51:48 -05:00
Joseph Savona
6a0ab4d2dd Add worktree script (#35593)
Intended to be used directly and/or from skills in an agent.

Usage is `./scripts/worktree.sh [--compiler] [--claude] <name>`. The
script:
* Checks that ./worktrees is in gitignore
* Checks the named worktree does not exist yet
* Creates the named worktree in ./worktrees/
* Installs deps
* cds into the worktree (optionally the compiler dir if `--compiler`)
* optionally runs claude in the worktree if `--claude`
2026-01-21 22:38:37 -08:00
Joseph Savona
03ee29da2f [eslint-plugin-react-hooks] Skip compilation for non-React files (#35589)
Add a fast heuristic to detect whether a file may contain React
components or hooks before running the full compiler. This avoids the
overhead of Babel AST parsing and compilation for utility files, config
files, and other non-React code.

The heuristic uses ESLint's already-parsed AST to check for functions
with React-like names at module scope:
- Capitalized functions: MyComponent, Button, App
- Hook pattern functions: useEffect, useState, useMyCustomHook

Files without matching function names are skipped and return an empty
result, which is cached to avoid re-checking for subsequent rules.

Also adds test coverage for the heuristic edge cases.
2026-01-21 12:49:15 -08:00
Sebastian "Sebbie" Silbermann
cdbd55f440 Type react-devtools-hook-installer and react-devtools-hook-settings-injector messages (#35586) 2026-01-21 19:13:24 +01:00
Sebastian Markbåge
b546603bcb [Fiber] getNearestMountedFiber should consider fibers with alternates as mounted (#35578) 2026-01-21 08:33:35 -05:00
Sebastian "Sebbie" Silbermann
7fccd6b5a3 [DevTools] Fix console links not being openable (#35229) 2026-01-21 10:34:26 +01:00
Sebastian Markbåge
d29087523a Cancel animation when a custom Timeline is used (#35567)
Follow up to #35559.

The clean up function of the custom timeline doesn't necessarily clean
up the animation. Just the timeline's internal state.

This affects Firefox which doesn't support ScrollTimeline so uses the
polyfill's custom timeline.
2026-01-19 20:53:05 -05:00
Sebastian Markbåge
d343c39cce Remove Gesture warning when cloning the root (#35566)
Currently we always clone the root when a gesture transition happens.
The was to add an optimization where if a Transition could be isolated
to an absolutely positioned subtree then we could just clone that
subtree or just do a plain insertion if it was simple an Enter. That way
when switching between two absolutely positioned pages the shell
wouldn't need to be cloned. In that case `detectMutationOrInsertClones`
would return false. However, currently it always return true because we
don't yet have that optimization.

The idea was to warn when the root required cloning to ensure that you
optimize it intentionally since it's easy to accidentally update more
than necessary. However, since this is not yet actionable I'm removing
this warning for now.

Instead, I add a warning for particularly bad cases where you really
shouldn't clone like iframe and video. They may not be very actionable
without the optimization since you can't scope it down to a subtree
without the optimization. So if they're above the gesture then they're
always cloned atm. However, it might also be that it's unnecessary to
keep them mounted if they could be removed or hidden with Activity.
2026-01-19 19:28:12 -05:00
Sebastian Markbåge
1ecd99c774 Temporarily Mount useInsertionEffect while a Gesture snapshot is being computed (#35565)
`useInsertionEffect` is meant to be used to insert `<style>` tags that
affect the layout. It allows precomputing a layout before it mounts.

Since we're not normally firing any effects during the "apply gesture"
phase where we create the clones, it's possible for the target snapshot
to be missing styles. This makes it so that `useInsertionEffect` for a
new tree are mounted before the snapshot is taken and then unmounted
before the animation starts.

Note that because we are mounting a clone of the DOM tree and the
previous DOM tree remains mounted during the snapshot, we can't unmount
any previous insertion effects. This can lead to conflicts but that is
similar to what can happen with conflicts for two mounted Activity
boundaries since insertion effects can remain mounted inside those.

A revealed Activity will have already had their insertion effects fired
while offscreen.

However, one thing this doesn't yet do is handle the case where a
`useInsertionEffect` is *updated* as part of a gesture being applied.
This means it's still possible for it to miss some styles in that case.
The interesting thing there is that since the old state and the new
state will both be applicable to the global DOM in this phase, what
should really happen is that we should mount the new updated state
without unmounting the old state and then unmount the updated state.
Meaning you can have the same hook in the mounted state twice at the
same time.
2026-01-19 19:27:59 -05:00
Sebastian Markbåge
c55ffb5ca3 Add Clean Up Callbacks to View Transition and Gesture Transition Events (#35564)
Stacked on #35556 and #35559.

Given that we don't automatically clean up all view transition
animations since #35337 and browsers are buggy, it's important that you
clean up any `Animation` started manually from the events. However,
there was no clean up function for when the View Transition is forced to
stop. This also makes it harder to clean up custom timers etc too.

This lets you return a clean up function from all the events on
`<ViewTransition>`.
2026-01-19 19:27:45 -05:00
Sebastian Markbåge
a49952b303 Properly clean up gesture Animations (#35559)
Follow up to #35337.

During a gesture, we always cancel the original animation and create a
new one that we control. That's the one we need to add to the set that
needs to be cancelled. Otherwise future gestures hang.

An unfortunate consequence is that any custom ones that you start e.g.
with #35556 or through other means aren't automatically cleaned up (in
fact there's not even a clean up callback yet). This can lead these to
freeze the whole UI afterwards. It would be really good to get this
fixed in browsers instead so we can revert #35337.
2026-01-19 19:26:28 -05:00
Sebastian Markbåge
4bcf67e746 Support onGestureEnter/Exit/Share/Update events (#35556)
This is like the onEnter/Exit/Share/Update events but for gestures. It
allows manually controlling the animation using the passed timeline.
2026-01-19 19:26:09 -05:00
Sebastian "Sebbie" Silbermann
41b3e9a670 [Fizz] Push a stalled use() to the ownerStack/debugTask (#35226) 2026-01-19 09:10:16 +01:00
Ricky
195fd2286b [tests] Fix flaky flight tests (#35513)
Flights tests are failing locally and in CI non-deterministically
because we're not disabling async hooks after tests, and GC can clear
WeakRefs non-deterministically.

This PR fixes the issue by adding an afterEach to disable installed
hooks, and normalizing the `value` to `value: {value: undefined}}` when
snapshotting.
2026-01-18 15:36:00 -05:00
Ricky
d87298ae16 [tests] add silent reporter (#35547)
Adds silent reporter so you can run tests and only see the failed tests

This helps reduce context agents use, if you're inclined to use agents:

<img width="630" height="292" alt="Screenshot 2026-01-17 at 12 39 58 PM"
src="https://github.com/user-attachments/assets/373b9803-59a6-4b9a-99f9-d74a7b41462e"
/>
2026-01-18 10:17:17 -05:00
Ricky
be3fb29904 [internal] revert change merged accidentally (#35546)
I accidentally pushed this to new flag to
https://github.com/facebook/react/pull/35541 and then merged it.

Reverting it so I can submit a review.
2026-01-17 13:21:46 -05:00
Ricky
23e5edd05c [flags] clean up enableUseEffectEventHook (#35541)
This is landed everywhere
2026-01-17 12:46:05 -05:00
Jack Pope
3926e2438f Fix ViewTransition null stateNode with SuspenseList (#35520)
I was experimenting with animations in SuspenseList and hit a crash
using ViewTransition as a direct child with `revealOrder="together"`

```
    TypeError: Cannot read properties of null (reading 'autoName')

      33 |     return props.name;
      34 |   }
    > 35 |   if (instance.autoName !== null) {
         |                ^
      36 |     return instance.autoName;
      37 |   }
```

When ViewTransition is direct child of SuspenseList, the second render
pass calls resetChildFibers, setting stateNode to null. Other fibers
create stateNode in completeWork. ViewTransition does not, so stateNode
is lost.

Followed the pattern used for Offscreen to update stateNode in beginWork
if it is null.

Also added a regression test.
2026-01-16 16:39:25 -05:00
Hendrik Liebau
6baff7ac76 [Flight] Allow cyclic references to be serialized when unwrapping lazy elements (#35471)
When `renderModelDestructive` unwraps a lazy element and subsequently
calls `renderModelDestructive` again with the resolved model, we should
preserve the parent connection so that cyclic references can be
serialized properly. This can occur in an advanced scenario where the
result from the Flight Client is serialized again with the Flight
Server, e.g. for slicing a precomputed payload into multiple parts.

Note: The added test only fails when run with `--prod`. In dev mode, the
component info outlining prevents the issue from occurring.
2026-01-16 18:42:09 +01:00
Sebastian "Sebbie" Silbermann
bef88f7c11 [DevTools] Stop setting unused global variables (#35532) 2026-01-16 16:13:29 +01:00
Sebastian "Sebbie" Silbermann
01c4d03d84 [DevTools] Clear element inspection if host element not owned by any renderer is selected (#35504) 2026-01-16 13:20:44 +01:00
Sebastian "Sebbie" Silbermann
cbc4d40663 Typecheck React DevTools extension main script (#35519) 2026-01-16 13:08:28 +01:00
Josh Story
db71391c5c [Fiber] Instrument the lazy initializer thenable in all cases (#35521)
When a lazy element or component is initialized a thenable is returned
which was only be conditionally instrumented in dev when asyncDebugInfo
was enabled. When instrumented these thenables can be used in
conjunction with the SuspendOnImmediate optimization where if a thenable
resolves before the stack unwinds we can continue rendering from the
last suspended fiber. Without this change a recent fix to the useId
implementation cannot be easily tested in production because this
optimization pathway isn't available to regular React.lazy thenables. To
land the prior PR I changed the thenables to a custom type so I could
instrument manually in the test. WIth this change we can just use a
regular Promise since ReactLazy will instrument in all
environments/flags now
2026-01-15 19:05:23 -08:00
Sebastian Markbåge
4cf906380d Optimize gesture by allowing the original work in progress tree to be a suspended commit (#35510)
Stacked on #35487.

This is slightly different because the first suspended commit is on
blockers that prevent us from committing which still needs to be
resolved first.

If a gesture lane has to be rerendered while the gesture is happening
then it reenters this state with a new tree. (Currently this doesn't
happen for a ping I think which is not really how it usually works but
better in this case.)
2026-01-15 20:51:36 -05:00
Sebastian Markbåge
eac3c95537 Defer useDeferredValue updates in Gestures (#35511)
If an initial value is specified, then it's always used regardless as
part of the gesture render.

If a gesture render causes an update, then previously that was not
treated as deferred and could therefore be blocking the render. However,
a gesture is supposed to flush synchronously ideally. Therefore we
should consider these as urgent.

The effect is that useDeferredValue renders the previous state.
2026-01-15 20:46:11 -05:00
Sebastian Markbåge
35a81cecf7 Entangle Gesture revert commit with the corresponding Action commit (#35487)
Stacked on #35486.

When a Gesture commits, it leaves behind work on a Transition lane
(`revertLane`). This entangles that lane with whatever lane we're using
in the event that cancels the Gesture. This ensures that the revert and
the result of any resulting Action commits as one batch. Typically the
Action would apply a new state that is similar or the same as the revert
of the Gesture.

This makes it resilient to unbatching in #35392.
2026-01-15 20:45:14 -05:00
Sebastian Markbåge
4028aaa50c Commit the Gesture lane if a gesture ends closer to the target state (#35486)
Stacked on #35485.

Before this PR, the `startGestureTransition` API would itself never
commit its state. After the gesture releases it stops the animation in
the next commit which just leaves the DOM tree in the original state. If
there's an actual state change from the Action then that's committed as
the new DOM tree. To avoid animating from the original state to the new
state again, this is DOM without an animation. However, this means that
you can't have the actual action committing be in a slightly different
state and animate between the final gesture state and into the new
action.

Instead, we now actually keep the render tree around and commit it in
the end. Basically we assume that if the Timeline was closer to the end
then visually you're already there and we can commit into that state.
Most of the time this will be at the actual end state when you release
but if you have something else cancelling the gesture (e.g.
`touchcancel`) it can still commit this state even though your gesture
recognizer might not consider this an Action. I think this is ok and
keeps it simple.

When the gesture lane commits, it'll leave a Transition behind as work
from the revert lanes on the Optimistic updates. This means that if you
don't do anything in the Action this will cause another commit right
after which reverts. This revert can animate the snap back.

There's a few fixes needed in follow up PRs:

- Fixed in #35487. ~To support unentangled Transitions we need to
explicitly entangle the revert lane with the Action to avoid committing
a revert followed by a forward instead of committing the forward
entangled with the revert. This just works now since everything is
entangled but won't work with #35392.~
- Fixed in #35510. ~This currently rerenders the gesture lane once
before committing if it was already completed but blocked. We should be
able to commit the already completed tree as is.~
2026-01-15 20:43:52 -05:00
Josh Story
f0fbb0d199 [Fiber] fix useId tracking on replay (#35518)
When Fiber replays work after suspending and resolving in a microtask it
stripped the Forked flag from Fibers because this flag type was not
considered a Static flag. The Forked nature of a Fiber is not render
dependent and should persist after unwinding work. By making this change
the replay correctly generates the necessary tree context.
2026-01-15 17:27:58 -08:00
Sebastian "Sebbie" Silbermann
bb8a76c6cc [DevTools] Show fallback in inspected element pane when no element is selected (#35503) 2026-01-15 14:28:02 +01:00
Sebastian "Sebbie" Silbermann
fae15df40e [DevTools] Add React Element pane to browser Elements panel (#35240) 2026-01-15 13:24:06 +01:00
Błażej Kustra
53daaf5aba Improve the detection of changed hooks (#35123)
## Summary

cc @hoxyq 

Fixes https://github.com/facebook/react/issues/28584. Follow up to PR:
https://github.com/facebook/react/pull/34547

This PR updates getChangedHooksIndices to account for the fact that
`useSyncExternalStore`, `useTransition`, `useActionState`,
`useFormState` internally mounts more than one hook while DevTools
should treat it as a single user-facing hook.

Approach idea came from
[this](https://github.com/facebook/react/pull/34547#issuecomment-3504113776)
comment 😄

Before:


https://github.com/user-attachments/assets/6bd5ce80-8b52-4bb8-8bb1-5e91b1e65043


After:


https://github.com/user-attachments/assets/47f56898-ab34-46b6-be7a-a54024dcefee



## How did you test this change?

I used this component to reproduce this issue locally (I followed
instructions in `packages/react-devtools/CONTRIBUTING.md`).

<details><summary>Details</summary>

```ts

import * as React from 'react';

function useDeepNestedHook() {
  React.useState(0); // 1
  return React.useState(1); // 2
}

function useNestedHook() {
  const deepState = useDeepNestedHook();
  React.useState(2); // 3
  React.useState(3); // 4

  return deepState;
}

// Create a simple store for useSyncExternalStore
function createStore(initialValue) {
  let value = initialValue;
  const listeners = new Set();
  return {
    getSnapshot: () => value,
    subscribe: listener => {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
    update: newValue => {
      value = newValue;
      listeners.forEach(listener => listener());
    },
  };
}

const syncExternalStore = createStore(0);

export default function InspectableElements(): React.Node {
  const [nestedState, setNestedState] = useNestedHook();

  // 5
  const syncExternalValue = React.useSyncExternalStore(
    syncExternalStore.subscribe,
    syncExternalStore.getSnapshot,
  );

  // 6
  const [isPending, startTransition] = React.useTransition();

  // 7
  const [formState, formAction, formPending] = React.useActionState(
    async (prevState, formData) => {
      return {count: (prevState?.count || 0) + 1};
    },
    {count: 0},
  );

  const handleTransition = () => {
    startTransition(() => {
      setState(Math.random());
    });
  };

  // 8
  const [state, setState] = React.useState('test');

  return (
    <>
      <div
        style={{
          padding: '20px',
          display: 'flex',
          flexDirection: 'column',
          gap: '10px',
        }}>
        <div
          onClick={() => setNestedState(Math.random())}
          style={{backgroundColor: 'red', padding: '10px', cursor: 'pointer'}}>
          State: {nestedState}
        </div>

        <button onClick={handleTransition} style={{padding: '10px'}}>
          Trigger Transition {isPending ? '(pending...)' : ''}
        </button>

        <div style={{display: 'flex', gap: '10px', alignItems: 'center'}}>
          <button
            onClick={() => syncExternalStore.update(syncExternalValue + 1)}
            style={{padding: '10px'}}>
            Trigger useSyncExternalStore
          </button>
          <span>Value: {syncExternalValue}</span>
        </div>

        <form
          action={formAction}
          style={{display: 'flex', gap: '10px', alignItems: 'center'}}>
          <button
            type="submit"
            style={{padding: '10px'}}
            disabled={formPending}>
            Trigger useFormState {formPending ? '(pending...)' : ''}
          </button>
          <span>Count: {formState.count}</span>
        </form>

        <div
          onClick={() => setState(Math.random())}
          style={{backgroundColor: 'red', padding: '10px', cursor: 'pointer'}}>
          State: {state}
        </div>
      </div>
    </>
  );
}
```


</details>

---------

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
2026-01-15 11:06:14 +00:00
Sebastian Markbåge
4a3d993e52 Add the suffix to cancelled view transition names (#35485)
When a View Transition might not need to update we add it to a queue. If
the parent are able to be reverted, we then cancel the already started
view transitions. We do this by adding an animation that hides the "old"
state and remove the view transition name from the old state.

There was a bug where if you have more than one child in a
`<ViewTransition>` we didn't add the right suffix to the name we added
in the queue so it wasn't adding an animation that hides the old state.
The effect was that it playing an exit animation instead of being
cancelled.
2026-01-14 10:00:06 -05:00
Ricky
3e1abcc8d7 [tests] Require exact error messages in assertConsole helpers (#35497)
Requires full error message in assert helpers. 

Some of the error messages we asset on add a native javascript stack
trace, which would be a pain to add to the messages and maintain. This
PR allows you to just add `\n in <stack>` placeholder to the error
message to denote a native stack trace is present in the message.

---
Note: i vibe coded this so it was a pain to backtrack this to break this
into a stack, I tried and gave up, sorry.
2026-01-13 15:52:53 -05:00
Josh Story
c18662405c [Fiber] Correctly handle replaying when hydrating (#35494)
When hydrating if something suspends and then resolves in a microtask it
is possible that React will resume the render without fully unwinding
work in progress. This can cause hydration cursors to be offset and lead
to hydration errors. This change adds a restore step when replaying
HostComponent to ensure the hydration cursor is in the appropriate
position when replaying.

fixes: #35210
2026-01-13 12:48:01 -08:00
Yukimasa Funaoka
583e200332 [DevTools] Enable minimal support in pages with sandbox Content-Security-Policy (#35208) 2026-01-13 17:49:44 +01:00
Sebastian "Sebbie" Silbermann
8a83073753 [test] Fix DevTools regression tests (#35501) 2026-01-13 16:00:16 +01:00
185 changed files with 5136 additions and 1233 deletions

View File

@@ -593,6 +593,7 @@ module.exports = {
mixin$Animatable: 'readonly',
MouseEventHandler: 'readonly',
NavigateEvent: 'readonly',
Partial: 'readonly',
PerformanceMeasureOptions: 'readonly',
PropagationPhases: 'readonly',
PropertyDescriptor: 'readonly',

View File

@@ -278,6 +278,7 @@ jobs:
if: steps.node_modules.outputs.cache-hit != 'true'
- run: yarn --cwd compiler install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
- run: node --version
- run: yarn test ${{ matrix.params }} --ci --shard=${{ matrix.shard }}
# Hardcoded to improve parallelism
@@ -445,6 +446,7 @@ jobs:
merge-multiple: true
- name: Display structure of build
run: ls -R build
- run: node --version
- run: yarn test --build ${{ matrix.test_params }} --shard=${{ matrix.shard }} --ci
test_build_devtools:
@@ -489,6 +491,7 @@ jobs:
merge-multiple: true
- name: Display structure of build
run: ls -R build
- run: node --version
- run: yarn test --build --project=devtools -r=experimental --shard=${{ matrix.shard }} --ci
process_artifacts_combined:

2
.gitignore vendored
View File

@@ -24,6 +24,8 @@ chrome-user-data
*.swp
*.swo
/tmp
/.worktrees
.claude/*.local.*
packages/react-devtools-core/dist
packages/react-devtools-extensions/chrome/build

View File

@@ -1,6 +1,8 @@
{
"permissions": {
"allow": [
"Bash(yarn snap:*)",
"Bash(yarn snap:build)",
"Bash(node scripts/enable-feature-flag.js:*)"
],
"deny": [],

2
compiler/.gitignore vendored
View File

@@ -8,7 +8,9 @@ dist
.vscode
!packages/playground/.vscode
testfilter.txt
.claude/settings.local.json
# forgive
*.vsix
.vscode-test

221
compiler/CLAUDE.md Normal file
View File

@@ -0,0 +1,221 @@
# React Compiler Knowledge Base
This document contains knowledge about the React Compiler gathered during development sessions. It serves as a reference for understanding the codebase architecture and key concepts.
## Project Structure
- `packages/babel-plugin-react-compiler/` - Main compiler package
- `src/HIR/` - High-level Intermediate Representation types and utilities
- `src/Inference/` - Effect inference passes (aliasing, mutation, etc.)
- `src/Validation/` - Validation passes that check for errors
- `src/Entrypoint/Pipeline.ts` - Main compilation pipeline with pass ordering
- `src/__tests__/fixtures/compiler/` - Test fixtures
- `error.todo-*.js` - Unsupported feature, correctly throws Todo error (graceful bailout)
- `error.bug-*.js` - Known bug, throws wrong error type or incorrect behavior
- `*.expect.md` - Expected output for each fixture
## Running Tests
```bash
# Run all tests
yarn snap
# Run tests matching a pattern
# Example: yarn snap -p 'error.*'
yarn snap -p <pattern>
# Run a single fixture in debug mode. Use the path relative to the __tests__/fixtures/compiler directory
# For each step of compilation, outputs the step name and state of the compiled program
# Example: yarn snap -p simple.js -d
yarn snap -p <file-basename> -d
# Update fixture outputs (also works with -p)
yarn snap -u
```
## Version Control
This repository uses Sapling (`sl`) for version control. Sapling is similar to Mercurial: there is not staging area, but new/deleted files must be explicitlyu added/removed.
```bash
# Check status
sl status
# Add new files, remove deleted files
sl addremove
# Commit all changes
sl commit -m "Your commit message"
# Commit with multi-line message using heredoc
sl commit -m "$(cat <<'EOF'
Summary line
Detailed description here
EOF
)"
```
## Key Concepts
### HIR (High-level Intermediate Representation)
The compiler converts source code to HIR for analysis. Key types in `src/HIR/HIR.ts`:
- **HIRFunction** - A function being compiled
- `body.blocks` - Map of BasicBlocks
- `context` - Captured variables from outer scope
- `params` - Function parameters
- `returns` - The function's return place
- `aliasingEffects` - Effects that describe the function's behavior when called
- **Instruction** - A single operation
- `lvalue` - The place being assigned to
- `value` - The instruction kind (CallExpression, FunctionExpression, LoadLocal, etc.)
- `effects` - Array of AliasingEffects for this instruction
- **Terminal** - Block terminators (return, branch, etc.)
- `effects` - Array of AliasingEffects
- **Place** - A reference to a value
- `identifier.id` - Unique IdentifierId
- **Phi nodes** - Join points for values from different control flow paths
- Located at `block.phis`
- `phi.place` - The result place
- `phi.operands` - Map of predecessor block to source place
### AliasingEffects System
Effects describe data flow and operations. Defined in `src/Inference/AliasingEffects.ts`:
**Data Flow Effects:**
- `Impure` - Marks a place as containing an impure value (e.g., Date.now() result, ref.current)
- `Capture a -> b` - Value from `a` is captured into `b` (mutable capture)
- `Alias a -> b` - `b` aliases `a`
- `ImmutableCapture a -> b` - Immutable capture (like Capture but read-only)
- `Assign a -> b` - Direct assignment
- `MaybeAlias a -> b` - Possible aliasing
- `CreateFrom a -> b` - Created from source
**Mutation Effects:**
- `Mutate value` - Value is mutated
- `MutateTransitive value` - Value and transitive captures are mutated
- `MutateConditionally value` - May mutate
- `MutateTransitiveConditionally value` - May mutate transitively
**Other Effects:**
- `Render place` - Place is used in render context (JSX props, component return)
- `Freeze place` - Place is frozen (made immutable)
- `Create place` - New value created
- `CreateFunction` - Function expression created, includes `captures` array
- `Apply` - Function application with receiver, function, args, and result
### Hook Aliasing Signatures
Located in `src/HIR/Globals.ts`, hooks can define custom aliasing signatures to control how data flows through them.
**Structure:**
```typescript
aliasing: {
receiver: '@receiver', // The hook function itself
params: ['@param0'], // Named positional parameters
rest: '@rest', // Rest parameters (or null)
returns: '@returns', // Return value
temporaries: [], // Temporary values during execution
effects: [ // Array of effects to apply when hook is called
{kind: 'Freeze', value: '@param0', reason: ValueReason.HookCaptured},
{kind: 'Assign', from: '@param0', into: '@returns'},
],
}
```
**Common patterns:**
1. **RenderHookAliasing** (useState, useContext, useMemo, useCallback):
- Freezes arguments (`Freeze @rest`)
- Marks arguments as render-time (`Render @rest`)
- Creates frozen return value
- Aliases arguments to return
2. **EffectHookAliasing** (useEffect, useLayoutEffect, useInsertionEffect):
- Freezes function and deps
- Creates internal effect object
- Captures function and deps into effect
- Returns undefined
3. **Event handler hooks** (useEffectEvent):
- Freezes callback (`Freeze @fn`)
- Aliases input to return (`Assign @fn -> @returns`)
- NO Render effect (callback not called during render)
**Example: useEffectEvent**
```typescript
const UseEffectEventHook = addHook(
DEFAULT_SHAPES,
{
positionalParams: [Effect.Freeze], // Takes one positional param
restParam: null,
returnType: {kind: 'Function', ...},
calleeEffect: Effect.Read,
hookKind: 'useEffectEvent',
returnValueKind: ValueKind.Frozen,
aliasing: {
receiver: '@receiver',
params: ['@fn'], // Name for the callback parameter
rest: null,
returns: '@returns',
temporaries: [],
effects: [
{kind: 'Freeze', value: '@fn', reason: ValueReason.HookCaptured},
{kind: 'Assign', from: '@fn', into: '@returns'},
// Note: NO Render effect - callback is not called during render
],
},
},
BuiltInUseEffectEventId,
);
// Add as both names for compatibility
['useEffectEvent', UseEffectEventHook],
['experimental_useEffectEvent', UseEffectEventHook],
```
**Key insight:** If a hook is missing an `aliasing` config, it falls back to `DefaultNonmutatingHook` which includes a `Render` effect on all arguments. This can cause false positives for hooks like `useEffectEvent` whose callbacks are not called during render.
## Feature Flags
Feature flags are configured in `src/HIR/Environment.ts`, for example `enableJsxOutlining`. Test fixtures can override the active feature flags used for that fixture via a comment pragma on the first line of the fixture input, for example:
```javascript
// enableJsxOutlining @enableChangeVariableCodegen:false
...code...
```
Would enable the `enableJsxOutlining` feature and disable the `enableChangeVariableCodegen` feature.
## Debugging Tips
1. Run `yarn snap -p <fixture>` to see full HIR output with effects
2. Look for `@aliasingEffects=` on FunctionExpressions
3. Look for `Impure`, `Render`, `Capture` effects on instructions
4. Check the pass ordering in Pipeline.ts to understand when effects are populated vs validated
## Error Handling for Unsupported Features
When the compiler encounters an unsupported but known pattern, use `CompilerError.throwTodo()` instead of `CompilerError.invariant()`. Todo errors cause graceful bailouts in production; Invariant errors are hard failures indicating unexpected/invalid states.
```typescript
// Unsupported but expected pattern - graceful bailout
CompilerError.throwTodo({
reason: `Support [description of unsupported feature]`,
loc: terminal.loc,
});
// Invariant is for truly unexpected/invalid states - hard failure
CompilerError.invariant(false, {
reason: `Unexpected [thing]`,
loc: terminal.loc,
});
```

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
class Component {
_renderMessage = () => {
const Message = () => {
@@ -22,7 +22,7 @@ class Component {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
class Component {
_renderMessage = () => {
const Message = () => {

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
class Component {
_renderMessage = () => {
const Message = () => {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @customOptOutDirectives:["use todo memo"]
// @expectNothingCompiled @customOptOutDirectives:["use todo memo"]
function Component() {
'use todo memo';
return <div>hello world!</div>;
@@ -18,7 +18,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
// @customOptOutDirectives:["use todo memo"]
// @expectNothingCompiled @customOptOutDirectives:["use todo memo"]
function Component() {
"use todo memo";
return <div>hello world!</div>;

View File

@@ -1,4 +1,4 @@
// @customOptOutDirectives:["use todo memo"]
// @expectNothingCompiled @customOptOutDirectives:["use todo memo"]
function Component() {
'use todo memo';
return <div>hello world!</div>;

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees
// @expectNothingCompiled @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees
import {useMemo} from 'react';
import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime';
@@ -37,7 +37,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees
// @expectNothingCompiled @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees
import { useMemo } from "react";
import { makeObject_Primitives, ValidateMemoization } from "shared-runtime";

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees
// @expectNothingCompiled @compilationMode:"infer" @enablePreserveExistingMemoizationGuarantees @validatePreserveExistingMemoizationGuarantees
import {useMemo} from 'react';
import {makeObject_Primitives, ValidateMemoization} from 'shared-runtime';

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @gating
// @expectNothingCompiled @gating
import {isForgetEnabled_Fixtures} from 'ReactForgetFeatureFlag';
export default 42;
@@ -12,7 +12,7 @@ export default 42;
## Code
```javascript
// @gating
// @expectNothingCompiled @gating
import { isForgetEnabled_Fixtures } from "ReactForgetFeatureFlag";
export default 42;

View File

@@ -1,4 +1,4 @@
// @gating
// @expectNothingCompiled @gating
import {isForgetEnabled_Fixtures} from 'ReactForgetFeatureFlag';
export default 42;

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Takes multiple parameters - not a component!
function Component(foo, bar) {
return <div />;
@@ -18,7 +18,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Takes multiple parameters - not a component!
function Component(foo, bar) {
return <div />;

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Takes multiple parameters - not a component!
function Component(foo, bar) {
return <div />;

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
import {useIdentity, identity} from 'shared-runtime';
function Component(fakeProps: number) {
@@ -20,7 +20,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
import { useIdentity, identity } from "shared-runtime";
function Component(fakeProps: number) {

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
import {useIdentity, identity} from 'shared-runtime';
function Component(fakeProps: number) {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
function Component(props) {
const result = f(props);
function helper() {
@@ -26,7 +26,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
function Component(props) {
const result = f(props);
function helper() {

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
function Component(props) {
const result = f(props);
function helper() {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
function Component(props) {
const ignore = <foo />;
return {foo: f(props)};
@@ -22,7 +22,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
function Component(props) {
const ignore = <foo />;
return { foo: f(props) };

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
function Component(props) {
const ignore = <foo />;
return {foo: f(props)};

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// This component is skipped bc it doesn't call any hooks or
// use JSX:
function Component(props) {
@@ -14,7 +14,7 @@ function Component(props) {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// This component is skipped bc it doesn't call any hooks or
// use JSX:
function Component(props) {

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// This component is skipped bc it doesn't call any hooks or
// use JSX:
function Component(props) {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Regression test for some internal code.
// This shows how the "callback rule" is more relaxed,
// and doesn't kick in unless we're confident we're in
@@ -20,7 +20,7 @@ function makeListener(instance) {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Regression test for some internal code.
// This shows how the "callback rule" is more relaxed,
// and doesn't kick in unless we're confident we're in

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Regression test for some internal code.
// This shows how the "callback rule" is more relaxed,
// and doesn't kick in unless we're confident we're in

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Valid because hooks can call hooks.
function createHook() {
return function useHook() {
@@ -16,7 +16,7 @@ function createHook() {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Valid because hooks can call hooks.
function createHook() {
return function useHook() {

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Valid because hooks can call hooks.
function createHook() {
return function useHook() {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Valid because hooks can use hooks.
function createHook() {
return function useHookWithHook() {
@@ -15,7 +15,7 @@ function createHook() {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Valid because hooks can use hooks.
function createHook() {
return function useHookWithHook() {

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Valid because hooks can use hooks.
function createHook() {
return function useHookWithHook() {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Valid because components can use hooks.
function createComponentWithHook() {
return function ComponentWithHook() {
@@ -15,7 +15,7 @@ function createComponentWithHook() {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Valid because components can use hooks.
function createComponentWithHook() {
return function ComponentWithHook() {

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// Valid because components can use hooks.
function createComponentWithHook() {
return function ComponentWithHook() {

View File

@@ -2,6 +2,7 @@
## Input
```javascript
// @expectNothingCompiled
// Valid because they're not matching use[A-Z].
fooState();
_use();
@@ -15,6 +16,7 @@ jest.useFakeTimer();
## Code
```javascript
// @expectNothingCompiled
// Valid because they're not matching use[A-Z].
fooState();
_use();

View File

@@ -1,3 +1,4 @@
// @expectNothingCompiled
// Valid because they're not matching use[A-Z].
fooState();
_use();

View File

@@ -2,6 +2,7 @@
## Input
```javascript
// @expectNothingCompiled
// Valid because classes can call functions.
// We don't consider these to be hooks.
class C {
@@ -16,6 +17,7 @@ class C {
## Code
```javascript
// @expectNothingCompiled
// Valid because classes can call functions.
// We don't consider these to be hooks.
class C {

View File

@@ -1,3 +1,4 @@
// @expectNothingCompiled
// Valid because classes can call functions.
// We don't consider these to be hooks.
class C {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// This is valid because "use"-prefixed functions called in
// unnamed function arguments are not assumed to be hooks.
unknownFunction(function (foo, bar) {
@@ -16,7 +16,7 @@ unknownFunction(function (foo, bar) {
## Code
```javascript
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// This is valid because "use"-prefixed functions called in
// unnamed function arguments are not assumed to be hooks.
unknownFunction(function (foo, bar) {

View File

@@ -1,4 +1,4 @@
// @compilationMode:"infer"
// @expectNothingCompiled @compilationMode:"infer"
// This is valid because "use"-prefixed functions called in
// unnamed function arguments are not assumed to be hooks.
unknownFunction(function (foo, bar) {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
// Invalid because it's dangerous.
@@ -22,7 +22,7 @@ useCustomHook();
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
// Invalid because it's dangerous.

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
// Invalid because it's dangerous.

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
// This is a false positive (it's valid) that unfortunately
@@ -20,7 +20,7 @@ class Foo extends Component {
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
// This is a false positive (it's valid) that unfortunately

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
// This is a false positive (it's valid) that unfortunately

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
// Technically this is a false positive.
@@ -23,7 +23,7 @@ const browserHistory = useBasename(createHistory)({
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
// Technically this is a false positive.

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
// Technically this is a false positive.

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {
@@ -16,7 +16,7 @@
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
class ClassComponentWithHook extends React.Component {
@@ -16,7 +16,7 @@ class ClassComponentWithHook extends React.Component {
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
class ClassComponentWithHook extends React.Component {

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
class ClassComponentWithHook extends React.Component {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
class ClassComponentWithFeatureFlag extends React.Component {
@@ -18,7 +18,7 @@ class ClassComponentWithFeatureFlag extends React.Component {
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
class ClassComponentWithFeatureFlag extends React.Component {

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
class ClassComponentWithFeatureFlag extends React.Component {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {
@@ -16,7 +16,7 @@
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
class C {
@@ -17,7 +17,7 @@ class C {
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
class C {

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
class C {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {
@@ -16,7 +16,7 @@
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {
@@ -16,7 +16,7 @@
## Code
```javascript
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {

View File

@@ -1,4 +1,4 @@
// @skip
// @expectNothingCompiled @skip
// Passed but should have failed
(class {

View File

@@ -2,6 +2,7 @@
## Input
```javascript
// @expectNothingCompiled
import {c as useMemoCache} from 'react/compiler-runtime';
function Component(props) {
@@ -26,6 +27,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
// @expectNothingCompiled
import { c as useMemoCache } from "react/compiler-runtime";
function Component(props) {

View File

@@ -1,3 +1,4 @@
// @expectNothingCompiled
import {c as useMemoCache} from 'react/compiler-runtime';
function Component(props) {

View File

@@ -2,6 +2,7 @@
## Input
```javascript
// @expectNothingCompiled
function Component() {
'use no forget';
return <div>Hello World</div>;
@@ -18,6 +19,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
// @expectNothingCompiled
function Component() {
"use no forget";
return <div>Hello World</div>;

View File

@@ -1,3 +1,4 @@
// @expectNothingCompiled
function Component() {
'use no forget';
return <div>Hello World</div>;

View File

@@ -2,6 +2,7 @@
## Input
```javascript
// @expectNothingCompiled
function Component(props) {
'use no memo';
let x = [props.foo];
@@ -19,6 +20,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
// @expectNothingCompiled
function Component(props) {
"use no memo";
let x = [props.foo];

View File

@@ -1,3 +1,4 @@
// @expectNothingCompiled
function Component(props) {
'use no memo';
let x = [props.foo];

View File

@@ -52,7 +52,11 @@ function makePluginOptions(
EffectEnum: typeof Effect,
ValueKindEnum: typeof ValueKind,
ValueReasonEnum: typeof ValueReason,
): [PluginOptions, Array<{filename: string | null; event: LoggerEvent}>] {
): {
options: PluginOptions;
loggerTestOnly: boolean;
logs: Array<{filename: string | null; event: LoggerEvent}>;
} {
// TODO(@mofeiZ) rewrite snap fixtures to @validatePreserveExistingMemo:false
let validatePreserveExistingMemoizationGuarantees = false;
let target: CompilerReactTarget = '19';
@@ -69,13 +73,12 @@ function makePluginOptions(
validatePreserveExistingMemoizationGuarantees = true;
}
const loggerTestOnly = firstLine.includes('@loggerTestOnly');
const logs: Array<{filename: string | null; event: LoggerEvent}> = [];
const logger: Logger = {
logEvent: firstLine.includes('@loggerTestOnly')
? (filename, event) => {
logs.push({filename, event});
}
: () => {},
logEvent: (filename, event) => {
logs.push({filename, event});
},
debugLogIRs: debugIRLogger,
};
@@ -96,7 +99,7 @@ function makePluginOptions(
enableReanimatedCheck: false,
target,
};
return [options, logs];
return {options, loggerTestOnly, logs};
}
export function parseInput(
@@ -245,7 +248,7 @@ export async function transformFixtureInput(
/**
* Get Forget compiled code
*/
const [options, logs] = makePluginOptions(
const {options, loggerTestOnly, logs} = makePluginOptions(
firstLine,
parseConfigPragmaFn,
debugIRLogger,
@@ -342,7 +345,7 @@ export async function transformFixtureInput(
}
const forgetOutput = await format(forgetCode, language);
let formattedLogs = null;
if (logs.length !== 0) {
if (loggerTestOnly && logs.length !== 0) {
formattedLogs = logs
.map(({event}) => {
return JSON.stringify(event, (key, value) => {
@@ -358,6 +361,23 @@ export async function transformFixtureInput(
})
.join('\n');
}
const expectNothingCompiled =
firstLine.indexOf('@expectNothingCompiled') !== -1;
const successFailures = logs.filter(
log =>
log.event.kind === 'CompileSuccess' || log.event.kind === 'CompileError',
);
if (successFailures.length === 0 && !expectNothingCompiled) {
return {
kind: 'err',
msg: 'No success/failure events, add `// @expectNothingCompiled` to the first line if this is expected',
};
} else if (successFailures.length !== 0 && expectNothingCompiled) {
return {
kind: 'err',
msg: 'Expected nothing to be compiled (from `// @expectNothingCompiled`), but some functions compiled or errored',
};
}
return {
kind: 'ok',
value: {

View File

@@ -26,5 +26,3 @@ export const FIXTURES_PATH = path.join(
'compiler',
);
export const SNAPSHOT_EXTENSION = '.expect.md';
export const FILTER_FILENAME = 'testfilter.txt';
export const FILTER_PATH = path.join(PROJECT_ROOT, FILTER_FILENAME);

View File

@@ -8,7 +8,7 @@
import fs from 'fs/promises';
import * as glob from 'glob';
import path from 'path';
import {FILTER_PATH, FIXTURES_PATH, SNAPSHOT_EXTENSION} from './constants';
import {FIXTURES_PATH, SNAPSHOT_EXTENSION} from './constants';
const INPUT_EXTENSIONS = [
'.js',
@@ -22,19 +22,9 @@ const INPUT_EXTENSIONS = [
];
export type TestFilter = {
debug: boolean;
paths: Array<string>;
};
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
function stripExtension(filename: string, extensions: Array<string>): string {
for (const ext of extensions) {
if (filename.endsWith(ext)) {
@@ -44,37 +34,6 @@ function stripExtension(filename: string, extensions: Array<string>): string {
return filename;
}
export async function readTestFilter(): Promise<TestFilter | null> {
if (!(await exists(FILTER_PATH))) {
throw new Error(`testfilter file not found at \`${FILTER_PATH}\``);
}
const input = await fs.readFile(FILTER_PATH, 'utf8');
const lines = input.trim().split('\n');
let debug: boolean = false;
const line0 = lines[0];
if (line0 != null) {
// Try to parse pragmas
let consumedLine0 = false;
if (line0.indexOf('@only') !== -1) {
consumedLine0 = true;
}
if (line0.indexOf('@debug') !== -1) {
debug = true;
consumedLine0 = true;
}
if (consumedLine0) {
lines.shift();
}
}
return {
debug,
paths: lines.filter(line => !line.trimStart().startsWith('//')),
};
}
export function getBasename(fixture: TestFixture): string {
return stripExtension(path.basename(fixture.inputPath), INPUT_EXTENSIONS);
}

View File

@@ -8,8 +8,8 @@
import watcher from '@parcel/watcher';
import path from 'path';
import ts from 'typescript';
import {FILTER_FILENAME, FIXTURES_PATH, PROJECT_ROOT} from './constants';
import {TestFilter, readTestFilter} from './fixture-utils';
import {FIXTURES_PATH, PROJECT_ROOT} from './constants';
import {TestFilter, getFixtures} from './fixture-utils';
import {execSync} from 'child_process';
export function watchSrc(
@@ -117,6 +117,16 @@ export type RunnerState = {
lastUpdate: number;
mode: RunnerMode;
filter: TestFilter | null;
debug: boolean;
// Input mode for interactive pattern entry
inputMode: 'none' | 'pattern';
inputBuffer: string;
// Autocomplete state
allFixtureNames: Array<string>;
matchingFixtures: Array<string>;
selectedIndex: number;
// Track last run status of each fixture (for autocomplete suggestions)
fixtureLastRunStatus: Map<string, 'pass' | 'fail'>;
};
function subscribeFixtures(
@@ -142,26 +152,6 @@ function subscribeFixtures(
});
}
function subscribeFilterFile(
state: RunnerState,
onChange: (state: RunnerState) => void,
) {
watcher.subscribe(PROJECT_ROOT, async (err, events) => {
if (err) {
console.error(err);
process.exit(1);
} else if (
events.findIndex(event => event.path.includes(FILTER_FILENAME)) !== -1
) {
if (state.mode.filter) {
state.filter = await readTestFilter();
state.mode.action = RunnerAction.Test;
onChange(state);
}
}
});
}
function subscribeTsc(
state: RunnerState,
onChange: (state: RunnerState) => void,
@@ -195,20 +185,226 @@ function subscribeTsc(
);
}
/**
* Levenshtein edit distance between two strings
*/
function editDistance(a: string, b: string): number {
const m = a.length;
const n = b.length;
// Create a 2D array for memoization
const dp: number[][] = Array.from({length: m + 1}, () =>
Array(n + 1).fill(0),
);
// Base cases
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
// Fill in the rest
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
}
return dp[m][n];
}
function filterFixtures(
allNames: Array<string>,
pattern: string,
): Array<string> {
if (pattern === '') {
return allNames;
}
const lowerPattern = pattern.toLowerCase();
const matches = allNames.filter(name =>
name.toLowerCase().includes(lowerPattern),
);
// Sort by edit distance (lower = better match)
matches.sort((a, b) => {
const distA = editDistance(lowerPattern, a.toLowerCase());
const distB = editDistance(lowerPattern, b.toLowerCase());
return distA - distB;
});
return matches;
}
const MAX_DISPLAY = 15;
function renderAutocomplete(state: RunnerState): void {
// Clear terminal
console.log('\u001Bc');
// Show current input
console.log(`Pattern: ${state.inputBuffer}`);
console.log('');
// Get current filter pattern if active
const currentFilterPattern =
state.mode.filter && state.filter ? state.filter.paths[0] : null;
// Show matching fixtures (limit to MAX_DISPLAY)
const toShow = state.matchingFixtures.slice(0, MAX_DISPLAY);
toShow.forEach((name, i) => {
const isSelected = i === state.selectedIndex;
const matchesCurrentFilter =
currentFilterPattern != null &&
name.toLowerCase().includes(currentFilterPattern.toLowerCase());
let prefix: string;
if (isSelected) {
prefix = '> ';
} else if (matchesCurrentFilter) {
prefix = '* ';
} else {
prefix = ' ';
}
console.log(`${prefix}${name}`);
});
if (state.matchingFixtures.length > MAX_DISPLAY) {
console.log(
` ... and ${state.matchingFixtures.length - MAX_DISPLAY} more`,
);
}
console.log('');
console.log('↑/↓/Tab navigate | Enter select | Esc cancel');
}
function subscribeKeyEvents(
state: RunnerState,
onChange: (state: RunnerState) => void,
) {
process.stdin.on('keypress', async (str, key) => {
// Handle input mode (pattern entry with autocomplete)
if (state.inputMode !== 'none') {
if (key.name === 'return') {
// Enter pressed - use selected fixture or typed text
let pattern: string;
if (
state.selectedIndex >= 0 &&
state.selectedIndex < state.matchingFixtures.length
) {
pattern = state.matchingFixtures[state.selectedIndex];
} else {
pattern = state.inputBuffer.trim();
}
state.inputMode = 'none';
state.inputBuffer = '';
state.allFixtureNames = [];
state.matchingFixtures = [];
state.selectedIndex = -1;
if (pattern !== '') {
state.filter = {paths: [pattern]};
state.mode.filter = true;
state.mode.action = RunnerAction.Test;
onChange(state);
}
return;
} else if (key.name === 'escape') {
// Cancel input mode
state.inputMode = 'none';
state.inputBuffer = '';
state.allFixtureNames = [];
state.matchingFixtures = [];
state.selectedIndex = -1;
// Redraw normal UI
onChange(state);
return;
} else if (key.name === 'up' || (key.name === 'tab' && key.shift)) {
// Navigate up in autocomplete list
if (state.matchingFixtures.length > 0) {
if (state.selectedIndex <= 0) {
state.selectedIndex =
Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
} else {
state.selectedIndex--;
}
renderAutocomplete(state);
}
return;
} else if (key.name === 'down' || (key.name === 'tab' && !key.shift)) {
// Navigate down in autocomplete list
if (state.matchingFixtures.length > 0) {
const maxIndex =
Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
if (state.selectedIndex >= maxIndex) {
state.selectedIndex = 0;
} else {
state.selectedIndex++;
}
renderAutocomplete(state);
}
return;
} else if (key.name === 'backspace') {
if (state.inputBuffer.length > 0) {
state.inputBuffer = state.inputBuffer.slice(0, -1);
state.matchingFixtures = filterFixtures(
state.allFixtureNames,
state.inputBuffer,
);
state.selectedIndex = -1;
renderAutocomplete(state);
}
return;
} else if (str && !key.ctrl && !key.meta) {
// Regular character - accumulate, filter, and render
state.inputBuffer += str;
state.matchingFixtures = filterFixtures(
state.allFixtureNames,
state.inputBuffer,
);
state.selectedIndex = -1;
renderAutocomplete(state);
return;
}
return; // Ignore other keys in input mode
}
// Normal mode keypress handling
if (key.name === 'u') {
// u => update fixtures
state.mode.action = RunnerAction.Update;
} else if (key.name === 'q') {
process.exit(0);
} else if (key.name === 'f') {
state.mode.filter = !state.mode.filter;
state.filter = state.mode.filter ? await readTestFilter() : null;
} else if (key.name === 'a') {
// a => exit filter mode and run all tests
state.mode.filter = false;
state.filter = null;
state.mode.action = RunnerAction.Test;
} else if (key.name === 'd') {
// d => toggle debug logging
state.debug = !state.debug;
state.mode.action = RunnerAction.Test;
} else if (key.name === 'p') {
// p => enter pattern input mode with autocomplete
state.inputMode = 'pattern';
state.inputBuffer = '';
// Load all fixtures for autocomplete
const fixtures = await getFixtures(null);
state.allFixtureNames = Array.from(fixtures.keys()).sort();
// Show failed fixtures first when no pattern entered
const failedFixtures = Array.from(state.fixtureLastRunStatus.entries())
.filter(([_, status]) => status === 'fail')
.map(([name]) => name)
.sort();
state.matchingFixtures =
failedFixtures.length > 0 ? failedFixtures : state.allFixtureNames;
state.selectedIndex = -1;
renderAutocomplete(state);
return; // Don't trigger onChange yet
} else {
// any other key re-runs tests
state.mode.action = RunnerAction.Test;
@@ -219,21 +415,37 @@ function subscribeKeyEvents(
export async function makeWatchRunner(
onChange: (state: RunnerState) => void,
filterMode: boolean,
debugMode: boolean,
initialPattern?: string,
): Promise<void> {
const state = {
// Determine initial filter state
let filter: TestFilter | null = null;
let filterEnabled = false;
if (initialPattern) {
filter = {paths: [initialPattern]};
filterEnabled = true;
}
const state: RunnerState = {
compilerVersion: 0,
isCompilerBuildValid: false,
lastUpdate: -1,
mode: {
action: RunnerAction.Test,
filter: filterMode,
filter: filterEnabled,
},
filter: filterMode ? await readTestFilter() : null,
filter,
debug: debugMode,
inputMode: 'none',
inputBuffer: '',
allFixtureNames: [],
matchingFixtures: [],
selectedIndex: -1,
fixtureLastRunStatus: new Map(),
};
subscribeTsc(state, onChange);
subscribeFixtures(state, onChange);
subscribeKeyEvents(state, onChange);
subscribeFilterFile(state, onChange);
}

View File

@@ -12,8 +12,8 @@ import * as readline from 'readline';
import ts from 'typescript';
import yargs from 'yargs';
import {hideBin} from 'yargs/helpers';
import {FILTER_PATH, PROJECT_ROOT} from './constants';
import {TestFilter, getFixtures, readTestFilter} from './fixture-utils';
import {PROJECT_ROOT} from './constants';
import {TestFilter, getFixtures} from './fixture-utils';
import {TestResult, TestResults, report, update} from './reporter';
import {
RunnerAction,
@@ -33,9 +33,9 @@ type RunnerOptions = {
sync: boolean;
workerThreads: boolean;
watch: boolean;
filter: boolean;
update: boolean;
pattern?: string;
debug: boolean;
};
const opts: RunnerOptions = yargs
@@ -59,18 +59,16 @@ const opts: RunnerOptions = yargs
.alias('u', 'update')
.describe('update', 'Update fixtures')
.default('update', false)
.boolean('filter')
.describe(
'filter',
'Only run fixtures which match the contents of testfilter.txt',
)
.default('filter', false)
.string('pattern')
.alias('p', 'pattern')
.describe(
'pattern',
'Optional glob pattern to filter fixtures (e.g., "error.*", "use-memo")',
)
.boolean('debug')
.alias('d', 'debug')
.describe('debug', 'Enable debug logging to print HIR for each pass')
.default('debug', false)
.help('help')
.strict()
.parseSync(hideBin(process.argv)) as RunnerOptions;
@@ -82,12 +80,15 @@ async function runFixtures(
worker: Worker & typeof runnerWorker,
filter: TestFilter | null,
compilerVersion: number,
debug: boolean,
requireSingleFixture: boolean,
): Promise<TestResults> {
// We could in theory be fancy about tracking the contents of the fixtures
// directory via our file subscription, but it's simpler to just re-read
// the directory each time.
const fixtures = await getFixtures(filter);
const isOnlyFixture = filter !== null && fixtures.size === 1;
const shouldLog = debug && (!requireSingleFixture || isOnlyFixture);
let entries: Array<[string, TestResult]>;
if (!opts.sync) {
@@ -96,12 +97,7 @@ async function runFixtures(
for (const [fixtureName, fixture] of fixtures) {
work.push(
worker
.transformFixture(
fixture,
compilerVersion,
(filter?.debug ?? false) && isOnlyFixture,
true,
)
.transformFixture(fixture, compilerVersion, shouldLog, true)
.then(result => [fixtureName, result]),
);
}
@@ -113,7 +109,7 @@ async function runFixtures(
let output = await runnerWorker.transformFixture(
fixture,
compilerVersion,
(filter?.debug ?? false) && isOnlyFixture,
shouldLog,
true,
);
entries.push([fixtureName, output]);
@@ -128,7 +124,7 @@ async function onChange(
worker: Worker & typeof runnerWorker,
state: RunnerState,
) {
const {compilerVersion, isCompilerBuildValid, mode, filter} = state;
const {compilerVersion, isCompilerBuildValid, mode, filter, debug} = state;
if (isCompilerBuildValid) {
const start = performance.now();
@@ -142,8 +138,18 @@ async function onChange(
worker,
mode.filter ? filter : null,
compilerVersion,
debug,
true, // requireSingleFixture in watch mode
);
const end = performance.now();
// Track fixture status for autocomplete suggestions
for (const [basename, result] of results) {
const failed =
result.actual !== result.expected || result.unexpectedError != null;
state.fixtureLastRunStatus.set(basename, failed ? 'fail' : 'pass');
}
if (mode.action === RunnerAction.Update) {
update(results);
state.lastUpdate = end;
@@ -159,11 +165,13 @@ async function onChange(
console.log(
'\n' +
(mode.filter
? `Current mode = FILTER, filter test fixtures by "${FILTER_PATH}".`
? `Current mode = FILTER, pattern = "${filter?.paths[0] ?? ''}".`
: 'Current mode = NORMAL, run all test fixtures.') +
'\nWaiting for input or file changes...\n' +
'u - update all fixtures\n' +
`f - toggle (turn ${mode.filter ? 'off' : 'on'}) filter mode\n` +
`d - toggle (turn ${debug ? 'off' : 'on'}) debug logging\n` +
'p - enter pattern to filter fixtures\n' +
(mode.filter ? 'a - run all tests (exit filter mode)\n' : '') +
'q - quit\n' +
'[any] - rerun tests\n',
);
@@ -180,15 +188,12 @@ export async function main(opts: RunnerOptions): Promise<void> {
worker.getStderr().pipe(process.stderr);
worker.getStdout().pipe(process.stdout);
// If pattern is provided, force watch mode off and use pattern filter
const shouldWatch = opts.watch && opts.pattern == null;
if (opts.watch && opts.pattern != null) {
console.warn('NOTE: --watch is ignored when a --pattern is supplied');
}
// Check if watch mode should be enabled
const shouldWatch = opts.watch;
if (shouldWatch) {
makeWatchRunner(state => onChange(worker, state), opts.filter);
if (opts.filter) {
makeWatchRunner(state => onChange(worker, state), opts.debug, opts.pattern);
if (opts.pattern) {
/**
* Warm up wormers when in watch mode. Loading the Forget babel plugin
* and all of its transitive dependencies takes 1-3s (per worker) on a M1.
@@ -236,14 +241,17 @@ export async function main(opts: RunnerOptions): Promise<void> {
let testFilter: TestFilter | null = null;
if (opts.pattern) {
testFilter = {
debug: true,
paths: [opts.pattern],
};
} else if (opts.filter) {
testFilter = await readTestFilter();
}
const results = await runFixtures(worker, testFilter, 0);
const results = await runFixtures(
worker,
testFilter,
0,
opts.debug,
false, // no requireSingleFixture in non-watch mode
);
if (opts.update) {
update(results);
isSuccess = true;

View File

@@ -1,12 +1,3 @@
.roboto-font {
font-family: "Roboto", serif;
font-optical-sizing: auto;
font-weight: 100;
font-style: normal;
font-variation-settings:
"wdth" 100;
}
.swipe-recognizer {
width: 300px;
background: #eee;

View File

@@ -4,6 +4,7 @@ import React, {
Activity,
useLayoutEffect,
useEffect,
useInsertionEffect,
useState,
useId,
useOptimistic,
@@ -41,6 +42,26 @@ const b = (
);
function Component() {
// Test inserting fonts with style tags using useInsertionEffect. This is not recommended but
// used to test that gestures etc works with useInsertionEffect so that stylesheet based
// libraries can be properly supported.
useInsertionEffect(() => {
const style = document.createElement('style');
style.textContent = `
.roboto-font {
font-family: "Roboto", serif;
font-optical-sizing: auto;
font-weight: 100;
font-style: normal;
font-variation-settings:
"wdth" 100;
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
return (
<ViewTransition
default={
@@ -82,8 +103,59 @@ export default function Page({url, navigate}) {
{rotate: '0deg', transformOrigin: '30px 8px'},
{rotate: '360deg', transformOrigin: '30px 8px'},
];
viewTransition.old.animate(keyframes, 250);
viewTransition.new.animate(keyframes, 250);
const animation1 = viewTransition.old.animate(keyframes, 250);
const animation2 = viewTransition.new.animate(keyframes, 250);
return () => {
animation1.cancel();
animation2.cancel();
};
}
function onGestureTransition(
timeline,
{rangeStart, rangeEnd},
viewTransition,
types
) {
const keyframes = [
{rotate: '0deg', transformOrigin: '30px 8px'},
{rotate: '360deg', transformOrigin: '30px 8px'},
];
const reverse = rangeStart > rangeEnd;
if (timeline instanceof AnimationTimeline) {
// Native Timeline
const options = {
timeline: timeline,
direction: reverse ? 'normal' : 'reverse',
rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
};
const animation1 = viewTransition.old.animate(keyframes, options);
const animation2 = viewTransition.new.animate(keyframes, options);
return () => {
animation1.cancel();
animation2.cancel();
};
} else {
// Custom Timeline
const options = {
direction: reverse ? 'normal' : 'reverse',
// We set the delay and duration to represent the span of the range.
delay: reverse ? rangeEnd : rangeStart,
duration: reverse ? rangeStart - rangeEnd : rangeEnd - rangeStart,
};
const animation1 = viewTransition.old.animate(keyframes, options);
const animation2 = viewTransition.new.animate(keyframes, options);
// Let the custom timeline take control of driving the animations.
const cleanup1 = timeline.animate(animation1);
const cleanup2 = timeline.animate(animation2);
return () => {
animation1.cancel();
animation2.cancel();
cleanup1();
cleanup2();
};
}
}
function swipeAction() {
@@ -131,7 +203,10 @@ export default function Page({url, navigate}) {
);
const exclamation = (
<ViewTransition name="exclamation" onShare={onTransition}>
<ViewTransition
name="exclamation"
onShare={onTransition}
onGestureShare={onGestureTransition}>
<span>
<div>!</div>
</span>
@@ -171,17 +246,20 @@ export default function Page({url, navigate}) {
}}>
<h1>{!show ? 'A' + counter : 'B'}</h1>
</ViewTransition>
{show ? (
<div>
{a}
{b}
</div>
) : (
<div>
{b}
{a}
</div>
)}
{
// Using url instead of renderedUrl here lets us only update this on commit.
url === '/?b' ? (
<div>
{a}
{b}
</div>
) : (
<div>
{b}
{a}
</div>
)
}
<ViewTransition>
{show ? (
<div>hello{exclamation}</div>

View File

@@ -114,16 +114,17 @@ export default function SwipeRecognizer({
);
}
function onGestureEnd(changed) {
// Reset scroll
if (changed) {
// Trigger side-effects
startTransition(action);
}
// We cancel the gesture before invoking side-effects to allow the gesture lane to fully commit
// before scheduling new updates.
if (activeGesture.current !== null) {
const cancelGesture = activeGesture.current;
activeGesture.current = null;
cancelGesture();
}
if (changed) {
// Trigger side-effects
startTransition(action);
}
}
function onScrollEnd() {
if (touchTimeline.current) {

View File

@@ -826,7 +826,7 @@ declare class WebSocket extends EventTarget {
bufferedAmount: number;
extensions: string;
onopen: (ev: any) => mixed;
onmessage: (ev: MessageEvent) => mixed;
onmessage: (ev: MessageEvent<>) => mixed;
onclose: (ev: CloseEvent) => mixed;
onerror: (ev: any) => mixed;
binaryType: 'blob' | 'arraybuffer';
@@ -855,8 +855,8 @@ declare class Worker extends EventTarget {
workerOptions?: WorkerOptions
): void;
onerror: null | ((ev: any) => mixed);
onmessage: null | ((ev: MessageEvent) => mixed);
onmessageerror: null | ((ev: MessageEvent) => mixed);
onmessage: null | ((ev: MessageEvent<>) => mixed);
onmessageerror: null | ((ev: MessageEvent<>) => mixed);
postMessage(message: any, ports?: any): void;
terminate(): void;
}
@@ -888,14 +888,14 @@ declare class WorkerGlobalScope extends EventTarget {
}
declare class DedicatedWorkerGlobalScope extends WorkerGlobalScope {
onmessage: (ev: MessageEvent) => mixed;
onmessageerror: (ev: MessageEvent) => mixed;
onmessage: (ev: MessageEvent<>) => mixed;
onmessageerror: (ev: MessageEvent<>) => mixed;
postMessage(message: any, transfer?: Iterable<any>): void;
}
declare class SharedWorkerGlobalScope extends WorkerGlobalScope {
name: string;
onconnect: (ev: MessageEvent) => mixed;
onconnect: (ev: MessageEvent<>) => mixed;
}
declare class WorkerLocation {
@@ -2056,8 +2056,8 @@ declare class MessagePort extends EventTarget {
start(): void;
close(): void;
onmessage: null | ((ev: MessageEvent) => mixed);
onmessageerror: null | ((ev: MessageEvent) => mixed);
onmessage: null | ((ev: MessageEvent<>) => mixed);
onmessageerror: null | ((ev: MessageEvent<>) => mixed);
}
declare class MessageChannel {

View File

@@ -151,7 +151,7 @@ type TransitionEventHandler = (event: TransitionEvent) => mixed;
type TransitionEventListener =
| {handleEvent: TransitionEventHandler, ...}
| TransitionEventHandler;
type MessageEventHandler = (event: MessageEvent) => mixed;
type MessageEventHandler = (event: MessageEvent<>) => mixed;
type MessageEventListener =
| {handleEvent: MessageEventHandler, ...}
| MessageEventHandler;
@@ -845,8 +845,8 @@ declare class PageTransitionEvent extends Event {
// https://www.w3.org/TR/2008/WD-html5-20080610/comms.html
// and
// https://html.spec.whatwg.org/multipage/comms.html#the-messageevent-interfaces
declare class MessageEvent extends Event {
data: mixed;
declare class MessageEvent<Data = mixed> extends Event {
data: Data;
origin: string;
lastEventId: string;
source: WindowProxy;

View File

@@ -109,8 +109,8 @@ declare class ErrorEvent extends Event {
// https://html.spec.whatwg.org/multipage/web-messaging.html#broadcasting-to-other-browsing-contexts
declare class BroadcastChannel extends EventTarget {
name: string;
onmessage: ?(event: MessageEvent) => void;
onmessageerror: ?(event: MessageEvent) => void;
onmessage: ?(event: MessageEvent<>) => void;
onmessageerror: ?(event: MessageEvent<>) => void;
constructor(name: string): void;
postMessage(msg: mixed): void;

View File

@@ -88,6 +88,7 @@
"jest-cli": "^29.4.2",
"jest-diff": "^29.4.2",
"jest-environment-jsdom": "^29.4.2",
"jest-silent-reporter": "^0.6.0",
"jest-snapshot-serializer-raw": "^1.2.0",
"minimatch": "^3.0.4",
"minimist": "^1.2.3",

View File

@@ -0,0 +1,147 @@
/**
* 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 {RuleTester} from 'eslint';
import {allRules} from '../src/shared/ReactCompiler';
const ESLintTesterV8 = require('eslint-v8').RuleTester;
/**
* A string template tag that removes padding from the left side of multi-line strings
* @param {Array} strings array of code strings (only one expected)
*/
function normalizeIndent(strings: TemplateStringsArray): string {
const codeLines = strings[0]?.split('\n') ?? [];
const leftPadding = codeLines[1]?.match(/\s+/)![0] ?? '';
return codeLines.map(line => line.slice(leftPadding.length)).join('\n');
}
type CompilerTestCases = {
valid: RuleTester.ValidTestCase[];
invalid: RuleTester.InvalidTestCase[];
};
const tests: CompilerTestCases = {
valid: [
// ===========================================
// Tests for mayContainReactCode heuristic with Flow syntax
// Files that should be SKIPPED (no React-like function names)
// These contain code that WOULD trigger errors if compiled,
// but since the heuristic skips them, no errors are reported.
// ===========================================
{
name: '[Heuristic/Flow] Skips files with only lowercase utility functions',
filename: 'utils.js',
code: normalizeIndent`
function helper(obj) {
obj.key = 'value';
return obj;
}
`,
},
{
name: '[Heuristic/Flow] Skips lowercase arrow functions even with mutations',
filename: 'helpers.js',
code: normalizeIndent`
const processData = (input) => {
input.modified = true;
return input;
};
`,
},
],
invalid: [
// ===========================================
// Tests for mayContainReactCode heuristic with Flow component/hook syntax
// These use Flow's component/hook declarations which should be detected
// ===========================================
{
name: '[Heuristic/Flow] Compiles Flow component declaration - detects prop mutation',
filename: 'component.js',
code: normalizeIndent`
component MyComponent(a: {key: string}) {
a.key = 'value';
return <div />;
}
`,
errors: [
{
message: /Modifying component props/,
},
],
},
{
name: '[Heuristic/Flow] Compiles exported Flow component declaration - detects prop mutation',
filename: 'component.js',
code: normalizeIndent`
export component MyComponent(a: {key: string}) {
a.key = 'value';
return <div />;
}
`,
errors: [
{
message: /Modifying component props/,
},
],
},
{
name: '[Heuristic/Flow] Compiles default exported Flow component declaration - detects prop mutation',
filename: 'component.js',
code: normalizeIndent`
export default component MyComponent(a: {key: string}) {
a.key = 'value';
return <div />;
}
`,
errors: [
{
message: /Modifying component props/,
},
],
},
{
name: '[Heuristic/Flow] Compiles Flow hook declaration - detects argument mutation',
filename: 'hooks.js',
code: normalizeIndent`
hook useMyHook(a: {key: string}) {
a.key = 'value';
return a;
}
`,
errors: [
{
message: /Modifying component props or hook arguments/,
},
],
},
{
name: '[Heuristic/Flow] Compiles exported Flow hook declaration - detects argument mutation',
filename: 'hooks.js',
code: normalizeIndent`
export hook useMyHook(a: {key: string}) {
a.key = 'value';
return a;
}
`,
errors: [
{
message: /Modifying component props or hook arguments/,
},
],
},
],
};
const eslintTester = new ESLintTesterV8({
parser: require.resolve('hermes-eslint'),
parserOptions: {
sourceType: 'module',
enableExperimentalComponentSyntax: true,
},
});
eslintTester.run('react-compiler', allRules['immutability'].rule, tests);

View File

@@ -46,6 +46,35 @@ const tests: CompilerTestCases = {
}
`,
},
// ===========================================
// Tests for mayContainReactCode heuristic
// Files that should be SKIPPED (no React-like function names)
// These contain code that WOULD trigger errors if compiled,
// but since the heuristic skips them, no errors are reported.
// ===========================================
{
name: '[Heuristic] Skips files with only lowercase utility functions',
filename: 'utils.ts',
// This mutates an argument, which would be flagged in a component/hook,
// but this file is skipped because there are no React-like function names
code: normalizeIndent`
function helper(obj) {
obj.key = 'value';
return obj;
}
`,
},
{
name: '[Heuristic] Skips lowercase arrow functions even with mutations',
filename: 'helpers.ts',
// Would be flagged if compiled, but skipped due to lowercase name
code: normalizeIndent`
const processData = (input) => {
input.modified = true;
return input;
};
`,
},
],
invalid: [
{
@@ -68,6 +97,101 @@ const tests: CompilerTestCases = {
},
],
},
// ===========================================
// Tests for mayContainReactCode heuristic
// Files that SHOULD be compiled (have React-like function names)
// These contain violations to prove compilation happens.
// ===========================================
{
name: '[Heuristic] Compiles PascalCase function declaration - detects prop mutation',
filename: 'component.tsx',
code: normalizeIndent`
function MyComponent({a}) {
a.key = 'value';
return <div />;
}
`,
errors: [
{
message: /Modifying component props/,
},
],
},
{
name: '[Heuristic] Compiles PascalCase arrow function - detects prop mutation',
filename: 'component.tsx',
code: normalizeIndent`
const MyComponent = ({a}) => {
a.key = 'value';
return <div />;
};
`,
errors: [
{
message: /Modifying component props/,
},
],
},
{
name: '[Heuristic] Compiles PascalCase function expression - detects prop mutation',
filename: 'component.tsx',
code: normalizeIndent`
const MyComponent = function({a}) {
a.key = 'value';
return <div />;
};
`,
errors: [
{
message: /Modifying component props/,
},
],
},
{
name: '[Heuristic] Compiles exported function declaration - detects prop mutation',
filename: 'component.tsx',
code: normalizeIndent`
export function MyComponent({a}) {
a.key = 'value';
return <div />;
}
`,
errors: [
{
message: /Modifying component props/,
},
],
},
{
name: '[Heuristic] Compiles exported arrow function - detects prop mutation',
filename: 'component.tsx',
code: normalizeIndent`
export const MyComponent = ({a}) => {
a.key = 'value';
return <div />;
};
`,
errors: [
{
message: /Modifying component props/,
},
],
},
{
name: '[Heuristic] Compiles default exported function - detects prop mutation',
filename: 'component.tsx',
code: normalizeIndent`
export default function MyComponent({a}) {
a.key = 'value';
return <div />;
}
`,
errors: [
{
message: /Modifying component props/,
},
],
},
],
};

View File

@@ -5,4 +5,8 @@ process.env.NODE_ENV = 'development';
module.exports = {
setupFiles: [require.resolve('../../scripts/jest/setupEnvironment.js')],
moduleFileExtensions: ['ts', 'js', 'json'],
moduleNameMapper: {
'^babel-plugin-react-compiler$':
'<rootDir>/../../compiler/packages/babel-plugin-react-compiler/dist/index.js',
},
};

View File

@@ -17,10 +17,107 @@ import BabelPluginReactCompiler, {
LoggerEvent,
} from 'babel-plugin-react-compiler';
import type {SourceCode} from 'eslint';
import type * as ESTree from 'estree';
import * as HermesParser from 'hermes-parser';
import {isDeepStrictEqual} from 'util';
import type {ParseResult} from '@babel/parser';
// Pattern for component names: starts with uppercase letter
const COMPONENT_NAME_PATTERN = /^[A-Z]/;
// Pattern for hook names: starts with 'use' followed by uppercase letter or digit
const HOOK_NAME_PATTERN = /^use[A-Z0-9]/;
/**
* Quick heuristic using ESLint's already-parsed AST to detect if the file
* may contain React components or hooks based on function naming patterns.
* Only checks top-level declarations since components/hooks are declared at module scope.
* Returns true if compilation should proceed, false to skip.
*/
function mayContainReactCode(sourceCode: SourceCode): boolean {
const ast = sourceCode.ast;
// Only check top-level statements - components/hooks are declared at module scope
for (const node of ast.body) {
if (checkTopLevelNode(node)) {
return true;
}
}
return false;
}
function checkTopLevelNode(node: ESTree.Node): boolean {
// Handle Flow component/hook declarations (hermes-eslint produces these node types)
// @ts-expect-error not part of ESTree spec
if (node.type === 'ComponentDeclaration' || node.type === 'HookDeclaration') {
return true;
}
// Handle: export function MyComponent() {} or export const useHook = () => {}
if (node.type === 'ExportNamedDeclaration') {
const decl = (node as ESTree.ExportNamedDeclaration).declaration;
if (decl != null) {
return checkTopLevelNode(decl);
}
return false;
}
// Handle: export default function MyComponent() {} or export default () => {}
if (node.type === 'ExportDefaultDeclaration') {
const decl = (node as ESTree.ExportDefaultDeclaration).declaration;
// Anonymous default function export - compile conservatively
if (
decl.type === 'FunctionExpression' ||
decl.type === 'ArrowFunctionExpression' ||
(decl.type === 'FunctionDeclaration' &&
(decl as ESTree.FunctionDeclaration).id == null)
) {
return true;
}
return checkTopLevelNode(decl as ESTree.Node);
}
// Handle: function MyComponent() {}
// Also handles Flow component/hook syntax transformed to FunctionDeclaration with flags
if (node.type === 'FunctionDeclaration') {
// Check for Hermes-added flags indicating Flow component/hook syntax
if (
'__componentDeclaration' in node ||
'__hookDeclaration' in node
) {
return true;
}
const id = (node as ESTree.FunctionDeclaration).id;
if (id != null) {
const name = id.name;
if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) {
return true;
}
}
}
// Handle: const MyComponent = () => {} or const useHook = function() {}
if (node.type === 'VariableDeclaration') {
for (const decl of (node as ESTree.VariableDeclaration).declarations) {
if (decl.id.type === 'Identifier') {
const init = decl.init;
if (
init != null &&
(init.type === 'ArrowFunctionExpression' ||
init.type === 'FunctionExpression')
) {
const name = decl.id.name;
if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) {
return true;
}
}
}
}
}
return false;
}
const COMPILER_OPTIONS: PluginOptions = {
outputMode: 'lint',
panicThreshold: 'none',
@@ -216,6 +313,24 @@ export default function runReactCompiler({
return entry;
}
// Quick heuristic: skip files that don't appear to contain React code.
// We still cache the empty result so subsequent rules don't re-run the check.
if (!mayContainReactCode(sourceCode)) {
const emptyResult: RunCacheEntry = {
sourceCode: sourceCode.text,
filename,
userOpts,
flowSuppressions: [],
events: [],
};
if (entry != null) {
Object.assign(entry, emptyResult);
} else {
cache.push(filename, emptyResult);
}
return {...emptyResult};
}
const runEntry = runReactCompilerImpl({
sourceCode,
filename,

View File

@@ -879,7 +879,7 @@ describe('ReactInternalTestUtils console assertions', () => {
if (__DEV__) {
console.warn('Hello\n in div');
}
assertConsoleWarnDev(['Hello']);
assertConsoleWarnDev(['Hello\n in div']);
});
it('passes if all warnings contain a stack', () => {
@@ -888,7 +888,11 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Good day\n in div');
console.warn('Bye\n in div');
}
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
assertConsoleWarnDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
it('fails if act is called without assertConsoleWarnDev', async () => {
@@ -1075,7 +1079,11 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.warn('Hi \n in div');
console.warn('Wow \n in div');
assertConsoleWarnDev(['Hi', 'Wow', 'Bye']);
assertConsoleWarnDev([
'Hi \n in div',
'Wow \n in div',
'Bye \n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
@@ -1085,9 +1093,9 @@ describe('ReactInternalTestUtils console assertions', () => {
- Expected warnings
+ Received warnings
- Hi
- Wow
- Bye
- Hi in div
- Wow in div
- Bye in div
+ Hi in div (at **)
+ Wow in div (at **)"
`);
@@ -1188,16 +1196,26 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Hello');
console.warn('Good day\n in div');
console.warn('Bye\n in div');
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
assertConsoleWarnDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Missing component stack for:
"Hello"
Unexpected warning(s) recorded.
If this warning should omit a component stack, pass [log, {withoutStack: true}].
If all warnings should omit the component stack, add {withoutStack: true} to the assertConsoleWarnDev call."
- Expected warnings
+ Received warnings
- Hello in div
- Good day in div
- Bye in div
+ Hello
+ Good day in div (at **)
+ Bye in div (at **)"
`);
});
@@ -1207,16 +1225,26 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Hello\n in div');
console.warn('Good day');
console.warn('Bye\n in div');
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
assertConsoleWarnDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Missing component stack for:
"Good day"
Unexpected warning(s) recorded.
If this warning should omit a component stack, pass [log, {withoutStack: true}].
If all warnings should omit the component stack, add {withoutStack: true} to the assertConsoleWarnDev call."
- Expected warnings
+ Received warnings
- Hello in div
- Good day in div
- Bye in div
+ Hello in div (at **)
+ Good day
+ Bye in div (at **)"
`);
});
@@ -1226,41 +1254,26 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Hello\n in div');
console.warn('Good day\n in div');
console.warn('Bye');
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
assertConsoleWarnDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Missing component stack for:
"Bye"
Unexpected warning(s) recorded.
If this warning should omit a component stack, pass [log, {withoutStack: true}].
If all warnings should omit the component stack, add {withoutStack: true} to the assertConsoleWarnDev call."
`);
});
- Expected warnings
+ Received warnings
// @gate __DEV__
it('fails if all warnings do not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.warn('Hello');
console.warn('Good day');
console.warn('Bye');
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Missing component stack for:
"Hello"
Missing component stack for:
"Good day"
Missing component stack for:
"Bye"
If this warning should omit a component stack, pass [log, {withoutStack: true}].
If all warnings should omit the component stack, add {withoutStack: true} to the assertConsoleWarnDev call."
- Hello in div
- Good day in div
- Bye in div
+ Hello in div (at **)
+ Good day in div (at **)
+ Bye"
`);
});
@@ -1339,12 +1352,13 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected warning(s) recorded.
If this warning should include a component stack, remove {withoutStack: true} from this warning.
If all warnings should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleWarnDev call."
- Expected warnings
+ Received warnings
- Hello
+ Hello in div (at **)"
`);
});
@@ -1361,16 +1375,16 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected warning(s) recorded.
Unexpected component stack for:
"Bye
in div (at **)"
- Expected warnings
+ Received warnings
If this warning should include a component stack, remove {withoutStack: true} from this warning.
If all warnings should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleWarnDev call."
- Hello
+ Hello in div (at **)
Good day
- Bye
+ Bye in div (at **)"
`);
});
});
@@ -1382,9 +1396,9 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Bye\n in div');
}
assertConsoleWarnDev([
'Hello',
'Hello\n in div',
['Good day', {withoutStack: true}],
'Bye',
'Bye\n in div',
]);
});
@@ -1490,12 +1504,13 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected warning(s) recorded.
If this warning should include a component stack, remove {withoutStack: true} from this warning.
If all warnings should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleWarnDev call."
- Expected warnings
+ Received warnings
- Hello
+ Hello in div (at **)"
`);
});
@@ -1524,16 +1539,16 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected warning(s) recorded.
Unexpected component stack for:
"Bye
in div (at **)"
- Expected warnings
+ Received warnings
If this warning should include a component stack, remove {withoutStack: true} from this warning.
If all warnings should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleWarnDev call."
- Hello
+ Hello in div (at **)
Good day
- Bye
+ Bye in div (at **)"
`);
});
});
@@ -1606,13 +1621,18 @@ describe('ReactInternalTestUtils console assertions', () => {
it('fails if component stack is passed twice', () => {
const message = expectToThrowFailure(() => {
console.warn('Hi %s%s', '\n in div', '\n in div');
assertConsoleWarnDev(['Hi']);
assertConsoleWarnDev(['Hi \n in div (at **)']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Received more than one component stack for a warning:
"Hi %s%s""
Unexpected warning(s) recorded.
- Expected warnings
+ Received warnings
Hi in div (at **)
+ in div (at **)"
`);
});
@@ -1621,16 +1641,23 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.warn('Hi %s%s', '\n in div', '\n in div');
console.warn('Bye %s%s', '\n in div', '\n in div');
assertConsoleWarnDev(['Hi', 'Bye']);
assertConsoleWarnDev([
'Hi \n in div (at **)',
'Bye \n in div (at **)',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Received more than one component stack for a warning:
"Hi %s%s"
Unexpected warning(s) recorded.
Received more than one component stack for a warning:
"Bye %s%s""
- Expected warnings
+ Received warnings
Hi in div (at **)
+ in div (at **)
Bye in div (at **)
+ in div (at **)"
`);
});
@@ -1646,7 +1673,7 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleWarnDev(['Hi', 'Bye']);
assertConsoleWarnDev(['Hi \n in div', 'Bye \n in div']);
});
// @gate __DEV__
@@ -1661,7 +1688,7 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleWarnDev(['Hi', 'Bye']);
assertConsoleWarnDev(['Hi \n in div', 'Bye \n in div']);
});
// @gate __DEV__
@@ -1677,7 +1704,11 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleWarnDev(['Hi', 'Wow', 'Bye']);
assertConsoleWarnDev([
'Hi \n in div',
'Wow \n in div',
'Bye \n in div',
]);
});
it('should fail if waitFor is called before asserting', async () => {
@@ -1884,7 +1915,7 @@ describe('ReactInternalTestUtils console assertions', () => {
if (__DEV__) {
console.error('Hello\n in div');
}
assertConsoleErrorDev(['Hello']);
assertConsoleErrorDev(['Hello\n in div']);
});
it('passes if all errors contain a stack', () => {
@@ -1893,7 +1924,11 @@ describe('ReactInternalTestUtils console assertions', () => {
console.error('Good day\n in div');
console.error('Bye\n in div');
}
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
assertConsoleErrorDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
it('fails if act is called without assertConsoleErrorDev', async () => {
@@ -2080,7 +2115,11 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.error('Hi \n in div');
console.error('Wow \n in div');
assertConsoleErrorDev(['Hi', 'Wow', 'Bye']);
assertConsoleErrorDev([
'Hi \n in div',
'Wow \n in div',
'Bye \n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
@@ -2090,9 +2129,9 @@ describe('ReactInternalTestUtils console assertions', () => {
- Expected errors
+ Received errors
- Hi
- Wow
- Bye
- Hi in div
- Wow in div
- Bye in div
+ Hi in div (at **)
+ Wow in div (at **)"
`);
@@ -2192,101 +2231,6 @@ describe('ReactInternalTestUtils console assertions', () => {
+ TypeError: Cannot read properties of undefined (reading 'stack') in Foo (at **)"
`);
});
// @gate __DEV__
it('fails if only error does not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello');
assertConsoleErrorDev(['Hello']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Hello"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('fails if first error does not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello\n in div');
console.error('Good day\n in div');
console.error('Bye');
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Bye"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('fails if last error does not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello');
console.error('Good day\n in div');
console.error('Bye\n in div');
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Hello"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('fails if middle error does not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello\n in div');
console.error('Good day');
console.error('Bye\n in div');
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Good day"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('fails if all errors do not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello');
console.error('Good day');
console.error('Bye');
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Hello"
Missing component stack for:
"Good day"
Missing component stack for:
"Bye"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('regression: checks entire string, not just the first letter', async () => {
@@ -2385,12 +2329,13 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected error(s) recorded.
If this error should include a component stack, remove {withoutStack: true} from this error.
If all errors should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleErrorDev call."
- Expected errors
+ Received errors
- Hello
+ Hello in div (at **)"
`);
});
@@ -2407,16 +2352,16 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected error(s) recorded.
Unexpected component stack for:
"Bye
in div (at **)"
- Expected errors
+ Received errors
If this error should include a component stack, remove {withoutStack: true} from this error.
If all errors should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleErrorDev call."
- Hello
+ Hello in div (at **)
Good day
- Bye
+ Bye in div (at **)"
`);
});
});
@@ -2428,9 +2373,9 @@ describe('ReactInternalTestUtils console assertions', () => {
console.error('Bye\n in div');
}
assertConsoleErrorDev([
'Hello',
'Hello\n in div',
['Good day', {withoutStack: true}],
'Bye',
'Bye\n in div',
]);
});
@@ -2536,12 +2481,13 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected error(s) recorded.
If this error should include a component stack, remove {withoutStack: true} from this error.
If all errors should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleErrorDev call."
- Expected errors
+ Received errors
- Hello
+ Hello in div (at **)"
`);
});
@@ -2570,16 +2516,16 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected error(s) recorded.
Unexpected component stack for:
"Bye
in div (at **)"
- Expected errors
+ Received errors
If this error should include a component stack, remove {withoutStack: true} from this error.
If all errors should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleErrorDev call."
- Hello
+ Hello in div (at **)
Good day
- Bye
+ Bye in div (at **)"
`);
});
@@ -2678,13 +2624,18 @@ describe('ReactInternalTestUtils console assertions', () => {
it('fails if component stack is passed twice', () => {
const message = expectToThrowFailure(() => {
console.error('Hi %s%s', '\n in div', '\n in div');
assertConsoleErrorDev(['Hi']);
assertConsoleErrorDev(['Hi \n in div (at **)']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Received more than one component stack for a warning:
"Hi %s%s""
Unexpected error(s) recorded.
- Expected errors
+ Received errors
Hi in div (at **)
+ in div (at **)"
`);
});
@@ -2693,16 +2644,23 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.error('Hi %s%s', '\n in div', '\n in div');
console.error('Bye %s%s', '\n in div', '\n in div');
assertConsoleErrorDev(['Hi', 'Bye']);
assertConsoleErrorDev([
'Hi \n in div (at **)',
'Bye \n in div (at **)',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Received more than one component stack for a warning:
"Hi %s%s"
Unexpected error(s) recorded.
Received more than one component stack for a warning:
"Bye %s%s""
- Expected errors
+ Received errors
Hi in div (at **)
+ in div (at **)
Bye in div (at **)
+ in div (at **)"
`);
});
@@ -2711,14 +2669,14 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.error('Hi \n in div');
console.error('Bye \n in div');
assertConsoleErrorDev('Hi', 'Bye');
assertConsoleErrorDev('Hi \n in div', 'Bye \n in div');
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleErrorDev(['Hi', 'Bye']);
assertConsoleErrorDev(['Hi \n in div', 'Bye \n in div']);
});
// @gate __DEV__
@@ -2733,7 +2691,7 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleErrorDev(['Hi', 'Bye']);
assertConsoleErrorDev(['Hi \n in div', 'Bye \n in div']);
});
// @gate __DEV__
@@ -2749,7 +2707,133 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleErrorDev(['Hi', 'Wow', 'Bye']);
assertConsoleErrorDev([
'Hi \n in div',
'Wow \n in div',
'Bye \n in div',
]);
});
describe('in <stack> placeholder', () => {
// @gate __DEV__
it('fails if `in <stack>` is used for a component stack instead of an error stack', () => {
const message = expectToThrowFailure(() => {
console.error('Warning message\n in div');
assertConsoleErrorDev(['Warning message\n in <stack>']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Incorrect use of \\n in <stack> placeholder. The placeholder is for JavaScript Error stack traces (messages starting with "Error:"), not for React component stacks.
Expected: "Warning message
in <stack>"
Received: "Warning message
in div (at **)"
If this error has a component stack, include the full component stack in your expected message (e.g., "Warning message\\n in ComponentName (at **)")."
`);
});
// @gate __DEV__
it('fails if `in <stack>` is used for multiple component stacks', () => {
const message = expectToThrowFailure(() => {
console.error('First warning\n in span');
console.error('Second warning\n in div');
assertConsoleErrorDev([
'First warning\n in <stack>',
'Second warning\n in <stack>',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Incorrect use of \\n in <stack> placeholder. The placeholder is for JavaScript Error stack traces (messages starting with "Error:"), not for React component stacks.
Expected: "First warning
in <stack>"
Received: "First warning
in span (at **)"
If this error has a component stack, include the full component stack in your expected message (e.g., "Warning message\\n in ComponentName (at **)").
Incorrect use of \\n in <stack> placeholder. The placeholder is for JavaScript Error stack traces (messages starting with "Error:"), not for React component stacks.
Expected: "Second warning
in <stack>"
Received: "Second warning
in div (at **)"
If this error has a component stack, include the full component stack in your expected message (e.g., "Warning message\\n in ComponentName (at **)")."
`);
});
it('allows `in <stack>` for actual error stack traces', () => {
// This should pass - \n in <stack> is correctly used for an error stack
console.error(new Error('Something went wrong'));
assertConsoleErrorDev(['Error: Something went wrong\n in <stack>']);
});
// @gate __DEV__
it('fails if error stack trace is present but \\n in <stack> is not expected', () => {
const message = expectToThrowFailure(() => {
console.error(new Error('Something went wrong'));
assertConsoleErrorDev(['Error: Something went wrong']);
});
expect(message).toMatch(`Unexpected error stack trace for:`);
expect(message).toMatch(`Error: Something went wrong`);
expect(message).toMatch(
'If this error should include an error stack trace, add \\n in <stack> to your expected message'
);
});
// @gate __DEV__
it('fails if `in <stack>` is expected but no stack is present', () => {
const message = expectToThrowFailure(() => {
console.error('Error: Something went wrong');
assertConsoleErrorDev([
'Error: Something went wrong\n in <stack>',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing error stack trace for:
"Error: Something went wrong"
The expected message uses \\n in <stack> but the actual error doesn't include an error stack trace.
If this error should not have an error stack trace, remove \\n in <stack> from your expected message."
`);
});
});
describe('[Environment] placeholder', () => {
// @gate __DEV__
it('expands [Server] to ANSI escape sequence for server badge', () => {
const badge = '\u001b[0m\u001b[7m Server \u001b[0m';
console.error(badge + 'Error: something went wrong');
assertConsoleErrorDev([
['[Server] Error: something went wrong', {withoutStack: true}],
]);
});
// @gate __DEV__
it('expands [Prerender] to ANSI escape sequence for server badge', () => {
const badge = '\u001b[0m\u001b[7m Prerender \u001b[0m';
console.error(badge + 'Error: something went wrong');
assertConsoleErrorDev([
['[Prerender] Error: something went wrong', {withoutStack: true}],
]);
});
// @gate __DEV__
it('expands [Cache] to ANSI escape sequence for server badge', () => {
const badge = '\u001b[0m\u001b[7m Cache \u001b[0m';
console.error(badge + 'Error: something went wrong');
assertConsoleErrorDev([
['[Cache] Error: something went wrong', {withoutStack: true}],
]);
});
});
it('should fail if waitFor is called before asserting', async () => {

View File

@@ -168,6 +168,53 @@ function normalizeCodeLocInfo(str) {
});
}
// Expands environment placeholders like [Server] into ANSI escape sequences.
// This allows test assertions to use a cleaner syntax like "[Server] Error:"
// instead of the full escape sequence "\u001b[0m\u001b[7m Server \u001b[0mError:"
function expandEnvironmentPlaceholders(str) {
if (typeof str !== 'string') {
return str;
}
// [Environment] -> ANSI escape sequence for environment badge
// The format is: reset + inverse + " Environment " + reset
return str.replace(
/^\[(\w+)] /g,
(match, env) => '\u001b[0m\u001b[7m ' + env + ' \u001b[0m',
);
}
// The error stack placeholder that can be used in expected messages
const ERROR_STACK_PLACEHOLDER = '\n in <stack>';
// A marker used to protect the placeholder during normalization
const ERROR_STACK_PLACEHOLDER_MARKER = '\n in <__STACK_PLACEHOLDER__>';
// Normalizes expected messages, handling special placeholders
function normalizeExpectedMessage(str) {
if (typeof str !== 'string') {
return str;
}
// Protect the error stack placeholder from normalization
// (normalizeCodeLocInfo would add "(at **)" to it)
const hasStackPlaceholder = str.includes(ERROR_STACK_PLACEHOLDER);
let result = str;
if (hasStackPlaceholder) {
result = result.replace(
ERROR_STACK_PLACEHOLDER,
ERROR_STACK_PLACEHOLDER_MARKER,
);
}
result = normalizeCodeLocInfo(result);
result = expandEnvironmentPlaceholders(result);
if (hasStackPlaceholder) {
// Restore the placeholder (remove the "(at **)" that was added)
result = result.replace(
ERROR_STACK_PLACEHOLDER_MARKER + ' (at **)',
ERROR_STACK_PLACEHOLDER,
);
}
return result;
}
function normalizeComponentStack(entry) {
if (
typeof entry[0] === 'string' &&
@@ -187,6 +234,15 @@ const isLikelyAComponentStack = message =>
message.includes('\n in ') ||
message.includes('\n at '));
// Error stack traces start with "*Error:" and contain "at" frames with file paths
// Component stacks contain "in ComponentName" patterns
// This helps validate that \n in <stack> is used correctly
const isLikelyAnErrorStackTrace = message =>
typeof message === 'string' &&
message.includes('Error:') &&
// Has "at" frames typical of error stacks (with file:line:col)
/\n\s+at .+\(.*:\d+:\d+\)/.test(message);
export function createLogAssertion(
consoleMethod,
matcherName,
@@ -236,13 +292,11 @@ export function createLogAssertion(
const withoutStack = options.withoutStack;
// Warn about invalid global withoutStack values.
if (consoleMethod === 'log' && withoutStack !== undefined) {
throwFormattedError(
`Do not pass withoutStack to assertConsoleLogDev, console.log does not have component stacks.`,
);
} else if (withoutStack !== undefined && withoutStack !== true) {
// withoutStack can only have a value true.
throwFormattedError(
`The second argument must be {withoutStack: true}.` +
`\n\nInstead received ${JSON.stringify(options)}.`,
@@ -256,8 +310,11 @@ export function createLogAssertion(
const unexpectedLogs = [];
const unexpectedMissingComponentStack = [];
const unexpectedIncludingComponentStack = [];
const unexpectedMissingErrorStack = [];
const unexpectedIncludingErrorStack = [];
const logsMismatchingFormat = [];
const logsWithExtraComponentStack = [];
const stackTracePlaceholderMisuses = [];
// Loop over all the observed logs to determine:
// - Which expected logs are missing
@@ -319,11 +376,11 @@ export function createLogAssertion(
);
}
expectedMessage = normalizeCodeLocInfo(currentExpectedMessage);
expectedMessage = normalizeExpectedMessage(currentExpectedMessage);
expectedWithoutStack = expectedMessageOrArray[1].withoutStack;
} else if (typeof expectedMessageOrArray === 'string') {
// Should be in the form assert(['log']) or assert(['log'], {withoutStack: true})
expectedMessage = normalizeCodeLocInfo(expectedMessageOrArray);
expectedMessage = normalizeExpectedMessage(expectedMessageOrArray);
// withoutStack: inherit from global option - simplify when withoutStack is removed.
if (consoleMethod === 'log') {
expectedWithoutStack = true;
} else {
@@ -381,19 +438,93 @@ export function createLogAssertion(
}
// Main logic to check if log is expected, with the component stack.
if (
typeof expectedMessage === 'string' &&
(normalizedMessage === expectedMessage ||
normalizedMessage.includes(expectedMessage))
) {
// Check for exact match OR if the message matches with a component stack appended
let matchesExpectedMessage = false;
let expectsErrorStack = false;
const hasErrorStack = isLikelyAnErrorStackTrace(message);
if (typeof expectedMessage === 'string') {
if (normalizedMessage === expectedMessage) {
matchesExpectedMessage = true;
} else if (expectedMessage.includes('\n in <stack>')) {
expectsErrorStack = true;
// \n in <stack> is ONLY for JavaScript Error stack traces (e.g., "Error: message\n at fn (file.js:1:2)")
// NOT for React component stacks (e.g., "\n in ComponentName (at **)").
// Validate that the actual message looks like an error stack trace.
if (!hasErrorStack) {
// The actual message doesn't look like an error stack trace.
// This is likely a misuse - someone used \n in <stack> for a component stack.
stackTracePlaceholderMisuses.push({
expected: expectedMessage,
received: normalizedMessage,
});
}
const expectedMessageWithoutStack = expectedMessage.replace(
'\n in <stack>',
'',
);
if (normalizedMessage.startsWith(expectedMessageWithoutStack)) {
// Remove the stack trace
const remainder = normalizedMessage.slice(
expectedMessageWithoutStack.length,
);
// After normalization, both error stacks and component stacks look like
// component stacks (at frames are converted to "in ... (at **)" format).
// So we check isLikelyAComponentStack for matching purposes.
if (isLikelyAComponentStack(remainder)) {
const messageWithoutStack = normalizedMessage.replace(
remainder,
'',
);
if (messageWithoutStack === expectedMessageWithoutStack) {
matchesExpectedMessage = true;
}
} else if (remainder === '') {
// \n in <stack> was expected but there's no stack at all
matchesExpectedMessage = true;
}
} else if (normalizedMessage === expectedMessageWithoutStack) {
// \n in <stack> was expected but actual has no stack at all (exact match without stack)
matchesExpectedMessage = true;
}
} else if (
hasErrorStack &&
!expectedMessage.includes('\n in <stack>') &&
normalizedMessage.startsWith(expectedMessage)
) {
matchesExpectedMessage = true;
}
}
if (matchesExpectedMessage) {
// withoutStack: Check for unexpected/missing component stacks.
// These checks can be simplified when withoutStack is removed.
if (isLikelyAComponentStack(normalizedMessage)) {
if (expectedWithoutStack === true) {
if (expectedWithoutStack === true && !hasErrorStack) {
// Only report unexpected component stack if it's not an error stack
// (error stacks look like component stacks after normalization)
unexpectedIncludingComponentStack.push(normalizedMessage);
}
} else if (expectedWithoutStack !== true) {
} else if (expectedWithoutStack !== true && !expectsErrorStack) {
unexpectedMissingComponentStack.push(normalizedMessage);
}
// Check for unexpected/missing error stacks
if (hasErrorStack && !expectsErrorStack) {
// Error stack is present but \n in <stack> was not in the expected message
unexpectedIncludingErrorStack.push(normalizedMessage);
} else if (
expectsErrorStack &&
!hasErrorStack &&
!isLikelyAComponentStack(normalizedMessage)
) {
// \n in <stack> was expected but the actual message doesn't have any stack at all
// (if it has a component stack, stackTracePlaceholderMisuses already handles it)
unexpectedMissingErrorStack.push(normalizedMessage);
}
// Found expected log, remove it from missing.
missingExpectedLogs.splice(0, 1);
} else {
@@ -422,6 +553,21 @@ export function createLogAssertion(
)}`;
}
// Wrong %s formatting is a failure.
// This is a common mistake when creating new warnings.
if (logsMismatchingFormat.length > 0) {
throwFormattedError(
logsMismatchingFormat
.map(
item =>
`Received ${item.args.length} arguments for a message with ${
item.expectedArgCount
} placeholders:\n ${printReceived(item.format)}`,
)
.join('\n\n'),
);
}
// Any unexpected warnings should be treated as a failure.
if (unexpectedLogs.length > 0) {
throwFormattedError(
@@ -466,18 +612,33 @@ export function createLogAssertion(
);
}
// Wrong %s formatting is a failure.
// This is a common mistake when creating new warnings.
if (logsMismatchingFormat.length > 0) {
// Any logs that include an error stack trace but \n in <stack> wasn't expected.
if (unexpectedIncludingErrorStack.length > 0) {
throwFormattedError(
logsMismatchingFormat
`${unexpectedIncludingErrorStack
.map(
item =>
`Received ${item.args.length} arguments for a message with ${
item.expectedArgCount
} placeholders:\n ${printReceived(item.format)}`,
stack =>
`Unexpected error stack trace for:\n ${printReceived(stack)}`,
)
.join('\n\n'),
.join(
'\n\n',
)}\n\nIf this ${logName()} should include an error stack trace, add \\n in <stack> to your expected message ` +
`(e.g., "Error: message\\n in <stack>").`,
);
}
// Any logs that are missing an error stack trace when \n in <stack> was expected.
if (unexpectedMissingErrorStack.length > 0) {
throwFormattedError(
`${unexpectedMissingErrorStack
.map(
stack =>
`Missing error stack trace for:\n ${printReceived(stack)}`,
)
.join(
'\n\n',
)}\n\nThe expected message uses \\n in <stack> but the actual ${logName()} doesn't include an error stack trace.` +
`\nIf this ${logName()} should not have an error stack trace, remove \\n in <stack> from your expected message.`,
);
}
@@ -496,6 +657,25 @@ export function createLogAssertion(
.join('\n\n'),
);
}
// Using \n in <stack> for component stacks is a misuse.
// \n in <stack> should only be used for JavaScript Error stack traces,
// not for React component stacks.
if (stackTracePlaceholderMisuses.length > 0) {
throwFormattedError(
`${stackTracePlaceholderMisuses
.map(
item =>
`Incorrect use of \\n in <stack> placeholder. The placeholder is for JavaScript Error ` +
`stack traces (messages starting with "Error:"), not for React component stacks.\n\n` +
`Expected: ${printReceived(item.expected)}\n` +
`Received: ${printReceived(item.received)}\n\n` +
`If this ${logName()} has a component stack, include the full component stack in your expected message ` +
`(e.g., "Warning message\\n in ComponentName (at **)").`,
)
.join('\n\n')}`,
);
}
}
};
}

View File

@@ -79,6 +79,18 @@ function normalizeIOInfo(config: DebugInfoConfig, ioInfo) {
status: promise.status,
};
}
} else if ('value' in ioInfo) {
// If value exists in ioInfo but is undefined (e.g., WeakRef was GC'd),
// ensure we still include it in the normalized output for consistency
copy.value = {
value: undefined,
};
} else if (ioInfo.name && ioInfo.name !== 'rsc stream') {
// For non-rsc-stream IO that doesn't have a value field, add a default.
// This handles the case where the server doesn't send the field when WeakRef is GC'd.
copy.value = {
value: undefined,
};
}
return copy;
}

View File

@@ -549,6 +549,13 @@ export function startGestureTransition() {
export function stopViewTransition(transition: RunningViewTransition) {}
export function addViewTransitionFinishedListener(
transition: RunningViewTransition,
callback: () => void,
) {
callback();
}
export type ViewTransitionInstance = null | {name: string, ...};
export function createViewTransitionInstance(

View File

@@ -1085,7 +1085,6 @@ describe('ReactFlight', () => {
});
});
// @gate renameElementSymbol
it('should emit descriptions of errors in dev', async () => {
const ClientErrorBoundary = clientReference(ErrorBoundary);
@@ -1729,7 +1728,8 @@ describe('ReactFlight', () => {
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with symbol properties like Symbol.iterator are not supported.\n' +
' <... value={{}}>\n' +
' ^^^^\n',
' ^^^^\n' +
' in (at **)',
]);
});
@@ -3258,7 +3258,7 @@ describe('ReactFlight', () => {
const transport = ReactNoopFlightServer.render({
root: ReactServer.createElement(App),
});
assertConsoleErrorDev(['Error: err']);
assertConsoleErrorDev(['Error: err' + '\n in <stack>']);
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');

View File

@@ -467,9 +467,11 @@ function useSyncExternalStore<T>(
// useSyncExternalStore() composes multiple hooks internally.
// Advance the current hook index the same number of times
// so that subsequent hooks have the right memoized state.
nextHook(); // SyncExternalStore
const hook = nextHook(); // SyncExternalStore
nextHook(); // Effect
const value = getSnapshot();
// Read from hook.memoizedState to get the value that was used during render,
// not the current value from getSnapshot() which may have changed.
const value = hook !== null ? hook.memoizedState : getSnapshot();
hookLog.push({
displayName: null,
primitive: 'SyncExternalStore',

View File

@@ -734,7 +734,11 @@ describe('ReactHooksInspection', () => {
});
const results = normalizeSourceLoc(tree);
expect(results).toHaveLength(1);
expect(results[0]).toMatchInlineSnapshot(`
expect(results[0]).toMatchInlineSnapshot(
{
subHooks: [{value: expect.any(Promise)}],
},
`
{
"debugInfo": null,
"hookSource": {
@@ -759,12 +763,13 @@ describe('ReactHooksInspection', () => {
"isStateEditable": false,
"name": "Use",
"subHooks": [],
"value": Promise {},
"value": Any<Promise>,
},
],
"value": undefined,
}
`);
`,
);
});
describe('useDebugValue', () => {

View File

@@ -293,7 +293,7 @@ export function connectToDevTools(options: ?ConnectOptions) {
scheduleRetry();
}
function handleMessage(event: MessageEvent) {
function handleMessage(event: MessageEvent<>) {
let data;
try {
if (typeof event.data === 'string') {

View File

@@ -17,6 +17,14 @@ const contentScriptsToInject = [
runAt: 'document_end',
world: chrome.scripting.ExecutionWorld.ISOLATED,
},
{
id: '@react-devtools/fallback-eval-context',
js: ['build/fallbackEvalContext.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
{
id: '@react-devtools/hook',
js: ['build/installHook.js'],

View File

@@ -97,6 +97,58 @@ export function handleDevToolsPageMessage(message) {
break;
}
case 'eval-in-inspected-window': {
const {
payload: {tabId, requestId, scriptId, args},
} = message;
chrome.tabs
.sendMessage(tabId, {
source: 'devtools-page-eval',
payload: {
scriptId,
args,
},
})
.then(response => {
if (!response) {
chrome.runtime.sendMessage({
source: 'react-devtools-background',
payload: {
type: 'eval-in-inspected-window-response',
requestId,
result: null,
error: 'No response from content script',
},
});
return;
}
const {result, error} = response;
chrome.runtime.sendMessage({
source: 'react-devtools-background',
payload: {
type: 'eval-in-inspected-window-response',
requestId,
result,
error,
},
});
})
.catch(error => {
chrome.runtime.sendMessage({
source: 'react-devtools-background',
payload: {
type: 'eval-in-inspected-window-response',
requestId,
result: null,
error: error?.message || String(error),
},
});
});
break;
}
}
}

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {evalScripts} from '../evalScripts';
window.addEventListener('message', event => {
if (event.data?.source === 'react-devtools-content-script-eval') {
const {scriptId, args, requestId} = event.data.payload;
const response = {result: null, error: null};
try {
if (!evalScripts[scriptId]) {
throw new Error(`No eval script with id "${scriptId}" exists.`);
}
response.result = evalScripts[scriptId].fn.apply(null, args);
} catch (err) {
response.error = err.message;
}
window.postMessage(
{
source: 'react-devtools-content-script-eval-response',
payload: {
requestId,
response,
},
},
'*',
);
}
});

View File

@@ -1,38 +1,50 @@
/* global chrome */
/** @flow */
// We can't use chrome.storage domain from scripts which are injected in ExecutionWorld.MAIN
// This is the only purpose of this script - to send persisted settings to installHook.js content script
async function messageListener(event: MessageEvent) {
import type {UnknownMessageEvent} from './messages';
import type {DevToolsHookSettings} from 'react-devtools-shared/src/backend/types';
import {postMessage} from './messages';
async function messageListener(event: UnknownMessageEvent) {
if (event.source !== window) {
return;
}
if (event.data.source === 'react-devtools-hook-installer') {
if (event.data.payload.handshake) {
const settings = await chrome.storage.local.get();
const settings: Partial<DevToolsHookSettings> =
await chrome.storage.local.get();
// If storage was empty (first installation), define default settings
if (typeof settings.appendComponentStack !== 'boolean') {
settings.appendComponentStack = true;
}
if (typeof settings.breakOnConsoleErrors !== 'boolean') {
settings.breakOnConsoleErrors = false;
}
if (typeof settings.showInlineWarningsAndErrors !== 'boolean') {
settings.showInlineWarningsAndErrors = true;
}
if (typeof settings.hideConsoleLogsInStrictMode !== 'boolean') {
settings.hideConsoleLogsInStrictMode = false;
}
if (
typeof settings.disableSecondConsoleLogDimmingInStrictMode !== 'boolean'
) {
settings.disableSecondConsoleLogDimmingInStrictMode = false;
}
const hookSettings: DevToolsHookSettings = {
appendComponentStack:
typeof settings.appendComponentStack === 'boolean'
? settings.appendComponentStack
: true,
breakOnConsoleErrors:
typeof settings.breakOnConsoleErrors === 'boolean'
? settings.breakOnConsoleErrors
: false,
showInlineWarningsAndErrors:
typeof settings.showInlineWarningsAndErrors === 'boolean'
? settings.showInlineWarningsAndErrors
: true,
hideConsoleLogsInStrictMode:
typeof settings.hideConsoleLogsInStrictMode === 'boolean'
? settings.hideConsoleLogsInStrictMode
: false,
disableSecondConsoleLogDimmingInStrictMode:
typeof settings.disableSecondConsoleLogDimmingInStrictMode ===
'boolean'
? settings.disableSecondConsoleLogDimmingInStrictMode
: false,
};
window.postMessage({
postMessage({
source: 'react-devtools-hook-settings-injector',
payload: {settings},
payload: {settings: hookSettings},
});
window.removeEventListener('message', messageListener);
@@ -41,7 +53,7 @@ async function messageListener(event: MessageEvent) {
}
window.addEventListener('message', messageListener);
window.postMessage({
postMessage({
source: 'react-devtools-hook-settings-injector',
payload: {handshake: true},
});

View File

@@ -1,39 +1,46 @@
/** @flow */
import type {UnknownMessageEvent} from './messages';
import type {DevToolsHookSettings} from 'react-devtools-shared/src/backend/types';
import {installHook} from 'react-devtools-shared/src/hook';
import {
getIfReloadedAndProfiling,
getProfilingSettings,
} from 'react-devtools-shared/src/utils';
import {postMessage} from './messages';
let resolveHookSettingsInjection;
let resolveHookSettingsInjection: (settings: DevToolsHookSettings) => void;
function messageListener(event: MessageEvent) {
function messageListener(event: UnknownMessageEvent) {
if (event.source !== window) {
return;
}
if (event.data.source === 'react-devtools-hook-settings-injector') {
const payload = event.data.payload;
// In case handshake message was sent prior to hookSettingsInjector execution
// We can't guarantee order
if (event.data.payload.handshake) {
window.postMessage({
if (payload.handshake) {
postMessage({
source: 'react-devtools-hook-installer',
payload: {handshake: true},
});
} else if (event.data.payload.settings) {
} else if (payload.settings) {
window.removeEventListener('message', messageListener);
resolveHookSettingsInjection(event.data.payload.settings);
resolveHookSettingsInjection(payload.settings);
}
}
}
// Avoid double execution
if (!window.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) {
const hookSettingsPromise = new Promise(resolve => {
const hookSettingsPromise = new Promise<DevToolsHookSettings>(resolve => {
resolveHookSettingsInjection = resolve;
});
window.addEventListener('message', messageListener);
window.postMessage({
postMessage({
source: 'react-devtools-hook-installer',
payload: {handshake: true},
});

View File

@@ -0,0 +1,42 @@
/** @flow */
import type {DevToolsHookSettings} from 'react-devtools-shared/src/backend/types';
export function postMessage(event: UnknownMessageEventData): void {
window.postMessage(event);
}
export interface UnknownMessageEvent
extends MessageEvent<UnknownMessageEventData> {}
export type UnknownMessageEventData =
| HookSettingsInjectorEventData
| HookInstallerEventData;
export type HookInstallerEventData = {
source: 'react-devtools-hook-installer',
payload: HookInstallerEventPayload,
};
export type HookInstallerEventPayload = HookInstallerEventPayloadHandshake;
export type HookInstallerEventPayloadHandshake = {
handshake: true,
};
export type HookSettingsInjectorEventData = {
source: 'react-devtools-hook-settings-injector',
payload: HookSettingsInjectorEventPayload,
};
export type HookSettingsInjectorEventPayload =
| HookSettingsInjectorEventPayloadHandshake
| HookSettingsInjectorEventPayloadSettings;
export type HookSettingsInjectorEventPayloadHandshake = {
handshake: true,
};
export type HookSettingsInjectorEventPayloadSettings = {
settings: DevToolsHookSettings,
};

View File

@@ -117,3 +117,49 @@ function connectPort() {
// $FlowFixMe[incompatible-use]
port.onDisconnect.addListener(handleDisconnect);
}
let evalRequestId = 0;
const evalRequestCallbacks = new Map<number, Function>();
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
switch (msg?.source) {
case 'devtools-page-eval': {
const {scriptId, args} = msg.payload;
const requestId = evalRequestId++;
window.postMessage(
{
source: 'react-devtools-content-script-eval',
payload: {
requestId,
scriptId,
args,
},
},
'*',
);
evalRequestCallbacks.set(requestId, sendResponse);
return true; // Indicate we will respond asynchronously
}
}
});
window.addEventListener('message', event => {
if (event.data?.source === 'react-devtools-content-script-eval-response') {
const {requestId, response} = event.data.payload;
const callback = evalRequestCallbacks.get(requestId);
try {
if (!callback)
throw new Error(
`No eval request callback for id "${requestId}" exists.`,
);
callback(response);
} catch (e) {
console.warn(
'React DevTools Content Script eval response error occurred:',
e,
);
} finally {
evalRequestCallbacks.delete(requestId);
}
}
});

View File

@@ -0,0 +1,112 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
export type EvalScriptIds =
| 'checkIfReactPresentInInspectedWindow'
| 'reload'
| 'setBrowserSelectionFromReact'
| 'setReactSelectionFromBrowser'
| 'viewAttributeSource'
| 'viewElementSource';
/*
.fn for fallback in Content Script context
.code for chrome.devtools.inspectedWindow.eval()
*/
type EvalScriptEntry = {
fn: (...args: any[]) => any,
code: (...args: any[]) => string,
};
/*
Can not access `Developer Tools Console API` (e.g., inspect(), $0) in this context.
So some fallback functions are no-op or throw error.
*/
export const evalScripts: {[key: EvalScriptIds]: EvalScriptEntry} = {
checkIfReactPresentInInspectedWindow: {
fn: () =>
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ &&
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0,
code: () =>
'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ &&' +
'window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0',
},
reload: {
fn: () => window.location.reload(),
code: () => 'window.location.reload();',
},
setBrowserSelectionFromReact: {
fn: () => {
throw new Error('Not supported in fallback eval context');
},
code: () =>
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
'(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' +
'false',
},
setReactSelectionFromBrowser: {
fn: () => {
throw new Error('Not supported in fallback eval context');
},
code: () =>
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' +
'false',
},
viewAttributeSource: {
fn: ({rendererID, elementID, path}) => {
return false; // Not supported in fallback eval context
},
code: ({rendererID, elementID, path}) =>
'{' + // The outer block is important because it means we can declare local variables.
'const renderer = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.rendererInterfaces.get(' +
JSON.stringify(rendererID) +
');' +
'if (renderer) {' +
' const value = renderer.getElementAttributeByPath(' +
JSON.stringify(elementID) +
',' +
JSON.stringify(path) +
');' +
' if (value) {' +
' inspect(value);' +
' true;' +
' } else {' +
' false;' +
' }' +
'} else {' +
' false;' +
'}' +
'}',
},
viewElementSource: {
fn: ({rendererID, elementID}) => {
return false; // Not supported in fallback eval context
},
code: ({rendererID, elementID}) =>
'{' + // The outer block is important because it means we can declare local variables.
'const renderer = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.rendererInterfaces.get(' +
JSON.stringify(rendererID) +
');' +
'if (renderer) {' +
' const value = renderer.getElementSourceFunctionById(' +
JSON.stringify(elementID) +
');' +
' if (value) {' +
' inspect(value);' +
' true;' +
' } else {' +
' false;' +
' }' +
'} else {' +
' false;' +
'}' +
'}',
},
};

View File

@@ -1,13 +1,12 @@
/* global chrome */
import {evalInInspectedWindow} from './evalInInspectedWindow';
export function setBrowserSelectionFromReact() {
// This is currently only called on demand when you press "view DOM".
// In the future, if Chrome adds an inspect() that doesn't switch tabs,
// we could make this happen automatically when you select another component.
chrome.devtools.inspectedWindow.eval(
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
'(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' +
'false',
evalInInspectedWindow(
'setBrowserSelectionFromReact',
[],
(didSelectionChange, evalError) => {
if (evalError) {
console.error(evalError);
@@ -19,10 +18,9 @@ export function setBrowserSelectionFromReact() {
export function setReactSelectionFromBrowser(bridge) {
// When the user chooses a different node in the browser Elements tab,
// copy it over to the hook object so that we can sync the selection.
chrome.devtools.inspectedWindow.eval(
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' +
'false',
evalInInspectedWindow(
'setReactSelectionFromBrowser',
[],
(didSelectionChange, evalError) => {
if (evalError) {
console.error(evalError);
@@ -34,7 +32,7 @@ export function setReactSelectionFromBrowser(bridge) {
return;
}
// Remember to sync the selection next time we show Components tab.
// Remember to sync the selection next time we show inspected element
bridge.send('syncSelectionFromBuiltinElementsPanel');
}
},

View File

@@ -0,0 +1,116 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {EvalScriptIds} from '../evalScripts';
import {evalScripts} from '../evalScripts';
type ExceptionInfo = {
code: ?string,
description: ?string,
isError: boolean,
isException: boolean,
value: any,
};
const EVAL_TIMEOUT = 1000 * 10;
let evalRequestId = 0;
const evalRequestCallbacks = new Map<
number,
(value: {result: any, error: any}) => void,
>();
function fallbackEvalInInspectedWindow(
scriptId: EvalScriptIds,
args: any[],
callback: (value: any, exceptionInfo: ?ExceptionInfo) => void,
) {
if (!evalScripts[scriptId]) {
throw new Error(`No eval script with id "${scriptId}" exists.`);
}
const code = evalScripts[scriptId].code.apply(null, args);
const tabId = chrome.devtools.inspectedWindow.tabId;
const requestId = evalRequestId++;
chrome.runtime.sendMessage({
source: 'devtools-page',
payload: {
type: 'eval-in-inspected-window',
tabId,
requestId,
scriptId,
args,
},
});
const timeout = setTimeout(() => {
evalRequestCallbacks.delete(requestId);
if (callback) {
callback(null, {
code,
description:
'Timed out while waiting for eval response from the inspected window.',
isError: true,
isException: false,
value: undefined,
});
}
}, EVAL_TIMEOUT);
evalRequestCallbacks.set(requestId, ({result, error}) => {
clearTimeout(timeout);
evalRequestCallbacks.delete(requestId);
if (callback) {
if (error) {
callback(null, {
code,
description: undefined,
isError: false,
isException: true,
value: error,
});
return;
}
callback(result, null);
}
});
}
export function evalInInspectedWindow(
scriptId: EvalScriptIds,
args: any[],
callback: (value: any, exceptionInfo: ?ExceptionInfo) => void,
) {
if (!evalScripts[scriptId]) {
throw new Error(`No eval script with id "${scriptId}" exists.`);
}
const code = evalScripts[scriptId].code.apply(null, args);
chrome.devtools.inspectedWindow.eval(code, (result, exceptionInfo) => {
if (!exceptionInfo) {
callback(result, exceptionInfo);
return;
}
// If an exception (e.g. CSP Blocked) occurred,
// fallback to the content script eval context
fallbackEvalInInspectedWindow(scriptId, args, callback);
});
}
chrome.runtime.onMessage.addListener(({payload, source}) => {
if (source === 'react-devtools-background') {
switch (payload?.type) {
case 'eval-in-inspected-window-response': {
const {requestId, result, error} = payload;
const callback = evalRequestCallbacks.get(requestId);
if (callback) {
callback({result, error});
}
break;
}
}
}
});

View File

@@ -1,6 +1,14 @@
/* global chrome */
/** @flow */
import type {RootType} from 'react-dom/src/client/ReactDOMRoot';
import type {FrontendBridge, Message} from 'react-devtools-shared/src/bridge';
import type {
TabID,
ViewElementSource,
} from 'react-devtools-shared/src/devtools/views/DevTools';
import type {SourceSelection} from 'react-devtools-shared/src/devtools/views/Editor/EditorPane';
import type {Element} from 'react-devtools-shared/src/frontend/types';
import {createElement} from 'react';
import {flushSync} from 'react-dom';
@@ -32,6 +40,7 @@ import {
} from './elementSelection';
import {viewAttributeSource} from './sourceSelection';
import {evalInInspectedWindow} from './evalInInspectedWindow';
import {startReactPolling} from './reactPolling';
import {cloneStyleTags} from './cloneStyleTags';
import fetchFileWithCaching from './fetchFileWithCaching';
@@ -50,9 +59,9 @@ const hookNamesModuleLoaderFunction = () => resolvedParseHookNames;
function createBridge() {
bridge = new Bridge({
listen(fn) {
const bridgeListener = message => fn(message);
const bridgeListener = (message: Message) => fn(message);
// Store the reference so that we unsubscribe from the same object.
const portOnMessage = port.onMessage;
const portOnMessage = ((port: any): ExtensionPort).onMessage;
portOnMessage.addListener(bridgeListener);
lastSubscribedBridgeListener = bridgeListener;
@@ -70,7 +79,7 @@ function createBridge() {
bridge.addListener('reloadAppForProfiling', () => {
localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true');
chrome.devtools.inspectedWindow.eval('window.location.reload();');
evalInInspectedWindow('reload', [], () => {});
});
bridge.addListener(
@@ -175,14 +184,20 @@ function createBridgeAndStore() {
// Otherwise, the Store may miss important initial tree op codes.
injectBackendManager(chrome.devtools.inspectedWindow.tabId);
const viewAttributeSourceFunction = (id, path) => {
const viewAttributeSourceFunction = (
id: Element['id'],
path: Array<string | number>,
) => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID != null) {
viewAttributeSource(rendererID, id, path);
}
};
const viewElementSourceFunction = (source, symbolicatedSource) => {
const viewElementSourceFunction: ViewElementSource = (
source,
symbolicatedSource,
) => {
const [, sourceURL, line, column] = symbolicatedSource
? symbolicatedSource
: source;
@@ -197,7 +212,7 @@ function createBridgeAndStore() {
root = createRoot(document.createElement('div'));
render = (overrideTab = mostRecentOverrideTab) => {
render = (overrideTab: TabID | null = mostRecentOverrideTab) => {
mostRecentOverrideTab = overrideTab;
root.render(
@@ -205,6 +220,7 @@ function createBridgeAndStore() {
bridge,
browserTheme: getBrowserTheme(),
componentsPortalContainer,
inspectedElementPortalContainer,
profilerPortalContainer,
editorPortalContainer,
currentSelectedSource,
@@ -225,7 +241,9 @@ function createBridgeAndStore() {
};
}
function ensureInitialHTMLIsCleared(container) {
function ensureInitialHTMLIsCleared(
container: HTMLElement & {_hasInitialHTMLBeenCleared?: boolean},
) {
if (container._hasInitialHTMLBeenCleared) {
return;
}
@@ -277,6 +295,52 @@ function createComponentsPanel() {
);
}
function createElementsInspectPanel() {
if (inspectedElementPortalContainer) {
// Panel is created and user opened it at least once
ensureInitialHTMLIsCleared(inspectedElementPortalContainer);
render();
return;
}
if (inspectedElementPane) {
// Panel is created, but wasn't opened yet, so no document is present for it
return;
}
const elementsPanel = chrome.devtools.panels.elements;
if (__IS_FIREFOX__ || !elementsPanel || !elementsPanel.createSidebarPane) {
// Firefox will not pass the window to the onShown listener despite setPage
// being called.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=2010549
// May not be supported in some browsers.
// See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/devtools/panels/ElementsPanel/createSidebarPane#browser_compatibility
return;
}
elementsPanel.createSidebarPane('React Element ⚛', createdPane => {
inspectedElementPane = createdPane;
createdPane.setPage('panel.html');
createdPane.setHeight('75px');
createdPane.onShown.addListener(portal => {
inspectedElementPortalContainer = portal.container;
if (inspectedElementPortalContainer != null && render) {
ensureInitialHTMLIsCleared(inspectedElementPortalContainer);
bridge.send('syncSelectionFromBuiltinElementsPanel');
render();
portal.injectStyles(cloneStyleTags);
logEvent({event_name: 'selected-inspected-element-pane'});
}
});
});
}
function createProfilerPanel() {
if (profilerPortalContainer) {
// Panel is created and user opened it at least once
@@ -350,13 +414,6 @@ function createSourcesEditorPanel() {
logEvent({event_name: 'selected-editor-pane'});
}
});
createdPane.onShown.addListener(() => {
bridge.emit('extensionEditorPaneShown');
});
createdPane.onHidden.addListener(() => {
bridge.emit('extensionEditorPaneHidden');
});
});
}
@@ -432,10 +489,10 @@ function performInTabNavigationCleanup() {
// Do not clean mostRecentOverrideTab on purpose, so we remember last opened
// React DevTools tab, when user does in-tab navigation
store = null;
bridge = null;
render = null;
root = null;
store = (null: $FlowFixMe);
bridge = (null: $FlowFixMe);
render = (null: $FlowFixMe);
root = (null: $FlowFixMe);
}
function performFullCleanup() {
@@ -457,18 +514,18 @@ function performFullCleanup() {
componentsPortalContainer = null;
profilerPortalContainer = null;
suspensePortalContainer = null;
root = null;
root = (null: $FlowFixMe);
mostRecentOverrideTab = null;
store = null;
bridge = null;
render = null;
store = (null: $FlowFixMe);
bridge = (null: $FlowFixMe);
render = (null: $FlowFixMe);
port?.disconnect();
port = null;
port = (null: $FlowFixMe);
}
function connectExtensionPort() {
function connectExtensionPort(): void {
if (port) {
throw new Error('DevTools port was already connected');
}
@@ -492,7 +549,7 @@ function connectExtensionPort() {
// so, when we call `port.disconnect()` from this script,
// this should not trigger this callback and port reconnection
port.onDisconnect.addListener(() => {
port = null;
port = (null: $FlowFixMe);
connectExtensionPort();
});
}
@@ -507,6 +564,7 @@ function mountReactDevTools() {
createComponentsPanel();
createProfilerPanel();
createSourcesEditorPanel();
createElementsInspectPanel();
// Suspense Tab is created via the hook
// TODO(enableSuspenseTab): Create eagerly once Suspense tab is stable
}
@@ -545,9 +603,9 @@ function mountReactDevToolsWhenReactHasLoaded() {
);
}
let bridge = null;
let bridge: FrontendBridge = (null: $FlowFixMe);
let lastSubscribedBridgeListener = null;
let store = null;
let store: Store = (null: $FlowFixMe);
let profilingData = null;
@@ -555,18 +613,35 @@ let componentsPanel = null;
let profilerPanel = null;
let suspensePanel = null;
let editorPane = null;
let inspectedElementPane = null;
let componentsPortalContainer = null;
let profilerPortalContainer = null;
let suspensePortalContainer = null;
let editorPortalContainer = null;
let inspectedElementPortalContainer = null;
let mostRecentOverrideTab = null;
let render = null;
let root = null;
let mostRecentOverrideTab: null | TabID = null;
let render: (overrideTab?: TabID) => void = (null: $FlowFixMe);
let root: RootType = (null: $FlowFixMe);
let currentSelectedSource: null | SourceSelection = null;
let port = null;
type ExtensionEvent = {
addListener(callback: (message: Message, port: ExtensionPort) => void): void,
removeListener(
callback: (message: Message, port: ExtensionPort) => void,
): void,
};
/** https://developer.chrome.com/docs/extensions/reference/api/runtime#type-Port */
type ExtensionPort = {
onDisconnect: ExtensionEvent,
onMessage: ExtensionEvent,
postMessage(message: mixed, transferable?: Array<mixed>): void,
disconnect(): void,
};
let port: ExtensionPort = (null: $FlowFixMe);
// In case when multiple navigation events emitted in a short period of time
// This debounced callback primarily used to avoid mounting React DevTools multiple times, which results
@@ -599,7 +674,7 @@ connectExtensionPort();
mountReactDevToolsWhenReactHasLoaded();
function onThemeChanged(themeName) {
function onThemeChanged() {
// Rerender with the new theme
render();
}
@@ -636,6 +711,12 @@ if (chrome.devtools.panels.setOpenResourceHandler) {
resource.url,
lineNumber - 1,
columnNumber - 1,
maybeError => {
if (maybeError && maybeError.isError) {
// Not a resource Chrome can open. Fallback to browser default behavior.
window.open(resource.url);
}
},
);
},
);

View File

@@ -1,4 +1,4 @@
/* global chrome */
import {evalInInspectedWindow} from './evalInInspectedWindow';
class CouldNotFindReactOnThePageError extends Error {
constructor() {
@@ -26,8 +26,9 @@ export function startReactPolling(
// This function will call onSuccess only if React was found and polling is not aborted, onError will be called for every other case
function checkIfReactPresentInInspectedWindow(onSuccess, onError) {
chrome.devtools.inspectedWindow.eval(
'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0',
evalInInspectedWindow(
'checkIfReactPresentInInspectedWindow',
[],
(pageHasReact, exceptionInfo) => {
if (status === 'aborted') {
onError(

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