Compare commits

...

294 Commits

Author SHA1 Message Date
Joe Savona
d808047504 [compiler] Add fault tolerance plan document
Add detailed plan for making the React Compiler fault-tolerant by
accumulating errors across all passes instead of stopping at the first
error. This enables reporting multiple compilation errors at once.
2026-02-23 08:56:54 -08:00
Joseph Savona
8b6b11f703 [compiler] Remove fallback compilation pipeline dead code (#35827)
Remove dead code left behind after the removal of retryCompileFunction,
enableFire, and inferEffectDependencies:
- Delete ValidateNoUntransformedReferences.ts (always a no-op)
- Remove CompileProgramMetadata type and retryErrors from ProgramContext
- Remove 'client-no-memo' output mode
- Change compileProgram return type from CompileProgramMetadata | null
to void
2026-02-23 08:54:49 -08:00
Andrew Clark
ab18f33d46 Fix context propagation through suspended Suspense boundaries (#35839)
When a Suspense boundary suspends during initial mount, the primary
children's fibers are discarded because there is no current tree to
preserve them. If the suspended promise never resolves, the only way to
retry is something external like a context change. However, lazy context
propagation could not find the consumer fibers — they no longer exist in
the tree — so the Suspense boundary was never marked for retry and
remained stuck in fallback state indefinitely.

The fix teaches context propagation to conservatively mark suspended
Suspense boundaries for retry when a parent context changes, even when
the consumer fibers can't be found. This matches the existing
conservative approach used for dehydrated (SSR) Suspense boundaries.
2026-02-20 22:03:11 -05:00
Joseph Savona
b16b768fbd [compiler] Feature flag cleanup (#35825)
Cleans up feature flags that do not have an active experiment and which
we don't currently plan to ship, one commit per flag. Notable removals:
* Automatic (inferred) effect dependencies / Fire: abandoned due to
early feedback. Shipped useEffectEvent which addresses some of the
use-cases.
* Inline JSX transform (experimented, not a consistent win)
* Context selectors (experimented, not a sufficient/consistent win given
the benefit the compiler already provides)
* Instruction Reordering (will try a different approach)

To decide which features to remove, I looked at Meta's internal repos as
well as eslint-pugin-react-hooks to see which flags were never
overridden anywhere. That gave a longer list of flags, from which I then
removed some features that I know are used in OSS.
2026-02-20 12:29:12 -08:00
Sebastian "Sebbie" Silbermann
2ba3065527 [Flight] Add support for transporting Error.cause (#35810) 2026-02-19 15:50:34 -08:00
Josh Story
38cd020c1f Don't outline Suspense boundaries with suspensey CSS during shell flush (#35824)
When flushing the shell, stylesheets with precedence are emitted in the
`<head>` which blocks paint regardless. Outlining a boundary solely
because it has suspensey CSS provides no benefit during the shell flush
and causes a higher-level fallback to be shown unnecessarily (e.g.
"Middle Fallback" instead of "Inner Fallback").

This change passes a flushingInShell flag to hasSuspenseyContent so the
host config can skip stylesheet-only suspensey content when flushing the
shell. Suspensey images (used for ViewTransition animation reveals)
still trigger outlining during the shell since their motivation is
different.

When flushing streamed completions the behavior is unchanged — suspensey
CSS still causes outlining so the parent content can display sooner
while the stylesheet loads.
2026-02-19 12:29:21 -08:00
Tim Neutkens
f247ebaf44 [Flight] Walk parsed JSON instead of using reviver for parsing RSC payload (#35776)
## Summary

Follow-up to https://github.com/vercel/next.js/pull/89823 with the
actual changes to React.

Replaces the `JSON.parse` reviver callback in `initializeModelChunk`
with a two-step approach: plain `JSON.parse()` followed by a recursive
`reviveModel()` post-process (same as in Flight Reply Server). This
yields a **~75% speedup** in RSC chunk deserialization.

| Payload | Original (ms) | Walk (ms) | Speedup |
|---------|---------------|-----------|---------|
| Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** |
| Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** |
| Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** |
| XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** |
| Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** |

## Problem

`createFromJSONCallback` returns a reviver function passed as the second
argument to `JSON.parse()`. This reviver is called for **every key-value
pair** in the parsed JSON. While the logic inside the reviver is
lightweight, the dominant cost is the **C++ → JavaScript boundary
crossing** — V8's `JSON.parse` is implemented in C++, and calling back
into JavaScript for every node incurs significant overhead.

Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x
slower** than bare `JSON.parse` without a reviver:

```
108 KB payload:
  Bare JSON.parse:    0.60 ms
  Trivial reviver:    2.95 ms  (+391%)
```

## Change

Replace the reviver with a two-step process:

1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no
callbacks
2. `reviveModel` — recursively walk the resulting object in pure
JavaScript to apply RSC transformations

The `reviveModel` function includes additional optimizations over the
original reviver:
- **Short-circuits plain strings**: only calls `parseModelString` when
the string starts with `$`, skipping the vast majority of strings (class
names, text content, etc.)
- **Stays entirely in JavaScript** — no C++ boundary crossings during
the walk

## Results

You can find the related applications in the [Next.js PR
](https://github.com/vercel/next.js/pull/89823)as I've been testing this
on Next.js applications.

### Table as Server Component with 1000 items

Before:

```
    "min": 13.782875000000786,
    "max": 22.23400000000038,
    "avg": 17.116868530000083,
    "p50": 17.10766700000022,
    "p75": 18.50787499999933,
    "p95": 20.426249999998618,
    "p99": 21.814125000000786
```

After:

```
    "min": 10.963916999999128,
    "max": 18.096083000000363,
    "avg": 13.543286884999988,
    "p50": 13.58350000000064,
    "p75": 14.871791999999914,
    "p95": 16.08429099999921,
    "p99": 17.591458000000785
```

### Table as Client Component with 1000 items

Before:

```
    "min": 3.888875000000553,
    "max": 9.044959000000745,
    "avg": 4.651271475000067,
    "p50": 4.555749999999534,
    "p75": 4.966624999999112,
    "p95": 5.47754200000054,
    "p99": 6.109499999998661
````

After:

```
    "min": 3.5986250000005384,
    "max": 5.374291000000085,
    "avg": 4.142990245000046,
    "p50": 4.10570799999914,
    "p75": 4.392041999999492,
    "p95": 4.740084000000934,
    "p99": 5.1652500000000146
```

### Nested Suspense

Before:

```
  Requests:  200
  Min:       73ms
  Max:       106ms
  Avg:       78ms
  P50:       77ms
  P75:       80ms
  P95:       85ms
  P99:       94ms
```

After:

```
  Requests:  200
  Min:       56ms
  Max:       67ms
  Avg:       59ms
  P50:       58ms
  P75:       60ms
  P95:       65ms
  P99:       66ms
```

### Even more nested Suspense (double-level Suspense)

Before:

```
  Requests:  200
  Min:       159ms
  Max:       208ms
  Avg:       169ms
  P50:       167ms
  P75:       173ms
  P95:       183ms
  P99:       188ms
```

After:

```
  Requests:  200
  Min:       125ms
  Max:       170ms
  Avg:       134ms
  P50:       132ms
  P75:       138ms
  P95:       148ms
  P99:       160ms
```

## How did you test this change?

Ran it across many Next.js benchmark applications.

The entire Next.js test suite passes with this change.

---------

Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
2026-02-19 08:37:41 -08:00
Sebastian "Sebbie" Silbermann
3a2bee26d2 [DevTools] Fix alignment of breadcrumbs separator (#35817) 2026-02-18 10:53:49 -08:00
chirokas
4842fbea02 [react-dom] Add support for onFullscreenChange and onFullscreenError events (#34621) 2026-02-17 17:34:16 -08:00
Nick Gerleman
61db53c179 [Native] Add RCTSelectableText as a recognized Text component (#35780)
## Summary

Add "RCTSelectableText" to the list of component names recognized as
being inside a text element, alongside "RCTText".

React Native's new text stack, tries to optimize and allows
differentiating between a custom TextView, with lower level control,
that can reuse the work performed during Fabric/Yoga layout, and a
native TextView, used for fidelity. On Android at least, the only place
we've needed native TextView for fidelity/native UX has been support for
`selectable` text, which has many unique UI interactions.

## How did you test this change?

When I patch this in, alongside
https://github.com/facebook/react-native/pull/55552, we no longer see
warnings when we render text inside of RCTSelectableText component.

---------

Co-authored-by: Eli White <github@eli-white.com>
2026-02-17 16:16:06 -08:00
Joseph Savona
4ac47537dd [compiler] Track locations for dependencies (#35794)
Tracks locations for reactive scope dependencies, both on the deps and
portions of the path. The immediate need for this is a non-public
experiment where we're exploring type-directed compilation, and
sometimes look up the types of expressions by location. We need to
preserve locations accurately for that to work, including the locations
of the deps.

## Test Plan

Locations for dependencies are not easy to test: i manually spot-checked
the new fixture to ensure that the deps look right. This is fine as
best-effort since it doesn't impact any of our core compilation logic, i
may fix forward if there are issues and will think about how to test.
2026-02-17 14:06:21 -08:00
Hendrik Liebau
47d1ad1454 [Flight] Skip transferReferencedDebugInfo during debug info resolution (#35795)
When the Flight Client resolves chunk references during model parsing,
it calls `transferReferencedDebugInfo` to propagate debug info entries
from referenced chunks to the parent chunk. Debug info on chunks is
later moved to their resolved values, where it is used by React DevTools
to show performance tracks and what a component was suspended by.

Debug chunks themselves (specifically `ReactComponentInfo`,
`ReactAsyncInfo`, `ReactIOInfo`, and their outlined references) are
metadata that is never rendered. They don't need debug info attached to
them. Without this fix, debug info entries accumulate on outlined debug
chunks via their references to other debug chunks (e.g. owner chains and
props deduplication paths). Since each outlined chunk's accumulated
entries are copied to every chunk that references it, this creates
exponential growth in deep component trees, which can cause the dev
server to hang and run out of memory.

This generalizes the existing skip of `transferReferencedDebugInfo` for
Element owner/stack references (which already recognizes that references
to debug chunks don't need debug info transferred) to all references
resolved during debug info resolution. It adds an
`isInitializingDebugInfo` flag set in `initializeDebugChunk` and
`resolveIOInfo`, which propagates through all nested
`initializeModelChunk` calls within the same synchronous stack. For the
async path, `waitForReference` captures the flag at call time into
`InitializationReference.isDebug`, so deferred fulfillments also skip
the transfer.
2026-02-16 09:22:32 -08:00
Azat S.
e8c6362678 [eslint-plugin-react-hooks] Add ESLint v10 support (#35720)
## Summary

ESLint v10.0.0 was released on February 7, 2026. The current
`peerDependencies` for `eslint-plugin-react-hooks` only allows up to
`^9.0.0`, which causes peer dependency warnings when installing with
ESLint v10.

This PR:

- Adds `^10.0.0` to the eslint peer dependency range
- Adds `eslint-v10` to devDependencies for testing
- Adds an `eslint-v10` e2e fixture (based on the existing `eslint-v9`
fixture)

ESLint v10's main breaking changes (removal of legacy eslintrc config,
deprecated context methods) don't affect this plugin - flat config is
already supported since v7.0.0, and the deprecated APIs already have
fallbacks in place.

## How did you test this change?

Ran the existing unit test suite:

```
cd packages/eslint-plugin-react-hooks && yarn test
```

All 5082 tests passed.
2026-02-13 10:26:01 -08:00
Ruslan Lesiutin
03ca38e6e7 [DevTools] Check suspense child node presence in parentSuspense.children before removing (#35775)
Currently, this silently removes the last child in the list, which
doesn't contain the `id`.
2026-02-13 15:33:22 +00:00
Sebastian "Sebbie" Silbermann
6066c782fe [DevTools] Dedicated empty state for roots that aren't suspended by anything (#35769) 2026-02-12 17:48:02 +01:00
Sebastian "Sebbie" Silbermann
705055d7ac [DevTools] Enable Suspense tab by default (#35768) 2026-02-12 16:50:29 +01:00
Ruslan Lesiutin
8374c2abf1 [DevTools] Remove experimental __IS_INTERNAL_MCP_BUILD__ flag and related code (#35755)
This is unused.
2026-02-11 16:59:43 +00:00
Ricky
892c68605c [fiber] bugfix - don't show <Offscreen> in error message. (#35763)
## Overview

While building the RSC sandboxes I notice error messages like:

>  An error occurred in the `<Offscreen>` component

This is an internal component so it should show either:

>  An error occurred in the `<Suspense>` component.

>  An error occurred in the `<Activity>` component.

It should only happen when there's a lazy in the direct child position
of a `<Suspense>` or `<Activity>` component.
2026-02-11 11:20:51 -05:00
Jack Pope
cd515d7e22 Minor DOM FragmentInstance refactors (#35641)
Handles TODOs, small follow up refactors
2026-02-11 10:03:36 -05:00
Jack Pope
78f5c504b7 Notify FragmentInstance of added/removed text (#35637)
Follow up to https://github.com/facebook/react/pull/35630

We don't currently have any operations that depend on the updating of
text nodes added or removed after Fragment mount. But for the sake of
completeness and extending the ability to any other host configs, this
change calls `commitNewChildToFragmentInstance` and
`deleteChildFromFragmentInstance` on HostText fibers.

Both DOM and Fabric configs early return because we cannot attach event
listeners or observers to text. In the future, there could be some
stateful Fragment feature that uses text that could extend this.
2026-02-11 09:26:22 -05:00
Sebastian "Sebbie" Silbermann
e49335e961 [DevTools] Display React.optimisticKey in key positions (#35760) 2026-02-11 00:35:36 +01:00
Sebastian "Sebbie" Silbermann
57b79b0388 [DevTools] Only block child Suspense boundaries if the parent has all shared suspenders removed (#35737) 2026-02-10 17:52:35 +01:00
Sebastian "Sebbie" Silbermann
70890e7c58 Consistent handling of work tags for rename, delete, and overriding state (#35740)
Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
2026-02-10 16:35:02 +01:00
Sebastian "Sebbie" Silbermann
f23aa1d9f5 [DevTools] Fix memory leak when unmounting hoistables (#35741) 2026-02-10 13:09:23 +01:00
Sebastian "Sebbie" Silbermann
49c3b270f9 [test] Include uniqueSuspenders in Suspense tree snapshots (#35736) 2026-02-10 12:51:58 +01:00
Ruslan Lesiutin
c6bb26bf83 [DevTools] Don't capture durations for disconnected subtrees when profiling (#35718)
After https://github.com/facebook/react/pull/34089, when updating
(possibly, mounting) inside disconnected subtree, we don't record this
as an operation. This only happens during reconnect. The issue is that
`recordProfilingDurations()` can be called, which diffs tree base
duration and reports it to the Frontend:

65db1000b9/packages/react-devtools-shared/src/backend/fiber/renderer.js (L4506-L4521)

This operation can be recorded before the "Add" operation, and it will
not be resolved properly on the Frontend side.

Before the fix:
```
commit tree › Suspense › should handle transitioning from fallback back to content during profiling

    Could not clone the node: commit tree does not contain fiber "5". This is a bug in React DevTools.

      162 |     const existingNode = nodes.get(id);
      163 |     if (existingNode == null) {
    > 164 |       throw new Error(
          |             ^
      165 |         `Could not clone the node: commit tree does not contain fiber "${id}". This is a bug in React DevTools.`,
      166 |       );
      167 |     }

      at getClonedNode (packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js:164:13)
      at updateTree (packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js:348:24)
      at getCommitTree (packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js:112:20)
      at ProfilingCache.getCommitTree (packages/react-devtools-shared/src/devtools/ProfilingCache.js:40:46)
      at Object.<anonymous> (packages/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder-test.js:257:44)
```
2026-02-09 22:18:17 +00:00
Sebastian "Sebbie" Silbermann
6a939d0b54 [DevTools] Allow renaming Host Component props (#35735) 2026-02-09 23:14:46 +01:00
Sebastian "Sebbie" Silbermann
4c9d62d2b4 [DevTools] Fix crash when revealing stable, filtered <Activity> children (#35734) 2026-02-09 22:52:24 +01:00
Ruslan Lesiutin
24f215ce8b [DevTools] Fix false-positive re-render reports for filtered nodes (#35723)
Fixes https://github.com/facebook/react/issues/33423,
https://github.com/facebook/react/issues/35245,
https://github.com/facebook/react/issues/19732.

As demoed
[here](https://github.com/facebook/react/issues/33423#issuecomment-2970750588),
React DevTools incorrectly highlights re-renders for descendants of
filtered-out nodes that didn't actually render.

There were multiple fixes suggesting changes in `didFiberRender()`
function, but these doesn't seem right, because this function is used in
a context of whether the Fiber actually rendered something (updated),
not re-rendered compared to the previous Fiber.

Instead, this PR adds additional validation at callsites that either
used for highlighting re-renders or capturing tree base durations and
are relying on `didFiberRender`. I've also added a few tests that
reproduce the failure scenario. Without the changes, the tests are
failing.
2026-02-09 20:39:33 +00:00
Sebastian "Sebbie" Silbermann
eab523e2a9 [Fiber] Avoid duplicate debug info for array children (#35733) 2026-02-09 20:36:56 +01:00
Hendrik Liebau
272441a9ad [Flight] Add unstable_allowPartialStream option to Flight Client (#35731)
When using a partial prerender stream, i.e. a prerender that is
intentionally aborted before all I/O has resolved, consumers of
`createFromReadableStream` would need to keep the stream unclosed to
prevent React Flight from erroring on unresolved chunks. However, some
browsers (e.g. Chrome, Firefox) keep unclosed ReadableStreams with
pending reads as native GC roots, retaining the entire Flight response.

With this PR we're adding an `unstable_allowPartialStream` option, that
allows consumers to close the stream normally. The Flight Client's
`close()` function then transitions pending chunks to halted instead of
erroring them. Halted chunks keep Suspense fallbacks showing (i.e. they
never resolve), and their `.then()` is a no-op so no new listeners
accumulate. Inner stream chunks (ReadableStream/AsyncIterable) are
closed gracefully, and `getChunk()` returns halted chunks for new IDs
that are accessed after closing the response. Blocked chunks are left
alone because they may be waiting on client-side async operations like
module loading, or on forward references to chunks that appeared later
in the stream, both of which resolve independently of closing.
2026-02-09 19:19:32 +01:00
Hendrik Liebau
b07aa7d643 [Flight] Fix encodeReply for JSX with temporary references (#35730)
`encodeReply` throws "React Element cannot be passed to Server Functions
from the Client without a temporary reference set" when a React element
is the root value of a `serializeModel` call (either passed directly or
resolved from a promise), even when a temporary reference set is
provided.

The cause is that `resolveToJSON` hits the `REACT_ELEMENT_TYPE` switch
case before reaching the `existingReference`/`modelRoot` check that
regular objects benefit from. The synthetic JSON root created by
`JSON.stringify` is never tracked in `writtenObjects`, so
`parentReference` is `undefined` and the code falls through to the
throw. This adds a `modelRoot` check in the `REACT_ELEMENT_TYPE` case,
following the same pattern used for promises and plain objects.

The added `JSX as root model` test also uncovered a pre-existing crash
in the Flight Client: when the JSX element round-trips back, it arrives
as a frozen object (client-created elements are frozen in DEV), and
`Object.defineProperty` for `_debugInfo` fails because frozen objects
are non-configurable. The same crash can occur with JSX exported as a
client reference. For now, we're adding `!Object.isFrozen()` guards in
`moveDebugInfoFromChunkToInnerValue` and `addAsyncInfo` to prevent the
crash, which means debug info is silently dropped for frozen elements.
The proper fix would likely be to clone the element so each rendering
context gets its own mutable copy with correct debug info.

closes #34984
closes #35690
2026-02-09 16:17:53 +01:00
Jimmy Lai
2dd9b7cf76 [Flight] Fix debug channel flag in Node.js server renderToPipeableStream (#35724)
## Summary

- Fixes the `createRequest` call in `renderToPipeableStream` to pass
`debugChannelReadable !== undefined` instead of `debugChannel !==
undefined` in the turbopack, esm, and parcel Node.js server
implementations
- The webpack version already had the correct check; this brings the
other bundler implementations in line

The bug was introduced in #33754. With `debugChannel !== undefined`, the
server could signal that debug info should be emitted even when only a
write-only debug channel is provided (no readable side), potentially
causing the client to block forever waiting for debug data that never
arrives.
2026-02-08 20:14:15 +01:00
Sebastian "Sebbie" Silbermann
65db1000b9 [test] Move profilingCommitTreeBuilder to versioned renderer (#35711) 2026-02-06 16:00:43 +01:00
Sebastian "Sebbie" Silbermann
2a879cdc95 [DevTools] Fix broken commit tree builder for initial operations (#35710) 2026-02-06 15:16:23 +01:00
Sebastian "Sebbie" Silbermann
9a5996a6c1 [flags] Cleanup enableHalt (#35708) 2026-02-06 10:33:51 +01:00
Sebastian "Sebbie" Silbermann
1c66ac740c [DevTools] Separate breadcrumbs with » (#35705) 2026-02-06 00:40:31 +01:00
Sebastian "Sebbie" Silbermann
8b276df415 [DevTools] Avoid scrollbars in Suspense breadcrumbs (#35700) 2026-02-05 23:27:57 +01:00
Ricky
95ffd6cd9c Disable parallel transitions in canary (#35709)
Accidentally enabled this
2026-02-05 13:34:23 -05:00
Ruslan Lesiutin
b9323509be [DevTools] Throw an error when attempting to clone non-existent node (#35702)
There is an existing issue with serialisation logic for the traces from
Profiler panel.

I've discovered that `TREE_OPERATION_UPDATE_TREE_BASE_DURATION`
operation for some reason appears earlier in a sequence of operations,
before the `TREE_OPERATION_ADD` that registers the new node. It ends up
cloning non-existent node, which just creates an empty object and adds
it to the map of nodes.

This change only adds additional layer of validation to cloning logic,
so we don't swallow the error, if we attempt to clone non-existent node.
2026-02-05 17:49:18 +00:00
Sebastian "Sebbie" Silbermann
bb53387716 [DevTools] Shrink/Deshrink Owners breadcrumbs on any resizes (#35694) 2026-02-05 12:08:02 +01:00
Ricky
3aaab92a26 [flags] add enableEffectEventMutationPhase (#35548)
Small optimization for useEffectEvent. Not sure we even need a flag for
it, but it will be a nice killswitch.

As an added benefit, it fixes a bug when `enableViewTransition` is on,
where we were not updating the useEffectEvent callback when a tree went
from hidden to visible.
2026-02-04 15:04:57 -05:00
Ricky
087a34696f [test] add activity test with gSBU and enableViewTransition bugfix (#35555)
Related to https://github.com/facebook/react/pull/35548,
`enableViewTransition` fixes a bug where `getSnapshotBeforeUpdate` was
running in hidden trees when it shouldn't (`componentWillUpdate` won't
run for hidden updates, and when it becomes visible it will be called
with `componentWillMount`).
2026-02-04 14:37:45 -05:00
Ricky
6913ea4d28 [flags] Add enableParallelTransitions (#35392)
## Overview

Adds a feature flag `enableParallelTransitions` to experiment with
engantling transitions less often.

## Motivation

Currently we over-entangle transition lanes. 

It's a common misunderstanding that React entangles all transitions,
always. We actually will complete transitions independently in many
cases. For example, [this
codepen](https://codepen.io/GabbeV/pen/pvyKBrM) from
[@gabbev](https://bsky.app/profile/gabbev.bsky.social/post/3m6uq2abihk2x)
shows transitions completing independently.

However, in many cases we entangle when we don't need to, instead of
letting the independent transitons complete independently. We still want
to entangle for updates that happen on the same queue.

## Example

As an example of what this flag would change, consider two independent
counter components:

```js
function Counter({ label }) {
  const [count, setCount] = useState(0);

  return (
    <div>
      <span>{use(readCache(`${label} ${count}`))} </span>
      <Button
        action={() => {
          setCount((c) => c + 1);
        }}
      >
        Next {label}
      </Button>
    </div>
  );
}
```
```js
export default function App() {
  return (
    <>
      <Counter label="A" />
      <Counter label="B" />
    </>
  );
}
```

### Before
The behavior today is to entange them, meaning they always commit
together:



https://github.com/user-attachments/assets/adead60e-8a98-4a20-a440-1efdf85b2142

### After

In this experiment, they will complete independently (if they don't
depend on each other):


https://github.com/user-attachments/assets/181632b5-3c92-4a29-a571-3637f3fab8cd

## Early Research

This change is in early research, and is not in the experimental
channel. We're going to experiment with this at Meta to understand how
much of a breaking change, and how beneficial it is before commiting to
shipping it in experimental and beyond.
2026-02-04 13:58:34 -05:00
Hendrik Liebau
cf993fb457 [Flight] Fix stack overflow in visitAsyncNode with deep async chains (#35612)
Database libraries like Gel/EdgeDB can create very long linear chains of
async sequences through temporal async sequencing in connection pools.
The recursive traversal of `node.previous` chains in `visitAsyncNode`
causes stack overflow on these deep chains.

The fix converts the `previous` chain traversal from recursive to
iterative. We collect the chain into an array, then process from deepest
to shallowest.

The `awaited` traversal remains recursive since its depth is bounded by
promise dependency depth, not by the number of event loop turns. Each
`awaited` branch still benefits from the iterative `previous` handling
within its own traversal.

I've verified that this fixes the
[repro](https://github.com/jere-co/next-debug) provided in #35246.

closes #35246
2026-02-04 19:43:23 +01:00
Jorge Cabiedes
c137dd6f54 Fix exhaustive deps bug with flow type casting. (#35691)
Summary:
I noticed there's a bug where the lint will recognize the type on a cast
annotation as a missing dependency;

```
        function MyComponent() {
          type ColumnKey = 'id' | 'name';
          type Item = {id: string, name: string};

          const columns = useMemo(
            () => [
              {
                type: 'text',
                key: 'id',
              } as TextColumn<ColumnKey, Item>,
                              ^^^^^^^^ here
            ],
            [],
          );
        }
```

This is due to the AST of AsExpressions being something like:

AsExpression
  └── typeAnnotation: GenericTypeAnnotation
        └── typeParameters: TypeParameterInstantiation
              └── params[0]: GenericTypeAnnotation
                    └── id: Identifier (name: "ColumnKey")

Where `ColumnKey` never has a TypeParameter Annotation. So we need to
consider it to be a flow type due to it belonging to a
GenericTypeAnnotation

Test Plan:
Added unit tests

Before:
```
Test Suites: 1 failed, 2 passed, 3 total
Tests:       2 failed, 5065 passed, 5067 total
Snapshots:   0 total
Time:        16.517 s
Ran all test suites.
error Command failed with exit code 1.
```

After:
```
 PASS  __tests__/ReactCompilerRuleTypescript-test.ts
 PASS  __tests__/ESLintRulesOfHooks-test.js (6.192 s)
 PASS  __tests__/ESLintRuleExhaustiveDeps-test.js (9.97 s)

Test Suites: 3 passed, 3 total
Tests:       5067 passed, 5067 total
Snapshots:   0 total
Time:        10.21 s, estimated 11 s
Ran all test suites.
  Done in 12.66s.
```
2026-02-04 08:24:24 -08:00
Anton Chesnokov
22a20e1f2f [compiler] Fix setState-in-effect for React.useEffect namespace calls (#35377) (#35419)
## Summary
Fix react-hooks/set-state-in-effect false negatives when Hooks are
called via a namespace import (e.g. `import * as React from 'react'` and
`React.useEffect(...))`. The validation now checks the MethodCall
property (the actual hook function) instead of the receiver object.

Issue: Bug: #35377

## How did you test this change?
Added a regression fixture;
Ran tests and verified it reports `EffectSetState` and matches the
expected output.

<img width="461" height="116" alt="Screenshot 2025-12-27 at 14 13 38"
src="https://github.com/user-attachments/assets/fff5aab9-0f2c-40e9-a6a5-b864c3fa6fbd"
/>
2026-02-04 11:07:17 -05:00
Joseph Savona
90c6d1b218 [compiler][snap] More minimization improvements (#35689)
* A few new minimization strategies, removing function params and
array/object pattern elements
* Ensure that we preserve the same set of errors based on not just
category+reason but also description.
2026-02-04 10:09:28 -05:00
Sebastian "Sebbie" Silbermann
f84ce5a45c [flags] Turn on enableViewTransition in OSS react-test-renderer (#35684) 2026-02-04 11:42:49 +01:00
Sebastian "Sebbie" Silbermann
c9ff56ec74 [DevTools] Disable Activity slices by default (#35685) 2026-02-04 10:56:33 +01:00
Joseph Savona
3ce1316b05 [compiler][snap] Fixes to relative path resolution; compile subcommand (#35688)
More snap improvements for use with agents:
* `yarn snap compile [--debug] <path>` for compiling any file,
optionally with debug logs
* `yarn snap minimize <path>` now accepts path as a positional param for
consistency w 'compile' command
* Both compile/minimize commands properly handle paths relative to the
compiler/ directory. When using `yarn snap` the current working
directory is compiler/packages/snap, but you're generally running it
from the compiler directory so this matches expectations of callers
better.
2026-02-03 22:12:21 -05:00
Joseph Savona
cd0c4879a2 [compiler] Fix for loops in try/catch (#35686)
This is a combination of a) a subagent for investigating compiler errors
and b) testing that agent by fixing bugs with for loops within
try/catch. My recent diffs to support maybe-throw within value blocks
was incomplete and handled many cases, like optionals/logicals/etc
within try/catch. However, the handling for for loops was making more
assumptions and needed additional fixes.

Key changes:
* `maybe-throw` terminal `handler` is now nullable. PruneMaybeThrows
nulls the handler for blocks that cannot throw, rather than changing to
a `goto`. This preserves more information, and makes it easier for
BuildReactiveFunction's visitValueBlock() to reconstruct the value
blocks
* Updates BuildReactiveFunction's handling of `for` init/test/update
(and similar for `for..of` and `for..in`) to correctly extract value
blocks. The previous logic made assumptions about the shape of the
SequenceExpression which were incorrect in some cases within try/catch.
The new helper extracts a flattened SequenceExpression.

Supporting changes:
* The agent itself (tested via this diff)
* Updated the script for invoking snap to keep `compiler/` as the
working directory, allowing relative paths to work more easily
* Add an `--update` (`-u`) flag to `yarn snap minimize`, which updates
the fixture in place w the minimized version
2026-02-03 18:04:34 -05:00
Sebastian "Sebbie" Silbermann
6853d7ab2f [Perf Tracks] Prevent crash when accessing $$typeof (#35679) 2026-02-03 17:53:45 +01:00
Sebastian "Sebbie" Silbermann
e32c126121 [flags] Turn on enableAsyncDebugInfo everywhere (#35683) 2026-02-03 17:52:57 +01:00
Ricky
3e00319b35 [Flight] allow context providers from client modules (#35675)
Allows Server Components to import Context from a `"use client'` module
and render its Provider.

Only tricky part was that I needed to add `REACT_CONTEXT_TYPE` handling
in mountLazyComponent so lazy-resolved Context types can be rendered.
Previously only functions, REACT_FORWARD_REF_TYPE, and REACT_MEMO_TYPE
were handled.

Tested in the Flight fixture.

ty bb claude

Closes https://github.com/facebook/react/issues/35340

---------

Co-authored-by: Sophie Alpert <git@sophiebits.com>
2026-02-03 10:22:57 -05:00
Sebastian "Sebbie" Silbermann
3419420e8b [flags] Cleanup enableActivity (#35681) 2026-02-03 16:08:18 +01:00
Janka Uryga
b1533b034e [Flight] Allow overriding request.timeOrigin via options.startTime (#35598)
Currently, IO that finished before the request started is not considered
IO:

6a0ab4d2dd/packages/react-server/src/ReactFlightServer.js (L5338-L5343)
This leads to loss of debug info when a flight stream is deserialized
and serialized again.
We can solve this by allowing "when the the request started" to be set
to a point in the past, when the original stream started by doing

```js
const startTime = performance.now() + performance.timeOrigin
// ... stuff happens and time passes...
ReactServer.renderToReadableStream(..., { startTime })
```
2026-02-03 15:29:51 +01:00
Ruslan Lesiutin
5dad2b47b8 [DevTools] Fix commit index reset when switching profiler roots (#35672)
Fixes https://github.com/facebook/react/issues/31463,
https://github.com/facebook/react/issues/30114.

When switching between roots in the profiler flamegraph, the commit
index was preserved from the previous root. This caused an error
"Invalid commit X. There are only Y commits." when the new root had
fewer commits than the selected index.

This fix resets the commit index to 0 (or null if no commits) when the
commitData changes, which happens when switching roots.
2026-02-03 12:44:04 +00:00
Sebastian "Sebbie" Silbermann
748ee74e22 Use modern JSX runtime in Flight fixture (#35677) 2026-02-03 12:05:24 +01:00
Joseph Savona
d4a325df4d [compiler] Add snap subcommand to minimize a test input (#35663)
Snap now supports subcommands 'test' (default) and 'minimize`. The
minimize subcommand attempts to minimize a single failing input fixture
by incrementally simplifying the ast so long as the same error occurs. I
spot-checked it and it seemed to work pretty well. This is intended for
use in a new subagent designed for investigating bugs — fixture
simplification is an important part of the process and we can automate
this rather than light tokens on fire.

Example Input:

```js
function Component(props) {
  const x = [];
  let result;
  for (let i = 0; i < 10; i++) {
    if (cond) {
      try {
        result = {key: bar([props.cond && props.foo])};
      } catch (e) {
        console.log(e);
      }
    }
  }
  x.push(result);
  return <Stringify x={x} />;
}
```

Command output:

```
$ yarn snap minimize --path .../input.js
Minimizing: .../input.js

Minimizing................

--- Minimized Code ---
function Component(props) {
  try {
    props && props;
  } catch (e) {}
}

Reduced from 16 lines to 5 lines
```

This demonstrates things like:
* Removing one statement at at time
* Replacing if/else with the test, consequent, or alternate. Similar for
other control-flow statements including try/catch
* Removing individual array/object expression properties
* Replacing single-value array/object with the value
* Replacing control-flow expression (logical, consequent) w the test or
left/right values
* Removing call arguments
* Replacing calls with a single argument with the argument
* Replacing calls with multiple arguments with an array of the arguments
* Replacing optional member/call with non-optional versions
* Replacing member expression with the object. If computed, also try
replacing w the key
* And a bunch more strategies, see the code
2026-02-02 22:03:47 -05:00
Sebastian "Sebbie" Silbermann
7b023d7073 [react-dom] Include submitter in submit events (#35590) 2026-02-02 21:17:31 +01:00
Sebastian "Sebbie" Silbermann
dcab44d757 [react-dom] Fire onReset when automatically resetting forms (#35176) 2026-02-02 21:17:14 +01:00
Joseph Savona
b8a6bfa22c [compiler] Support optional/logical/etc within try/catch (#35606)
Adds support for value terminals (optional/logical/ternary/sequence)
within try/catch clauses.

Try/catch expressions insert maybe-throw terminals after each
instruction, but BuildReactiveFunction's value block extraction was not
expecting these terminals. The fix is to roughly treat maybe-throw
similarly to goto, falling through to the continuation block, but there
are a few edge cases to handle.

I've also added extensive tests, including testing that errors correctly
flow to the catch handler.
2026-02-02 09:27:05 -08:00
Sebastian "Sebbie" Silbermann
ed4bd540ca [Flight] Warn once if eval is disabled in dev environment (#35661) 2026-02-02 12:56:14 +01:00
Nathan
64b4605cb8 [compiler] fix source location for return statements (#35660)
Fixes missing source locations for ReturnStatement nodes in generated
ast. Simple change using existing pattern, only required changes to the
codegen step, no other pipeline changes.

**Most file changes are new lines in generated code.** [First
commit](d15e90ebe0)
has the relevant changes, second commit has the noisy snap updates.

I added an exception to the validator to not report an error when a
return statement will be optimized to an implicit return by codegen, as
there's no separate return statement to instrument anyways in the final
ast. An edge case when it comes to preserving source locations for
instrumentation that is likely not as common for most babel transforms
since they are not doing optimizations.
2026-01-30 08:37:19 -08:00
Sebastian "Sebbie" Silbermann
da64117876 [Perf Tracks] Handle function names that aren't strings (#35659) 2026-01-29 18:32:18 +01:00
Ricky
230772f99d [tests] Fix ReactDOMAttribute-test (#35654)
In https://github.com/facebook/react/pull/35646 I thought there was a
bug in trusted types, but the bug is in jsdom.

For trusted types we still want to check the coersion and throw for a
good dev warning, but prod will also throw becuase the browser will
implicitly coerce to a string. This ensures there's no behavior
difference between dev and prod.

So the right fix is to add in the JSDOM hack that's used in
`ReactDOMSelect-test.js`.
2026-01-28 16:00:40 -05:00
Jack Pope
90b2dd442c Add additional fixtures for FragmentInstance text node support (#35631)
Stacked on https://github.com/facebook/react/pull/35630

- Adds test case for compareDocumentPosition, missing before and also
extending to text nodes
- Adds event handling fixture case for text
- Adds getRootNode fixture case for text
2026-01-28 14:55:07 -05:00
Jack Pope
875b06489f Add text node support to FragmentInstance operations (#35630)
This PR adds text node support to FragmentInstance operations, allowing
fragment refs to properly handle fragments that contain text nodes
(either mixed with elements or text-only).

Not currently adding/removing new text nodes as we don't need to track
them for events or observers in DOM. Will follow up on this and with
Fabric support.

## Support through parent element
- `dispatchEvent`
- `compareDocumentPosition`
- `getRootNode`

## Support through Range API
- `getClientRects`: Uses Range to calculate bounding rects for text
nodes
- `scrollIntoView`: Uses Range to scroll to text node positions directly

## No support
- `focus`/`focusLast`/`blur`: Noop for text-only fragments
- `observeUsing`:  Warns for text-only fragments in DEV
- `addEventListener`/`removeEventListener`: Ignores text nodes, but
still works on Fragment level through `dispatchEvent`
2026-01-28 14:45:17 -05:00
Jan Olaf Martin
d4d099f05b [flags] make enableTrustedTypesIntegration dynamic (#35646)
Co-authored-by: Rick Hanlon <rickhanlonii@meta.com>
2026-01-28 13:15:33 -05:00
Sebastian "Sebbie" Silbermann
c0c37063e2 [Flight] Restore original function name in dev, server callstacks served with unsafe-eval (#35650) 2026-01-28 18:41:08 +01:00
Sebastian "Sebbie" Silbermann
87ae75b33f [Perf Tracks] Use minus (-) instead of en dash for removed props (#35649) 2026-01-28 12:14:59 +01:00
Sebastian "Sebbie" Silbermann
ff191f24b5 [Perf Tracks] Handle arrays with bigints in deep objects (#35648) 2026-01-28 11:54:50 +01:00
Ricky
e66ef6480e [tests] remove withoutStack from assertConsole helpers (#35498)
Stacked on https://github.com/facebook/react/pull/35497

-----

Now that the assert helpers require a component stack, we don't need the
`{withoutStack: true}` option.
2026-01-27 22:34:03 -05:00
Sebastian "Sebbie" Silbermann
8c34556ca8 [Flight] Fix react-markup types for server references (#35634) 2026-01-26 21:13:16 +01:00
Hendrik Liebau
10680271fa [Flight] Add more DoS mitigations to Flight Reply, and harden Flight (#35632)
This fixes security vulnerabilities in Server Functions.

---------

Co-authored-by: Sebastian Markbåge <sebastian@calyptus.eu>
Co-authored-by: Josh Story <josh.c.story@gmail.com>
Co-authored-by: Janka Uryga <lolzatu2@gmail.com>
Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com>
2026-01-26 20:24:58 +01:00
Ruslan Lesiutin
699abc89ce [flags] make enableComponentPerformanceTrack static everywhere (#35629)
Follow-up to https://github.com/facebook/react/pull/34665.

Already gated on `enableProfilerTimer` everywhere, which is only enabled
for `__PROFILE__`, except for Flight should be unified in a future.
2026-01-26 18:38:56 +00:00
Sebastian "Sebbie" Silbermann
3e319a943c [DevTools] Apply component filters on initial load (#35587) 2026-01-26 11:06:04 +01:00
Sebastian "Sebbie" Silbermann
2c30ebc4e3 [DevTools] Update inspected element on component filter changes (#35599) 2026-01-26 11:04:06 +01:00
Ricky
a0566250b2 [repo] init claude config (#35617)
## Overview

Adds a claude setup that works with the nested /compiler setup.

The constraints are:
- when working in the root repo, don't use the compiler configs (easy)
- when working in the compiler/ don't use the parent contigs (hard)

The first one is easy: there's a claude.md and .claude directory in
/compiler that is only loaded when you start a session from /compuler.
The second one is hard, because if you start a session from /compiler,
the parent claude files and skills are loaded.

I was able to deny the permissions to the parent skills in
settings.json, but the descriptions are still loaded into context and I
don't think that's avoidable.

To keep the parent claude file out of context, I created a hook hack: I
moved all the non-compiler claude file context to instructions.md and
added a SessionStart hook to cat the file into context if the cwd isn't
the /compiler. Works well, but won't show it as part of the `/context`
slash command.


## Skills

I also added a number of skills specific to the React repo:

| Skill | Description |
|-------|-------------|
| `/extract-errors` |  `yarn extract-errors` |
| `/feature-flags` | how feature flags work and `@gate`  |
| `/fix` | linc and prettier |
| `/flags` | `yarn flags` |
| `/flow` | `yarn flow <variant>` |
| `/test` | `yarn test-*` |
| `/verify` | `run all the lints/tests/flow to verify` |

### Example: Flow

| before | after |
|-----|-----|
| <img width="1076" height="442" alt="flow-before"
src="https://github.com/user-attachments/assets/73eec143-d0af-4771-b501-c9dc29cc09ac"
/> | <img width="1076" height="273" alt="flow-after"
src="https://github.com/user-attachments/assets/292d33af-1d98-4252-9c08-744b33e88b86"
/> |

### Example: Tests

| before | after |
|-----|-----|
| <img width="1048" height="607" alt="test-before"
src="https://github.com/user-attachments/assets/aa558ccf-2cee-4d22-b1f1-e4221c5a59dd"
/> | <img width="1075" height="359" alt="test-after"
src="https://github.com/user-attachments/assets/eb795392-6f46-403f-b9bb-8851ed790165"
/> |
2026-01-23 20:16:06 -05:00
Joseph Savona
870cccd656 [compiler] Summaries of the compiler passes to assist agents in development (#35595)
Autogenerated summaries of each of the compiler passes which allow
agents to get the key ideas of a compiler pass, including key
input/output invariants, without having to reprocess the file each time.
In the subsequent diff this seemed to help.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35595).
* #35607
* #35298
* #35596
* #35573
* __->__ #35595
* #35539
2026-01-23 11:26:47 -08:00
Joseph Savona
c3b95b0979 [compiler] Improve snap workflow for debugging errors (#35539)
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)

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35539).
* #35607
* #35298
* #35596
* #35573
* #35595
* __->__ #35539
2026-01-23 11:26:33 -08:00
Joseph Savona
006ae37972 [compiler] Collapse CompilerError.{invariant,simpleInvariant} (#35614)
`invariant()` was a pain to use - we always record a single location,
but the API required passing a compiler detail. This PR replaces
`invariant()` (keeping the name) with `simpleInvariant()`s signature,
and updates call sites accordingly. I've noticed that agents
consistently get invariant() wrong, which aligns with it being tedious
to call when you're writing code by hand. The simplified API should help
a bit.
2026-01-23 11:07:11 -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
Sebastian "Sebbie" Silbermann
5aec1b2a8d [DevTools] Attach async info in filtered fallback to parent of Suspense (#35456) 2026-01-10 11:33:48 +01:00
lauren
d6cae440e3 [ci] Add size-balanced test sequencer for better shard distribution (#35458)
Jest's default test sequencer sorts alphabetically, causing large test
files
(eg ReactDOMFloat-test.js at 9k lines,
ReactHooksWithNoopRenderer-test.js at 4k
lines) to cluster in shard 3/5. This made shard 3/5 average 117s vs 77s
for
other shards, a 52% slowdown. I'm using filesize as a rough proxy for
number of tests.

This custom sequencer sorts tests by file size and distributes large
files evenly across all shards
instead of clustering them together.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35458).
* __->__ #35458
* #35459
2026-01-06 21:29:22 -05:00
lauren
00908be9ff [ci] Increase DevTools test shards and bump timeout (#35459)
[ci] Increase DevTools test shards and bump timeout

- Increase DevTools test shards from 3 to 5
- Bump timeout to 20s

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35459).
* #35458
* __->__ #35459
2026-01-06 21:23:05 -05:00
lauren
0e180141bf [ci] Separate DevTools test-build into dedicated job with fewer shards (#35457)
DevTools has ~45 test files which don't distribute well across 10
shards,
causing shard 3 to run 2x slower than others (104s vs ~50s). This moves
DevTools build tests to a separate job with 3 shards for better load
balancing.
2026-01-06 20:23:40 -05:00
Jon Jensen
65eec428c4 Use FormData submitter parameter (#29028) 2025-12-18 11:34:15 +01:00
Hendrik Liebau
454fc41fc7 [test] Add tests for cyclic arrays in Flight and Flight Reply (#35347)
We already had tests for cyclic objects, but not for cyclic arrays.
2025-12-17 18:08:16 +01:00
Sebastian Markbåge
f93b9fd44b Skip hydration errors when a view transition has been applied (#35380)
When the Fizz runtime runs a view-transition we apply
`view-transition-name` and `view-transition-class` to the `style`. These
can be observed by Fiber when hydrating which incorrectly leads to
hydration errors.

More over, even after we remove them, the `style` attribute has now been
normalized which we are unable to diff because we diff against the SSR
generated `style` attribute string and not the normalized form. So if
there are other inline styles defined, we have to skip diffing them in
this scenario.
2025-12-17 09:37:43 -05:00
Christian Van
b731fe28cc Improve cyclic thenable detection in ReactFlightReplyServer (#35369)
## Summary

This PR improves cyclic thenable detection in
`ReactFlightReplyServer.js`. Fixes #35368.
The previous fix only detected direct self-references (`inspectedValue
=== chunk`) and relied on the `cycleProtection` counter to eventually
bail out of longer cycles. This change keeps the existing
MAX_THENABLE_CYCLE_DEPTH ($1000$) `cycleProtection` cap as a hard
guardrail and adds a visited set so that we can detect self-cycles and
multi-node cycles as soon as any `ReactPromise` is revisited and while
still bounding the amount of work we do for deep acyclic chains via
`cycleProtection`.

## How did you test this change?

- Ran the existing test suite for the server renderer:

  ```bash
  yarn test react-server
  yarn test --prod react-server
  yarn flow dom-node
  yarn linc
  ```

---------

Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
2025-12-17 12:22:26 +01:00
Jack Pope
88ee1f5955 Add reporting modes for react-hooks/exhaustive-effect-dependencies and temporarily enable (#35365)
`react-hooks/exhaustive-effect-dependencies` from
`ValidateExhaustiveDeps` reports errors for both missing and extra
effect deps. We already have `react-hooks/exhaustive-deps` that errors
on missing dependencies. In the future we'd like to consolidate this all
to the compiler based error, but for now there's a lot of overlap. Let's
enable testing the extra dep warning by splitting out reporting modes.

This PR
- Creates `on`, `off`, `missing-only`, and `extra-only` reporting modes
for the effect dep validation flag
- Temporarily enables the new rule with `extra-only` in
`eslint-plugin-react-hooks`
- Adds additional null checking to `manualMemoLoc` to fix a bug found
when running against the fixture
2025-12-15 18:59:27 -05:00
emily8rown
bcf97c7564 Devtools disable log dimming strict mode setting (#35207)
<!--

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

-->

## Summary

Currently, every second console log is dimmed, receiving a special style
that indicates to user that it was raising because of [React Strict
Mode](https://react.dev/reference/react/StrictMode) second rendering.
This introduces a setting to disable this.

## How did you test this change?
Test in console-test.js


https://github.com/user-attachments/assets/af6663ac-f79b-4824-95c0-d46b0c8dec12

Browser extension react devtools


https://github.com/user-attachments/assets/7e2ecb7a-fbdf-4c72-ab45-7e3a1c6e5e44

React native dev tools:


https://github.com/user-attachments/assets/d875b3ac-1f27-43f8-8d9d-12b2d65fa6e6

---------

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
2025-12-15 13:41:43 +00:00
Sebastian "Sebbie" Silbermann
ba5b843692 [test] Exclude repository root from assertions (#35361) 2025-12-15 11:45:17 +01:00
Jack Pope
b061b597f7 Upgrade nextjs for compiler playground (#35353)
Upgrading due to CVE-2025-55183 and CVE-2025-67779
2025-12-12 09:06:31 -05:00
Jorge Cabiedes
38a6f4e4a1 [compiler] Only run validations with env.logErrors on outputMode: 'lint' (#35216)
Summary:
These validations are not essential for compilation, with this we only
run that logic when outputMode is 'lint'

Test Plan:
Update fixtures and run tests
2025-12-11 16:36:36 -08:00
Nathan
b85cf6af3d [compiler] Fix VariableDeclarator source location (#35348)
Putting up https://github.com/facebook/react/pull/35129 again
Reverted in https://github.com/facebook/react/pull/35346 after breaking
main before security patch

This change impacts output formatting in a lot of snaps, so is very
sensitive to additions in main to the fixtures resulting in broken tests
after merging, so we should try merge quickly after rebasing or do a
fast follow to the merge with a snap update.
2025-12-11 18:02:05 -05:00
Sebastian Markbåge
b45bb335db [Flight] Add extra loop protection (#35351)
In case we get into loops.
2025-12-11 17:23:01 -05:00
Sebastian Markbåge
80cb7a9925 Revert "[compiler] Fix VariableDeclarator source location (#35129)" (#35346)
This broke main.
2025-12-11 15:27:07 -05:00
Sebastian Markbåge
894bc73cb4 [Flight] Patch Promise cycles and toString on Server Functions (#35345)
Server Functions can be stringified (sometimes implicitly) when passed
as data. This adds an override to hide the source code in that case -
just in case someone puts sensitive information in there.

Note that this still preserves the `name` field but this is also
available on the export but in practice is likely minified anyway.
There's nothing else on these referenes we'd consider unsafe unless you
explicitly expose expandos which are part of the `"use server"` export.

This adds a safety check to ensure you don't encode cyclic Promises.
This isn't a parser bug per se. Promises do have a safety mechanism that
avoids them infinite looping. However, since we use custom Thenables,
what can happen is that every time a native Promise awaits it, another
Promise wrapper is created around the Thenable which foils the
ECMAScript Promise cycle detection which can lead to an infinite loop.

This also ensures that embedded `ReadableStream` and `AsyncIterable`
streams are properly closed if the source stream closes early both on
the Server and Client. This doesn't cause an infinite loop but just to
make sure resource clean up can proceed properly.

We're also adding some more explicit clear errors for invalid payloads
since we no longer need to obfuscate the original issue.
2025-12-11 15:24:24 -05:00
Nathan
d3eb566291 [compiler] Fix VariableDeclarator source location (#35129)
### What
Fixes source locations for VariableDeclarator in the generated AST.
Fixes a number of the errors in the snapshot I added yesterday in the
source loc validator PR https://github.com/facebook/react/pull/35109

I'm not entirely sure why, but a side effect of the fix has resulted in
a ton of snaps needing updating, with some empty lines no longer present
in the generated output. I broke the change up into 2 separate commits.
The [first
commit](f4e4dc0f44)
has the core change and the update to the missing source locations test
expectation, and the [second
commit](cd4d9e944c)
has the rest of the snapshot updates.

### How
- Add location for variable declarators in ast codegen.
- We don't actually have the location preserved in HIR, since when we
lower the declarations we pass through the location for the
VariableDeclaration. Since VariableDeclarator is just a container for
each of the assignments, the start of the `id` and end of the `init` can
be used to accurately reconstruct it when generating the AST.
- Add source locations for object/array patterns for destructuring
assignment source location support
2025-12-11 14:35:03 -05:00
Ruslan Lesiutin
37bcdcde04 fix[devtools]: feature-check document with typeof instead of direct reference (#35343)
Follow-up to https://github.com/facebook/react/pull/35296.

We can get `ReferenceError` if this is unavailable. Using `typeof` check
instead for safety.
2025-12-11 12:15:00 +00:00
Ruslan Lesiutin
5a970933c0 fix[devtools]: feature-check structure stack trace methods (#35293)
`Error.prepareStackTrace` is non-standard feature and not all JavaScript
runtimes implement the methods that we are using in React DevTools
backend.

This PR adds additional checks for the presence of the methods that we
are using.
2025-12-10 19:21:54 +00:00
Ruslan Lesiutin
5d80124345 fix[devtools]: still show overlay, if getClientRects is not implemented (#35294)
Follow-up to https://github.com/facebook/react/pull/34653.

React Native doesn't implement `getClientRect`, since this is applicable
to CSS box, which is not a concept for Native (maybe yet).

I am loosening the condition that gates `showOverlay()` call to pass if
`getClientRect` is not implemented.

Conceptually, everything that is inside `react-devtools-shared/backend`
should be Host-agnostic, because both on Web and Native it is installed
inside the Host JavaScript runtime, be it main frame of the page, or RN
instance. Since overlay & highlighting logic also lives there, it should
also follow these principles.
2025-12-10 19:21:28 +00:00
Jack Pope
eade0d0fb7 Attach instance handle to DOM in DEV for enableInternalInstanceMap (#35341)
Continue attaching `internalInstanceKey` to DOM nodes in DEV. This
prevents breaking some internal dev tooling while we experiment with the
broader change. Note that this does not reference the DOM handle within
the flag, just attaches it and deletes it. Internals tracking is still
done through the private map.
2025-12-10 13:35:20 -05:00
emily8rown
d763f3131e [Devtools] Navigating commits performance panel hotkey (#35238)
## Summary
Add keyboard shortcuts (Cmd/Ctrl + Left/Right arrow keys) to navigate
between commits in the Profiler's snapshot view.

Moved `filteredCommitIndices` management and commit navigation logic
(`selectNextCommitIndex`, `selectPrevCommitIndex`) from
`SnapshotSelector` into `useCommitFilteringAndNavigation` used by
`ProfilerContext` to enable keyboard shortcuts from the top-level
Profiler component.

## How did you test this change?
- New tests in ProfilerContext-tests
- Built browser extension: `yarn build:<browser name>`
- tested in browser: `yarn run test:<browser name>`
- Manually verified Left/Right arrow navigation cycles through commits
- Verified navigation respects commit duration filter
- Verified reload-and-profile button unaffected

Chrome:


https://github.com/user-attachments/assets/01d2a749-13dc-4d08-8bcb-3d4d45a5f97c

Edge with duration filter:


https://github.com/user-attachments/assets/a7f76ff7-2a0b-4b9c-a0ce-d4449373308b

firefox mixing hotkey with clicking arrow buttons:


https://github.com/user-attachments/assets/48912d68-7c75-40f2-a203-5e6d7e6b2d99
2025-12-10 13:07:35 +00:00
lauren
734f1bf1ac [eprh] Enable enableUseKeyedState and enableVerboseNoSetStateInEffect (#35338)
Temporarily enables these 2 flags for internal testing.
2025-12-09 14:20:22 -05:00
dan
61331f3c9e Fix ViewTransition crash in Mobile Safari (#35337)
Speculative fix to https://github.com/facebook/react/issues/35336
written by Claude.

I have verified that applying a similar patch locally to the repro from
#35336 does fix the crash.

I'm not familiar enough with the underlying APIs to tell whether the fix
is correct or sufficient.
2025-12-10 03:35:15 +09:00
Sebastian "Sebbie" Silbermann
55480b4d22 [playground] Downgrade Next.js to a secure version (#35317)
Was bumped to a canary in https://github.com/facebook/react/pull/34499/
which got never released as stable.

Presumeably to use `Activity` which only made it into Activity in later
Next.js releases. However, `Activity` never ended up being used due to
incompatibilities with Monaco Editor. Downgrading should be safe.

Downgrading to fix
https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp.
This will allow new deploys since Vercel is currently blocking new
deploys of unsafe version

---------

Co-authored-by: Eugene Choi <4eugenechoi@gmail.com>
2025-12-08 14:06:57 -05:00
lauren
3640f38a72 [compiler] Add enableVerboseNoSetStateInEffect to suggest options to user/agent (#35306)
The current `validateNoSetStateInEffects` error has potential false
positives because
we cannot fully statically detect patterns where calling setState in an
effect is
actually valid. This flag `enableVerboseNoSetStateInEffect` adds a
verbose error mode that presents multiple possible
use-cases, allowing an agent to reason about which fix is appropriate
before acting:

1. Non-local derived data - suggests restructuring state ownership
2. Derived event pattern - suggests requesting an event callback from
parent
3. Force update / external sync - suggests using `useSyncExternalStore`

This gives agents the context needed to make informed decisions rather
than
blindly applying a fix that may not be correct for the specific
situation.
2025-12-08 12:16:28 -05:00
Joseph Savona
ec9cc003d2 [compiler][poc] Reuse ValidateExhaustiveDeps for effect dep validation (#35285)
Alternative approach to #35282 for validating effect deps in the
compiler that builds on the machinery in ValidateExhaustiveDependencies.
Key changes to that pass:

* Refactor to track the dependencies of array expressions as temporaries
so we can look them up later if they appear as effect deps.
* Instead of not storing temporaries for LoadLocals of locally created
variables, we store the temporary but also propagate the local-ness
through. This allows us to record deps at the top level, necessary for
effect deps. Previously the pass was only ever concerned with tracking
deps within function expressions.
* Refactor the bulk of the dependency-checking logic from
`onFinishMemoize()` into a standalone helper to use it for the new
`onEffect()` helper as well.
* Add a new ErrorCategory for effect deps, use it for errors on
effects
* Put the effect dep validation behind a feature flag
* Adjust the error reason for effect errors

---------

Co-authored-by: Jack Pope <jackpope1@gmail.com>
2025-12-08 10:58:38 -05:00
Sebastian "Sebbie" Silbermann
380778d296 [test] Cleanup stack assertions in tests mixing React Server and Client (#35316) 2025-12-07 17:45:06 +01:00
Sebastian "Sebbie" Silbermann
41745339cd Run CI for backport releases (#35313) 2025-12-07 11:46:19 +01:00
Sebastian "Sebbie" Silbermann
c0b7c0d31f [scripts] Remove perf-counters (#35308) 2025-12-06 12:33:42 +01:00
Joseph Savona
2cb08e65b3 [compiler] Fix bug w functions depending on hoisted primitives (#35284)
Fixes an edge case where a function expression would fail to take a
dependency if it referenced a hoisted `const` inferred as a primitive
value. We were incorrectly skipping primitve-typed operands when
determing scopes for merging in InferReactiveScopeVariables.

This was super tricky to debug, for posterity the trick is that Context
variables (StoreContext etc) are modeled just like a mutable object,
where assignment to the variable is equivalent to `object.value = ...`
and reading the variable is equivalent to `object.value` property
access. Comparing to an equivalent version of the repro case replaced
with an object and property read/writes showed that everything was
exactly right, except that InferReactiveScopeVariables wasn't merging
the scopes of the function and the context variable, which led me right
to the problematic line.

Closes #35122
2025-12-05 14:29:06 -05:00
Ruslan Lesiutin
ad5971febd fix[devtools]: no-op unsupported backend bridge events (#35296)
Follow-up to https://github.com/facebook/react/pull/34641.
Similar to https://github.com/facebook/react/pull/35293,
https://github.com/facebook/react/pull/35294.

React DevTools backend can be used in non-DOM environments, so we have
to feature-check some DOM APIs.
For now I am just no-oping newly added commands for Native, we should
revisit this decision once we would roll out Suspense panel there, if
needed. I am not sure if scrolling will be required as much as it is
needed on Web.

`isReactNativeEnvironment()` check is kinda clowny, but we've been
relying on it for quite some time already.
2025-12-05 16:41:58 +00:00
Sebastian "Sebbie" Silbermann
378973b387 [Flight] Move react-server-dom-webpack/*.unbundled to private react-server-dom-unbundled (#35290)
Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
2025-12-05 03:59:21 +01:00
Sebastian Markbåge
3016ff87d8 [Flight] Never parse "then" functions (#35289)
AFAIK this is not needed to prevent any exploit but we don't really need
this. We allow functions on pretty much any other object anyway but
never on the "then" property since those would be serialized as Promises
by the client anyway.
2025-12-04 19:05:52 -05:00
lauren
f99241b2e6 [compiler] Add enableUseKeyedState flag and improve setState-in-render errors (#35230)
Adds a new `enableUseKeyedState` compiler flag that changes the error
message for unconditional setState calls during render.

When `enableUseKeyedState` is enabled, the error recommends using
`useKeyedState(initialState, key)` to reset state when dependencies
change. When disabled (the default), it links to the React docs for the
manual pattern of storing previous values in state.

Both error messages now include helpful bullet points explaining the two
main alternatives:
1. Use useKeyedState (or manual pattern) to reset state when other
state/props change
2. Compute derived data directly during render without using state
2025-12-04 18:29:10 -05:00
Kyℓe Hensel
66ae640b36 [eprh] fix react-compiler rules missing meta.docs.url property (#35258)
## Summary

To help people access the documentation easier, we can [add
`meta.docs.url`](https://eslint.org/docs/latest/extend/custom-rules#:~:text=Specifies%20the%20URL)
to the new react-compiler rules. This allows IDEs to make the rule name
a clickable link.

## How did you test this change?

`yarn test`, `yarn prettier`, `yarn lint` and in a separate project
[using file:// URLs](https://stackoverflow.com/a/38417065)
2025-12-04 15:28:13 -05:00
Felipe Cardozo
bf1afade8d [react-dom/server] Fix hanging on Deno (#35235) 2025-12-04 06:50:27 +01:00
Sebastian "Sebbie" Silbermann
0526c799d4 Update changelog with latest releases (#35279) 2025-12-03 18:13:43 +01:00
Sebastian Markbåge
7dc903cd29 Patch FlightReplyServer with fixes from ReactFlightClient (#35277)
FlightReplyServer are for client->server and ReactFlightClient is for
server->client. They're not 100% symmetrical.

We did a number of refactors to ReactFlightClient in PRs like #29823 and
#33664 to change the structure of the resolution. This PR brings those
changes to synchronize the two approaches. Which addresses deep
resolution of cycles and deferred error handling.

This also fixes a critical security vulnerability.
2025-12-03 10:41:19 -05:00
Sebastian "Sebbie" Silbermann
36df5e8b42 [release] Allow building single release channel with processed versions (#35270) 2025-12-02 22:05:10 +01:00
Jack Pope
09f05694a2 [compiler] Extend setState in effect validation to useEffectEvent (#35214)
ValidateNoSetStateInEffects already supports transitive setter
functions. This PR marks any synchonous state setter useEffectEvent
function so we can validate that uEE isn't being used only as
misdirection to avoid the validation within an effect body.

The error points to the call of the effect event.

Example:

```js
export default function MyApp() {
  const [count, setCount] = useState(0)
  const effectEvent = useEffectEvent(() => {
    setCount(10)
  })
  useEffect(() => {
    effectEvent()
  }, [])
  return <div>{count}</div>;
```

```
Found 1 error:

Error: Calling setState synchronously within an effect can trigger cascading renders

Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.

Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).

   5 |   })
   6 |   useEffect(() => {
>  7 |     effectEvent()
     |     ^^^^^^^^^^^ Avoid calling setState() directly within an effect
   8 |   }, [])
   9 |   return <div>{count}</div>;
  10 | }
```
2025-12-01 14:55:42 -05:00
Sebastian "Sebbie" Silbermann
0af4fd80ed [test] Update ESLint e2e tests (#35233) 2025-12-01 19:03:59 +01:00
Sebastian "Sebbie" Silbermann
1721e73e14 [test] Fix Error Proxy in Node.js 21+ (#35227) 2025-11-29 16:52:39 +01:00
Sebastian "Sebbie" Silbermann
6875c3eab4 [test] Only run tests overriding fallback and error states in supported versions (#35234) 2025-11-27 16:15:14 +01:00
Sebastian "Sebbie" Silbermann
74fa1667a7 [DevTools] Move "Back to full tree view" into Activity list item (#35164) 2025-11-26 10:42:18 +01:00
Joseph Savona
627b583650 [compiler][snap] Fix for filter mode with nested files, 'error.' prefix (#35215)
Fixes some issues i ran into w my recent snap changes:
* Correctly match against patterns that contain subdirectories, eg
`fbt/fbt-call`
* When checking if the input pattern has an extension, only prune known
supported extensions. Our convention of `error.<name>` for fixtures that
error makes the rest of the test name look like an extension to
`path.extname()`.

Tested with lots of different patterns including `error.` examples at
the top level and in nested directories, etc.
2025-11-25 15:39:07 -08:00
Joseph Savona
fb18ad3fd3 [compiler] Exhaustive deps: extra tests, improve diagnostic (#35213)
First, this adds some more tests and organizes them into an
`exhaustive-deps/` subdirectory.

Second, the diagnostics are overhauled. For each memo block we now
report a single diagnostic which summarizes the issue, plus individual
errors for each missing/extra dependency. Within the extra deps, we
distinguish whether it's truly extra vs whether its just a more (too)
precise version of an inferred dep. For example, if you depend on
`x.y.z` but the inferred dep was `x.y`. Finally, we print the full
inferred deps at the end as a hint (it's also a suggestion, but this
makes it more clear what would be suggested).
2025-11-25 12:09:09 -08:00
Joseph Savona
ddff35441a [compiler] Enable validateExhaustiveMemoizationDependencies by default (#35201)
Enables `@validateExhaustiveMemoizationDependencies` feature flag by
default, and disables it in select tests that failed due to the change.
Some of our tests intentionally use incorrect memo dependencies in order
to test edge cases.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35201).
* #35213
* __->__ #35201
2025-11-25 12:07:55 -08:00
Joseph Savona
d39a1d6b63 [compiler] Distingush optional/extraneous deps (#35204)
In ValidateExhaustiveDependencies, I previously changed to allow
extraneous dependencies as long as they were non-reactive. Here we make
that more precise, and distinguish between values that are definitely
referenced in the memo function but optional as dependencies vs values
that are not even referenced in the memo function. The latter now error
as extraneous even if they're non-reactive. This also turned up a case
where constant-folded primitives could show up as false positives of the
latter category, so now we track manual deps which quality for constant
folding and don't error on them.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35204).
* #35213
* #35201
* __->__ #35204
2025-11-25 12:06:25 -08:00
Joseph Savona
16e16ec6ff [compiler] Script to enable a feature by default and update tests (#35202)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35202).
* #35201
* __->__ #35202
2025-11-24 12:21:35 -08:00
Joseph Savona
9599e7a787 [compiler] Adjustments to exhaustive deps messages, disable the lint rule (#35192)
Similar to ValidateHookUsage, we implement this check in the compiler
for safety but (for now) continue to rely on the existing rule for
actually reporting errors to users.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35192).
* #35201
* #35202
* __->__ #35192
2025-11-24 12:20:12 -08:00
Joseph Savona
67c1487ffd [compiler] Allow extraneous non-reactive locals (#35190)
The existing exhaustive-deps rule allows omitting non-reactive
dependencies, even if they're not memoized. Conceptually, if a value is
non-reactive then it cannot semantically change. Even if the value is a
new object, that object represents the exact same value and doesn't
necessitate redoing downstream computation. Thus its fine to exclude
nonreactive dependencies, whether they're a stable type or not.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35190).
* #35201
* #35202
* #35192
* __->__ #35190
2025-11-24 12:18:49 -08:00
Joseph Savona
454e01e603 [compiler] Allow manual dependencies to have different optionality than inferred deps (#35186)
Since adding this validation we've already changed our inference to use
knowledge from manual memoization to inform when values are frozen and
which values are non-nullable. To align with that, if the user chooses
to use different optionality btw the deps and the memo block/callback,
that's fine. The key is that eg `x?.y` will invalidate whenever `x.y`
would, so from a memoization correctness perspective its fine. It's not
our job to be a type checker: if a value is potentially nullable, it
should likely use a nullable property access in both places but
TypeScript/Flow can check that.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35186).
* #35201
* #35202
* #35192
* #35190
* __->__ #35186
2025-11-24 12:17:03 -08:00
Joseph Savona
c9a8cf3411 [compiler] Allow nonreactive stable types as extraneous deps (#35185)
When checking ValidateExhaustiveDeps internally, this seems to be the
most common case that it flags. The current exhaustive-deps rule allows
extraneous deps if they are a set of stable types. So here we reuse our
existing isStableType() util in the compiler to allow this case.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35185).
* #35201
* #35202
* #35192
* #35190
* #35186
* __->__ #35185
2025-11-24 12:15:06 -08:00
Joseph Savona
fca172e3f3 [compiler] Ignore ESLint suppressions when ValidateMemoDeps enabled (#35184)
With `ValidateExhaustiveMemoDependencies` we can now check exhaustive
dependencies for useMemo and useCallback within the compiler, without
relying on the separate exhaustive-deps rule. Until now we've bailed out
of any component/hook that suppresses this rule, since the suppression
_might_ affect a memoization value. Compiling code with incorrect memo
deps can change behavior so this wasn't safe. The downside was that a
suppression within a useEffect could prevent memoization, even though
non-exhaustive deps for effects do not cause problems for memoization
specifically.

So here, we change to ignore ESLint suppressions if we have both the
compiler's hooks validation and memo deps validations enabled.

Now we just have to test out the new validation and refine before we can
enable this by default.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35184).
* #35201
* #35202
* #35192
* #35190
* #35186
* #35185
* __->__ #35184
2025-11-24 12:12:49 -08:00
emily8rown
fd524fe02a [DevTools] hotkey to start/stop profiling (#35160)
## Summary

The built-in browser profiler supports starting/stopping with Cmd+E. For
Symmetry this adds the same hotkey for react devtools profiler.

## How did you test this change?
yarn build:\<browser name\> 
yarn run test:\<browser name\>

<img width="483" height="135" alt="Screenshot 2025-11-17 at 14 30 34"
src="https://github.com/user-attachments/assets/426939aa-15da-4c21-87a4-e949e6949482"
/>

firefox:

https://github.com/user-attachments/assets/6f225b90-828f-4e79-a364-59d6bc942f83

edge:

https://github.com/user-attachments/assets/5b2e9242-f0e8-481b-99a2-2dd78099f3ac

chrome:

https://github.com/user-attachments/assets/790aab02-2867-4499-aec1-e32e38c763f9

---------

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
2025-11-21 11:37:10 -05:00
Joseph Savona
40b4a5bf71 [compiler] ValidateExhaustiveDeps disallows unnecessary non-reactive deps (#34472)
Just to be consistent, we disallow unnecessary deps even if they're
known to be non-reactive.
2025-11-20 19:30:35 -08:00
Joseph Savona
df75af4edc [compiler] Auto-fix for non-exhaustive deps (#34471)
Records more information in DropManualMemoization so that we know the
full span of the manual dependencies array (if present). This allows
ValidateExhaustiveDeps to include a suggestion with the correct deps.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34471).
* #34472
* __->__ #34471
2025-11-20 19:28:08 -08:00
Joseph Savona
bcc3fd8b05 [compiler] Implement exhaustive dependency checking for manual memoization (#34394)
The compiler currently drops manual memoization and rewrites it using
its own inference. If the existing manual memo dependencies has missing
or extra dependencies, compilation can change behavior by running the
computation more often (if deps were missing) or less often (if there
were extra deps). We currently address this by relying on the developer
to use the ESLint plugin and have `eslint-disable-next-line
react-hooks/exhaustive-deps` suppressions in their code. If a
suppression exists, we skip compilation.

But not everyone is using the linter! Relying on the linter is also
imprecise since it forces us to bail out on exhaustive-deps checks that
only effect (ahem) effects — and while it isn't good to have incorrect
deps on effects, it isn't a problem for compilation.

So this PR is a rough sketch of validating manual memoization
dependencies in the compiler. Long-term we could use this to also check
effect deps and replace the ExhaustiveDeps lint rule, but for now I'm
focused specifically on manual memoization use-cases. If this works, we
can stop bailing out on ESLint suppressions, since the compiler will
implement all the appropriate checks (we already check rules of hooks).

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34394).
* #34472
* #34471
* __->__ #34394
2025-11-20 19:26:26 -08:00
Joseph Savona
50e7ec8a69 [compiler] Deprecate noEmit, add outputMode (#35112)
This deprecates the `noEmit: boolean` flag and adds `outputMode:
'client' | 'client-no-memo' | 'ssr' | 'lint'` as the replacement.
OutputMode defaults to null and takes precedence if specified, otherwise
we use 'client' mode for noEmit=false and 'lint' mode for noEmit=true.

Key points:
* Retrying failed compilation switches from 'client' mode to
'client-no-memo'
* Validations are enabled behind
Environment.proto.shouldEnableValidations, enabled for all modes except
'client-no-memo'. Similar for dropping manual memoization.
* OptimizeSSR is now gated by the outputMode==='ssr', not a feature flag
* Creation of reactive scopes, and related codegen logic, is now gated
by outputMode==='client'
2025-11-20 15:12:40 -08:00
Joseph Savona
4cf770d7e1 [compiler][poc] Quick experiment with SSR-optimization pass (#35102)
Just a quick poc:
* Inline useState when the initializer is known to not be a function.
The heuristic could be improved but will handle a large number of cases
already.
* Prune effects
* Prune useRef if the ref is unused, by pruning 'ref' props on primitive
components. Then DCE does the rest of the work - with a small change to
allow `useRef()` calls to be dropped since function calls aren't
normally eligible for dropping.
* Prune event handlers, by pruning props whose names start w "on" from
primitive components. Then DCE removes the functions themselves.

Per the fixture, this gets pretty far.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35102).
* #35112
* __->__ #35102
2025-11-20 15:02:38 -08:00
Jorge Cabiedes
7d67591041 [compiler] Remove useState argument constraint. no-derived-computations-in-effects (#35174)
Summary:
I missed this conditional messing things up for undefined useState()
calls. We should be tracking them.

I also missed a test that expect an error was not throwing.

Test Plan:
Update broken test

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35174).
* __->__ #35174
* #35173
2025-11-20 10:45:17 -08:00
Jorge Cabiedes
7ee974de92 [compiler] Prevent innaccurate derivation recording on FunctionExpressions on no-derived-computation-in-effects (#35173)
Summary:
The operands of a function expression are the elements passed as
context. This means that it doesn't make sense to record mutations for
them.

The relevant mutations will happen in the function body, so we need to
prevent FunctionExpression type instruction from running the logic for
effect mutations.

This was also causing some values to depend on themselves in some cases
triggering an infinite loop. Also added n invariant to prevent this
issue

Test Plan:
Added fixture test

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35173).
* #35174
* __->__ #35173
2025-11-20 10:44:45 -08:00
Sebastian "Sebbie" Silbermann
8ac5f4eb36 Fix form status reset when component state is updated (#34075)
Co-authored-by: Vordgi <sasha2822222@gmail.com>
2025-11-19 18:22:07 +01:00
Sebastian Markbåge
eb89912ee5 Add expertimental optimisticKey behind a flag (#35162)
When dealing with optimistic state, a common problem is not knowing the
id of the thing we're waiting on. Items in lists need keys (and single
items should often have keys too to reset their state). As a result you
have to generate fake keys. It's a pain to manage those and when the
real item comes in, you often end up rendering that with a different
`key` which resets the state of the component tree. That in turns works
against the grain of React and a lot of negatives fall out of it.

This adds a special `optimisticKey` symbol that can be used in place of
a `string` key.

```js
import {optimisticKey} from 'react';
...
const [optimisticItems, setOptimisticItems] = useOptimistic([]);
const children = savedItems.concat(
  optimisticItems.map(item =>
    <Item key={optimisticKey} item={item} />
  )
);
return <div>{children}</div>;
```

The semantics of this `optimisticKey` is that the assumption is that the
newly saved item will be rendered in the same slot as the previous
optimistic items. State is transferred into whatever real key ends up in
the same slot.

This might lead to some incorrect transferring of state in some cases
where things don't end up lining up - but it's worth it for simplicity
in many cases since dealing with true matching of optimistic state is
often very complex for something that only lasts a blink of an eye.

If a new item matches a `key` elsewhere in the set, then that's favored
over reconciling against the old slot.

One quirk with the current algorithm is if the `savedItems` has items
removed, then the slots won't line up by index anymore and will be
skewed. We might be able to add something where the optimistic set is
always reconciled against the end. However, it's probably better to just
assume that the set will line up perfectly and otherwise it's just best
effort that can lead to weird artifacts.

An `optimisticKey` will match itself for updates to the same slot, but
it will not match any existing slot that is not an `optimisticKey`. So
it's not an `any`, which I originally called it, because it doesn't
match existing real keys against new optimistic keys. Only one
direction.
2025-11-18 16:29:18 -05:00
Ricky
0972e23908 [compiler] Consider setter from useOptimistic non-reactive (#35141)
Closes https://github.com/facebook/react/issues/35138
2025-11-18 10:50:43 -05:00
Sebastian "Sebbie" Silbermann
194c12d949 [DevTools] Name root "Transition" when focusing on Activity (#35108) 2025-11-18 10:16:58 +01:00
Sebastian "Sebbie" Silbermann
7f1a085b28 [DevTools] Show list of named Activities in Suspense tab (#35092) 2025-11-18 09:52:44 +01:00
Joseph Savona
ea4899e13f [compiler][snap] Support pattern of files to test as CLI argument (#35148)
I've been trying out LLM agents for compiler development, and one thing
i found is that the agent naturally wants to run `yarn snap <pattern>`
to test a specific fixture, and I want to be able to tell it (directly
or in rules/skills) to do this in order to get the debug output from all
the compiler passes. Agents can figure out our current testfilter.txt
file system but that's just tedious. So here we add support for `yarn
snap -p <pattern>`. If you pass in a pattern with an extension, we
target that extension specifically. If you pass in a .expect.md file, we
look at that specific fixture. And if the pattern doesn't have
extensions, we search for `<pattern>{.js,.jsx,.ts,.tsx}`. When patterns
are enabled we automatically log as in debug mode (if there is a single
match), and disable watch mode.

Open to feedback!
2025-11-17 12:09:09 -08:00
Joseph Savona
b946a249b5 [compiler] Improve setState-in-effects rule to account for ref-gated conditionals (#35147)
Conditionally calling setState in an effect is sometimes necessary, but
should generally follow the pattern of using a "previous vaue" ref to
manually compare and ensure that the setState is idempotent. See fixture
for an example.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35147).
* #35148
* __->__ #35147
2025-11-17 12:07:43 -08:00
Joseph Savona
d6b1a0573b [compiler] Extract reusable logic for control dominators (#35146)
The next PR needs to check if a block is controlled by a value derived
from a ref.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35146).
* #35148
* #35147
* __->__ #35146
2025-11-17 12:05:52 -08:00
Joseph Savona
b315a0f713 [compiler] Fix for destructuring with mixed declaration/reassignment (#35144)
Destructing statements that start off as declarations can end up
becoming reassignments if the variable is a scope declaration, so we
have existing logic to handle cases where some parts of a destructure
need to be converted into new locals, with a reassignment to the hoisted
scope variable afterwards. However, there is an edge case where all of
the values are reassigned, in which case we don't need to rewrite and
can just set the instruction kind to reassign.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35144).
* #35148
* #35147
* #35146
* __->__ #35144
2025-11-17 11:34:49 -08:00
Sebastian "Sebbie" Silbermann
7df96b0c1a [Flight] Complete list of Node.js' internal Promise awaits (#35161) 2025-11-17 19:28:26 +01:00
Sebastian "Sebbie" Silbermann
45bc3c9f04 [Flight] Reduce risk of maximum call stack exceeded when emitting async sequence (#35159) 2025-11-17 18:54:13 +01:00
Hendrik Liebau
fb2177c153 [Flight] Fix pending chunks count for streams & async iterables in DEV (#35143)
In DEV, we need to prevent the response from being GC'd while there are
still pending chunks for ReadableSteams or pending results for
AsyncIterables.

Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>
2025-11-14 23:52:11 +01:00
Joseph Savona
647e13366c [compiler] fix bad rebase from sapling (#35145)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35145).
* #35144
* __->__ #35145
2025-11-14 14:50:38 -08:00
Joseph Savona
19b769fa5f [compiler] Fix for inferring props-derived-value as mutable (#35140)
Fix for the repro from the previous PR. A `Capture x -> y` effect should
downgrade to `ImmutableCapture` when the source value is maybe-frozen.
MaybeFrozen represents the union of a frozen value with a non-frozen
value.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35140).
* __->__ #35140
* #35139
2025-11-14 12:14:34 -08:00
Joseph Savona
dbf2538355 [compiler] Repro for false positive mutation of a value derived from props (#35139)
Repro from the compiler WG (Thanks Cody!) of a case where the compiler
incorrectly thinks a value is mutable.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35139).
* #35140
* __->__ #35139
2025-11-14 12:14:23 -08:00
Eliot Pontarelli
21f282425c [compiler] Allow ref access in callbacks passed to event handler props (#35062)
## Summary

Fixes #35040. The React compiler incorrectly flags ref access within
event handlers as ref access at render time. For example, this code
would fail to compile with error "Cannot access refs during render":

```tsx
  const onSubmit = async (data) => {
    const file = ref.current?.toFile(); // Incorrectly flagged as error
  };

  <form onSubmit={handleSubmit(onSubmit)}>
```
This is a false positive because any built-in DOM event handler is
guaranteed not to run at render time. This PR only supports built-in
event handlers because there are no guarantees that user-made event
handlers will not run at render time.

## How did you test this change?

I created 4 test fixtures which validate this change:
* allow-ref-access-in-event-handler-wrapper.tsx - Sync handler test
input
* allow-ref-access-in-event-handler-wrapper.expect.md - Sync handler
expected output
* allow-ref-access-in-async-event-handler-wrapper.tsx - Async handler
test input
* allow-ref-access-in-async-event-handler-wrapper.expect.md - Async
handler expected output

All linters and test suites also pass.
2025-11-14 09:00:33 -08:00
Jorge Cabiedes
257b033fc7 [Compiler] Avoid capturing global setStates for no-derived-computations lint (#35135)
Summary:
This only matters when enableTreatSetIdentifiersAsStateSetters=true

This pattern is still bad. But Right now the validation can only
recommend to move stuff to "calculate in render"

A global setState should not be moved to render, not even conditionally
and you can't remove state without crossing Component boundaries, which
makes this a different kind of fix.

So while we are only suggesting "calculate in render" as a fix we should
disallow the lint from throwing in this case IMO

Test Plan:
Added a fixture

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35135).
* __->__ #35135
* #35134
2025-11-13 22:56:06 -08:00
Jorge Cabiedes
de97ef9ad5 [Compiler] Don't count a setState in the dependency array of the effect it is called on as a usage (#35134)
Summary:
The validation only allows setState declaration as a usage outside of
the effect.

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

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

Test Plan:
Added a fixture

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35134).
* #35135
* __->__ #35134
2025-11-13 22:52:23 -08:00
Hendrik Liebau
93fc57400b [Flight] Fix broken byte stream parsing caused by buffer detachment (#35127)
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})`
instances passed through React Server Components (RSC) would stall after
reading only the first chunk or the first few chunks in the client. This
issue was masked by using `web-streams-polyfill` in tests, but manifests
with native Web Streams implementations.

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

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

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

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

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

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

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

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

### how it works

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

### caveats/drawbacks

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

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

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

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

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

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

Test Plan:
added test

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

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

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



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

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

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

Test Plan:
updated snap tests

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

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

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

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

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

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

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



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

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

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

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

This no longer causes an infinite loop.

Added a test case in the next PR in the stack

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

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

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

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

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

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

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

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

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

Before:

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

After:

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

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

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

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

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

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

## How did you test this change?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```
debugChannel?: Readable
```

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

This is flagged behind `enableFragmentRefsInstanceHandles`.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```
$ yarn generate-changelog --help

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

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

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

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

```
$ yarn generate-changelog

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

// etc etc...
```

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

And finally, for debugging the script you can add `--debug` to see
what's going on.
2025-10-27 15:48:36 -04:00
Eugene Choi
ba0590f306 [playground] Upgrade playwright (#34991)
Some vulnerabilities were detected in older versions of Playwright,
upgrading for the playground.
2025-10-27 13:42:02 -04:00
Joseph Savona
408b38ef73 [compiler] Improve display of errors on multi-line expressions (#34963)
When a longer function or expression is identified as the source of an
error, we currently print the entire expression in our error message.
This is because we delegate to a Babel helper to print codeframes. Here,
we add some checking and abbreviate the result if it spans too many
lines.
2025-10-23 11:30:28 -07:00
Jorge Cabiedes
09056abde7 [Compiler] Improve error for calculate in render useEffect validation (#34580)
Summary:
Change error and update snapshots

The error now mentions what values are causing the issue which should
provide better context on how to fix the issue

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34580).
* __->__ #34580
* #34579
* #34578
* #34577
* #34575
* #34574
2025-10-23 11:05:55 -07:00
lauren
c91783c1f2 [eprh] Bump ReactVersions for next version (#34965)
This was outdated from previously.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34965).
* __->__ #34965
* #34964
2025-10-23 13:43:27 -04:00
lauren
e0654becf7 [eprh] Update changelog for 7.0.1 (#34964)
Add changelog entry for 7.0.1

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34964).
* #34965
* __->__ #34964
2025-10-23 13:43:16 -04:00
Timothy Lau
6160773f30 [playground] Refactor ConfigEditor to use <Activity> component (#34958)
## Summary

This PR addresses a pending TODO comment left in
https://github.com/facebook/react/pull/34499


eb2f784e75/compiler/apps/playground/components/Editor/ConfigEditor.tsx (L37)

This change removes the temporary workaround and replaces it with
`<Activity>`, as originally intended.

## How did you test this change?

- Updated the component to use `<Activity>` directly
- Verified the editor renders correctly in both development and
production builds.
- The `<Activity>` UI updates as expected.



https://github.com/user-attachments/assets/ce976123-da59-4579-b063-b308a9167b21
2025-10-23 11:13:18 -04:00
Karl Horky
eb2f784e75 Add hint for Node.js cjs-module-lexer for eslint-plugin-react-hook types (#34953)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

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

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

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

Supersedes #34951

## Summary

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

Fix the runtime error with named imports and make the last remaining
[Are The Types
Wrong?](https://arethetypeswrong.github.io/?p=eslint-plugin-react-hooks%400.0.0-experimental-6b344c7c-20251022)
error with `eslint-plugin-react-hooks` go away, thanks to the hint from
Andrew Branch:

- https://github.com/facebook/react/issues/34801#issuecomment-3433478810

## How did you test this change?

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

I tried adding this to `node_modules` and it fixed the failures when
importing named imports like `import { configs, meta, rules } from
'eslint-plugin-react-hooks'`:

```bash
➜  eslint-config-upleveled git:(renovate/react-monorepo) pnpm eslint . --max-warnings 0

Oops! Something went wrong! :(

ESLint: 9.37.0

file:///Users/k/p/eslint-config-upleveled/index.js:13
import reactHooks, { configs } from 'eslint-plugin-react-hooks';
                     ^^^^^^^
SyntaxError: Named export 'configs' not found. The requested module 'eslint-plugin-react-hooks' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from 'eslint-plugin-react-hooks';
const { configs } = pkg;

    at ModuleJob._instantiate (node:internal/modules/esm/module_job:228:21)
    at async ModuleJob.run (node:internal/modules/esm/module_job:335:5)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:647:26)
    at async dynamicImportConfig (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/config/config-loader.js:186:17)
    at async loadConfigFile (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/config/config-loader.js:276:9)
    at async ConfigLoader.calculateConfigArray (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/config/config-loader.js:589:23)
    at async #calculateConfigArray (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/config/config-loader.js:743:23)
    at async directoryFilter (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/eslint/eslint-helpers.js:309:5)
    at async NodeHfs.<anonymous> (file:///Users/k/p/eslint-config-upleveled/node_modules/.pnpm/@humanfs+core@0.19.1/node_modules/@humanfs/core/src/hfs.js:586:29)
    at async NodeHfs.walk (file:///Users/k/p/eslint-config-upleveled/node_modules/.pnpm/@humanfs+core@0.19.1/node_modules/@humanfs/core/src/hfs.js:614:3)
➜  eslint-config-upleveled git:(renovate/react-monorepo) pnpm eslint . --max-warnings 0
➜  eslint-config-upleveled git:(renovate/react-monorepo) # no error
```

The named imports identifiers `configs`, `meta`, and `rules` also
contain values, as a sanity check:

- https://github.com/facebook/react/pull/34951#issuecomment-3433555636

cc @poteto
2025-10-22 17:51:01 -04:00
Karl Horky
723b25c644 Add hint for Node.js cjs-module-lexer for eslint-plugin-react-hook types (#34951)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

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

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

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

## Summary

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

Fix the runtime error with named imports and make the last remaining
[Are The Types
Wrong?](https://arethetypeswrong.github.io/?p=eslint-plugin-react-hooks%400.0.0-experimental-6b344c7c-20251022)
error with `eslint-plugin-react-hooks` go away, thanks to the hint from
@andrewbranch:

- https://github.com/facebook/react/issues/34801#issuecomment-3433478810

## How did you test this change?

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

I tried adding this to `node_modules` and it fixed the failures when
importing named imports like `import { configs, meta, rules } from
'eslint-plugin-react-hooks'`:

```bash
➜  eslint-config-upleveled git:(renovate/react-monorepo) pnpm eslint . --max-warnings 0

Oops! Something went wrong! :(

ESLint: 9.37.0

file:///Users/k/p/eslint-config-upleveled/index.js:13
import reactHooks, { configs } from 'eslint-plugin-react-hooks';
                     ^^^^^^^
SyntaxError: Named export 'configs' not found. The requested module 'eslint-plugin-react-hooks' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from 'eslint-plugin-react-hooks';
const { configs } = pkg;

    at ModuleJob._instantiate (node:internal/modules/esm/module_job:228:21)
    at async ModuleJob.run (node:internal/modules/esm/module_job:335:5)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:647:26)
    at async dynamicImportConfig (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/config/config-loader.js:186:17)
    at async loadConfigFile (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/config/config-loader.js:276:9)
    at async ConfigLoader.calculateConfigArray (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/config/config-loader.js:589:23)
    at async #calculateConfigArray (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/config/config-loader.js:743:23)
    at async directoryFilter (/Users/k/p/eslint-config-upleveled/node_modules/.pnpm/eslint@9.37.0/node_modules/eslint/lib/eslint/eslint-helpers.js:309:5)
    at async NodeHfs.<anonymous> (file:///Users/k/p/eslint-config-upleveled/node_modules/.pnpm/@humanfs+core@0.19.1/node_modules/@humanfs/core/src/hfs.js:586:29)
    at async NodeHfs.walk (file:///Users/k/p/eslint-config-upleveled/node_modules/.pnpm/@humanfs+core@0.19.1/node_modules/@humanfs/core/src/hfs.js:614:3)
➜  eslint-config-upleveled git:(renovate/react-monorepo) pnpm eslint . --max-warnings 0
➜  eslint-config-upleveled git:(renovate/react-monorepo) # no error
```

The named imports identifiers `configs`, `meta`, and `rules` also
contain values, as a sanity check:

- https://github.com/facebook/react/pull/34951#issuecomment-3433555636

cc @poteto
2025-10-22 14:05:49 -04:00
lauren
bbb7a1fdf7 [eprh] Type configs.flat more strictly (#34950)
Addresses #34801 where `configs.flat` is possibly undefined as it was
typed as a record of arbitrary string keys.

<img width="990" height="125" alt="Screenshot 2025-10-22 at 1 16 44 PM"
src="https://github.com/user-attachments/assets/8b0d37b9-d7b0-4fc0-aa62-1b0968dae75f"
/>
2025-10-22 13:18:44 -04:00
Karl Horky
6b344c7c53 Switch to export = to fix eslint-plugin-react-hooks types (#34949)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

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

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

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

## Summary

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

Resolve the type error with the types, according to [Are the types
wrong?](https://arethetypeswrong.github.io/?p=eslint-plugin-react-hooks%407.0.0),
as an additional

- Last attempt: https://github.com/facebook/react/pull/34746
- Original issue: https://github.com/facebook/react/issues/34745

## How did you test this change?

I edited `node_modules/eslint-plugin-react-hooks/index.d.ts` in my
`"module": "Node16"` + `"type": "module"` project and my error went
away:

- https://github.com/facebook/react/issues/34801#issuecomment-3433053067

cc @poteto @michaelfaith @andrewbranch 

<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
  If you leave this empty, your PR will very likely be closed.
-->
2025-10-22 12:31:09 -04:00
lauren
71b3a03cc9 [forgive] Various fixes to prepare for internal sync (#34928)
Fixes a few small things:

- Update imports to reference root babel-plugin-react-compiler rather
than from `[...]/src/...`
- Remove unused cosmiconfig options parsing for now
- Update type exports in babel-plugin-react-compiler accordingly
2025-10-21 10:57:18 -04:00
Błażej Kustra
39c6545cef Fix indices of hooks in devtools when using useSyncExternalStore (#34547)
## Summary

This PR updates getChangedHooksIndices to account for the fact that
useSyncExternalStore internally mounts two hooks, while DevTools should
treat it as a single user-facing hook.

It introduces a helper isUseSyncExternalStoreHook to detect this case
and adjust iteration so the extra internal hook is skipped when counting
changes.

Before:


https://github.com/user-attachments/assets/0db72a4e-21f7-44c7-ba02-669a272631e5

After:


https://github.com/user-attachments/assets/4da71392-0396-408d-86a7-6fbc82d8c4f5

## How did you test this change?

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

```ts
function Test() {
  // 1
  React.useSyncExternalStore(
    () => {},
    () => {},
    () => {},
  );
  // 2
  const [state, setState] = useState('test'); 
  return (
    <>
      <div
        onClick={() => setState(Math.random())}
        style={{backgroundColor: 'red'}}>
        {state}
      </div>
    </>
  );
}
```
2025-10-21 13:59:20 +01:00
Ruslan Lesiutin
613cf80f26 [DevTools] chore: add useSyncExternalStore examples to shell (#34932)
Few examples of using `useSyncExternalStore` that can be useful for
debugging hook tree reconstruction logic and hook names parsing feature.
2025-10-21 13:51:44 +01:00
Nathan
ea0c17b095 [compiler] loosen computed key restriction for compiler (#34902)
We have a whole ton of compiler errors due to us using a helper to
return breakpoints for CSS-in-js, which results in code like:

```
const styles = {
  [responsive.up('xl')]: { ... }
}
```

this results in TONS of bailouts due to `(BuildHIR::lowerExpression)
Expected Identifier, got CallExpression key in ObjectExpression`.

I was looking into what it would take to fix it and why we don't allow
it, and following the paper trail is seems like the gotchas have been
fixed with the new mutability aliasing model that is fully rolled out.
It looks like this is the same pattern/issue that was fixed (see
https://github.com/facebook/react/blob/main/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.js
and the old bug in
d58c07b563/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-object-expression-computed-key-modified-during-after-construction-hoisted-sequence-expr.expect.md).

@josephsavona can you confirm if that's the case and if we're able to
drop this restriction now? (or alternatively, is there another case we
can ignore?)
2025-10-20 13:52:11 -07:00
Sebastian Markbåge
031595d720 [DevTools] Title color tweak (#34927)
<img width="521" height="365" alt="Screenshot 2025-10-20 at 11 53 50 AM"
src="https://github.com/user-attachments/assets/1a073c09-d440-4498-b2b3-c0dcb2272c96"
/>
2025-10-20 14:54:27 -04:00
Ruslan Lesiutin
3cde211b0c React DevTools 7.0.0 -> 7.0.1 (#34926)
Full list of changes:

* Text layout fixes for stack traces with badges
([eps1lon](https://github.com/eps1lon) in
[#34925](https://github.com/facebook/react/pull/34925))
* chore: read from build/COMMIT_SHA fle as fallback for commit hash
([hoxyq](https://github.com/hoxyq) in
[#34915](https://github.com/facebook/react/pull/34915))
* fix: dont ship source maps for css in prod builds
([hoxyq](https://github.com/hoxyq) in
[#34913](https://github.com/facebook/react/pull/34913))
* Lower case "rsc stream" debug info
([sebmarkbage](https://github.com/sebmarkbage) in
[#34921](https://github.com/facebook/react/pull/34921))
* BuiltInCallSite should have padding-left
([sebmarkbage](https://github.com/sebmarkbage) in
[#34922](https://github.com/facebook/react/pull/34922))
* Show the Suspense boundary name in the rect if there's no overlap
([sebmarkbage](https://github.com/sebmarkbage) in
[#34918](https://github.com/facebook/react/pull/34918))
* Don't attach filtered IO to grandparent Suspense
([eps1lon](https://github.com/eps1lon) in
[#34916](https://github.com/facebook/react/pull/34916))
* Infer name from stack if it's the generic "lazy" name
([sebmarkbage](https://github.com/sebmarkbage) in
[#34907](https://github.com/facebook/react/pull/34907))
* Use same Suspense naming heuristics when reconnecting
([eps1lon](https://github.com/eps1lon) in
[#34898](https://github.com/facebook/react/pull/34898))
* Assign a different color and label based on environment
([sebmarkbage](https://github.com/sebmarkbage) in
[#34893](https://github.com/facebook/react/pull/34893))
* Compute environment names for the timeline
([sebmarkbage](https://github.com/sebmarkbage) in
[#34892](https://github.com/facebook/react/pull/34892))
* Don't highlight the root rect if no roots has unique suspenders
([sebmarkbage](https://github.com/sebmarkbage) in
[#34885](https://github.com/facebook/react/pull/34885))
* Highlight the rect when the corresponding timeline bean is hovered
([sebmarkbage](https://github.com/sebmarkbage) in
[#34881](https://github.com/facebook/react/pull/34881))
* Repeat the "name" if there's no short description in groups
([sebmarkbage](https://github.com/sebmarkbage) in
[#34894](https://github.com/facebook/react/pull/34894))
* Tweak the rects design and create multi-environment color scheme
([sebmarkbage](https://github.com/sebmarkbage) in
[#34880](https://github.com/facebook/react/pull/34880))
* Adjust the rects size by one pixel smaller
([sebmarkbage](https://github.com/sebmarkbage) in
[#34876](https://github.com/facebook/react/pull/34876))
* Remove steps title from scrubber
([sebmarkbage](https://github.com/sebmarkbage) in
[#34878](https://github.com/facebook/react/pull/34878))
* Include some sub-pixel precision in rects
([sebmarkbage](https://github.com/sebmarkbage) in
[#34873](https://github.com/facebook/react/pull/34873))
* Don't pluralize if already plural
([sebmarkbage](https://github.com/sebmarkbage) in
[#34870](https://github.com/facebook/react/pull/34870))
* Don't try to load anonymous or empty urls
([sebmarkbage](https://github.com/sebmarkbage) in
[#34869](https://github.com/facebook/react/pull/34869))
* Add inspection button to Suspense tab
([sebmarkbage](https://github.com/sebmarkbage) in
[#34867](https://github.com/facebook/react/pull/34867))
* Don't select on hover ([sebmarkbage](https://github.com/sebmarkbage)
in [#34860](https://github.com/facebook/react/pull/34860))
* Don't highlight on timeline
([sebmarkbage](https://github.com/sebmarkbage) in
[#34861](https://github.com/facebook/react/pull/34861))
* The bridge event types should only be defined in one direction
([sebmarkbage](https://github.com/sebmarkbage) in
[#34859](https://github.com/facebook/react/pull/34859))
* Attempt at a better "unique suspender" text
([sebmarkbage](https://github.com/sebmarkbage) in
[#34854](https://github.com/facebook/react/pull/34854))
* Track whether a boundary is currently suspended and make transparent
([sebmarkbage](https://github.com/sebmarkbage) in
[#34853](https://github.com/facebook/react/pull/34853))
* Don't hide overflow rectangles
([sebmarkbage](https://github.com/sebmarkbage) in
[#34852](https://github.com/facebook/react/pull/34852))
* Measure text nodes ([sebmarkbage](https://github.com/sebmarkbage) in
[#34851](https://github.com/facebook/react/pull/34851))
* Don't measure fallbacks when suspended
([sebmarkbage](https://github.com/sebmarkbage) in
[#34850](https://github.com/facebook/react/pull/34850))
* Filter out built-in stack frames
([sebmarkbage](https://github.com/sebmarkbage) in
[#34828](https://github.com/facebook/react/pull/34828))
* Exclude Suspense boundaries in hidden Activity
([eps1lon](https://github.com/eps1lon) in
[#34756](https://github.com/facebook/react/pull/34756))
* Group consecutive suspended by rows by the same name
([sebmarkbage](https://github.com/sebmarkbage) in
[#34830](https://github.com/facebook/react/pull/34830))
* Preserve the original index when sorting suspended by
([sebmarkbage](https://github.com/sebmarkbage) in
[#34829](https://github.com/facebook/react/pull/34829))
* Don't show the root as being non-compliant
([sebmarkbage](https://github.com/sebmarkbage) in
[#34827](https://github.com/facebook/react/pull/34827))
* Ignore suspense boundaries, without visual representation, in the
timeline ([sebmarkbage](https://github.com/sebmarkbage) in
[#34824](https://github.com/facebook/react/pull/34824))
* Explicitly say which id to scroll to and only once
([sebmarkbage](https://github.com/sebmarkbage) in
[#34823](https://github.com/facebook/react/pull/34823))
* devtools: fix ellipsis truncation for key values
([sophiebits](https://github.com/sophiebits) in
[#34796](https://github.com/facebook/react/pull/34796))
* fix(devtools): remove duplicated "Display density" field in General
settings ([Anatole-Godard](https://github.com/Anatole-Godard) in
[#34792](https://github.com/facebook/react/pull/34792))
* Gate SuspenseTab ([hoxyq](https://github.com/hoxyq) in
[#34754](https://github.com/facebook/react/pull/34754))
* Release `<ViewTransition />` to Canary
([eps1lon](https://github.com/eps1lon) in
[#34712](https://github.com/facebook/react/pull/34712))
2025-10-20 18:39:28 +01:00
Sebastian "Sebbie" Silbermann
1d3664665b [DevTools] Text layout fixes for stack traces with badges (#34925) 2025-10-20 19:33:47 +02:00
Joseph Savona
2bcbf254f1 [compiler] Fix false positive for useMemo reassigning context vars (#34904)
Within a function expression local variables may use StoreContext for
local context variables, so the reassignment check here was firing too
often. We should only report an error for variables that are declared
outside the function, ie part of its `context`.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34904).
* #34903
* __->__ #34904
2025-10-20 08:42:04 -07:00
Ruslan Lesiutin
aaad0ea055 [DevTools] chore: read from build/COMMIT_SHA fle as fallback for commit hash (#34915)
This eliminates the gap in a reproducer for the React DevTools browser
extension from the source code that we submit to Firefox extension
stores.

We use the commit hash as part of the Backend version, here:

2cfb221937/packages/react-devtools-extensions/utils.js (L26-L38)

The problem is that we archive the source code for Mozilla extension
store reviews and there is no git. But since we still download the React
sources from the CI, we could reuse the hash from `build/COMMIT_HASH`
file.
2025-10-20 16:14:47 +01:00
Ruslan Lesiutin
02c80f0d87 [DevTools] fix: dont ship source maps for css in prod builds (#34913)
This has been causing some issues with the submission review on Firefox
store: we use OS-level paths in these source maps, which makes the build
artifact different from the one that's been submitted.

Also saves ~100Kb for main.js artifact.
2025-10-20 13:39:42 +01:00
Sebastian Markbåge
21272a680f Lower case "rsc stream" debug info (#34921)
This is an aesthetic thing. Most simple I/O entries are things like
"script", "stylesheet", "fetch" etc. which are all a single word and
lower case. The "RSC stream" name sticks out and draws unnecessary
attention to itself where as it's really the least interesting to look
at.

I don't love the name because I'm not sure how to explain it. It's
really mainly the byte size of the payload itself without considering
things like server awaits things which will have their own cause. So I'm
trying to communicate the download size of the stream of downloading the
`.rsc` file or the `"rsc stream"`.
2025-10-20 02:42:38 -04:00
Sebastian Markbåge
1440f4f42d [DevTools] BuiltInCallSite should have padding-left (#34922)
We don't normally show this but when we do, it should have the same
padding as other callsites.

<img width="313" height="241" alt="Screenshot 2025-10-19 at 10 46 22 PM"
src="https://github.com/user-attachments/assets/7f72149e-d748-4b71-8291-889038d676e7"
/>
2025-10-20 01:52:50 -04:00
Sebastian Markbåge
f6a4882859 [DevTools] Show the Suspense boundary name in the rect if there's no overlap (#34918)
This shows the title in the top corner of the rect if there's enough
space.

The complex bit here is that it can be noisy if too many boundaries
occupy the same space to overlap or partially overlap.

This uses an R-tree to store all the rects to find overlapping
boundaries to cut the available space to draw inside the rect. We use
this to compute the rectangle within the rect which doesn't have any
overlapping boundaries.

The roots don't count as overlapping. Similarly, a parent rect is not
consider overlapping a child. However, if two sibling boundaries occupy
the same space, no title will be drawn.

<img width="734" height="813" alt="Screenshot 2025-10-19 at 5 34 49 PM"
src="https://github.com/user-attachments/assets/2b848b9c-3b78-48e5-9476-dd59a7baf6bf"
/>

We might also consider drawing the "Initial Paint" title at the root but
that's less interesting. It's interesting in the beginning before you
know about the special case at the root but after that it's just always
the same value so just adds noise.
2025-10-19 22:17:45 -04:00
Sebastian "Sebbie" Silbermann
b485f7cf64 [DevTools] Don't attach filtered IO to grandparent Suspense (#34916) 2025-10-20 00:47:27 +02:00
Sebastian Markbåge
2cfb221937 [Flight] Allow passing DEV only startTime as an option (#34912)
When you use the `createFromFetch` API we assume that the start time of
the request is the same time as when you call `createFromFetch` but in
principle you could use it with a Promise that starts earlier and just
happens to resolve to a `Response`.

When you use `createFromReadableStream` that is almost definitely the
case. E.g. you might have started it way earlier and you don't call
`createFromReadableStream` until you get the headers back (the fetch
promise resolves).

This adds an option to pass in the start time for debug purposes if you
started the request before starting to parse it.
2025-10-19 16:38:33 -04:00
Sebastian Markbåge
58bdc0bb96 [Flight] Ignore bound-anonymous-fn resources as they're not considered I/O (#34911)
When you create a snapshot from an AsyncLocalStorage in Node.js, that
creates a new bound AsyncResource which everything runs inside of.


3437e1c4bd/lib/internal/async_local_storage/async_hooks.js (L61-L67)

This resource is itself tracked by our async debug tracking as I/O. We
can't really distinguish these in general from other AsyncResources
which are I/O.

However, by default they're given the name `"bound-anonymous-fn"` if you
pass it an anonymous function or in the case of a snapshot, that's
built-in:


3437e1c4bd/lib/async_hooks.js (L262-L263)

We can at least assume that these are non-I/O. If you want to ensure
that a bound resource is not considered I/O, you can ensure your
function isn't assigned a name or give it this explicit name.

The other issue here is that, the sequencing here is that we track the
callsite of the `.snapshot()` or `.bind()` call as the trigger. So if
that was outside of render for example, then it would be considered
non-I/O. However, this might miss stuff if you resolve promises inside
the `.run()` of the snapshot if the `.run()` call itself was spawned by
I/O which should be tracked. Time will tell if those patterns appear.
However, in cases like nested renders (e.g. Next.js's "use cache") then
restoring it as if it was outside the parent render is what you do want.
2025-10-19 14:56:56 -04:00
Sebastian Markbåge
bf11d2fb2f [DevTools] Infer name from stack if it's the generic "lazy" name (#34907)
Stacked on #34906.

Infer name from stack if it's the generic "lazy" name. It might be
wrapped in an abstraction. E.g. `next/dynamic`.

Also use the function name as a description of a resolved function
value.

<img width="310" height="166" alt="Screenshot 2025-10-18 at 10 42 05 AM"
src="https://github.com/user-attachments/assets/c63170b9-2b19-4f30-be7a-6429bb3ef3d9"
/>
2025-10-19 14:56:40 -04:00
Sebastian Markbåge
ec7d9a7249 Resolve the .default export of a React.lazy as the canonical value (#34906)
For debug purposes this is the value that the `React.lazy` resolves to.
It also lets us look at that value for descriptions like its name.
2025-10-19 14:56:25 -04:00
Sebastian "Sebbie" Silbermann
40c7a7f6ca [DevTools] Use same Suspense naming heuristics when reconnecting (#34898) 2025-10-18 12:54:05 +02:00
1689 changed files with 53500 additions and 31984 deletions

46
.claude/instructions.md Normal file
View File

@@ -0,0 +1,46 @@
# React
**Scope**: All code EXCEPT `/compiler/` (compiler has its own instructions).
## Project Structure
| Directory | Purpose |
|-----------|---------|
| `/packages/` | Publishable packages (react, react-dom, scheduler, etc.) |
| `/scripts/` | Build, test, and development scripts |
| `/fixtures/` | Test applications for manual testing |
| `/compiler/` | React Compiler (separate sub-project) |
## Key Packages
| Package | Purpose |
|---------|---------|
| `react` | Core React library |
| `react-dom` | DOM renderer |
| `react-reconciler` | Core reconciliation algorithm |
| `scheduler` | Cooperative scheduling |
| `react-server-dom-*` | Server Components |
| `react-devtools-*` | Developer Tools |
| `react-refresh` | Fast Refresh runtime |
## Requirements
- **Node**: Must be installed. Stop and prompt user if missing.
- **Package Manager**: Use `yarn` only.
## Verification
**IMPORTANT**: Use `/verify` to validate all changes before committing.
## Commands
| Command | Purpose |
|----------|----------------------|
| `/fix` | Lint and format code |
| `/test` | Run tests |
| `/flow` | Type check with Flow |
| `/flags` | Check feature flags |
## Building
Builds are handled by CI. Do not run locally unless instructed.

44
.claude/settings.json Normal file
View File

@@ -0,0 +1,44 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "if [[ \"$PWD\" != */compiler* ]]; then cat .claude/instructions.md 2>/dev/null || true; fi"
}
]
}
]
},
"permissions": {
"allow": [
"Skill(extract-errors)",
"Skill(feature-flags)",
"Skill(fix)",
"Skill(flags)",
"Skill(flow)",
"Skill(test)",
"Skill(verify)",
"Bash(yarn test:*)",
"Bash(yarn test-www:*)",
"Bash(yarn test-classic:*)",
"Bash(yarn test-stable:*)",
"Bash(yarn linc:*)",
"Bash(yarn lint:*)",
"Bash(yarn flow:*)",
"Bash(yarn prettier:*)",
"Bash(yarn build:*)",
"Bash(yarn extract-errors:*)",
"Bash(yarn flags:*)"
],
"deny": [
"Bash(yarn download-build:*)",
"Bash(yarn download-build-for-head:*)",
"Bash(npm:*)",
"Bash(pnpm:*)",
"Bash(bun:*)",
"Bash(npx:*)"
]
}
}

View File

@@ -0,0 +1,12 @@
---
name: extract-errors
description: Use when adding new error messages to React, or seeing "unknown error code" warnings.
---
# Extract Error Codes
## Instructions
1. Run `yarn extract-errors`
2. Report if any new errors need codes assigned
3. Check if error codes are up to date

View File

@@ -0,0 +1,79 @@
---
name: feature-flags
description: Use when feature flag tests fail, flags need updating, understanding @gate pragmas, debugging channel-specific test failures, or adding new flags to React.
---
# React Feature Flags
## Flag Files
| File | Purpose |
|------|---------|
| `packages/shared/ReactFeatureFlags.js` | Default flags (canary), `__EXPERIMENTAL__` overrides |
| `packages/shared/forks/ReactFeatureFlags.www.js` | www channel, `__VARIANT__` overrides |
| `packages/shared/forks/ReactFeatureFlags.native-fb.js` | React Native, `__VARIANT__` overrides |
| `packages/shared/forks/ReactFeatureFlags.test-renderer.js` | Test renderer |
## Gating Tests
### `@gate` pragma (test-level)
Use when the feature is completely unavailable without the flag:
```javascript
// @gate enableViewTransition
it('supports view transitions', () => {
// This test only runs when enableViewTransition is true
// and is SKIPPED (not failed) when false
});
```
### `gate()` inline (assertion-level)
Use when the feature exists but behavior differs based on flag:
```javascript
it('renders component', async () => {
await act(() => root.render(<App />));
if (gate(flags => flags.enableNewBehavior)) {
expect(container.textContent).toBe('new output');
} else {
expect(container.textContent).toBe('legacy output');
}
});
```
## Adding a New Flag
1. Add to `ReactFeatureFlags.js` with default value
2. Add to each fork file (`*.www.js`, `*.native-fb.js`, etc.)
3. If it should vary in www/RN, set to `__VARIANT__` in the fork file
4. Gate tests with `@gate flagName` or inline `gate()`
## Checking Flag States
Use `/flags` to view states across channels. See the `flags` skill for full command options.
## `__VARIANT__` Flags (GKs)
Flags set to `__VARIANT__` simulate gatekeepers - tested twice (true and false):
```bash
/test www <pattern> # __VARIANT__ = true
/test www variant false <pattern> # __VARIANT__ = false
```
## Debugging Channel-Specific Failures
1. Run `/flags --diff <channel1> <channel2>` to compare values
2. Check `@gate` conditions - test may be gated to specific channels
3. Run `/test <channel> <pattern>` to isolate the failure
4. Verify flag exists in all fork files if newly added
## Common Mistakes
- **Forgetting both variants** - Always test `www` AND `www variant false` for `__VARIANT__` flags
- **Using @gate for behavior differences** - Use inline `gate()` if both paths should run
- **Missing fork files** - New flags must be added to ALL fork files, not just the main one
- **Wrong gate syntax** - It's `gate(flags => flags.name)`, not `gate('name')`

View File

@@ -0,0 +1,17 @@
---
name: fix
description: Use when you have lint errors, formatting issues, or before committing code to ensure it passes CI.
---
# Fix Lint and Formatting
## Instructions
1. Run `yarn prettier` to fix formatting
2. Run `yarn linc` to check for remaining lint issues
3. Report any remaining manual fixes needed
## Common Mistakes
- **Running prettier on wrong files** - `yarn prettier` only formats changed files
- **Ignoring linc errors** - These will fail CI, fix them before committing

View File

@@ -0,0 +1,39 @@
---
name: flags
description: Use when you need to check feature flag states, compare channels, or debug why a feature behaves differently across release channels.
---
# Feature Flags
Arguments:
- $ARGUMENTS: Optional flags
## Options
| Option | Purpose |
|--------|---------|
| (none) | Show all flags across all channels |
| `--diff <ch1> <ch2>` | Compare flags between channels |
| `--cleanup` | Show flags grouped by cleanup status |
| `--csv` | Output in CSV format |
## Channels
- `www`, `www-modern` - Meta internal
- `canary`, `next`, `experimental` - OSS channels
- `rn`, `rn-fb`, `rn-next` - React Native
## Legend
✅ enabled, ❌ disabled, 🧪 `__VARIANT__`, 📊 profiling-only
## Instructions
1. Run `yarn flags $ARGUMENTS`
2. Explain the output to the user
3. For --diff, highlight meaningful differences
## Common Mistakes
- **Forgetting `__VARIANT__` flags** - These are tested both ways in www; check both variants
- **Comparing wrong channels** - Use `--diff` to see exact differences

View File

@@ -0,0 +1,30 @@
---
name: flow
description: Use when you need to run Flow type checking, or when seeing Flow type errors in React code.
---
# Flow Type Checking
Arguments:
- $ARGUMENTS: Renderer to check (default: dom-node)
## Renderers
| Renderer | When to Use |
|----------|-------------|
| `dom-node` | Default, recommended for most changes |
| `dom-browser` | Browser-specific DOM code |
| `native` | React Native |
| `fabric` | React Native Fabric |
## Instructions
1. Run `yarn flow $ARGUMENTS` (use `dom-node` if no argument)
2. Report type errors with file locations
3. For comprehensive checking (slow), use `yarn flow-ci`
## Common Mistakes
- **Running without a renderer** - Always specify or use default `dom-node`
- **Ignoring suppressions** - Check if `$FlowFixMe` comments are masking real issues
- **Missing type imports** - Ensure types are imported from the correct package

View File

@@ -0,0 +1,46 @@
---
name: test
description: Use when you need to run tests for React core. Supports source, www, stable, and experimental channels.
---
Run tests for the React codebase.
Arguments:
- $ARGUMENTS: Channel, flags, and test pattern
Usage Examples:
- `/test ReactFiberHooks` - Run with source channel (default)
- `/test experimental ReactFiberHooks` - Run with experimental channel
- `/test www ReactFiberHooks` - Run with www-modern channel
- `/test www variant false ReactFiberHooks` - Test __VARIANT__=false
- `/test stable ReactFiberHooks` - Run with stable channel
- `/test classic ReactFiberHooks` - Run with www-classic channel
- `/test watch ReactFiberHooks` - Run in watch mode (TDD)
Release Channels:
- `(default)` - Source/canary channel, uses ReactFeatureFlags.js defaults
- `experimental` - Source/experimental channel with __EXPERIMENTAL__ flags = true
- `www` - www-modern channel with __VARIANT__ flags = true
- `www variant false` - www channel with __VARIANT__ flags = false
- `stable` - What ships to npm
- `classic` - Legacy www-classic (rarely needed)
Instructions:
1. Parse channel from arguments (default: source)
2. Map to yarn command:
- (default) → `yarn test --silent --no-watchman <pattern>`
- experimental → `yarn test -r=experimental --silent --no-watchman <pattern>`
- stable → `yarn test-stable --silent --no-watchman <pattern>`
- classic → `yarn test-classic --silent --no-watchman <pattern>`
- www → `yarn test-www --silent --no-watchman <pattern>`
- www variant false → `yarn test-www --variant=false --silent --no-watchman <pattern>`
3. Report test results and any failures
Hard Rules:
1. **Use --silent to see failures** - This limits the test output to only failures.
2. **Use --no-watchman** - This is a common failure in sandboxing.
Common Mistakes:
- **Running without a pattern** - Runs ALL tests, very slow. Always specify a pattern.
- **Forgetting both www variants** - Test `www` AND `www variant false` for `__VARIANT__` flags.
- **Test skipped unexpectedly** - Check for `@gate` pragma; see `feature-flags` skill.

View File

@@ -0,0 +1,24 @@
---
name: verify
description: Use when you want to validate changes before committing, or when you need to check all React contribution requirements.
---
# Verification
Run all verification steps.
Arguments:
- $ARGUMENTS: Test pattern for the test step
## Instructions
Run these first in sequence:
1. Run `yarn prettier` - format code (stop if fails)
2. Run `yarn linc` - lint changed files (stop if fails)
Then run these with subagents in parallel:
1. Use `/flow` to type check (stop if fails)
2. Use `/test` to test changes in source (stop if fails)
3. Use `/test www` to test changes in www (stop if fails)
If all pass, show success summary. On failure, stop immediately and report the issue with suggested fixes.

View File

@@ -331,6 +331,7 @@ module.exports = {
'packages/react-server-dom-turbopack/**/*.js',
'packages/react-server-dom-parcel/**/*.js',
'packages/react-server-dom-fb/**/*.js',
'packages/react-server-dom-unbundled/**/*.js',
'packages/react-test-renderer/**/*.js',
'packages/react-debug-tools/**/*.js',
'packages/react-devtools-extensions/**/*.js',
@@ -506,7 +507,6 @@ module.exports = {
__IS_FIREFOX__: 'readonly',
__IS_EDGE__: 'readonly',
__IS_NATIVE__: 'readonly',
__IS_INTERNAL_MCP_BUILD__: 'readonly',
__IS_INTERNAL_VERSION__: 'readonly',
chrome: 'readonly',
},
@@ -592,6 +592,7 @@ module.exports = {
mixin$Animatable: 'readonly',
MouseEventHandler: 'readonly',
NavigateEvent: 'readonly',
Partial: 'readonly',
PerformanceMeasureOptions: 'readonly',
PropagationPhases: 'readonly',
PropertyDescriptor: 'readonly',
@@ -634,6 +635,7 @@ module.exports = {
FocusOptions: 'readonly',
OptionalEffectTiming: 'readonly',
__REACT_ROOT_PATH_TEST__: 'readonly',
spyOnDev: 'readonly',
spyOnDevAndProd: 'readonly',
spyOnProd: 'readonly',

View File

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

View File

@@ -3,6 +3,10 @@ name: (Runtime) Build and Test
on:
push:
branches: [main]
tags:
# To get CI for backport releases.
# This will duplicate CI for releases from main which is acceptable
- "v*"
pull_request:
paths-ignore:
- compiler/**
@@ -41,7 +45,7 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
lookup-only: true
- uses: actions/setup-node@v4
if: steps.node_modules.outputs.cache-hit != 'true'
@@ -55,10 +59,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-node_modules-v6-
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- run: yarn install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
- name: Save cache
@@ -67,7 +69,7 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
runtime_compiler_node_modules_cache:
name: Cache Runtime, Compiler node_modules
@@ -82,7 +84,7 @@ jobs:
with:
path: |
**/node_modules
key: runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
lookup-only: true
- uses: actions/setup-node@v4
if: steps.node_modules.outputs.cache-hit != 'true'
@@ -98,10 +100,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
restore-keys: |
runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-and-compiler-node_modules-v6-
key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- run: yarn install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
- run: yarn --cwd compiler install --frozen-lockfile
@@ -112,7 +112,7 @@ jobs:
with:
path: |
**/node_modules
key: runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
# ----- FLOW -----
discover_flow_inline_configs:
@@ -154,10 +154,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-node_modules-v6-
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
@@ -184,10 +182,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-node_modules-v6-
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
@@ -216,7 +212,7 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
@@ -274,16 +270,15 @@ jobs:
with:
path: |
**/node_modules
key: runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
restore-keys: |
runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-and-compiler-node_modules-v6-
key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
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
@@ -306,7 +301,7 @@ jobs:
with:
path: |
**/node_modules
key: runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
- name: Install runtime dependencies
run: yarn install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
@@ -349,10 +344,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
restore-keys: |
runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-and-compiler-node_modules-v6-
key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
@@ -390,9 +383,6 @@ jobs:
-r=experimental --env=development,
-r=experimental --env=production,
# Dev Tools
--project=devtools -r=experimental,
# TODO: Update test config to support www build tests
# - "-r=www-classic --env=development --variant=false"
# - "-r=www-classic --env=production --variant=false"
@@ -440,10 +430,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
restore-keys: |
runtime-and-compiler-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-and-compiler-node_modules-v6-
key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
@@ -458,8 +446,54 @@ 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:
name: yarn test-build (devtools)
needs: [build_and_lint, runtime_node_modules_cache]
strategy:
fail-fast: false
matrix:
shard:
- 1/5
- 2/5
- 3/5
- 4/5
- 5/5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
cache-dependency-path: yarn.lock
- name: Restore cached node_modules
uses: actions/cache/restore@v4
id: node_modules
with:
path: |
**/node_modules
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
- name: Restore archived build
uses: actions/download-artifact@v4
with:
pattern: _build_*
path: build
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:
name: Process artifacts combined
needs: [build_and_lint, runtime_node_modules_cache]
@@ -483,10 +517,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-node_modules-v6-
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
@@ -548,10 +580,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-node_modules-v6-
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
@@ -588,10 +618,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-node_modules-v6-
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
@@ -740,10 +768,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-node_modules-v6-
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
@@ -802,10 +828,8 @@ jobs:
with:
path: |
**/node_modules
key: runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
runtime-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-
runtime-node_modules-v6-
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile

View File

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

View File

@@ -29,6 +29,7 @@ jobs:
- "7"
- "8"
- "9"
- "10"
steps:
- uses: actions/checkout@v4
with:

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,3 +1,9 @@
## 19.2.1 (Dec 3, 2025)
### React Server Components
- Bring React Server Component fixes to Server Actions (@sebmarkbage [#35277](https://github.com/facebook/react/pull/35277))
## 19.2.0 (October 1st, 2025)
Below is a list of all new features, APIs, and bug fixes.
@@ -71,6 +77,12 @@ Read the [React 19.2 release post](https://react.dev/blog/2025/10/01/react-19-2)
- [createContainer](https://github.com/facebook/react/blob/v19.2.0/packages/react-reconciler/src/ReactFiberReconciler.js#L255-L261) and [createHydrationContainer](https://github.com/facebook/react/blob/v19.2.0/packages/react-reconciler/src/ReactFiberReconciler.js#L305-L312) had their parameter order adjusted after `on*` handlers to account for upcoming experimental APIs
## 19.1.2 (Dec 3, 2025)
### React Server Components
- Bring React Server Component fixes to Server Actions (@sebmarkbage [#35277](https://github.com/facebook/react/pull/35277))
## 19.1.1 (July 28, 2025)
### React
@@ -123,6 +135,12 @@ An Owner Stack is a string representing the components that are directly respons
* Exposed `registerServerReference` in client builds to handle server references in different environments. [#32534](https://github.com/facebook/react/pull/32534)
* Added react-server-dom-parcel package which integrates Server Components with the [Parcel bundler](https://parceljs.org/) [#31725](https://github.com/facebook/react/pull/31725), [#32132](https://github.com/facebook/react/pull/32132), [#31799](https://github.com/facebook/react/pull/31799), [#32294](https://github.com/facebook/react/pull/32294), [#31741](https://github.com/facebook/react/pull/31741)
## 19.0.1 (Dec 3, 2025)
### React Server Components
- Bring React Server Component fixes to Server Actions (@sebmarkbage [#35277](https://github.com/facebook/react/pull/35277))
## 19.0.0 (December 5, 2024)
Below is a list of all new features, APIs, deprecations, and breaking changes. Read [React 19 release post](https://react.dev/blog/2024/04/25/react-19) and [React 19 upgrade guide](https://react.dev/blog/2024/04/25/react-19-upgrade-guide) for more information.

8
CLAUDE.md Normal file
View File

@@ -0,0 +1,8 @@
# React
React is a JavaScript library for building user interfaces.
## Monorepo Overview
- **React**: All files outside `/compiler/`
- **React Compiler**: `/compiler/` directory (has its own instructions)

View File

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

View File

@@ -0,0 +1,113 @@
---
name: investigate-error
description: Investigates React compiler errors to determine the root cause and identify potential mitigation(s). Use this agent when the user asks to 'investigate a bug', 'debug why this fixture errors', 'understand why the compiler is failing', 'find the root cause of a compiler issue', or when they provide a snippet of code and ask to debug. Use automatically when encountering a failing test case, in order to understand the root cause.
model: opus
color: pink
---
You are an expert React Compiler debugging specialist with deep knowledge of compiler internals, intermediate representations, and optimization passes. Your mission is to systematically investigate compiler bugs to identify root causes and provide actionable information for fixes.
## Your Investigation Process
### Step 1: Create Test Fixture
Create a new fixture file at `packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/<fixture-name>.js` containing the problematic code. Use a descriptive name that reflects the issue (e.g., `bug-optional-chain-in-effect.js`).
### Step 2: Run Debug Compilation
Execute `yarn snap -d -p <fixture-name>` to compile the fixture with full debug output. This shows the state of the program after each compilation pass. You can also use `yarn snap compile -d <path-to-fixture>`.
### Step 3: Analyze Compilation Results
### Step 3a: If the fixture compiles successfully
- Compare the output against the user's expected behavior
- Review each compilation pass output from the `-d` flag
- Identify the first pass where the output diverges from expected behavior
- Proceed to binary search simplification
### Step 3b: If the fixture errors
Execute `yarn snap minimize --update <path-to-fixture>` to remove non-critical aspects of the failing test case. This **updates the fixture in place**.
Re-read the fixture file to see the latest, minimal reproduction of the error.
### Step 4: Iteratively adjust the fixture until it stops erroring
After the previous step the fixture will have all extraneous aspects removed. Try to make further edits to determine the specific feature that is causing the error.
Ideas:
* Replace immediately-invoked function expressions with labeled blocks
* Remove statements
* Simplify calls (remove arguments, replace the call with its lone argument)
* Simplify control flow statements by picking a single branch. Try using a labeled block with just the selected block
* Replace optional member/call expressions with non-optional versions
* Remove items in array/object expressions
* Remove properties from member expressions
Try to make the minimal possible edit to get the fixture stop erroring.
### Step 5: Compare Debug Outputs
With both minimal versions (failing and non-failing):
- Run `yarn snap -d -p <fixture-name>` on both
- Compare the debug output pass-by-pass
- Identify the exact pass where behavior diverges
- Note specific differences in HIR, effects, or generated code
### Step 6: Investigate Compiler Logic
- Read the documentation for the problematic pass in `packages/babel-plugin-react-compiler/docs/passes/`
- Examine the pass implementation in `packages/babel-plugin-react-compiler/src/`
- Key directories to investigate:
- `src/HIR/` - IR definitions and utilities
- `src/Inference/` - Effect inference (aliasing, mutation)
- `src/Validation/` - Validation passes
- `src/Optimization/` - Optimization passes
- `src/ReactiveScopes/` - Reactive scope analysis
- Identify specific code locations that may be handling the pattern incorrectly
## Output Format
Provide a structured investigation report:
```
## Investigation Summary
### Bug Description
[Brief description of the issue]
### Minimal Failing Fixture
```javascript
// packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/<name>.js
[minimal code that reproduces the error]
```
### Minimal Non-Failing Fixture
```javascript
// The simplest change that makes it work
[code that compiles correctly]
```
### Problematic Compiler Pass
[Name of the pass where the issue occurs]
### Root Cause Analysis
[Explanation of what the compiler is doing wrong]
### Suspect Code Locations
- `packages/babel-plugin-react-compiler/src/<path>:<line>:<column>` - [description of what may be incorrect]
- [additional locations if applicable]
### Suggested Fix Direction
[Brief suggestion of how the bug might be fixed]
```
## Key Debugging Tips
1. The debug output (`-d` flag) shows the program state after each pass - use this to pinpoint where things go wrong
2. Look for `@aliasingEffects=` on FunctionExpressions to understand data flow
3. Check for `Impure`, `Render`, `Capture` effects on instructions
4. The pass ordering in `Pipeline.ts` shows when effects are populated vs validated
5. Todo errors indicate unsupported but known patterns; Invariant errors indicate unexpected states
## Important Reminders
- Always create the fixture file before running tests
- Use descriptive fixture names that indicate the bug being investigated
- Keep both failing and non-failing minimal versions for your report
- Provide specific file:line:column references when identifying suspect code
- Read the relevant pass documentation before making conclusions about the cause

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(yarn snap:*)",
"Bash(yarn snap:build)",
"Bash(node scripts/enable-feature-flag.js:*)"
],
"deny": [
"Skill(extract-errors)",
"Skill(feature-flags)",
"Skill(fix)",
"Skill(flags)",
"Skill(flow)",
"Skill(test)",
"Skill(verify)"
]
}
}

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

248
compiler/CLAUDE.md Normal file
View File

@@ -0,0 +1,248 @@
# 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
When modifying the compiler, you MUST read the documentation about that pass in `compiler/packages/babel-plugin-react-compiler/docs/passes/` to learn more about the role of that pass within the compiler.
- `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
```
## Compiling Arbitrary Files
Use `yarn snap compile` to compile any file (not just fixtures) with the React Compiler:
```bash
# Compile a file and see the output
yarn snap compile <path>
# Compile with debug logging to see the state after each compiler pass
# This is an alternative to `yarn snap -d -p <pattern>` when you don't have a fixture file yet
yarn snap compile --debug <path>
```
## Minimizing Test Cases
Use `yarn snap minimize` to automatically reduce a failing test case to its minimal reproduction:
```bash
# Minimize a file that causes a compiler error
yarn snap minimize <path>
# Minimize and update the file in-place with the minimized version
yarn snap minimize --update <path>
```
## 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 @enableNameAnonymousFunctions:false
...code...
```
Would enable the `enableJsxOutlining` feature and disable the `enableNameAnonymousFunctions` 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

@@ -12,6 +12,7 @@
# next.js
/.next/
/out/
/next-env.d.ts
# production
/build

View File

@@ -1,14 +0,0 @@
import { c as _c } from "react/compiler-runtime";
export default function TestComponent(t0) {
const $ = _c(2);
const { x } = t0;
let t1;
if ($[0] !== x || true) {
t1 = <Button>{x}</Button>;
$[0] = x;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}

View File

@@ -283,37 +283,6 @@ test('error is displayed when config has validation error', async ({page}) => {
expect(output.replace(/\s+/g, ' ')).toContain('Unexpected compilationMode');
});
test('disableMemoizationForDebugging flag works as expected', async ({
page,
}) => {
const store: Store = {
source: TEST_SOURCE,
config: `import type { PluginOptions } from 'babel-plugin-react-compiler/dist';
({
environment: {
disableMemoizationForDebugging: true
}
} satisfies PluginOptions);`,
showInternals: false,
};
const hash = encodeStore(store);
await page.goto(`/#${hash}`, {waitUntil: 'networkidle'});
await page.waitForFunction(isMonacoLoaded);
await expandConfigs(page);
await page.screenshot({
fullPage: true,
path: 'test-results/07-config-disableMemoizationForDebugging-flag.png',
});
const text =
(await page.locator('.monaco-editor-output').allInnerTexts()) ?? [];
const output = await formatPrint(text);
expect(output).not.toEqual('');
expect(output).toMatchSnapshot('disableMemoizationForDebugging-output.txt');
});
test('error is displayed when source has syntax error', async ({page}) => {
const syntaxErrorSource = `function TestComponent(props) {
const oops = props.

View File

@@ -33,13 +33,14 @@ export default function ConfigEditor({
}): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(false);
// TODO: Add back <Activity> after upgrading next.js
return (
// TODO: Use <Activity> when it is compatible with Monaco: https://github.com/suren-atoyan/monaco-react/issues/753
<>
<div
style={{
display: isExpanded ? 'block' : 'none',
}}>
{/* <Activity mode={isExpanded ? 'visible' : 'hidden'}> */}
<ExpandedEditor
onToggle={() => {
startTransition(() => {
@@ -54,6 +55,8 @@ export default function ConfigEditor({
style={{
display: !isExpanded ? 'block' : 'none',
}}>
{/* </Activity>
<Activity mode={isExpanded ? 'hidden' : 'visible'}></Activity> */}
<CollapsedEditor
onToggle={() => {
startTransition(() => {
@@ -63,6 +66,7 @@ export default function ConfigEditor({
}}
/>
</div>
{/* </Activity> */}
</>
);
}
@@ -123,6 +127,8 @@ function ExpandedEditor({
return (
<ViewTransition
update={{[CONFIG_PANEL_TRANSITION]: 'slide-in', default: 'none'}}>
{/* enter={{[CONFIG_PANEL_TRANSITION]: 'slide-in', default: 'none'}}
exit={{[CONFIG_PANEL_TRANSITION]: 'slide-out', default: 'none'}}> */}
<Resizable
minWidth={300}
maxWidth={600}

View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -26,8 +26,8 @@
"@babel/traverse": "^7.18.9",
"@babel/types": "7.26.3",
"@heroicons/react": "^1.0.6",
"@monaco-editor/react": "^4.4.6",
"@playwright/test": "^1.51.1",
"@monaco-editor/react": "^4.8.0-rc.2",
"@playwright/test": "^1.56.1",
"@use-gesture/react": "^10.2.22",
"hermes-eslint": "^0.25.0",
"hermes-parser": "^0.25.0",
@@ -35,18 +35,18 @@
"lru-cache": "^11.2.2",
"lz-string": "^1.5.0",
"monaco-editor": "^0.52.0",
"next": "15.6.0-canary.7",
"next": "15.5.9",
"notistack": "^3.0.0-alpha.7",
"prettier": "^3.3.3",
"pretty-format": "^29.3.1",
"re-resizable": "^6.9.16",
"react": "19.1.1",
"react-dom": "19.1.1"
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@types/node": "18.11.9",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
"@types/react": "19.2",
"@types/react-dom": "19.2",
"autoprefixer": "^10.4.13",
"clsx": "^1.2.1",
"concurrently": "^7.4.0",
@@ -58,7 +58,7 @@
"wait-on": "^7.2.0"
},
"resolutions": {
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9"
"@types/react": "19.2",
"@types/react-dom": "19.2"
}
}

View File

@@ -79,6 +79,15 @@
::view-transition-group(.slide-in) {
z-index: 1;
}
::view-transition-old(.slide-out) {
animation-name: slideOutLeft;
}
::view-transition-new(.slide-out) {
animation-name: slideInLeft;
}
::view-transition-group(.slide-out) {
z-index: 1;
}
@keyframes slideOutLeft {
from {

View File

@@ -19,7 +19,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"plugins": [
{

View File

@@ -701,24 +701,24 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@monaco-editor/loader@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.4.0.tgz#f08227057331ec890fa1e903912a5b711a2ad558"
integrity sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==
"@monaco-editor/loader@^1.6.1":
version "1.6.1"
resolved "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.6.1.tgz#c99177d87765abf10de31a0086084e714acfbc0f"
integrity sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg==
dependencies:
state-local "^1.0.6"
"@monaco-editor/react@^4.4.6":
version "4.6.0"
resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.6.0.tgz#bcc68671e358a21c3814566b865a54b191e24119"
integrity sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==
"@monaco-editor/react@^4.8.0-rc.2":
version "4.8.0-rc.2"
resolved "https://registry.npmjs.org/@monaco-editor/react/-/react-4.8.0-rc.2.tgz#e9acf652e23e9f640671a69875f496dde7f098aa"
integrity sha512-RzFHKBCnRA4RnozaG/EPhKsbkhX5wcApSa5MElR/AD2ojxhMY+QP+G8aJpxALCnIwKs6L0dec5MJ0nAjMUEqnA==
dependencies:
"@monaco-editor/loader" "^1.4.0"
"@monaco-editor/loader" "^1.6.1"
"@next/env@15.6.0-canary.7":
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.6.0-canary.7.tgz#cdbf2967a9437ef09eef755e203f315acc4d8d8f"
integrity sha512-LNZ7Yd3Cl9rKvjYdeJmszf2HmSDP76SQmfafKep2Ux16ZXKoN5OjwVHFTltKNdsB3vt2t+XJzLP2rhw5lBoFBA==
"@next/env@15.5.9":
version "15.5.9"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.5.9.tgz#53c2c34dc17cd87b61f70c6cc211e303123b2ab8"
integrity sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==
"@next/eslint-plugin-next@15.5.2":
version "15.5.2"
@@ -727,45 +727,45 @@
dependencies:
fast-glob "3.3.1"
"@next/swc-darwin-arm64@15.6.0-canary.7":
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.6.0-canary.7.tgz#628cd34ce9120000f1cb5b08963426431174fc57"
integrity sha512-POsBrxhrR3qvqXV+JZ6ZoBc8gJf8rhYe+OedceI1piPVqtJYOJa3EB4eaqcc+kMsllKRrH/goNlhLwtyhE+0Qg==
"@next/swc-darwin-arm64@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz#f0c9ccfec2cd87cbd4b241ce4c779a7017aed958"
integrity sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==
"@next/swc-darwin-x64@15.6.0-canary.7":
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.6.0-canary.7.tgz#37d4ebab14da74a2f8028daf6d76aab410153e06"
integrity sha512-lmk9ysBuSiPlAJZTCo/3O4mXNFosg6EDIf4GrmynIwCG2as6/KxzyD1WqFp56Exp8eFDjP7SFapD10sV43vCsA==
"@next/swc-darwin-x64@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz#18009e9fcffc5c0687cc9db24182ddeac56280d9"
integrity sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==
"@next/swc-linux-arm64-gnu@15.6.0-canary.7":
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.6.0-canary.7.tgz#ce700cc0e0d24763136838223105a524b36694fa"
integrity sha512-why8k6d0SBm3AKoOD5S7ir3g+BF34l9oFKIoZrLaZaKBvNGpFcjc7Ovc2TunNMeaMJzv9k1dHYSap0EI5oSuzg==
"@next/swc-linux-arm64-gnu@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz#fe7c7e08264cf522d4e524299f6d3e63d68d579a"
integrity sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==
"@next/swc-linux-arm64-musl@15.6.0-canary.7":
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.6.0-canary.7.tgz#c791b8e15bf2c338b4cc0387fe7afb3ef83ecfcf"
integrity sha512-HzvTRsKvYj32Va4YuJN3n3xOxvk+6QwB63d/EsgmdkeA/vrqciUAmJDYpuzZEvRc3Yp2nyPq8KZxtHAr6ISZ2Q==
"@next/swc-linux-arm64-musl@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz#94228fe293475ec34a5a54284e1056876f43a3cf"
integrity sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==
"@next/swc-linux-x64-gnu@15.6.0-canary.7":
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.6.0-canary.7.tgz#c01c3a3d8e71660c49298dd053d078379b6b5919"
integrity sha512-6yRFrg2qWXOqa+1BI53J9EmHWFzKg9U2r+5R7n7BFUp8PH5SC92WBsmYTnh/RkvAYvdupiVzMervwwswCs6kFg==
"@next/swc-linux-x64-gnu@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz#078c71201dfe7fcfb8fa6dc92aae6c94bc011cdc"
integrity sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==
"@next/swc-linux-x64-musl@15.6.0-canary.7":
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.6.0-canary.7.tgz#3f4b39faef4a5f88b13e4c726b008ddc9717f819"
integrity sha512-O/JjvOvNK/Wao/OIQaA6evDkxkmFFQgJ1/hI1dVk6/PAeKmW2/Q+6Dodh97eAkOwedS1ZdQl2mojf87TzLvzdQ==
"@next/swc-linux-x64-musl@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz#72947f5357f9226292353e0bb775643da3c7a182"
integrity sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==
"@next/swc-win32-arm64-msvc@15.6.0-canary.7":
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.6.0-canary.7.tgz#9bc5da0907b7ce67eedda02a6d56a09d9a539ccf"
integrity sha512-p9DvrDgnePofZCtiWVY7qZtwXxiOGJlAyy2LoGPYSGOUDhjbTG8j6XMUFXpV9UwpH+l7st522psO1BVzbpT8IQ==
"@next/swc-win32-arm64-msvc@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz#397b912cd51c6a80e32b9c0507ecd82514353941"
integrity sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==
"@next/swc-win32-x64-msvc@15.6.0-canary.7":
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.6.0-canary.7.tgz#5b271c591ccbe67d5fa966dd22db86c547414fd1"
integrity sha512-f1ywT3xWu4StWKA1mZRyGfelu/h+W0OEEyBxQNXzXyYa0VGZb9LyCNb5cYoNKBm0Bw18Hp1PVe0bHuusemGCcw==
"@next/swc-win32-x64-msvc@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz#e02b543d9dc6c1631d4ac239cb1177245dfedfe4"
integrity sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -798,12 +798,12 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@playwright/test@^1.51.1":
version "1.51.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.51.1.tgz#75357d513221a7be0baad75f01e966baf9c41a2e"
integrity sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==
"@playwright/test@^1.56.1":
version "1.56.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.56.1.tgz#6e3bf3d0c90c5cf94bf64bdb56fd15a805c8bd3f"
integrity sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==
dependencies:
playwright "1.51.1"
playwright "1.56.1"
"@rtsao/scc@^1.1.0":
version "1.1.0"
@@ -854,22 +854,15 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
"@types/react-dom@19.1.9":
version "19.1.9"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.9.tgz#5ab695fce1e804184767932365ae6569c11b4b4b"
integrity sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==
"@types/react-dom@19.2":
version "19.2.2"
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz#a4cc874797b7ddc9cb180ef0d5dc23f596fc2332"
integrity sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==
"@types/react@19.1.12":
version "19.1.12"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.12.tgz#7bfaa76aabbb0b4fe0493c21a3a7a93d33e8937b"
integrity sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==
dependencies:
csstype "^3.0.2"
"@types/react@19.1.13":
version "19.1.13"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.13.tgz#fc650ffa680d739a25a530f5d7ebe00cdd771883"
integrity sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==
"@types/react@19.2":
version "19.2.2"
resolved "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz#ba123a75d4c2a51158697160a4ea2ff70aa6bf36"
integrity sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==
dependencies:
csstype "^3.0.2"
@@ -3211,25 +3204,25 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
next@15.6.0-canary.7:
version "15.6.0-canary.7"
resolved "https://registry.yarnpkg.com/next/-/next-15.6.0-canary.7.tgz#bfc2ac3c9a78e23d550c303d18247a263e6b5bc1"
integrity sha512-4ukX2mxat9wWT6E0Gw/3TOR9ULV1q399E42F86cwsPSFgTWa04ABhcTqO0r9J/QR1YWPR8WEgh9qUzmWA/1yEw==
next@15.5.9:
version "15.5.9"
resolved "https://registry.yarnpkg.com/next/-/next-15.5.9.tgz#1b80d05865cc27e710fb4dcfc6fd9e726ed12ad4"
integrity sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==
dependencies:
"@next/env" "15.6.0-canary.7"
"@next/env" "15.5.9"
"@swc/helpers" "0.5.15"
caniuse-lite "^1.0.30001579"
postcss "8.4.31"
styled-jsx "5.1.6"
optionalDependencies:
"@next/swc-darwin-arm64" "15.6.0-canary.7"
"@next/swc-darwin-x64" "15.6.0-canary.7"
"@next/swc-linux-arm64-gnu" "15.6.0-canary.7"
"@next/swc-linux-arm64-musl" "15.6.0-canary.7"
"@next/swc-linux-x64-gnu" "15.6.0-canary.7"
"@next/swc-linux-x64-musl" "15.6.0-canary.7"
"@next/swc-win32-arm64-msvc" "15.6.0-canary.7"
"@next/swc-win32-x64-msvc" "15.6.0-canary.7"
"@next/swc-darwin-arm64" "15.5.7"
"@next/swc-darwin-x64" "15.5.7"
"@next/swc-linux-arm64-gnu" "15.5.7"
"@next/swc-linux-arm64-musl" "15.5.7"
"@next/swc-linux-x64-gnu" "15.5.7"
"@next/swc-linux-x64-musl" "15.5.7"
"@next/swc-win32-arm64-msvc" "15.5.7"
"@next/swc-win32-x64-msvc" "15.5.7"
sharp "^0.34.3"
node-releases@^2.0.18:
@@ -3460,17 +3453,17 @@ pirates@^4.0.1:
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9"
integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==
playwright-core@1.51.1:
version "1.51.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.51.1.tgz#d57f0393e02416f32a47cf82b27533656a8acce1"
integrity sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==
playwright-core@1.56.1:
version "1.56.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.1.tgz#24a66481e5cd33a045632230aa2c4f0cb6b1db3d"
integrity sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==
playwright@1.51.1:
version "1.51.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.51.1.tgz#ae1467ee318083968ad28d6990db59f47a55390f"
integrity sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==
playwright@1.56.1:
version "1.56.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.56.1.tgz#62e3b99ddebed0d475e5936a152c88e68be55fbf"
integrity sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==
dependencies:
playwright-core "1.51.1"
playwright-core "1.56.1"
optionalDependencies:
fsevents "2.3.2"
@@ -3589,12 +3582,12 @@ re-resizable@^6.9.16:
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.10.0.tgz#d684a096ab438f1a93f59ad3a580a206b0ce31ee"
integrity sha512-hysSK0xmA5nz24HBVztlk4yCqCLCvS32E6ZpWxVKop9x3tqCa4yAj1++facrmkOf62JsJHjmjABdKxXofYioCw==
react-dom@19.1.1:
version "19.1.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.1.1.tgz#2daa9ff7f3ae384aeb30e76d5ee38c046dc89893"
integrity sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==
react-dom@19.2.3:
version "19.2.3"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==
dependencies:
scheduler "^0.26.0"
scheduler "^0.27.0"
react-is@^16.13.1:
version "16.13.1"
@@ -3606,10 +3599,10 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
react@19.1.1:
version "19.1.1"
resolved "https://registry.yarnpkg.com/react/-/react-19.1.1.tgz#06d9149ec5e083a67f9a1e39ce97b06a03b644af"
integrity sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==
react@19.2.3:
version "19.2.3"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==
read-cache@^1.0.0:
version "1.0.0"
@@ -3785,10 +3778,10 @@ safe-regex-test@^1.1.0:
es-errors "^1.3.0"
is-regex "^1.2.1"
scheduler@^0.26.0:
version "0.26.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.26.0.tgz#4ce8a8c2a2095f13ea11bf9a445be50c555d6337"
integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==
scheduler@^0.27.0:
version "0.27.0"
resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==
semver@^6.3.1:
version "6.3.1"

View File

@@ -17,7 +17,32 @@ yarn snap:build
yarn snap --watch
```
`snap` is our custom test runner, which creates "golden" test files that have the expected output for each input fixture, as well as the results of executing a specific input (or sequence of inputs) in both the uncompiled and compiler versions of the input.
`snap` is our custom test runner, which creates "golden" test files that have the expected output for each input fixture, as well as the results of executing a specific input (or sequence of inputs) in both the uncompiled and compiler versions of the input.
### Compiling Arbitrary Files
You can compile any file (not just fixtures) using:
```sh
# Compile a file and see the output
yarn snap compile <path>
# Compile with debug output to see the state after each compiler pass
# This is an alternative to `yarn snap -d -p <pattern>` when you don't have a fixture file yet
yarn snap compile --debug <path>
```
### Minimizing Test Cases
To reduce a failing test case to its minimal reproduction:
```sh
# Minimize a file that causes a compiler error
yarn snap minimize <path>
# Minimize and update the file in-place
yarn snap minimize --update <path>
```
When contributing changes, we prefer to:
* Add one or more fixtures that demonstrate the current compiled output for a particular combination of input and configuration. Send this as a first PR.

View File

@@ -0,0 +1,320 @@
## React Compiler Fault Tolerance
Update React Compiler (@compiler/ directory) to always run all passes and return either the transformed code (if no error) or a list of one or more compilation errors.
## Background
Currently React Compiler runs through a series of passes in Pipeline.ts. If an error occurs in a pass the compiler will generally either throw the error in the pass where it occurs, or return a Result<_, CompilerError> which is then unwrapped in Pipeline.ts, throwing there. This means that a single error that triggers early can prevent later validation from running, meaning the user has to first fix one error in order to see another.
## New Approach
The compiler should always run all passes in the pipeline, up to and including CodegenReactiveFunction. During this process it should accumulate errors. If at the end of compilation there were no accumulated errors, return `Ok(generatedfunction)`. Else, return `Err(CompilerError)` with *all* the accumulated errors.
Note that some errors may continue to cause an eager bailout:
* If an error is not an instanceof CompilerError, throw it as it occurs
* If an error is a CompilerError invariant, throw it as it occurs since this represents a truly exceptional, unexpected case
## Detailed Design
* The Environment needs a way to record errors as compilation proceeds. This should generally store the error (and log, if a logger is configured), but should immediately throw if the error is an invariant (see above).
* BuildHIR should always produce an HIR without error. For syntax forms that are unsupported (currently throwing a Todo error), we should instead construct record the todo error on the environment, and construct a partial HIR. The exact form of the partial HIR can be situation specific:
* `var` is currently unsupported, but we could pretend it was `let`
* `finally` blocks are unsupported, we could just prune them, or move the code after the try/catch (put the finally logic in the consequent)
* This may mean updating the HIR to allow representing partial code
* `eval()` can just be an Unsupported InstructionValue variant
* All of the passes need to be updated to stop returning Result or CompilerError, and instead record their errors on the environment. They should always be able to proceed even in the presence of errors. For example, in InferMutationAliasingEffects if we discover that the code mutates a frozen value, we can record this as an error and then just pretend the mutation didn't happen - ie construct a scope as if the mutating code was not a mutation after all.
* Finally, the end of the pipeline should check for errors and either turn `Ok(GeneratedFunction)` or `Err(aggregatedErrors)`. The code calling into the pipeline then needs to handle this appropriately.
## Detailed Plan
### Phase 1: Environment Error Accumulation Infrastructure
Add error accumulation to the `Environment` class so that any pass can record errors during compilation without halting.
- [ ] **1.1 Add error accumulator to Environment** (`src/HIR/Environment.ts`)
- Add a `#errors: CompilerError` field, initialized in the constructor
- Add a `recordError(error: CompilerDiagnostic | CompilerErrorDetail)` method that:
- If an Invariant-category detail, immediately throw it
- Otherwise, push the diagnostic/detail onto `#errors` (and log via `this.logger` if configured)
- Add a `recordErrors(error: CompilerError)` method that calls `recordError()` for each of the details on the given error.
- Add a `hasErrors(): boolean` getter
- Add a `aggregateErrors(): CompilerError` method that returns the accumulated error object
- Consider whether `recordError` should accept the same options as `CompilerError.push()` for convenience (reason, description, severity, loc, etc.)
- [ ] **1.2 Add a `tryRecord` helper on Environment** (`src/HIR/Environment.ts`)
- Add a `tryRecord(fn: () => void): void` method that wraps a callback in try/catch:
- If `fn` throws a `CompilerError` that is NOT an invariant, record it via `recordError`
- If `fn` throws a non-CompilerError or a CompilerError invariant, re-throw
- This helper is the migration path for passes that currently throw: wrap their call in `env.tryRecord(() => pass(hir))` so exceptions become recorded errors
### Phase 2: Update Pipeline.ts to Accumulate Errors
Change `runWithEnvironment` to run all passes and check for errors at the end instead of letting exceptions propagate.
- [ ] **2.1 Change `runWithEnvironment` return type** (`src/Entrypoint/Pipeline.ts`)
- Change return type from `CodegenFunction` to `Result<CodegenFunction, CompilerError>`
- At the end of the pipeline, check `env.hasErrors()`:
- If no errors: return `Ok(ast)`
- If errors: return `Err(env.aggregateErrors())`
- [ ] **2.2 Update `compileFn` to propagate the Result** (`src/Entrypoint/Pipeline.ts`)
- Change `compileFn` return type from `CodegenFunction` to `Result<CodegenFunction, CompilerError>`
- Propagate the Result from `runWithEnvironment`
- [ ] **2.3 Update `run` to propagate the Result** (`src/Entrypoint/Pipeline.ts`)
- Same change for the internal `run` function
- [ ] **2.4 Update callers in Program.ts** (`src/Entrypoint/Program.ts`)
- In `tryCompileFunction`, change from try/catch around `compileFn` to handling the `Result`:
- If `Ok(codegenFn)`: return the compiled function
- If `Err(compilerError)`: return `{kind: 'error', error: compilerError}`
- Keep the try/catch only for truly unexpected (non-CompilerError) exceptions and invariants
- The existing `handleError`/`logError`/`panicThreshold` logic in `processFn` should continue to work unchanged since it already handles `CompilerError` instances
### Phase 3: Update BuildHIR (lower) to Always Produce HIR
Currently `lower()` returns `Result<HIRFunction, CompilerError>`. It already accumulates errors internally via `builder.errors`, but returns `Err` when errors exist. Change it to always return `Ok(hir)` while recording errors on the environment.
- [ ] **3.1 Change `lower` to always return HIRFunction** (`src/HIR/BuildHIR.ts`)
- Change return type from `Result<HIRFunction, CompilerError>` to `HIRFunction`
- Instead of returning `Err(builder.errors)` at line 227-229, record errors on `env` via `env.recordError(builder.errors)` and return the (partial) HIR
- Update the pipeline to call `lower(func, env)` directly instead of `lower(func, env).unwrap()`
- [ ] **3.2 Handle `var` declarations as `let`** (`src/HIR/BuildHIR.ts`, line ~855)
- Currently throws `Todo("Handle var kinds in VariableDeclaration")`
- Instead: record the Todo error on env, then treat the `var` as `let` and continue lowering
- [ ] **3.3 Handle `try/finally` by pruning `finally`** (`src/HIR/BuildHIR.ts`, lines ~1281-1296)
- Currently throws Todo for `try` without `catch` and `try` with `finally`
- Instead: record the Todo error, then lower the `try/catch` portion only (put the `finally` block content in the fallthrough of the try/catch)
- [ ] **3.4 Handle `eval()` via UnsupportedNode** (`src/HIR/BuildHIR.ts`, line ~3568)
- Currently throws `UnsupportedSyntax("The 'eval' function is not supported")`
- Instead: record the error, emit an `UnsupportedNode` instruction value with the original AST node
- [ ] **3.5 Handle `with` statement via UnsupportedNode** (`src/HIR/BuildHIR.ts`, line ~1382)
- Currently throws `UnsupportedSyntax`
- Instead: record the error, emit the body statements as-is (or skip them), continue
- [ ] **3.6 Handle inline `class` declarations** (`src/HIR/BuildHIR.ts`, line ~1402)
- Currently throws `UnsupportedSyntax`
- Already creates an `UnsupportedNode`; just record the error instead of throwing
- [ ] **3.7 Handle remaining Todo errors in expression lowering** (`src/HIR/BuildHIR.ts`)
- For each of the ~35 Todo error sites in `lowerExpression`, `lowerAssignment`, `lowerMemberExpression`, etc.:
- Record the Todo error on the environment
- Emit an `UnsupportedNode` instruction value with the original Babel AST node as fallback
- Key sites include: pipe operator, tagged templates with interpolations, compound logical assignment (`&&=`, `||=`, `??=`), `for await...of`, object getters/setters, UpdateExpression on context variables, complex destructuring patterns
- The `UnsupportedNode` variant already exists in HIR and passes through codegen unchanged, so no new HIR types are needed for most cases
- [ ] **3.8 Handle `throw` inside `try/catch`** (`src/HIR/BuildHIR.ts`, line ~284)
- Currently throws Todo
- Instead: record the error, and represent the `throw` as a terminal that ends the block (the existing `throw` terminal type may already handle this, or we can use `UnsupportedNode`)
- [ ] **3.9 Handle `for` loops with missing test or expression init** (`src/HIR/BuildHIR.ts`, lines ~559, ~632)
- Record the error and construct a best-effort loop HIR (e.g., for `for(;;)`, use `true` as the test expression)
- [ ] **3.10 Handle nested function lowering failures** (`src/HIR/BuildHIR.ts`, `lowerFunction` at line ~3504)
- Currently calls `lower()` recursively and merges errors if it fails (`builder.errors.merge(functionErrors)`)
- With the new approach, the nested `lower()` always returns an HIR, but errors are recorded on the shared environment
- Ensure the parent function continues lowering even if a nested function had errors
### Phase 4: Update Validation Passes
All validation passes need to record errors on the environment instead of returning `Result` or throwing. They should still detect the same problems, but the pipeline should continue after each one.
#### Pattern A passes (currently return `Result`, called with `.unwrap()`)
These passes already accumulate errors internally and return `Result<void, CompilerError>`. The change is: instead of returning the Result, record errors on `env` and return void. Remove the `.unwrap()` call in Pipeline.ts.
- [ ] **4.1 `validateHooksUsage`** (`src/Validation/ValidateHooksUsage.ts`)
- Change signature from `(fn: HIRFunction): Result<void, CompilerError>` to `(fn: HIRFunction): void`
- Record errors on `fn.env` instead of returning `errors.asResult()`
- Update Pipeline.ts call site (line 211): remove `.unwrap()`
- [ ] **4.2 `validateNoCapitalizedCalls`** (`src/Validation/ValidateNoCapitalizedCalls.ts`)
- Change signature to return void
- Fix the hybrid pattern: the direct `CallExpression` path currently throws via `CompilerError.throwInvalidReact()` — change to record on env
- The `MethodCall` path already accumulates — change to record on env
- Update Pipeline.ts call site (line 214): remove `.unwrap()`
- [ ] **4.3 `validateUseMemo`** (`src/Validation/ValidateUseMemo.ts`)
- Change signature to return void
- Record hard errors on env instead of returning `errors.asResult()`
- The soft `voidMemoErrors` path already uses `env.logErrors()` — keep as-is or also record
- Update Pipeline.ts call site (line 170): remove `.unwrap()`
- [ ] **4.4 `dropManualMemoization`** (`src/Inference/DropManualMemoization.ts`)
- Change signature to return void
- Record errors on env instead of returning `errors.asResult()`
- Update Pipeline.ts call site (line 178): remove `.unwrap()`
- [ ] **4.5 `validateNoRefAccessInRender`** (`src/Validation/ValidateNoRefAccessInRender.ts`)
- Change signature to return void
- Record errors on env instead of returning Result
- Update Pipeline.ts call site (line 275): remove `.unwrap()`
- [ ] **4.6 `validateNoSetStateInRender`** (`src/Validation/ValidateNoSetStateInRender.ts`)
- Change signature to return void
- Record errors on env
- Update Pipeline.ts call site (line 279): remove `.unwrap()`
- [ ] **4.7 `validateNoImpureFunctionsInRender`** (`src/Validation/ValidateNoImpureFunctionsInRender.ts`)
- Change signature to return void
- Record errors on env
- Update Pipeline.ts call site (line 300): remove `.unwrap()`
- [ ] **4.8 `validateNoFreezingKnownMutableFunctions`** (`src/Validation/ValidateNoFreezingKnownMutableFunctions.ts`)
- Change signature to return void
- Record errors on env
- Update Pipeline.ts call site (line 303): remove `.unwrap()`
- [ ] **4.9 `validateExhaustiveDependencies`** (`src/Validation/ValidateExhaustiveDependencies.ts`)
- Change signature to return void
- Record errors on env
- Update Pipeline.ts call site (line 315): remove `.unwrap()`
- [ ] **4.10 `validateMemoizedEffectDependencies`** (`src/Validation/ValidateMemoizedEffectDependencies.ts`)
- Change signature to return void (note: operates on `ReactiveFunction`)
- Record errors on the function's env
- Update Pipeline.ts call site (line 565): remove `.unwrap()`
- [ ] **4.11 `validatePreservedManualMemoization`** (`src/Validation/ValidatePreservedManualMemoization.ts`)
- Change signature to return void (note: operates on `ReactiveFunction`)
- Record errors on the function's env
- Update Pipeline.ts call site (line 572): remove `.unwrap()`
- [ ] **4.12 `validateSourceLocations`** (`src/Validation/ValidateSourceLocations.ts`)
- Change signature to return void
- Record errors on env
- Update Pipeline.ts call site (line 585): remove `.unwrap()`
#### Pattern B passes (currently use `env.logErrors()`)
These already use a soft-logging pattern and don't block compilation. They can be migrated to `env.recordError()` so all errors are aggregated in one place.
- [ ] **4.13 `validateNoDerivedComputationsInEffects_exp`** — change to record on env directly
- [ ] **4.14 `validateNoSetStateInEffects`** — change to record on env directly
- [ ] **4.15 `validateNoJSXInTryStatement`** — change to record on env directly
- [ ] **4.16 `validateStaticComponents`** — change to record on env directly
#### Pattern D passes (currently throw directly, no Result)
These throw `CompilerError` directly (not via Result). They need the most work.
- [ ] **4.17 `validateContextVariableLValues`** (`src/Validation/ValidateContextVariableLValues.ts`)
- Currently throws via `CompilerError.throwTodo()` and `CompilerError.invariant()`
- Change to record Todo errors on env and continue
- Keep invariant throws (those indicate internal bugs)
- [ ] **4.18 `validateLocalsNotReassignedAfterRender`** (`src/Validation/ValidateLocalsNotReassignedAfterRender.ts`)
- Currently constructs a `CompilerError` and `throw`s it directly
- Change to record errors on env
- [ ] **4.19 `validateNoDerivedComputationsInEffects`** (`src/Validation/ValidateNoDerivedComputationsInEffects.ts`)
- Currently throws directly
- Change to record errors on env
### Phase 5: Update Inference Passes
The inference passes are the most critical to handle correctly because they produce side effects (populating effects on instructions, computing mutable ranges) that downstream passes depend on. They must continue producing valid (even if imprecise) output when errors are encountered.
- [ ] **5.1 `inferMutationAliasingEffects`** (`src/Inference/InferMutationAliasingEffects.ts`)
- Currently returns `Result<void, CompilerError>` — errors are about mutation of frozen/global values
- Change to record errors on `fn.env` instead of accumulating internally
- **Key recovery strategy**: When a mutation of a frozen value is detected, record the error but treat the operation as a non-mutating read. This way downstream passes see a consistent (if conservative) view
- When a mutation of a global is detected, record the error but continue with the global unchanged
- Update Pipeline.ts (lines 233-239): remove the conditional `.isErr()` / throw pattern
- [ ] **5.2 `inferMutationAliasingRanges`** (`src/Inference/InferMutationAliasingRanges.ts`)
- Currently returns `Result<Array<AliasingEffect>, CompilerError>`
- This pass has a meaningful success value (the function's external aliasing effects)
- Change to: always produce a best-effort effects array, record errors on env
- When errors are encountered, produce conservative effects (e.g., assume no external mutation)
- Update Pipeline.ts (lines 258-267): remove the conditional throw pattern, call directly
### Phase 6: Update Codegen
- [ ] **6.1 `codegenFunction`** (`src/ReactiveScopes/CodegenReactiveFunction.ts`)
- Currently returns `Result<CodegenFunction, CompilerError>`
- Change to: always produce a `CodegenFunction`, record errors on env
- If codegen encounters an error (e.g., an instruction it can't generate code for), it should:
- Record the error
- For `UnsupportedNode` values: pass through the original AST node (already works this way)
- For other error cases: emit a placeholder or the original AST where possible
- Update Pipeline.ts (line 575-578): remove `.unwrap()`
### Phase 7: Pipeline.ts Pass-by-Pass Migration
Walk through `runWithEnvironment` and wrap each pass call site. This is the integration work tying Phases 3-6 together.
- [ ] **7.1 Wrap `lower()` call** (line 163)
- Change from `lower(func, env).unwrap()` to `lower(func, env)` (direct return after Phase 3.1)
- [ ] **7.2 Wrap validation calls that use `.unwrap()`** (lines 169-303)
- Remove `.unwrap()` from all validation calls after they're updated in Phase 4
- For validations guarded by `env.enableValidations`, keep the guard but remove the `.unwrap()`
- [ ] **7.3 Wrap inference calls** (lines 233-267)
- After Phase 5, `inferMutationAliasingEffects` and `inferMutationAliasingRanges` record errors directly
- Remove the `mutabilityAliasingErrors` / `mutabilityAliasingRangeErrors` variables and their conditional throw logic
- [ ] **7.4 Wrap `env.logErrors()` calls** (lines 286-331)
- After Phase 4.13-4.16, these passes record on env directly
- Remove the `env.logErrors()` wrapper calls
- [ ] **7.5 Wrap codegen** (lines 575-578)
- After Phase 6.1, `codegenFunction` returns directly
- Remove the `.unwrap()`
- [ ] **7.6 Add final error check** (end of `runWithEnvironment`)
- After all passes complete, check `env.hasErrors()`
- If no errors: return `Ok(ast)`
- If errors: return `Err(env.aggregateErrors())`
- [ ] **7.7 Consider wrapping each pass in `env.tryRecord()`** as a safety net
- Even after individual passes are updated, wrapping each pass call in `env.tryRecord()` provides defense-in-depth
- If a pass unexpectedly throws a CompilerError (e.g., from a code path we missed), it gets caught and recorded rather than aborting the pipeline
- Non-CompilerError exceptions and invariants still propagate immediately
### Phase 8: Testing
- [ ] **8.1 Update existing `error.todo-*` fixture expectations**
- Currently, fixtures with `error.todo-` prefix expect a single error and bailout
- After fault tolerance, some of these may now produce multiple errors
- Update the `.expect.md` files to reflect the new aggregated error output
- [ ] **8.2 Add multi-error test fixtures**
- Create test fixtures that contain multiple independent errors (e.g., both a `var` declaration and a mutation of a frozen value)
- Verify that all errors are reported, not just the first one
- [ ] **8.3 Add test for invariant-still-throws behavior**
- Verify that `CompilerError.invariant()` failures still cause immediate abort
- Verify that non-CompilerError exceptions still cause immediate abort
- [ ] **8.4 Add test for partial HIR codegen**
- Verify that when BuildHIR produces partial HIR (with `UnsupportedNode` values), later passes handle it gracefully and codegen produces the original AST for unsupported portions
- [ ] **8.5 Verify error severity in aggregated output**
- Test that the aggregated `CompilerError` correctly reports `hasErrors()` vs `hasWarning()` vs `hasHints()` based on the mix of accumulated diagnostics
- Verify that `panicThreshold` behavior in Program.ts is correct for aggregated errors
- [ ] **8.6 Run full test suite**
- Run `yarn snap` and `yarn snap -u` to update all fixture expectations
- Ensure no regressions in passing tests
### Implementation Notes
**Ordering**: Phases 1 → 2 → 3 → 4/5/6 (parallel) → 7 → 8. Phase 1 (Environment infrastructure) is the foundation. Phase 2 (Pipeline return type) sets up the contract. Phases 3-6 can be done incrementally — each pass can be migrated independently using `env.tryRecord()` as a transitional wrapper. Phase 7 is the integration. Phase 8 validates everything.
**Incremental migration path**: Rather than updating all passes at once, each pass can be individually migrated. During the transition:
1. First add `env.tryRecord()` in Phase 7.7 around all pass calls in the pipeline — this immediately provides fault tolerance by catching any thrown CompilerError
2. Then individually update passes (Phases 3-6) to record errors directly on env, which is cleaner but not required for the basic behavior
3. This means the feature can be landed incrementally: Phase 1 + 2 + 7.7 gives basic fault tolerance, then individual passes can be refined over time
**What NOT to change**:
- `CompilerError.invariant()` must continue to throw immediately — these represent internal bugs
- Non-CompilerError exceptions must continue to throw — these are unexpected JS errors
- The `assertConsistentIdentifiers`, `assertTerminalSuccessorsExist`, `assertTerminalPredsExist`, `assertValidBlockNesting`, `assertValidMutableRanges`, `assertWellFormedBreakTargets`, `assertScopeInstructionsWithinScopes` assertion functions should continue to throw — they are invariant checks on internal data structure consistency
- The `panicThreshold` mechanism in Program.ts should continue to work — it now operates on the aggregated error from the Result rather than a caught exception, but the behavior is the same

View File

@@ -0,0 +1,157 @@
# lower (BuildHIR)
## File
`src/HIR/BuildHIR.ts`
## Purpose
Converts a Babel AST function node into a High-level Intermediate Representation (HIR), which represents code as a control-flow graph (CFG) with basic blocks, instructions, and terminals. This is the first major transformation pass in the React Compiler pipeline, enabling precise expression-level memoization analysis.
## Input Invariants
- Input must be a valid Babel `NodePath<t.Function>` (FunctionDeclaration, FunctionExpression, or ArrowFunctionExpression)
- The function must be a component or hook (determined by the environment)
- Babel scope analysis must be available for binding resolution
- An `Environment` instance must be provided with compiler configuration
- Optional `bindings` map for nested function lowering (recursive calls)
- Optional `capturedRefs` map for context variables captured from outer scope
## Output Guarantees
- Returns `Result<HIRFunction, CompilerError>` - either a successfully lowered function or compilation errors
- The HIR function contains:
- A complete CFG with basic blocks (`body.blocks: Map<BlockId, BasicBlock>`)
- Each block has an array of instructions and exactly one terminal
- All control flow is explicit (if/else, loops, switch, logical operators, ternary)
- Parameters are converted to `Place` or `SpreadPattern`
- Context captures are tracked in `context` array
- Function metadata (id, async, generator, directives)
- All identifiers get unique `IdentifierId` values
- Instructions have placeholder instruction IDs (set to 0, assigned later)
- Effects are null (populated by later inference passes)
## Algorithm
The lowering algorithm uses a recursive descent pattern with a `HIRBuilder` helper class:
1. **Initialization**: Create an `HIRBuilder` with environment and optional bindings. Process captured context variables.
2. **Parameter Processing**: For each function parameter:
- Simple identifiers: resolve binding and create Place
- Patterns (object/array): create temporary Place, then emit destructuring assignments
- Rest elements: wrap in SpreadPattern
- Unsupported: emit Todo error
3. **Body Processing**:
- Arrow function expressions: lower body expression to temporary, emit implicit return
- Block statements: recursively lower each statement
4. **Statement Lowering** (`lowerStatement`): Handle each statement type:
- **Control flow**: Create separate basic blocks for branches, loops connect back to conditional blocks
- **Variable declarations**: Create `DeclareLocal`/`DeclareContext` or `StoreLocal`/`StoreContext` instructions
- **Expressions**: Lower to temporary and discard result
- **Hoisting**: Detect forward references and emit `DeclareContext` for hoisted identifiers
5. **Expression Lowering** (`lowerExpression`): Convert expressions to `InstructionValue`:
- **Identifiers**: Create `LoadLocal`, `LoadContext`, or `LoadGlobal` based on binding
- **Literals**: Create `Primitive` values
- **Operators**: Create `BinaryExpression`, `UnaryExpression` etc.
- **Calls**: Distinguish `CallExpression` vs `MethodCall` (member expression callee)
- **Control flow expressions**: Create separate value blocks for branches (ternary, logical, optional chaining)
- **JSX**: Lower to `JsxExpression` with lowered tag, props, and children
6. **Block Management**: The builder maintains:
- A current work-in-progress block accumulating instructions
- Completed blocks map
- Scope stack for break/continue resolution
- Exception handler stack for try/catch
7. **Termination**: Add implicit void return at end if no explicit return
## Key Data Structures
### HIRBuilder (from HIRBuilder.ts)
- `#current: WipBlock` - Work-in-progress block being populated
- `#completed: Map<BlockId, BasicBlock>` - Finished blocks
- `#scopes: Array<Scope>` - Stack for break/continue target resolution (LoopScope, LabelScope, SwitchScope)
- `#exceptionHandlerStack: Array<BlockId>` - Stack of catch handlers for try/catch
- `#bindings: Bindings` - Map of variable names to their identifiers
- `#context: Map<t.Identifier, SourceLocation>` - Captured context variables
- Methods: `push()`, `reserve()`, `enter()`, `terminate()`, `terminateWithContinuation()`
### Core HIR Types
- **BasicBlock**: Contains `instructions: Array<Instruction>`, `terminal: Terminal`, `preds: Set<BlockId>`, `phis: Set<Phi>`, `kind: BlockKind`
- **Instruction**: Contains `id`, `lvalue` (Place), `value` (InstructionValue), `effects` (null initially), `loc`
- **Terminal**: Block terminator - `if`, `branch`, `goto`, `return`, `throw`, `for`, `while`, `switch`, `ternary`, `logical`, etc.
- **Place**: Reference to a value - `{kind: 'Identifier', identifier, effect, reactive, loc}`
- **InstructionValue**: The operation - `LoadLocal`, `StoreLocal`, `CallExpression`, `BinaryExpression`, `FunctionExpression`, etc.
### Block Kinds
- `block` - Regular sequential block
- `loop` - Loop header/test block
- `value` - Block that produces a value (ternary/logical branches)
- `sequence` - Sequence expression block
- `catch` - Exception handler block
## Edge Cases
1. **Hoisting**: Forward references to `let`/`const`/`function` declarations emit `DeclareContext` before the reference, enabling correct temporal dead zone handling
2. **Context Variables**: Variables captured by nested functions use `LoadContext`/`StoreContext` instead of `LoadLocal`/`StoreLocal`
3. **For-of/For-in Loops**: Synthesize iterator instructions (`GetIterator`, `IteratorNext`, `NextPropertyOf`)
4. **Optional Chaining**: Creates nested `OptionalTerminal` structures with short-circuit branches
5. **Logical Expressions**: Create branching structures where left side stores to temporary, right side only evaluated if needed
6. **Try/Catch**: Adds `MaybeThrowTerminal` after each instruction in try block, modeling potential control flow to handler
7. **JSX in fbt**: Tracks `fbtDepth` counter to handle whitespace differently in fbt/fbs tags
8. **Unsupported Syntax**: `var` declarations, `with` statements, inline `class` declarations, `eval` - emit appropriate errors
## TODOs
- `returnTypeAnnotation: null, // TODO: extract the actual return type node if present`
- `TODO(gsn): In the future, we could only pass in the context identifiers that are actually used by this function and its nested functions`
- Multiple `// TODO remove type cast` in destructuring pattern handling
- `// TODO: should JSX namespaced names be handled here as well?`
## Example
Input JavaScript:
```javascript
export default function foo(x, y) {
if (x) {
return foo(false, y);
}
return [y * 10];
}
```
Output HIR (simplified):
```
foo(<unknown> x$0, <unknown> y$1): <unknown> $12
bb0 (block):
[1] <unknown> $6 = LoadLocal <unknown> x$0
[2] If (<unknown> $6) then:bb2 else:bb1 fallthrough=bb1
bb2 (block):
predecessor blocks: bb0
[3] <unknown> $2 = LoadGlobal(module) foo
[4] <unknown> $3 = false
[5] <unknown> $4 = LoadLocal <unknown> y$1
[6] <unknown> $5 = Call <unknown> $2(<unknown> $3, <unknown> $4)
[7] Return Explicit <unknown> $5
bb1 (block):
predecessor blocks: bb0
[8] <unknown> $7 = LoadLocal <unknown> y$1
[9] <unknown> $8 = 10
[10] <unknown> $9 = Binary <unknown> $7 * <unknown> $8
[11] <unknown> $10 = Array [<unknown> $9]
[12] Return Explicit <unknown> $10
```
Key observations:
- The function has 3 basic blocks: entry (bb0), consequent (bb2), alternate/fallthrough (bb1)
- The if statement creates an `IfTerminal` at the end of bb0
- Each branch ends with its own `ReturnTerminal`
- All values are stored in temporaries (`$N`) or named identifiers (`x$0`, `y$1`)
- Instructions have sequential IDs within blocks
- Types and effects are `<unknown>` at this stage (populated by later passes)

View File

@@ -0,0 +1,182 @@
# enterSSA
## File
`src/SSA/EnterSSA.ts`
## Purpose
Converts the HIR from a non-SSA form (where variables can be reassigned) into Static Single Assignment (SSA) form, where each variable is defined exactly once and phi nodes are inserted at control flow join points to merge values from different paths.
## Input Invariants
- The HIR must have blocks in reverse postorder (predecessors visited before successors, except for back-edges)
- Block predecessor information (`block.preds`) must be populated correctly
- The function's `context` array must be empty for the root function (outer function declarations)
- Identifiers may be reused across multiple definitions/assignments (non-SSA form)
## Output Guarantees
- Each identifier has a unique `IdentifierId` - no identifier is defined more than once
- All operand references use the SSA-renamed identifiers
- Phi nodes are inserted at join points where values from different control flow paths converge
- Function parameters are SSA-renamed
- Nested functions (FunctionExpression, ObjectMethod) are recursively converted to SSA form
- Context variables (captured from outer scopes) are handled specially and not redefined
## Algorithm
The pass uses the Braun et al. algorithm ("Simple and Efficient Construction of Static Single Assignment Form") with adaptations for handling loops and nested functions.
### Key Steps:
1. **Block Traversal**: Iterate through blocks in order (assumed reverse postorder from previous passes)
2. **Definition Tracking**: Maintain a per-block `defs` map from original identifiers to their SSA-renamed versions
3. **Renaming**:
- When a value is **defined** (lvalue), create a new SSA identifier with fresh `IdentifierId`
- When a value is **used** (operand), look up the current SSA identifier via `getIdAt`
4. **Phi Node Insertion**: When looking up an identifier at a block with multiple predecessors:
- If all predecessors have been visited, create a phi node collecting values from each predecessor
- If some predecessors are unvisited (back-edge/loop), create an "incomplete phi" that will be fixed later
5. **Incomplete Phi Resolution**: When all predecessors of a block are finally visited, fix any incomplete phi nodes by populating their operands
6. **Nested Function Handling**: Recursively apply SSA transformation to nested functions, temporarily adding a fake predecessor edge to enable identifier lookup from the enclosing scope
### Phi Node Placement Logic (`getIdAt`):
- If the identifier is defined locally in the current block, return it
- If at entry block with no predecessors and not found, mark as unknown (global)
- If some predecessors are unvisited (loop), create incomplete phi
- If exactly one predecessor, recursively look up in that predecessor
- If multiple predecessors, create phi node with operands from all predecessors
## Key Data Structures
- **SSABuilder**: Main class managing the transformation
- `#states: Map<BasicBlock, State>` - Per-block state (defs map and incomplete phis)
- `unsealedPreds: Map<BasicBlock, number>` - Count of unvisited predecessors per block
- `#unknown: Set<Identifier>` - Identifiers assumed to be globals
- `#context: Set<Identifier>` - Context variables that should not be redefined
- **State**: Per-block state containing:
- `defs: Map<Identifier, Identifier>` - Maps original identifiers to SSA-renamed versions
- `incompletePhis: Array<IncompletePhi>` - Phi nodes waiting for predecessor values
- **IncompletePhi**: Tracks a phi node created before all predecessors were visited
- `oldPlace: Place` - Original place being phi'd
- `newPlace: Place` - SSA-renamed phi result place
- **Phi**: The actual phi node in the HIR
- `place: Place` - The result of the phi
- `operands: Map<BlockId, Place>` - Maps predecessor block to the place providing the value
## Edge Cases
- **Loops (back-edges)**: When a variable is used in a loop header before the loop body assigns it, an incomplete phi is created and later fixed when the loop body block is visited
- **Globals**: If an identifier is used but never defined (reaching the entry block without a definition), it's assumed to be a global and not renamed
- **Context variables**: Variables captured from an outer function scope are tracked specially and not redefined when reassigned
- **Nested functions**: Function expressions and object methods are processed recursively with a temporary predecessor edge linking them to the enclosing block
## TODOs
- `[hoisting] EnterSSA: Expected identifier to be defined before being used` - Handles cases where hoisting causes an identifier to be used before definition (throws a Todo error for graceful bailout)
## Example
### Input (simple reassignment with control flow):
```javascript
function foo() {
let y = 2;
if (y > 1) {
y = 1;
} else {
y = 2;
}
let x = y;
}
```
### Before SSA (HIR):
```
bb0 (block):
[1] $0 = 2
[2] $2 = StoreLocal Let y$1 = $0
[3] $7 = LoadLocal y$1
[4] $8 = 1
[5] $9 = Binary $7 > $8
[6] If ($9) then:bb2 else:bb3 fallthrough=bb1
bb2 (block):
predecessor blocks: bb0
[7] $3 = 1
[8] $4 = StoreLocal Reassign y$1 = $3 // Same y$1 reassigned
[9] Goto bb1
bb3 (block):
predecessor blocks: bb0
[10] $5 = 2
[11] $6 = StoreLocal Reassign y$1 = $5 // Same y$1 reassigned
[12] Goto bb1
bb1 (block):
predecessor blocks: bb2 bb3
[13] $10 = LoadLocal y$1 // Which y$1?
[14] $12 = StoreLocal Let x$11 = $10
```
### After SSA:
```
bb0 (block):
[1] $15 = 2
[2] $17 = StoreLocal Let y$16 = $15 // y$16: initial definition
[3] $18 = LoadLocal y$16
[4] $19 = 1
[5] $20 = Binary $18 > $19
[6] If ($20) then:bb2 else:bb3 fallthrough=bb1
bb2 (block):
predecessor blocks: bb0
[7] $21 = 1
[8] $23 = StoreLocal Reassign y$22 = $21 // y$22: new SSA name
[9] Goto bb1
bb3 (block):
predecessor blocks: bb0
[10] $24 = 2
[11] $26 = StoreLocal Reassign y$25 = $24 // y$25: new SSA name
[12] Goto bb1
bb1 (block):
predecessor blocks: bb2 bb3
y$27: phi(bb2: y$22, bb3: y$25) // PHI NODE: merges y$22 and y$25
[13] $28 = LoadLocal y$27 // Uses phi result
[14] $30 = StoreLocal Let x$29 = $28
```
### Loop Example (while loop with back-edge):
```javascript
function foo() {
let x = 1;
while (x < 10) {
x = x + 1;
}
return x;
}
```
### After SSA:
```
bb0 (block):
[1] $13 = 1
[2] $15 = StoreLocal Let x$14 = $13 // x$14: initial definition
[3] While test=bb1 loop=bb3 fallthrough=bb2
bb1 (loop):
predecessor blocks: bb0 bb3
x$16: phi(bb0: x$14, bb3: x$23) // PHI merges initial and loop-updated values
[4] $17 = LoadLocal x$16
[5] $18 = 10
[6] $19 = Binary $17 < $18
[7] Branch ($19) then:bb3 else:bb2
bb3 (block):
predecessor blocks: bb1
[8] $20 = LoadLocal x$16 // Uses phi result
[9] $21 = 1
[10] $22 = Binary $20 + $21
[11] $24 = StoreLocal Reassign x$23 = $22 // x$23: new SSA name in loop body
[12] Goto(Continue) bb1
bb2 (block):
predecessor blocks: bb1
[13] $25 = LoadLocal x$16 // Uses phi result
[14] Return Explicit $25
```
The phi node at `bb1` (the loop header) is initially created as an "incomplete phi" when first visited because `bb3` (the loop body) hasn't been visited yet. Once `bb3` is processed and its terminal is handled, the incomplete phi is fixed by calling `fixIncompletePhis` to populate the operand from `bb3`.

View File

@@ -0,0 +1,90 @@
# eliminateRedundantPhi
## File
`src/SSA/EliminateRedundantPhi.ts`
## Purpose
Eliminates phi nodes whose operands are trivially the same, replacing all usages of the phi's output identifier with the single source identifier. This simplifies the HIR by removing unnecessary join points that do not actually merge distinct values.
## Input Invariants
- The function must be in SSA form (i.e., `enterSSA` has already run)
- Blocks are in reverse postorder (guaranteed by the HIR structure)
- Phi nodes exist at the start of blocks where control flow merges
## Output Guarantees
- All redundant phi nodes are removed from the HIR
- All references to eliminated phi identifiers are rewritten to the source identifier
- Non-redundant phi nodes (those merging two or more distinct values) are preserved
- Nested function expressions (FunctionExpression, ObjectMethod) also have their redundant phis eliminated and contexts rewritten
## Algorithm
A phi node is considered redundant when:
1. **All operands are the same identifier**: e.g., `x2 = phi(x1, x1, x1)` - the phi is replaced with `x1`
2. **All operands are either the same identifier OR the phi's output**: e.g., `x2 = phi(x1, x2, x1, x2)` - this handles loop back-edges where the phi references itself
The algorithm works as follows:
1. Visit blocks in reverse postorder, building a rewrite table (`Map<Identifier, Identifier>`)
2. For each phi node in a block:
- First rewrite operands using any existing rewrites (to handle cascading eliminations)
- Check if all operands (excluding self-references) point to the same identifier
- If so, add a mapping from the phi's output to that identifier and delete the phi
3. After processing phis, rewrite all instruction lvalues, operands, and terminal operands
4. For nested functions, recursively call `eliminateRedundantPhi` with shared rewrites
5. If the CFG has back-edges (loops) and new rewrites were added, repeat the entire process
The loop termination condition `rewrites.size > size && hasBackEdge` ensures:
- Without loops: completes in a single pass (reverse postorder guarantees forward propagation)
- With loops: repeats until no new rewrites are found (fixpoint)
## Key Data Structures
- **`Phi`** (from `src/HIR/HIR.ts`): Represents a phi node with:
- `place: Place` - the output identifier
- `operands: Map<BlockId, Place>` - maps predecessor block IDs to source places
- **`rewrites: Map<Identifier, Identifier>`**: Maps eliminated phi outputs to their replacement identifier
- **`visited: Set<BlockId>`**: Tracks visited blocks to detect back-edges (loops)
## Edge Cases
- **Loop back-edges**: When a block has a predecessor that hasn't been visited yet (in reverse postorder), that predecessor is a back-edge. The algorithm handles self-referential phis like `x2 = phi(x1, x2)` by ignoring operands equal to the phi's output.
- **Cascading eliminations**: When one phi's output is used in another phi's operands, the algorithm rewrites operands before checking redundancy, enabling transitive elimination in a single pass (for non-loop cases).
- **Nested functions**: FunctionExpression and ObjectMethod values contain nested HIR that may have their own phis. The algorithm recursively processes these with a shared rewrite table, ensuring context captures are also rewritten.
- **Empty phi check**: The algorithm includes an invariant check that phi operands are never empty (which would be invalid HIR).
## TODOs
(None found in the source code)
## Example
Consider this fixture from `rewrite-phis-in-lambda-capture-context.js`:
```javascript
function Component() {
const x = 4;
const get4 = () => {
while (bar()) {
if (baz) { bar(); }
}
return () => x;
};
return get4;
}
```
**After SSA pass**, the inner function has redundant phis due to the loop:
```
bb2 (loop):
predecessor blocks: bb1 bb5
x$29: phi(bb1: x$21, bb5: x$30) // Loop header phi
...
bb5 (block):
predecessor blocks: bb6 bb4
x$30: phi(bb6: x$29, bb4: x$29) // Redundant: both operands are x$29
...
```
**After EliminateRedundantPhi**:
- `x$30 = phi(x$29, x$29)` is eliminated because both operands are `x$29`
- `x$29 = phi(x$21, x$30)` becomes `x$29 = phi(x$21, x$29)` after rewriting, which is also redundant (one operand is the phi itself, the other is `x$21`)
- Both phis are eliminated, and all uses of `x$29` and `x$30` are rewritten to `x$21`
The result: the context capture `@context[x$29]` becomes `@context[x$21]`, correctly propagating that `x` is never modified inside the loop.

View File

@@ -0,0 +1,110 @@
# constantPropagation
## File
`src/Optimization/ConstantPropagation.ts`
## Purpose
Applies Sparse Conditional Constant Propagation (SCCP) to fold compile-time evaluable expressions to constant values, propagate those constants through the program, and eliminate unreachable branches when conditionals have known constant values.
## Input Invariants
- HIR must be in SSA form (runs after `enterSSA`)
- Redundant phi nodes should be eliminated (runs after `eliminateRedundantPhi`)
- Consistent identifiers must be ensured (`assertConsistentIdentifiers`)
- Terminal successors must exist (`assertTerminalSuccessorsExist`)
## Output Guarantees
- Instructions with compile-time evaluable operands are replaced with `Primitive` constants
- `ComputedLoad`/`ComputedStore` with constant string/number properties are converted to `PropertyLoad`/`PropertyStore`
- `LoadLocal` and `StoreLocal` propagate known constant values
- `IfTerminal` with constant boolean test values are replaced with `goto` terminals
- Unreachable blocks are removed and the CFG is minimized
- Phi nodes with unreachable predecessor operands are pruned
- Nested functions (`FunctionExpression`, `ObjectMethod`) are recursively processed
## Algorithm
The pass uses Sparse Conditional Constant Propagation (SCCP) with fixpoint iteration:
1. **Data Structure**: A `Constants` map (`Map<IdentifierId, Constant>`) tracks known constant values (either `Primitive` or `LoadGlobal`)
2. **Single Pass per Iteration**: Visits all blocks in order:
- Evaluates phi nodes - if all operands have the same constant value, the phi result is constant
- Evaluates instructions - replaces evaluable expressions with constants
- Evaluates terminals - if an `IfTerminal` test is a constant, replaces it with a `goto`
3. **Fixpoint Loop**: If any terminals changed (branch elimination):
- Recomputes block ordering (`reversePostorderBlocks`)
- Removes unreachable code (`removeUnreachableForUpdates`, `removeDeadDoWhileStatements`, `removeUnnecessaryTryCatch`)
- Renumbers instructions (`markInstructionIds`)
- Updates predecessors (`markPredecessors`)
- Prunes phi operands from unreachable predecessors
- Eliminates newly-redundant phis (`eliminateRedundantPhi`)
- Merges consecutive blocks (`mergeConsecutiveBlocks`)
- Repeats until no more changes
4. **Instruction Evaluation**: Handles various instruction types:
- **Primitives/LoadGlobal**: Directly constant
- **BinaryExpression**: Folds arithmetic (`+`, `-`, `*`, `/`, `%`, `**`), bitwise (`|`, `&`, `^`, `<<`, `>>`, `>>>`), and comparison (`<`, `<=`, `>`, `>=`, `==`, `===`, `!=`, `!==`) operators
- **UnaryExpression**: Folds `!` (boolean negation) and `-` (numeric negation)
- **PostfixUpdate/PrefixUpdate**: Folds `++`/`--` on constant numbers
- **PropertyLoad**: Folds `.length` on constant strings
- **TemplateLiteral**: Folds template strings with constant interpolations
- **ComputedLoad/ComputedStore**: Converts to property access when property is constant string/number
## Key Data Structures
- `Constant = Primitive | LoadGlobal` - The lattice values (no top/bottom, absence means unknown)
- `Constants = Map<IdentifierId, Constant>` - Maps identifier IDs to their known constant values
- Uses HIR types: `Instruction`, `Phi`, `Place`, `Primitive`, `LoadGlobal`, `InstructionValue`
## Edge Cases
- **Last instruction of sequence blocks**: Skipped to preserve evaluation order
- **Phi nodes with back-edges**: Single-pass analysis means loop back-edges won't have constant values propagated
- **Template literals with Symbol**: Not folded (would throw at runtime)
- **Template literals with objects/arrays**: Not folded (custom toString behavior)
- **Division results**: Computed at compile time (may produce `NaN`, `Infinity`, etc.)
- **LoadGlobal in phis**: Only propagated if all operands reference the same global name
- **Nested functions**: Constants from outer scope are propagated into nested function expressions
## TODOs
- `// TODO: handle more cases` - The default case in `evaluateInstruction` has room for additional instruction types
## Example
**Input:**
```javascript
function Component() {
let a = 1;
let b;
if (a === 1) {
b = true;
} else {
b = false;
}
let c;
if (b) {
c = 'hello';
} else {
c = null;
}
return c;
}
```
**After ConstantPropagation:**
- `a === 1` evaluates to `true`
- The `if (a === 1)` branch is eliminated, only consequent remains
- `b` is known to be `true`
- `if (b)` branch is eliminated, only consequent remains
- `c` is known to be `'hello'`
- All intermediate blocks are merged
**Output:**
```javascript
function Component() {
return "hello";
}
```
The pass performs iterative simplification: first iteration determines `a === 1` is `true` and eliminates that branch. The CFG is updated, phi for `b` is pruned to single operand making `b = true`. Second iteration uses `b = true` to eliminate the next branch. This continues until no more branches can be eliminated.

View File

@@ -0,0 +1,109 @@
# deadCodeElimination
## File
`src/Optimization/DeadCodeElimination.ts`
## Purpose
Eliminates instructions whose values are unused, reducing generated code size. The pass performs mark-and-sweep analysis to identify and remove dead code while preserving side effects and program semantics.
## Input Invariants
- Must run after `InferMutationAliasingEffects` because "dead" code may still affect effect inference
- HIR is in SSA form with phi nodes
- Unreachable blocks are already pruned during HIR construction
## Output Guarantees
- All instructions with unused lvalues (that are safe to prune) are removed
- Unused phi nodes are deleted
- Unused context variables are removed from `fn.context`
- Destructuring patterns are rewritten to remove unused bindings
- `StoreLocal` instructions with unused initializers are converted to `DeclareLocal`
## Algorithm
Two-phase mark-and-sweep with fixed-point iteration for loops:
**Phase 1: Mark (findReferencedIdentifiers)**
1. Detect if function has back-edges (loops)
2. Iterate blocks in reverse postorder (successors before predecessors) to visit usages before declarations
3. For each block:
- Mark all terminal operands as referenced
- Process instructions in reverse order:
- If lvalue is used OR instruction is not pruneable, mark the lvalue and all operands as referenced
- Special case for `StoreLocal`: only mark initializer if the SSA lvalue is actually read
- Mark phi operands if the phi result is used
4. If loops exist and new identifiers were marked, repeat until fixed point
**Phase 2: Sweep**
1. Remove unused phi nodes from each block
2. Remove instructions with unused lvalues using `retainWhere`
3. Rewrite retained instructions:
- **Array destructuring**: Replace unused elements with holes, truncate trailing holes
- **Object destructuring**: Remove unused properties (only if rest element is unused or absent)
- **StoreLocal**: Convert to `DeclareLocal` if initializer value is never read
4. Remove unused context variables
## Key Data Structures
- **State class**: Tracks referenced identifiers
- `identifiers: Set<IdentifierId>` - SSA-specific usages
- `named: Set<string>` - Named variable usages (any version)
- `isIdOrNameUsed()` - Checks if identifier or any version of named variable is used
- `isIdUsed()` - Checks if specific SSA id is used
- **hasBackEdge/findBlocksWithBackEdges**: Detect loops requiring fixed-point iteration
## Edge Cases
- **Preserved even if unused:**
- `debugger` statements (to not break debugging workflows)
- Call expressions and method calls (may have side effects)
- Await expressions
- Store operations (ComputedStore, PropertyStore, StoreGlobal)
- Delete operations (ComputedDelete, PropertyDelete)
- Iterator operations (GetIterator, IteratorNext, NextPropertyOf)
- Context operations (LoadContext, DeclareContext, StoreContext)
- Memoization markers (StartMemoize, FinishMemoize)
- **SSR mode special case:**
- In SSR mode, unused `useState`, `useReducer`, and `useRef` hooks can be removed
- **Object destructuring with rest:**
- Cannot remove unused properties if rest element is used (would change rest's value)
- **Block value instructions:**
- Last instruction of value blocks (not 'block' kind) is never pruned as it's the block's value
## TODOs
- "TODO: we could be more precise and make this conditional on whether any arguments are actually modified" (for mutating instructions)
## Example
**Input:**
```javascript
function Component(props) {
const _ = 42;
return props.value;
}
```
**After DeadCodeElimination:**
The `const _ = 42` assignment is removed since `_` is never used:
```javascript
function Component(props) {
return props.value;
}
```
**Array destructuring example:**
Input:
```javascript
function foo(props) {
const [x, unused, y] = props.a;
return x + y;
}
```
Output (middle element becomes a hole):
```javascript
function foo(props) {
const [x, , y] = props.a;
return x + y;
}
```

View File

@@ -0,0 +1,124 @@
# inferTypes
## File
`src/TypeInference/InferTypes.ts`
## Purpose
Infers types for all identifiers in the HIR by generating type equations and solving them using unification. This pass annotates identifiers with concrete types (Primitive, Object, Function) based on the operations performed on them and the types of globals/hooks they interact with.
## Input Invariants
- The HIR must be in SSA form (the pass runs after `enterSSA` and `eliminateRedundantPhi`)
- Constant propagation has already run
- Global declarations and hook shapes are available via the Environment
## Output Guarantees
- All identifier types are resolved from type variables (`Type`) to concrete types where possible
- Phi nodes have their operand types unified to produce a single result type
- Function return types are inferred from the unified types of all return statements
- Property accesses on known objects/hooks resolve to the declared property types
- Component props parameters are typed as `TObject<BuiltInProps>`
- Component ref parameters are typed as `TObject<BuiltInUseRefId>`
## Algorithm
The pass uses a classic constraint-based type inference approach with three phases:
1. **Constraint Generation (`generate`)**: Traverses all instructions and generates type equations:
- Primitives, literals, unary/binary operations -> `Primitive` type
- Hook/function calls -> Function type with fresh return type variable
- Property loads -> `Property` type that defers to object shape lookup
- Destructuring -> Property types for each extracted element
- Phi nodes -> `Phi` type with all operand types as candidates
- JSX -> `Object<BuiltInJsx>`
- Arrays -> `Object<BuiltInArray>`
- Objects -> `Object<BuiltInObject>`
2. **Unification (`Unifier.unify`)**: Solves constraints by unifying type equations:
- Type variables are bound to concrete types via substitution
- Property types are resolved by looking up the object's shape
- Phi types are resolved by finding a common type among operands (or falling back to `Phi` if incompatible)
- Function types are unified by unifying their return types
- Occurs check prevents infinite types (cycles in type references)
3. **Application (`apply`)**: Applies the computed substitutions to all identifiers in the HIR, replacing type variables with their resolved types.
## Key Data Structures
- **TypeVar** (`kind: 'Type'`): A type variable with a unique TypeId, used for unknowns
- **Unifier**: Maintains a substitution map from TypeId to Type, with methods for unification and cycle detection
- **TypeEquation**: A pair of types that should be equal, used as constraints
- **PhiType** (`kind: 'Phi'`): Represents the join of multiple types from control flow merge points
- **PropType** (`kind: 'Property'`): Deferred property lookup that resolves based on object shape
- **FunctionType** (`kind: 'Function'`): Callable type with optional shapeId and return type
- **ObjectType** (`kind: 'Object'`): Object with optional shapeId for shape lookup
## Edge Cases
### Phi Type Resolution
When phi operands have incompatible types, the pass attempts to find a union:
- `Union(Primitive | MixedReadonly) = MixedReadonly`
- `Union(Array | MixedReadonly) = Array`
- If no union is possible, the type remains as `Phi`
### Ref-like Name Inference
When `enableTreatRefLikeIdentifiersAsRefs` is enabled, property access on variables matching the pattern `/^(?:[a-zA-Z$_][a-zA-Z$_0-9]*)Ref$|^ref$/` with property name `current` infers:
- Object type as `TObject<BuiltInUseRefId>`
- Property type as `TObject<BuiltInRefValue>`
### Cycle Detection
The `occursCheck` method prevents infinite types by detecting when a type variable appears in its own substitution. When a cycle is detected, `tryResolveType` removes the cyclic reference from Phi operands.
### Context Variables
- `DeclareContext` and `LoadContext` generate no type equations (intentionally untyped)
- `StoreContext` with `Const` kind does propagate the rvalue type to enable ref inference through context variables
## TODOs
1. **Hook vs Function type ambiguity**:
> "TODO: callee could be a hook or a function, so this type equation isn't correct. We should change Hook to a subtype of Function or change unifier logic."
2. **PropertyStore rvalue inference**:
> "TODO: consider using the rvalue type here" - Currently uses a dummy type for PropertyStore to avoid inferring rvalue types from lvalue assignments.
## Example
**Input (infer-phi-primitive.js):**
```javascript
function foo(a, b) {
let x;
if (a) {
x = 1;
} else {
x = 2;
}
let y = x;
return y;
}
```
**Before InferTypes (SSA form):**
```
<unknown> x$26: phi(bb2: <unknown> x$21, bb3: <unknown> x$24)
[10] <unknown> $27 = LoadLocal <unknown> x$26
[11] <unknown> $29 = StoreLocal Let <unknown> y$28 = <unknown> $27
```
**After InferTypes:**
```
<unknown> x$26:TPrimitive: phi(bb2: <unknown> x$21:TPrimitive, bb3: <unknown> x$24:TPrimitive)
[10] <unknown> $27:TPrimitive = LoadLocal <unknown> x$26:TPrimitive
[11] <unknown> $29:TPrimitive = StoreLocal Let <unknown> y$28:TPrimitive = <unknown> $27:TPrimitive
```
The pass infers that:
- Literals `1` and `2` are `TPrimitive`
- The phi of two primitives is `TPrimitive`
- Variables `x` and `y` are `TPrimitive`
- The function return type is `TPrimitive`
**Hook type inference example (useState):**
```javascript
const [x, setX] = useState(initialValue);
```
After InferTypes:
- `useState` -> `TFunction<BuiltInUseState>:TObject<BuiltInUseState>`
- Return value `$27` -> `TObject<BuiltInUseState>`
- Destructured `setX` -> `TFunction<BuiltInSetState>:TPrimitive`

View File

@@ -0,0 +1,84 @@
# analyseFunctions
## File
`src/Inference/AnalyseFunctions.ts`
## Purpose
Recursively analyzes all nested function expressions and object methods in a function to infer their aliasing effect signatures, which describe how the function affects its captured variables when invoked.
## Input Invariants
- The HIR has been through SSA conversion and type inference
- FunctionExpression and ObjectMethod instructions have an empty `aliasingEffects` array (`@aliasingEffects=[]`)
- Context variables (captured variables from outer scope) exist on `fn.context` but do not have their effect populated
## Output Guarantees
- Every FunctionExpression and ObjectMethod has its `aliasingEffects` array populated with the effects the function performs when called (mutations, captures, aliasing to return value, etc.)
- Each context variable's `effect` property is set to either `Effect.Capture` (if the variable is captured or mutated by the inner function) or `Effect.Read` (if only read)
- Context variable mutable ranges are reset to `{start: 0, end: 0}` and scopes are set to `null` to prepare for the outer function's subsequent `inferMutationAliasingRanges` pass
## Algorithm
1. **Recursive traversal**: Iterates through all blocks and instructions looking for `FunctionExpression` or `ObjectMethod` instructions
2. **Depth-first processing**: For each function expression found, calls `lowerWithMutationAliasing()` which:
- Recursively calls `analyseFunctions()` on the inner function (handles nested functions)
- Runs `inferMutationAliasingEffects()` on the inner function to determine effects
- Runs `deadCodeElimination()` to clean up
- Runs `inferMutationAliasingRanges()` to compute mutable ranges and extract externally-visible effects
- Runs `rewriteInstructionKindsBasedOnReassignment()` and `inferReactiveScopeVariables()`
- Stores the computed effects in `fn.aliasingEffects`
3. **Context variable effect classification**: Scans the computed effects to determine which context variables are captured/mutated vs only read:
- Effects like `Capture`, `Alias`, `Assign`, `MaybeAlias`, `CreateFrom` mark the source as captured
- Mutation effects (`Mutate`, `MutateTransitive`, etc.) mark the target as captured
- Sets `operand.effect = Effect.Capture` or `Effect.Read` accordingly
4. **Range reset**: Resets mutable ranges and scopes on context variables to prepare for outer function analysis
## Key Data Structures
- **HIRFunction.aliasingEffects**: Array of `AliasingEffect` storing the externally-visible behavior of a function when called
- **Place.effect**: Effect enum value (`Capture` or `Read`) describing how a context variable is used
- **AliasingEffect**: Union type describing data flow (Capture, Alias, Assign, etc.) and mutations (Mutate, MutateTransitive, etc.)
- **FunctionExpression/ObjectMethod.loweredFunc.func**: The inner HIRFunction to analyze
## Edge Cases
- **Nested functions**: Handled via recursive call to `analyseFunctions()` before processing the current function - innermost functions are analyzed first
- **ObjectMethod**: Treated identically to FunctionExpression
- **Apply effects invariant**: The pass asserts that no `Apply` effects remain in the function's signature - these should have been resolved to more precise effects by `inferMutationAliasingRanges()`
- **Conditional mutations**: Effects like `MutateTransitiveConditionally` are tracked - a function that conditionally mutates a captured variable will have that effect in its signature
- **Immutable captures**: `ImmutableCapture`, `Freeze`, `Create`, `Impure`, `Render` effects do not contribute to marking context variables as `Capture`
## TODOs
- No TODO comments in the pass itself
## Example
Consider a function that captures and conditionally mutates a variable:
```javascript
function useHook(a, b) {
let z = {a};
let y = b;
let x = function () {
if (y) {
maybeMutate(z); // Unknown function, may mutate z
}
};
return x;
}
```
**Before AnalyseFunctions:**
```
Function @context[y$28, z$25] @aliasingEffects=[]
```
**After AnalyseFunctions:**
```
Function @context[read y$28, capture z$25] @aliasingEffects=[
MutateTransitiveConditionally z$25,
Create $14 = primitive
]
```
The pass infers:
- `y` is only read (used in the condition)
- `z` is captured into the function and conditionally mutated transitively (because `maybeMutate()` is unknown)
- The inner function's signature includes `MutateTransitiveConditionally z$25` to indicate this potential mutation
This signature is then used by `InferMutationAliasingEffects` on the outer function to understand that creating this function captures `z`, and calling the function may mutate `z`.

View File

@@ -0,0 +1,144 @@
# inferMutationAliasingEffects
## File
`src/Inference/InferMutationAliasingEffects.ts`
## Purpose
Infers the mutation and aliasing effects for all instructions and terminals in the HIR, making the effects of built-in instructions/functions as well as user-defined functions explicit. These effects form the basis for subsequent analysis to determine the mutable range of each value in the program and for validation against invalid code patterns like mutating frozen values.
## Input Invariants
- HIR must be in SSA form (run after SSA pass)
- Types must be inferred (run after InferTypes pass)
- Functions must be analyzed (run after AnalyseFunctions pass) - this provides `aliasingEffects` on FunctionExpressions
- Each instruction must have an lvalue (destination place)
## Output Guarantees
- Every instruction has an `effects` array (or null if no effects) containing `AliasingEffect` objects
- Terminals that affect data flow (return, try/catch) have their `effects` populated
- Each instruction's lvalue is guaranteed to be defined in the inference state after visiting
- Effects describe: creation of values, data flow (Assign, Alias, Capture), mutations (Mutate, MutateTransitive), freezing, and errors (MutateFrozen, MutateGlobal, Impure)
## Algorithm
The pass uses abstract interpretation with the following key phases:
1. **Initialization**:
- Create initial `InferenceState` mapping identifiers to abstract values
- Initialize context variables as `ValueKind.Context`
- Initialize parameters as `ValueKind.Frozen` (for top-level components/hooks) or `ValueKind.Mutable` (for function expressions)
2. **Two-Phase Effect Processing**:
- **Phase 1 - Signature Computation**: For each instruction, compute a "candidate signature" based purely on instruction semantics and types (cached per instruction via `computeSignatureForInstruction`)
- **Phase 2 - Effect Application**: Apply the signature to the current abstract state via `applySignature`, which refines effects based on the actual runtime kinds of values
3. **Fixed-Point Iteration**:
- Process blocks in a worklist, queuing successors after each block
- Merge states at control flow join points using lattice operations
- Iterate until no changes occur (max 100 iterations as safety limit)
- Phi nodes are handled by unioning the abstract values from all predecessors
4. **Effect Refinement** (in `applyEffect`):
- `MutateConditionally` effects are dropped if value is not mutable
- `Capture` effects are downgraded to `ImmutableCapture` if source is frozen
- `Mutate` on frozen values becomes `MutateFrozen` error
- `Assign` from primitives/globals creates new values rather than aliasing
## Key Data Structures
### InferenceState
Maintains two maps:
- `#values: Map<InstructionValue, AbstractValue>` - Maps allocation sites to their abstract kind
- `#variables: Map<IdentifierId, Set<InstructionValue>>` - Maps identifiers to the set of values they may point to (set to handle phi joins)
### AbstractValue
```typescript
type AbstractValue = {
kind: ValueKind;
reason: ReadonlySet<ValueReason>;
};
```
### ValueKind (lattice)
```
MaybeFrozen <- top (unknown if frozen or mutable)
|
Frozen <- immutable, cannot be mutated
Mutable <- can be mutated locally
Context <- mutable box (context variables)
|
Global <- global value
Primitive <- copy-on-write semantics
```
The `mergeValueKinds` function implements the lattice join:
- `Frozen | Mutable -> MaybeFrozen`
- `Context | Mutable -> Context`
- `Context | Frozen -> MaybeFrozen`
### AliasingEffect Types
Key effect kinds handled:
- **Create**: Creates a new value at a place
- **Assign**: Direct assignment (pointer copy)
- **Alias**: Mutation of destination implies mutation of source
- **Capture**: Information flow (MutateTransitive propagates through)
- **MaybeAlias**: Possible aliasing for unknown function returns
- **Mutate/MutateTransitive**: Direct/transitive mutation
- **MutateConditionally/MutateTransitiveConditionally**: Conditional versions
- **Freeze**: Marks value as immutable
- **Apply**: Function call with complex data flow
## Edge Cases
1. **Spread Destructuring from Props**: The `findNonMutatedDestructureSpreads` pre-pass identifies spread patterns from frozen values that are never mutated, allowing them to be treated as frozen.
2. **Hoisted Context Declarations**: Special handling for variables declared with hoisting (`HoistedConst`, `HoistedFunction`, `HoistedLet`) to detect access before declaration.
3. **Try-Catch Aliasing**: When a `maybe-throw` terminal is reached, call return values are aliased into the catch binding since exceptions can throw return values.
4. **Function Expressions**: Functions are considered mutable only if they have mutable captures or tracked side effects (MutateFrozen, MutateGlobal, Impure).
5. **Iterator Mutation**: Non-builtin iterators may alias their collection and mutation of the iterator is conditional.
6. **Array.push and Similar**: Uses legacy signature system with `Store` effect on receiver and `Capture` of arguments.
## TODOs
- `// TODO: using InstructionValue as a bit of a hack, but it's pragmatic` - context variable initialization
- `// TODO: call applyEffect() instead` - try-catch aliasing
- `// TODO: make sure we're also validating against global mutations somewhere` - global mutation validation for effects/event handlers
- `// TODO; include "render" here?` - whether to track Render effects in function hasTrackedSideEffects
- `// TODO: consider using persistent data structures to make clone cheaper` - performance optimization for state cloning
- `// TODO check this` and `// TODO: what kind here???` - DeclareLocal value kinds
## Example
For the code:
```javascript
const arr = [];
arr.push({});
arr.push(x, y);
```
After `InferMutationAliasingEffects`, the effects are:
```
[10] $39 = Array []
Create $39 = mutable // Array literal creates mutable value
[11] $41 = StoreLocal arr$40 = $39
Assign arr$40 = $39 // arr points to the array value
Assign $41 = $39
[15] $45 = MethodCall $42.push($44)
Apply $45 = $42.$43($44) // Records the call
Mutate $42 // push mutates the array
Capture $42 <- $44 // {} is captured into array
Create $45 = primitive // push returns number (length)
[20] $50 = MethodCall $46.push($48, $49)
Apply $50 = $46.$47($48, $49)
Mutate $46 // push mutates the array
Capture $46 <- $48 // x captured into array
Capture $46 <- $49 // y captured into array
Create $50 = primitive
```
The key insight is that `Mutate` effects extend the mutable range of the array, and `Capture` effects record data flow so that if the array is later frozen (e.g., returned from a component), the captured values are also considered frozen for validation purposes.

View File

@@ -0,0 +1,149 @@
# inferMutationAliasingRanges
## File
`src/Inference/InferMutationAliasingRanges.ts`
## Purpose
This pass builds an abstract model of the heap and interprets the effects of the given function to determine: (1) the mutable ranges of all identifiers, (2) the externally-visible effects of the function (mutations of params/context-vars, aliasing relationships), and (3) the legacy `Effect` annotation for each Place.
## Input Invariants
- InferMutationAliasingEffects must have already run, populating `instr.effects` on each instruction with aliasing/mutation effects
- SSA form must be established (identifiers are in SSA)
- Type inference has been run (InferTypes)
- Functions have been analyzed (AnalyseFunctions)
- Dead code elimination has been performed
## Output Guarantees
- Every identifier has a populated `mutableRange` (start:end instruction IDs)
- Every Place has a legacy `Effect` annotation (Read, Capture, Store, Freeze, etc.)
- The function's `aliasingEffects` array is populated with externally-visible effects (mutations of params/context-vars, aliasing between params/context-vars/return)
- Validation errors are collected for invalid effects like `MutateFrozen` or `MutateGlobal`
## Algorithm
The pass operates in three main phases:
**Part 1: Build Data Flow Graph and Infer Mutable Ranges**
1. Creates an `AliasingState` which maintains a `Node` for each identifier
2. Iterates through all blocks and instructions, processing effects in program order
3. For each effect:
- `Create`/`CreateFunction`: Creates a new node in the graph
- `CreateFrom`/`Assign`/`Alias`: Adds alias edges between nodes (with ordering index)
- `MaybeAlias`: Adds conditional alias edges
- `Capture`: Adds capture edges (for transitive mutations)
- `Mutate*`: Queues mutations for later processing
- `Render`: Queues render effects for later processing
4. Phi node operands are connected once their predecessor blocks have been visited
5. After the graph is built, mutations are processed:
- Mutations propagate both forward (via edges) and backward (via aliases/captures)
- Each mutation extends the `mutableRange.end` of affected identifiers
- Transitive mutations also traverse capture edges backward
- `MaybeAlias` edges downgrade mutations to `Conditional`
6. Render effects are processed to mark values as rendered
**Part 2: Populate Legacy Per-Place Effects**
- Sets legacy effects on lvalues and operands based on instruction effects and mutable ranges
- Fixes up mutable range start values for identifiers that are mutated after creation
**Part 3: Infer Externally-Visible Function Effects**
- Creates a `Create` effect for the return value
- Simulates transitive mutations of each param/context-var/return to detect capture relationships
- Produces `Alias`/`Capture` effects showing data flow between params/context-vars/return
## Key Data Structures
### `AliasingState`
The main state class maintaining the data flow graph:
- `nodes: Map<Identifier, Node>` - Maps identifiers to their graph nodes
### `Node`
Represents an identifier in the data flow graph:
```typescript
type Node = {
id: Identifier;
createdFrom: Map<Identifier, number>; // CreateFrom edges (source -> index)
captures: Map<Identifier, number>; // Capture edges (source -> index)
aliases: Map<Identifier, number>; // Alias/Assign edges (source -> index)
maybeAliases: Map<Identifier, number>; // MaybeAlias edges (source -> index)
edges: Array<{index, node, kind}>; // Forward edges to other nodes
transitive: {kind: MutationKind; loc} | null; // Transitive mutation info
local: {kind: MutationKind; loc} | null; // Local mutation info
lastMutated: number; // Index of last mutation affecting this node
mutationReason: MutationReason | null; // Reason for mutation
value: {kind: 'Object'} | {kind: 'Phi'} | {kind: 'Function'; function: HIRFunction};
render: Place | null; // Render context if used in JSX
};
```
### `MutationKind`
Enum describing mutation certainty:
```typescript
enum MutationKind {
None = 0,
Conditional = 1, // May mutate (e.g., via MaybeAlias or MutateConditionally)
Definite = 2, // Definitely mutates
}
```
## Edge Cases
### Phi Nodes
- Phi nodes are created as special `{kind: 'Phi'}` nodes
- Phi operands from predecessor blocks are processed with pending edges until the predecessor is visited
- When traversing "forwards" through edges and encountering a phi, backward traversal is stopped (prevents mutation from one phi input affecting other inputs)
### Transitive vs Local Mutations
- Local mutations (`Mutate`) only affect alias/assign edges backward
- Transitive mutations (`MutateTransitive`) also affect capture edges backward
- Both affect all forward edges
### MaybeAlias
- Mutations through MaybeAlias edges are downgraded to `Conditional`
- This prevents false positive errors when we cannot be certain about aliasing
### Function Values
- Functions are tracked specially as `{kind: 'Function'}` nodes
- When a function is mutated (transitively), errors from the function body are propagated
- This handles cases where mutating a captured value in a function affects render safety
### Render Effect Propagation
- Render effects traverse backward through alias/capture/createFrom edges
- Functions that have not been mutated are skipped during render traversal (except for JSX-returning functions)
- Ref types (`isUseRefType`) stop render traversal
## TODOs
1. Assign effects should have an invariant that the node is not initialized yet. Currently `InferFunctionExpressionAliasingEffectSignatures` infers Assign effects that should be Alias, causing reinitialization.
2. Phi place effects are not properly set today.
3. Phi mutable range start calculation is imprecise - currently just sets it to the instruction before the block rather than computing the exact start.
## Example
Consider the following code:
```javascript
function foo() {
let a = {}; // Create a (instruction 1)
let b = {}; // Create b (instruction 3)
a = b; // Assign a <- b (instruction 8)
mutate(a, b); // MutateTransitiveConditionally a, b (instruction 16)
return a;
}
```
The pass builds a graph:
1. Creates node for `{}` at instruction 1 (initially assigned to `a`)
2. Creates node for `{}` at instruction 3 (initially assigned to `b`)
3. At instruction 8, creates alias edge: `b -> a` with index 8
4. At instruction 16, mutations are queued for `a` and `b`
When processing the mutation of `a` at instruction 16:
- Extends `a`'s mutableRange.end to 17
- Traverses backward through alias edge to `b`, extends `b`'s mutableRange.end to 17
- Since `a = b`, both objects must be considered mutable until instruction 17
The output shows identifiers with range annotations like `$25[3:17]` meaning:
- `$25` is the identifier
- `3` is the instruction where it was created
- `17` is the instruction after which it is no longer mutated
For aliased values, the ranges are unified - all values that could be affected by a mutation have their ranges extended to include that mutation point.

View File

@@ -0,0 +1,169 @@
# inferReactivePlaces
## File
`src/Inference/InferReactivePlaces.ts`
## Purpose
Determines which `Place`s (identifiers and temporaries) in the HIR are **reactive** - meaning they may *semantically* change over the course of the component or hook's lifetime. This information is critical for memoization: reactive places form the dependencies that, when changed, should invalidate cached values.
A place is reactive if it derives from any source of reactivity:
1. **Props** - Component parameters may change between renders
2. **Hooks** - Hooks can access state or context which can change
3. **`use` operator** - Can access context which may change
4. **Mutation with reactive operands** - Values mutated in instructions that have reactive operands become reactive themselves
5. **Conditional assignment based on reactive control flow** - Values assigned in branches controlled by reactive conditions become reactive
## Input Invariants
- HIR is in SSA form with phi nodes at join points
- `inferMutationAliasingEffects` and `inferMutationAliasingRanges` have run, establishing:
- Effect annotations on operands (Effect.Capture, Effect.Store, Effect.Mutate, etc.)
- Mutable ranges on identifiers
- Aliasing relationships captured by `findDisjointMutableValues`
- All operands have known effects (asserts on `Effect.Unknown`)
## Output Guarantees
- Every reactive Place has `place.reactive = true`
- Reactivity is transitively complete (derived from reactive → reactive)
- All identifiers in a mutable alias group share reactivity
- Reactivity is propagated to operands used within nested function expressions
## Algorithm
The algorithm uses **fixpoint iteration** to propagate reactivity forward through the control-flow graph:
### Initialization
1. Create a `ReactivityMap` backed by disjoint sets of mutably-aliased identifiers
2. Mark all function parameters as reactive (props are reactive by definition)
3. Create a `ControlDominators` helper to identify blocks controlled by reactive conditions
### Fixpoint Loop
Iterate until no changes occur:
For each block:
1. **Phi Nodes**: Mark phi nodes reactive if:
- Any operand is reactive, OR
- Any predecessor block is controlled by a reactive condition (control-flow dependency)
2. **Instructions**: For each instruction:
- Track stable identifier sources (for hooks like `useRef`, `useState` dispatch)
- Check if any operand is reactive
- Hook calls and `use` operator are sources of reactivity
- If instruction has reactive input:
- Mark lvalues reactive (unless they are known-stable like `setState` functions)
- If instruction has reactive input OR is in reactive-controlled block:
- Mark mutable operands (Capture, Store, Mutate effects) as reactive
3. **Terminals**: Check terminal operands for reactivity
### Post-processing
Propagate reactivity to inner functions (nested `FunctionExpression` and `ObjectMethod`).
## Key Data Structures
### ReactivityMap
```typescript
class ReactivityMap {
hasChanges: boolean = false; // Tracks if fixpoint changed
reactive: Set<IdentifierId> = new Set(); // Set of reactive identifiers
aliasedIdentifiers: DisjointSet<Identifier>; // Mutable alias groups
}
```
- Uses disjoint sets so that when one identifier in an alias group becomes reactive, they all are effectively reactive
- `isReactive(place)` checks and marks `place.reactive = true` as a side effect
- `snapshot()` resets change tracking and returns whether changes occurred
### StableSidemap
```typescript
class StableSidemap {
map: Map<IdentifierId, {isStable: boolean}> = new Map();
}
```
Tracks sources of stability (e.g., `useState()[1]` dispatch function). Forward data-flow analysis that:
- Records hook calls that return stable types
- Propagates stability through PropertyLoad and Destructure from stable containers
- Propagates through LoadLocal and StoreLocal
### ControlDominators
Uses post-dominator frontier analysis to determine which blocks are controlled by reactive branch conditions.
## Edge Cases
### Backward Reactivity Propagation via Mutable Aliasing
```javascript
const x = [];
const z = [x];
x.push(props.input);
return <div>{z}</div>;
```
Here `z` aliases `x` which is later mutated with reactive data. The disjoint set ensures `z` becomes reactive even though the mutation happens after its creation.
### Stable Types Are Not Reactive
```javascript
const [state, setState] = useState();
// setState is stable - not marked reactive despite coming from reactive hook
```
The `StableSidemap` tracks these and skips marking them reactive.
### Ternary with Stable Values Still Reactive
```javascript
props.cond ? setState1 : setState2
```
Even though both branches are stable types, the result depends on reactive control flow, so it cannot be marked non-reactive just based on type.
### Phi Nodes with Reactive Predecessors
When a phi's predecessor block is controlled by a reactive condition, the phi becomes reactive even if its operands are all non-reactive constants.
## TODOs
No explicit TODO comments are present in the source file. However, comments note:
- **ComputedLoads not handled for stability**: Only PropertyLoad propagates stability from containers, not ComputedLoad. The comment notes this is safe because stable containers have differently-typed elements, but ComputedLoad handling could be added.
## Example
### Fixture: `reactive-dependency-fixpoint.js`
**Input:**
```javascript
function Component(props) {
let x = 0;
let y = 0;
while (x === 0) {
x = y;
y = props.value;
}
return [x];
}
```
**Before InferReactivePlaces:**
```
bb1 (loop):
store x$26:TPhi:TPhi: phi(bb0: read x$21:TPrimitive, bb3: read x$32:TPhi)
store y$30:TPhi:TPhi: phi(bb0: read y$24:TPrimitive, bb3: read y$37)
...
bb3 (block):
[12] mutate? $35 = LoadLocal read props$19
[13] mutate? $36 = PropertyLoad read $35.value
[14] mutate? $38 = StoreLocal Reassign mutate? y$37 = read $36
```
**After InferReactivePlaces:**
```
bb1 (loop):
store x$26:TPhi{reactive}:TPhi: phi(bb0: read x$21:TPrimitive, bb3: read x$32:TPhi{reactive})
store y$30:TPhi{reactive}:TPhi: phi(bb0: read y$24:TPrimitive, bb3: read y$37{reactive})
[6] mutate? $27:TPhi{reactive} = LoadLocal read x$26:TPhi{reactive}
...
bb3 (block):
[12] mutate? $35{reactive} = LoadLocal read props$19{reactive}
[13] mutate? $36{reactive} = PropertyLoad read $35{reactive}.value
[14] mutate? $38{reactive} = StoreLocal Reassign mutate? y$37{reactive} = read $36{reactive}
```
**Key observations:**
- `props$19` is marked `{reactive}` as a function parameter
- The reactivity propagates through the loop:
- First iteration: `y$37` becomes reactive from `props.value`
- Second iteration: `x$32` becomes reactive from `y$30` (which is reactive via the phi from `y$37`)
- The phi nodes `x$26` and `y$30` become reactive because their bb3 operands are reactive
- The fixpoint algorithm handles this backward propagation through the loop correctly
- The final output `$40` is reactive, so the array `[x]` will be memoized with `x` as a dependency

View File

@@ -0,0 +1,176 @@
# inferReactiveScopeVariables
## File
`src/ReactiveScopes/InferReactiveScopeVariables.ts`
## Purpose
This is the **1st of 4 passes** that determine how to break a React function into discrete reactive scopes (independently memoizable units of code). Its specific responsibilities are:
1. **Identify operands that mutate together** - Variables that are mutated in the same instruction must be placed in the same reactive scope
2. **Assign a unique ReactiveScope to each group** - Each disjoint set of co-mutating identifiers gets assigned a unique ScopeId
3. **Compute the mutable range** - The scope's range is computed as the union of all member identifiers' mutable ranges
The pass does NOT determine which instructions compute each scope, only which variables belong together.
## Input Invariants
- `InferMutationAliasingEffects` has run - Effects describe mutations, captures, and aliasing
- `InferMutationAliasingRanges` has run - Each identifier has a valid `mutableRange` property
- `InferReactivePlaces` has run - Places are marked as reactive or not
- `RewriteInstructionKindsBasedOnReassignment` has run - Let/Const properly determined
- All instructions have been numbered with valid `InstructionId` values
- Phi nodes are properly constructed at block join points
## Output Guarantees
- Each identifier that is part of a mutable group has its `identifier.scope` property set to a `ReactiveScope` object
- All identifiers in the same scope share the same `ReactiveScope` reference
- The scope's `range` is the union (min start, max end) of all member mutable ranges
- The scope's `range` is validated to be within [1, maxInstruction+1]
- Identifiers that only have single-instruction lifetimes (read once) may not be assigned to a scope unless they allocate
## Algorithm
### Phase 1: Find Disjoint Mutable Values (`findDisjointMutableValues`)
Uses a Union-Find (Disjoint Set) data structure to group identifiers that mutate together:
1. **Handle Phi Nodes**: For each phi in each block:
- If the phi's result is mutated after creation (mutableRange.end > first instruction in block), union the phi with all its operands
- This ensures values that flow through control flow and are later mutated are grouped together
2. **Handle Instructions**: For each instruction:
- Collect mutable operands based on instruction type:
- If lvalue has extended mutable range OR instruction may allocate, include lvalue
- For StoreLocal/StoreContext: Include lvalue if it has extended mutable range, include value if mutable
- For Destructure: Include each pattern operand with extended range, include source if mutable
- For MethodCall: Include all mutable operands plus the computed property (to keep method resolution in same scope)
- For other instructions: Include all mutable operands
- Exclude global variables (mutableRange.start === 0) since they cannot be recreated
- Union all collected operands together
### Phase 2: Assign Scopes
1. Iterate over all identifiers in the disjoint set using `forEach(item, groupIdentifier)`
2. For each unique group, create a new ReactiveScope:
- Generate a unique ScopeId from the environment
- Initialize range from the first member's mutableRange
- Set up empty dependencies, declarations, reassignments sets
3. For subsequent members of the same group:
- Expand the scope's range to encompass the member's mutableRange
- Merge source locations
4. Assign the scope to each identifier: `identifier.scope = scope`
5. Update each identifier's mutableRange to match the scope's range
**Validation**: After scope assignment, validate that all scopes have valid ranges within [1, maxInstruction+1].
## Key Data Structures
### DisjointSet<Identifier>
A Union-Find data structure optimized for grouping items into disjoint sets:
```typescript
class DisjointSet<T> {
#entries: Map<T, T>; // Maps each item to its parent (root points to self)
union(items: Array<T>): void; // Merge items into one set
find(item: T): T | null; // Find the root of item's set (with path compression)
forEach(fn: (item, group) => void): void; // Iterate all items with their group root
}
```
Path compression is used during `find()` to flatten the tree structure, improving subsequent lookup performance.
### ReactiveScope
```typescript
type ReactiveScope = {
id: ScopeId;
range: MutableRange; // [start, end) instruction range
dependencies: Set<ReactiveScopeDependency>; // Inputs (populated later)
declarations: Map<IdentifierId, ReactiveScopeDeclaration>; // Outputs (populated later)
reassignments: Set<Identifier>; // Reassigned variables (populated later)
earlyReturnValue: {...} | null; // For scopes with early returns
merged: Set<ScopeId>; // IDs of scopes merged into this one
loc: SourceLocation;
};
```
## Edge Cases
### Global Variables
Excluded from scopes (mutableRange.start === 0) since they cannot be recreated during memoization.
### Phi Nodes After Mutation
When a phi's result is mutated after the join point, all phi operands must be in the same scope to ensure the mutation can be recomputed correctly.
### MethodCall Property Resolution
The computed property load for a method call is explicitly added to the same scope as the call itself.
### Allocating Instructions
Instructions that allocate (Array, Object, JSX, etc.) add their lvalue to the scope even if the lvalue has a single-instruction range.
### Single-Instruction Ranges
Values with range `[n, n+1)` (used exactly once) are only included if they allocate, otherwise they're just read.
### enableForest Config
When enabled, phi operands are unconditionally unioned with the phi result (even without mutation after the phi).
## TODOs
1. `// TODO: improve handling of module-scoped variables and globals` - The current approach excludes globals entirely, but a more nuanced handling could be beneficial.
2. Known issue with aliasing and mutable lifetimes (from header comments):
```javascript
let x = {};
let y = [];
x.y = y; // RHS is not considered mutable here bc not further mutation
mutate(x); // bc y is aliased here, it should still be considered mutable above
```
This suggests the pass may miss some co-mutation relationships when aliasing is involved.
## Example
### Fixture: `reactive-scope-grouping.js`
**Input:**
```javascript
function foo() {
let x = {};
let y = [];
let z = {};
y.push(z); // y and z co-mutate (z captured into y)
x.y = y; // x and y co-mutate (y captured into x)
return x;
}
```
**After InferReactiveScopeVariables:**
```
[1] mutate? $19_@0[1:14] = Object { } // x's initial object, scope @0
[2] store $21_@0[1:14] = StoreLocal x // x in scope @0
[3] mutate? $22_@1[3:11] = Array [] // y's array, scope @1
[4] store $24_@1[3:11] = StoreLocal y // y in scope @1
[5] mutate? $25_@2 = Object { } // z's object, scope @2
[10] MethodCall y.push(z) // Mutates y, captures z
[13] PropertyStore x.y = y // Mutates x, captures y
```
The `y.push(z)` joins y and z into scope @1, and `x.y = y` joins x and y into scope @0. Because y is now in @0, and z was captured into y, ultimately x, y, and z all end up in the same scope @0.
**Compiled Output:**
```javascript
function foo() {
const $ = _c(1);
let x;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
x = {};
const y = [];
const z = {};
y.push(z);
x.y = y;
$[0] = x;
} else {
x = $[0];
}
return x;
}
```
All three objects (x, y, z) are created within the same memoization block because they co-mutate and could potentially alias each other.

View File

@@ -0,0 +1,151 @@
# rewriteInstructionKindsBasedOnReassignment
## File
`src/SSA/RewriteInstructionKindsBasedOnReassignment.ts`
## Purpose
Rewrites the `InstructionKind` of variable declaration and assignment instructions to correctly reflect whether variables should be declared as `const` or `let` in the final output. It determines this based on whether a variable is subsequently reassigned after its initial declaration.
The key insight is that this pass runs **after dead code elimination (DCE)**, so a variable that was originally declared with `let` in the source (because it was reassigned) may be converted to `const` if the reassignment was removed by DCE. However, variables originally declared as `const` cannot become `let`.
## Input Invariants
- SSA form: Each identifier has a unique `IdentifierId` and `DeclarationId`
- Dead code elimination has run: Unused assignments have been removed
- Mutation/aliasing inference complete: Runs after `InferMutationAliasingRanges` and `InferReactivePlaces` in the main pipeline
- All instruction kinds are initially set (typically `Let` for variables that may be reassigned)
## Output Guarantees
- **First declaration gets `Const` or `Let`**: The first `StoreLocal` for a named variable is marked as:
- `InstructionKind.Const` if the variable is never reassigned after
- `InstructionKind.Let` if the variable has subsequent reassignments
- **Reassignments marked as `Reassign`**: Any subsequent `StoreLocal` to the same `DeclarationId` is marked as `InstructionKind.Reassign`
- **Destructure consistency**: All places in a destructuring pattern must have consistent kinds (all Const or all Reassign)
- **Update operations trigger Let**: `PrefixUpdate` and `PostfixUpdate` operations (like `++x` or `x--`) mark the original declaration as `Let`
## Algorithm
1. **Initialize declarations map**: Create a `Map<DeclarationId, LValue | LValuePattern>` to track declared variables.
2. **Seed with parameters and context**: Add all named function parameters and captured context variables to the map with kind `Let` (since they're already "declared" outside the function body).
3. **Process blocks in order**: Iterate through all blocks and instructions:
- **DeclareLocal**: Record the declaration in the map (invariant: must not already exist)
- **StoreLocal**:
- If not in map: This is the first store, add to map with `kind = Const`
- If already in map: This is a reassignment. Update original declaration to `Let`, set current instruction to `Reassign`
- **Destructure**:
- For each operand in the pattern, check if it's already declared
- All operands must be consistent (all new declarations OR all reassignments)
- Set pattern kind to `Const` for new declarations, `Reassign` for existing ones
- **PrefixUpdate / PostfixUpdate**: Look up the declaration and mark it as `Let` (these always imply reassignment)
## Key Data Structures
```typescript
// Main tracking structure
const declarations = new Map<DeclarationId, LValue | LValuePattern>();
// InstructionKind enum (from HIR.ts)
enum InstructionKind {
Const = 'Const', // const declaration
Let = 'Let', // let declaration
Reassign = 'Reassign', // reassignment to existing binding
Catch = 'Catch', // catch clause binding
HoistedLet = 'HoistedLet', // hoisted let
HoistedConst = 'HoistedConst', // hoisted const
HoistedFunction = 'HoistedFunction', // hoisted function
Function = 'Function', // function declaration
}
```
## Edge Cases
### DCE Removes Reassignment
A `let x = 0; x = 1;` where `x = 1` is unused becomes `const x = 0;` after DCE.
### Destructuring with Mixed Operands
The invariant checks ensure all operands in a destructure pattern are either all new declarations or all reassignments. Mixed cases cause a compiler error.
### Value Blocks with DCE
There's a TODO for handling reassignment in value blocks where the original declaration was removed by DCE.
### Parameters and Context Variables
These are pre-seeded as `Let` in the declarations map since they're conceptually "declared" at function entry.
### Update Expressions
`++x` and `x--` always mark the variable as `Let`, even if used inline.
## TODOs
```typescript
CompilerError.invariant(block.kind !== 'value', {
reason: `TODO: Handle reassignment in a value block where the original
declaration was removed by dead code elimination (DCE)`,
...
});
```
This indicates an edge case where a destructuring reassignment occurs in a value block but the original declaration was eliminated by DCE. This is currently an invariant violation rather than handled gracefully.
## Example
### Fixture: `reassignment.js`
**Input Source:**
```javascript
function Component(props) {
let x = [];
x.push(props.p0);
let y = x;
x = [];
let _ = <Component x={x} />;
y.push(props.p1);
return <Component x={x} y={y} />;
}
```
**Before Pass (InferReactivePlaces output):**
```
[2] StoreLocal Let x$32 = $31 // x is initially marked Let
[9] StoreLocal Let y$40 = $39 // y is initially marked Let
[11] StoreLocal Reassign x$43 = $42 // reassignment already marked
```
**After Pass:**
```
[2] StoreLocal Let x$32 = $31 // x stays Let (has reassignment at line 11)
[9] StoreLocal Const y$40 = $39 // y becomes Const (never reassigned)
[11] StoreLocal Reassign x$43 = $42 // stays Reassign
```
**Final Generated Code:**
```javascript
function Component(props) {
const $ = _c(4);
let t0;
if ($[0] !== props.p0 || $[1] !== props.p1) {
let x = []; // let because reassigned
x.push(props.p0);
const y = x; // const because never reassigned
// ... x = t1; (reassignment)
y.push(props.p1);
t0 = <Component x={x} y={y} />;
// ...
}
return t0;
}
```
The pass correctly identified that `x` needs `let` (since it's reassigned on line 6 of the source) while `y` can use `const` (it's never reassigned after initialization).
## Where This Pass is Called
1. **Main Pipeline** (`src/Entrypoint/Pipeline.ts:322`): Called after `InferReactivePlaces` and before `InferReactiveScopeVariables`.
2. **AnalyseFunctions** (`src/Inference/AnalyseFunctions.ts:58`): Called when lowering inner function expressions as part of the function analysis phase.

View File

@@ -0,0 +1,131 @@
# alignMethodCallScopes
## File
`src/ReactiveScopes/AlignMethodCallScopes.ts`
## Purpose
Ensures that `MethodCall` instructions and their associated `PropertyLoad` instructions (which load the method being called) have consistent scope assignments. The pass enforces one of two invariants:
1. Both the MethodCall lvalue and the property have the **same** reactive scope
2. **Neither** has a reactive scope
This alignment is critical because the PropertyLoad and MethodCall are semantically a single operation (`receiver.method(args)`) and must be memoized together as a unit. If they had different scopes, the generated code would incorrectly try to memoize the property load separately from the method call, which could break correctness.
## Input Invariants
- The function has been converted to HIR form
- `inferReactiveScopeVariables` has already run, assigning initial reactive scopes to identifiers based on mutation analysis
- Each instruction's lvalue has an `identifier.scope` that is either a `ReactiveScope` or `null`
- For `MethodCall` instructions, the `value.property` field contains a `Place` referencing the loaded method
## Output Guarantees
After this pass runs:
- For every `MethodCall` instruction in the function:
- If the lvalue has a scope AND the property has a scope, they point to the **same merged scope**
- If only the lvalue has a scope, the property's scope is set to match the lvalue's scope
- If only the property has a scope, the property's scope is set to `null` (so neither has a scope)
- Merged scopes have their `range` extended to cover the union of the original scopes' ranges
- Nested functions (FunctionExpression, ObjectMethod) are recursively processed
## Algorithm
### Phase 1: Collect Scope Relationships
```
For each instruction in all blocks:
If instruction is a MethodCall:
lvalueScope = instruction.lvalue.identifier.scope
propertyScope = instruction.value.property.identifier.scope
If both have scopes:
Record that these scopes should be merged (using DisjointSet.union)
Else if only lvalue has scope:
Record that property should be assigned to lvalueScope
Else if only property has scope:
Record that property should be assigned to null (no scope)
If instruction is FunctionExpression or ObjectMethod:
Recursively process the nested function
```
### Phase 2: Merge Scopes
```
For each merged scope group:
Pick a "root" scope
Extend root's range to cover all merged scopes:
root.range.start = min(all scope start points)
root.range.end = max(all scope end points)
```
### Phase 3: Apply Changes
```
For each instruction:
If lvalue was recorded for remapping:
Set identifier.scope to the mapped value
Else if identifier has a scope that was merged:
Set identifier.scope to the merged root scope
```
## Key Data Structures
1. **`scopeMapping: Map<IdentifierId, ReactiveScope | null>`**
- Maps property identifier IDs to their new scope assignment
- Value of `null` means the scope should be removed
2. **`mergedScopes: DisjointSet<ReactiveScope>`**
- Union-find data structure tracking scopes that need to be merged
- Used when both MethodCall and property have different scopes
3. **`ReactiveScope`** (from HIR)
- Contains `range: { start: InstructionId, end: InstructionId }`
- The range defines which instructions are part of the scope
## Edge Cases
### Both Have the Same Scope Already
No action needed (implicit in the logic).
### Nested Functions
The pass recursively processes `FunctionExpression` and `ObjectMethod` instructions to handle closures.
### Multiple MethodCalls Sharing Scopes
The DisjointSet handles transitive merging - if A merges with B, and B merges with C, all three end up in the same scope.
### Property Without Scope, MethodCall Without Scope
No action needed (both already aligned at `null`).
## TODOs
There are no explicit TODO comments in the source code.
## Example
### Fixture: `alias-capture-in-method-receiver.js`
**Source code:**
```javascript
function Component() {
let a = someObj();
let x = [];
x.push(a);
return [x, a];
}
```
**Before AlignMethodCallScopes:**
```
[7] store $24_@1[4:10]:TFunction = PropertyLoad capture $23_@1.push
[9] mutate? $26:TPrimitive = MethodCall store $23_@1.read $24_@1(capture $25)
```
- PropertyLoad result `$24_@1` has scope `@1`
- MethodCall result `$26` has no scope (`null`)
**After AlignMethodCallScopes:**
```
[7] store $24[4:10]:TFunction = PropertyLoad capture $23_@1.push
[9] mutate? $26:TPrimitive = MethodCall store $23_@1.read $24(capture $25)
```
- PropertyLoad result `$24` now has **no scope** (the `_@1` suffix removed)
- MethodCall result `$26` still has no scope
**Why this matters:**
Without this alignment, later passes might try to memoize the `.push` property load separately from the actual `push()` call. This would be incorrect because:
1. Reading a method from an object and calling it are semantically one operation
2. The property load's value (the bound method) is only valid immediately when called on the same receiver
3. Separate memoization could lead to stale method references or incorrect this-binding

View File

@@ -0,0 +1,128 @@
# alignObjectMethodScopes
## File
`src/ReactiveScopes/AlignObjectMethodScopes.ts`
## Purpose
Ensures that object method values and their enclosing object expressions share the same reactive scope. This is critical for code generation because JavaScript requires object method definitions to be inlined within their containing object literals. If the object method and object expression were in different reactive scopes (which map to different memoization blocks), the generated code would be invalid since you cannot reference an object method defined in one block from an object literal in a different block.
From the file's documentation:
> "To produce a well-formed JS program in Codegen, object methods and object expressions must be in the same ReactiveBlock as object method definitions must be inlined."
## Input Invariants
- Reactive scopes have been inferred: This pass runs after `InferReactiveScopeVariables`
- ObjectMethod and ObjectExpression have non-null scopes: The pass asserts this with an invariant check
- Scopes are disjoint across functions: The pass assumes that scopes do not overlap between parent and nested functions
## Output Guarantees
- ObjectMethod and ObjectExpression share the same scope: Any ObjectMethod used as a property in an ObjectExpression will have its scope merged with the ObjectExpression's scope
- Merged scope covers both ranges: The resulting merged scope's range is expanded to cover the minimum start and maximum end of all merged scopes
- All identifiers are repointed: All identifiers whose scopes were merged are updated to point to the canonical root scope
- Inner functions are also processed: The pass recursively handles nested ObjectMethod and FunctionExpression values
## Algorithm
### Phase 1: Find Scopes to Merge (`findScopesToMerge`)
1. Iterate through all blocks and instructions in the function
2. Track all ObjectMethod declarations in a set by their lvalue identifier
3. When encountering an ObjectExpression, check each operand:
- If an operand's identifier was previously recorded as an ObjectMethod declaration
- Get the scope of both the ObjectMethod operand and the ObjectExpression lvalue
- Assert both scopes are non-null
- Union these two scopes together in a DisjointSet data structure
### Phase 2: Merge and Repoint Scopes (`alignObjectMethodScopes`)
1. Recursively process inner functions first (ObjectMethod and FunctionExpression values)
2. Canonicalize the DisjointSet to get a mapping from each scope to its root
3. **Step 1 - Merge ranges**: For each scope that maps to a different root:
- Expand the root's range to encompass both the original range and the merged scope's range
- `root.range.start = min(scope.range.start, root.range.start)`
- `root.range.end = max(scope.range.end, root.range.end)`
4. **Step 2 - Repoint identifiers**: For each instruction's lvalue:
- If the identifier has a scope that was merged
- Update the identifier's scope reference to point to the canonical root
## Key Data Structures
1. **DisjointSet<ReactiveScope>** - A union-find data structure that tracks which scopes should be merged together. Uses path compression for efficient `find()` operations.
2. **Set<Identifier>** - Tracks which identifiers are ObjectMethod declarations, used to identify when an ObjectExpression operand is an object method.
3. **ReactiveScope** - Contains:
- `id: ScopeId` - Unique identifier
- `range: MutableRange` - Start and end instruction IDs
- `dependencies` - Inputs to the scope
- `declarations` - Values produced by the scope
4. **MutableRange** - Has `start` and `end` InstructionId fields that define the scope's extent.
## Edge Cases
### Nested Object Methods
When an object method itself contains another object with methods, the pass recursively processes inner functions first before handling the outer function's scopes.
### Multiple Object Methods in Same Object
If an object has multiple method properties, all their scopes will be merged with the object's scope through the DisjointSet.
### Object Methods in Conditional Expressions
Object methods inside ternary expressions still need scope alignment to ensure the method and its containing object are in the same reactive block.
### Method Call After Object Creation
The pass works in conjunction with `AlignMethodCallScopes` (which runs immediately before) to ensure that method calls on objects with object methods are also properly scoped.
## TODOs
None explicitly marked in the source file.
## Example
### Fixture: `object-method-shorthand.js`
**Input:**
```javascript
function Component() {
let obj = {
method() {
return 1;
},
};
return obj.method();
}
```
**Before AlignObjectMethodScopes:**
```
InferReactiveScopeVariables:
[1] mutate? $12_@0:TObjectMethod = ObjectMethod ... // scope @0
[2] mutate? $14_@1[2:7]:TObject = Object { method: ... } // scope @1 (range 2:7)
```
The ObjectMethod `$12` is in scope `@0` while the ObjectExpression `$14` is in scope `@1` with range `[2:7]`.
**After AlignObjectMethodScopes:**
```
AlignObjectMethodScopes:
[1] mutate? $12_@0[1:7]:TObjectMethod = ObjectMethod ... // scope @0, range now 1:7
[2] mutate? $14_@0[1:7]:TObject = Object { method: ... } // also scope @0, range 1:7
```
Both identifiers are in the same scope `@0`, and the scope's range has been expanded to `[1:7]` to cover both instructions.
**Final Generated Code:**
```javascript
function Component() {
const $ = _c(1);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
const obj = {
method() {
return 1;
},
};
t0 = obj.method();
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
```
The object literal with its method and the subsequent method call are all inside the same memoization block, producing valid JavaScript where the method definition is inlined within the object literal.

View File

@@ -0,0 +1,177 @@
# alignReactiveScopesToBlockScopesHIR
## File
`src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts`
## Purpose
This is the **2nd of 4 passes** that determine how to break a function into discrete reactive scopes (independently memoizable units of code). The pass aligns reactive scope boundaries to control flow (block scope) boundaries.
The problem it solves: Prior inference passes assign reactive scopes to operands based on mutation ranges at arbitrary instruction points in the control-flow graph. However, to generate memoization blocks around instructions, scopes must be aligned to block-scope boundaries -- you cannot memoize half of a loop or half of an if-block.
**Example from the source code comments:**
```javascript
function foo(cond, a) {
// original scope end
// expanded scope end
const x = []; | |
if (cond) { | |
... | |
x.push(a); <--- original scope ended here
... |
} <--- scope must extend to here
}
```
## Input Invariants
- `InferReactiveScopeVariables` has run: Each identifier has been assigned a `ReactiveScope` with a `range` (start/end instruction IDs) based on mutation analysis
- The HIR is in SSA form: Blocks have unique IDs, instructions have unique IDs, and control flow is represented with basic blocks
- Each block has a terminal with possible successors and fallthroughs
- Each scope has a mutable range `{start: InstructionId, end: InstructionId}` indicating when the scope is active
## Output Guarantees
- **Scopes end at valid block boundaries**: A reactive scope may only end at the same block scope level as it began. The scope's `range.end` is updated to the first instruction of the fallthrough block after any control flow structure that the scope overlaps
- **Scopes start at valid block boundaries**: For labeled breaks (gotos to a label), scopes that extend beyond the goto have their `range.start` extended back to include the label
- **Value blocks (ternary, logical, optional) are handled specially**: Scopes inside value blocks are extended to align with the outer block scope's instruction range
## Algorithm
The pass performs a single forward traversal over all blocks:
### 1. Tracking Active Scopes
- Maintains `activeScopes: Set<ReactiveScope>` - scopes whose range overlaps the current block
- Maintains `activeBlockFallthroughRanges: Array<{range, fallthrough}>` - stack of pending block-fallthrough ranges
### 2. Per-Block Processing
For each block:
- Prune `activeScopes` to only those that extend past the current block's start
- If this block is a fallthrough target, pop the range from the stack and extend all active scopes' start to the range start
### 3. Recording Places
For each instruction lvalue and operand:
- If the place has a scope, add it to `activeScopes`
- If inside a value block, extend the scope's range to match the value block's outer range
### 4. Handling Block Fallthroughs
When a terminal has a fallthrough (not a simple branch):
- Extend all active scopes whose `range.end > terminal.id` to at least the first instruction of the fallthrough block
- Push the fallthrough range onto the stack for future scopes
### 5. Handling Labeled Breaks (Goto)
When encountering a goto to a label (not the natural fallthrough):
- Find the corresponding fallthrough range on the stack
- Extend all active scopes to span from the label start to its fallthrough end
### 6. Value Block Handling
For ternary, logical, and optional terminals:
- Create `ValueBlockNode` to track the outer block's instruction range
- Scopes inside value blocks inherit this range, ensuring they align to the outer block scope
## Key Data Structures
```typescript
type ValueBlockNode = {
kind: 'node';
id: InstructionId;
valueRange: MutableRange; // Range of outer block scope
children: Array<ValueBlockNode | ReactiveScopeNode>;
};
type ReactiveScopeNode = {
kind: 'scope';
id: InstructionId;
scope: ReactiveScope;
};
// Tracked during traversal:
activeBlockFallthroughRanges: Array<{
range: InstructionRange;
fallthrough: BlockId;
}>;
activeScopes: Set<ReactiveScope>;
valueBlockNodes: Map<BlockId, ValueBlockNode>;
```
## Edge Cases
### Labeled Breaks
When a `goto` jumps to a label (not the natural fallthrough), scopes must be extended to include the entire labeled block range, preventing the break from jumping out of the scope.
### Value Blocks (Ternary/Logical/Optional)
These create nested "value" contexts. Scopes inside must be aligned to the outer block scope's boundaries, not the value block's boundaries.
### Nested Control Flow
Deeply nested if-statements require the scope to be extended through all levels back to the outermost block where the scope started.
### do-while and try/catch
The terminal's successor might be a block (not value block), which is handled specially.
## TODOs
1. `// TODO: consider pruning activeScopes per instruction` - Currently, `activeScopes` is only pruned at block start points. Some scopes may no longer be active by the time a goto is encountered.
2. `// TODO: add a variant of eachTerminalSuccessor() that visits _all_ successors, not just those that are direct successors for normal control-flow ordering.` - The current implementation uses `mapTerminalSuccessors` which may not visit all successors in all cases.
## Example
### Fixture: `extend-scopes-if.js`
**Input:**
```javascript
function foo(a, b, c) {
let x = [];
if (a) {
if (b) {
if (c) {
x.push(0); // Mutation of x ends here (instruction 12-13)
}
}
}
if (x.length) { // instruction 16
return x;
}
return null;
}
```
**Before AlignReactiveScopesToBlockScopesHIR:**
```
x$23_@0[1:13] // Scope range 1-13
```
The scope for `x` ends at instruction 13 (inside the innermost if block).
**After AlignReactiveScopesToBlockScopesHIR:**
```
x$23_@0[1:16] // Scope range extended to 1-16
```
The scope is extended to instruction 16 (the first instruction after all the nested if-blocks), aligning to the block scope boundary.
**Generated Code:**
```javascript
function foo(a, b, c) {
const $ = _c(4);
let x;
if ($[0] !== a || $[1] !== b || $[2] !== c) {
x = [];
if (a) {
if (b) {
if (c) {
x.push(0);
}
}
}
// Scope ends here, after ALL the if-blocks
$[0] = a;
$[1] = b;
$[2] = c;
$[3] = x;
} else {
x = $[3];
}
// Code outside the scope
if (x.length) {
return x;
}
return null;
}
```
The memoization block correctly wraps the entire nested if-structure, not just part of it.

View File

@@ -0,0 +1,134 @@
# mergeOverlappingReactiveScopesHIR
## File
`src/HIR/MergeOverlappingReactiveScopesHIR.ts`
## Purpose
This pass ensures that reactive scope ranges form valid, non-overlapping blocks in the output JavaScript program. It merges reactive scopes that would otherwise be inconsistent with each other due to:
1. **Overlapping ranges**: Scopes whose instruction ranges partially overlap (not disjoint and not nested) must be merged because the compiler cannot produce valid `if-else` memo blocks for overlapping scopes.
2. **Cross-scope mutations**: When an instruction within one scope mutates a value belonging to a different (outer) scope, those scopes must be merged to maintain correctness.
The pass guarantees that after execution, any two reactive scopes are either:
- Entirely disjoint (no common instructions)
- Properly nested (one scope is completely contained within the other)
## Input Invariants
- Reactive scope variables have been inferred (`InferReactiveScopeVariables` pass has run)
- Scopes have been aligned to block scopes (`AlignReactiveScopesToBlockScopesHIR` pass has run)
- Each `Place` may have an associated `ReactiveScope` with a `range` (start/end instruction IDs)
- Scopes may still have overlapping ranges or contain instructions that mutate outer scopes
## Output Guarantees
- **No overlapping scopes**: All reactive scopes either are disjoint or properly nested
- **Consistent mutation boundaries**: Instructions only mutate their "active" scope (the innermost containing scope)
- **Merged scope ranges**: Merged scopes have their ranges extended to cover the union of all constituent scopes
- **Updated references**: All `Place` references have their `identifier.scope` updated to point to the merged scope
## Algorithm
### Phase 1: Collect Scope Information (`collectScopeInfo`)
- Iterates through all instructions and terminals in the function
- Records for each `Place`:
- The scope it belongs to (`placeScopes` map)
- When scopes start and end (`scopeStarts` and `scopeEnds` arrays, sorted in descending order by ID)
- Only records scopes with non-empty ranges (`range.start !== range.end`)
### Phase 2: Detect Overlapping Scopes (`getOverlappingReactiveScopes`)
Uses a stack-based traversal to track "active" scopes at each instruction:
1. **For each instruction/terminal**:
- **Handle scope endings**: Pop completed scopes from the active stack. If a scope ends while other scopes that started later are still active (detected by finding the scope is not at the top of the stack), those scopes overlap and must be merged via `DisjointSet.union()`.
- **Handle scope starts**: Push new scopes onto the active stack (sorted by end time descending so earlier-ending scopes are at the top). Merge any scopes that have identical start/end ranges.
- **Handle mutations**: For each operand/lvalue, if it:
- Has an associated scope
- Is mutable at the current instruction
- The scope is active but not at the top of the stack (i.e., an outer scope)
Then merge all scopes from the mutated outer scope to the top of the stack.
2. **Special case**: Primitive operands in `FunctionExpression` and `ObjectMethod` are skipped.
### Phase 3: Merge Scopes and Rewrite References
1. For each scope in the disjoint set, compute the merged range as the union (min start, max end)
2. Update all `Place.identifier.scope` references to point to the merged "group" scope
## Key Data Structures
### ScopeInfo
```typescript
type ScopeInfo = {
scopeStarts: Array<{id: InstructionId; scopes: Set<ReactiveScope>}>;
scopeEnds: Array<{id: InstructionId; scopes: Set<ReactiveScope>}>;
placeScopes: Map<Place, ReactiveScope>;
};
```
### TraversalState
```typescript
type TraversalState = {
joined: DisjointSet<ReactiveScope>; // Union-find for merged scopes
activeScopes: Array<ReactiveScope>; // Stack of currently active scopes
};
```
### DisjointSet<ReactiveScope>
A union-find data structure that tracks which scopes should be merged into the same group.
## Edge Cases
### Identical Scope Ranges
When multiple scopes have the exact same start and end, they are automatically merged since they would produce the same reactive block.
### Empty Scopes
Scopes where `range.start === range.end` are skipped entirely.
### Primitive Captures in Functions
When a `FunctionExpression` or `ObjectMethod` captures a primitive operand, it's excluded from scope merging analysis.
### JSX Single-Instruction Scopes
The comment in the code notes this isn't perfect - mutating scopes may get merged with JSX single-instruction scopes.
### Non-Mutating Captures
The pass records both mutating and non-mutating scopes to handle cases where still-mutating values are aliased by inner scopes.
## TODOs
From the comments in the source file, the design constraints arise from the current compiler output design:
- **Instruction ordering is preserved**: If reordering were allowed, disjoint ranges could be produced by reordering mutating instructions
- **One if-else block per scope**: The current design doesn't allow composing a reactive scope from disconnected instruction ranges
## Example
### Fixture: `overlapping-scopes-interleaved.js`
**Input Code:**
```javascript
function foo(a, b) {
let x = [];
let y = [];
x.push(a);
y.push(b);
}
```
**Before MergeOverlappingReactiveScopesHIR:**
```
[1] $20_@0[1:9] = Array [] // x belongs to scope @0, range [1:9]
[2] x$21_@0[1:9] = StoreLocal...
[3] $23_@1[3:13] = Array [] // y belongs to scope @1, range [3:13]
[4] y$24_@1[3:13] = StoreLocal...
```
Scopes @0 [1:9] and @1 [3:13] overlap: @0 starts at 1, @1 starts at 3, @0 ends at 9, @1 ends at 13. This is invalid.
**After MergeOverlappingReactiveScopesHIR:**
```
[1] $20_@0[1:13] = Array [] // Merged scope @0, range [1:13]
[2] x$21_@0[1:13] = StoreLocal...
[3] $23_@0[1:13] = Array [] // Now also scope @0
[4] y$24_@0[1:13] = StoreLocal...
```
Both `x` and `y` now belong to the same merged scope @0 with range [1:13], producing a single `if-else` memo block in the output.

View File

@@ -0,0 +1,161 @@
# buildReactiveScopeTerminalsHIR
## File
`src/HIR/BuildReactiveScopeTerminalsHIR.ts`
## Purpose
This pass transforms the HIR by inserting `ReactiveScopeTerminal` nodes to explicitly demarcate the boundaries of reactive scopes within the control flow graph. It converts the implicit scope ranges (stored on identifiers as `identifier.scope.range`) into explicit control flow structure by:
1. Inserting a `scope` terminal at the **start** of each reactive scope
2. Inserting a `goto` terminal at the **end** of each reactive scope
3. Creating fallthrough blocks to properly connect the scopes to the rest of the CFG
This transformation makes scope boundaries first-class elements in the CFG, which is essential for later passes that generate the memoization code (the `if ($[n] !== dep)` checks).
## Input Invariants
- **Properly nested scopes and blocks**: The pass assumes `assertValidBlockNesting` has passed, meaning all program blocks and reactive scopes form a proper tree hierarchy
- **Aligned scope ranges**: Reactive scope ranges have been correctly aligned and merged by previous passes
- **Valid instruction IDs**: All instructions have sequential IDs that define the scope boundaries
- **Scopes attached to identifiers**: Reactive scopes are found by traversing all `Place` operands and collecting unique non-empty scopes
## Output Guarantees
- **Explicit scope terminals**: Each reactive scope is represented in the CFG as a `ReactiveScopeTerminal` with:
- `block` - The BlockId containing the scope's instructions
- `fallthrough` - The BlockId that executes after the scope
- **Proper block structure**: Original blocks are split at scope boundaries
- **Restored HIR invariants**: The pass restores RPO ordering, predecessor sets, instruction IDs, and scope/identifier ranges
- **Updated phi nodes**: Phi operands are repointed when their source blocks are split
## Algorithm
### Step 1: Collect Scope Rewrites
```
for each reactive scope (in range pre-order):
push StartScope rewrite at scope.range.start
push EndScope rewrite at scope.range.end
```
The `recursivelyTraverseItems` helper traverses scopes in pre-order (outer scopes before inner scopes).
### Step 2: Apply Rewrites by Splitting Blocks
```
reverse queuedRewrites (to pop in ascending instruction order)
for each block:
for each instruction (or terminal):
while there are rewrites <= current instruction ID:
split block at current index
insert scope terminal (for start) or goto terminal (for end)
emit final block segment with original terminal
```
### Step 3: Repoint Phi Nodes
When a block is split, its final segment gets a new BlockId. Phi operands that referenced the original block are updated to reference the new final block.
### Step 4: Restore HIR Invariants
- Recompute RPO (reverse post-order) block traversal
- Recalculate predecessor sets
- Renumber instruction IDs
- Fix scope and identifier ranges to match new instruction IDs
## Key Data Structures
### TerminalRewriteInfo
```typescript
type TerminalRewriteInfo =
| {
kind: 'StartScope';
blockId: BlockId; // New block for scope content
fallthroughId: BlockId; // Block after scope ends
instrId: InstructionId; // Where to insert
scope: ReactiveScope; // The scope being created
}
| {
kind: 'EndScope';
instrId: InstructionId; // Where to insert
fallthroughId: BlockId; // Same as corresponding StartScope
};
```
### RewriteContext
```typescript
type RewriteContext = {
source: BasicBlock; // Original block being split
instrSliceIdx: number; // Current slice start index
nextPreds: Set<BlockId>; // Predecessors for next emitted block
nextBlockId: BlockId; // BlockId for next emitted block
rewrites: Array<BasicBlock>; // Accumulated split blocks
};
```
### ScopeTraversalContext
```typescript
type ScopeTraversalContext = {
fallthroughs: Map<ScopeId, BlockId>; // Cache: scope -> its fallthrough block
rewrites: Array<TerminalRewriteInfo>;
env: Environment;
};
```
## Edge Cases
### Multiple Rewrites at Same Instruction ID
The while loop in Step 2 handles multiple scope start/ends at the same instruction ID.
### Nested Scopes
The pre-order traversal ensures outer scopes are processed before inner scopes, creating proper nesting in the CFG.
### Empty Blocks After Split
When a scope boundary falls at the start of a block, the split may create a block with no instructions (only a terminal).
### Control Flow Within Scopes
The pass preserves existing control flow (if/else, loops) within scopes; it only adds scope entry/exit points.
### Early Returns
When a return occurs within a scope, the scope terminal still has a fallthrough block, but that block may contain `Unreachable` terminal.
## TODOs
Line 283-284:
```typescript
// TODO make consistent instruction IDs instead of reusing
```
## Example
### Fixture: `reactive-scopes-if.js`
**Before BuildReactiveScopeTerminalsHIR:**
```
bb0 (block):
[1] $29_@0[1:22] = Array [] // x with scope @0 range [1:22]
[2] StoreLocal x$30_@0 = $29_@0
[3] $32 = LoadLocal a$26
[4] If ($32) then:bb2 else:bb3 fallthrough=bb1
bb2:
[5] $33_@1[5:11] = Array [] // y with scope @1 range [5:11]
...
```
**After BuildReactiveScopeTerminalsHIR:**
```
bb0 (block):
[1] Scope @0 [1:28] block=bb9 fallthrough=bb10 // <-- scope terminal inserted
bb9:
[2] $29_@0 = Array []
[3] StoreLocal x$30_@0 = $29_@0
[4] $32 = LoadLocal a$26
[5] If ($32) then:bb2 else:bb3 fallthrough=bb1
bb2:
[6] Scope @1 [6:14] block=bb11 fallthrough=bb12 // <-- nested scope terminal
bb11:
[7] $33_@1 = Array []
...
[13] Goto bb12 // <-- scope end goto
bb12:
...
bb1:
[27] Goto bb10 // <-- scope @0 end goto
bb10:
[28] $50 = LoadLocal x$30_@0
[29] Return $50
```
The key transformation is that scope boundaries become explicit control flow: a `Scope` terminal enters the scope content block, and a `Goto` terminal exits to the fallthrough block. This structure is later used to generate the memoization checks.

View File

@@ -0,0 +1,158 @@
# flattenReactiveLoopsHIR
## File
`src/ReactiveScopes/FlattenReactiveLoopsHIR.ts`
## Purpose
This pass **prunes reactive scopes that are nested inside loops** (for, for-in, for-of, while, do-while). The compiler does not yet support memoization within loops because:
1. Loop iterations would require reconciliation across runs (similar to how `key` is used in JSX for lists)
2. There is no way to identify values across iterations
3. The current approach is to memoize *around* the loop rather than *within* it
When a reactive scope is found inside a loop body, the pass converts its terminal from `scope` to `pruned-scope`. A `pruned-scope` terminal is later treated specially during codegen - its instructions are emitted inline without any memoization guards.
## Input Invariants
- The HIR has been through `buildReactiveScopeTerminalsHIR`, which creates `scope` terminal nodes for reactive scopes
- The HIR is in valid block form with proper terminal kinds
- The block ordering respects control flow (blocks are iterated in order, with loop fallthroughs appearing after loop bodies)
## Output Guarantees
- All `scope` terminals that appear inside any loop body are converted to `pruned-scope` terminals
- Scopes outside of loops remain unchanged as `scope` terminals
- The structure of blocks is preserved; only the terminal kind is mutated
- The `pruned-scope` terminal retains all the same fields as `scope` (block, fallthrough, scope, id, loc)
## Algorithm
The algorithm uses a **linear scan with a stack-based loop tracking** approach:
```
1. Initialize an empty array `activeLoops` to track which loop(s) we are currently inside
2. For each block in the function body (in order):
a. Remove the current block ID from activeLoops (if present)
- This happens when we reach a loop's fallthrough block, exiting the loop
b. Examine the block's terminal:
- If it's a loop terminal (do-while, for, for-in, for-of, while):
Push the loop's fallthrough block ID onto activeLoops
- If it's a scope terminal AND activeLoops is non-empty:
Convert the terminal to pruned-scope (keeping all other fields)
- All other terminal kinds are ignored
```
Key insight: The algorithm tracks when we "enter" a loop by pushing the fallthrough ID when encountering a loop terminal, and "exits" the loop when that fallthrough block is visited.
## Key Data Structures
### activeLoops: Array<BlockId>
A stack of block IDs representing loop fallthroughs. When non-empty, we are inside one or more nested loops.
### PrunedScopeTerminal
```typescript
export type PrunedScopeTerminal = {
kind: 'pruned-scope';
fallthrough: BlockId;
block: BlockId;
scope: ReactiveScope;
id: InstructionId;
loc: SourceLocation;
};
```
### retainWhere
Utility from utils.ts - an in-place array filter that removes elements not matching the predicate.
## Edge Cases
### Nested Loops
The algorithm handles nested loops correctly because `activeLoops` is an array that can contain multiple fallthrough IDs. A scope deep inside multiple nested loops will still be pruned.
### Scope Spanning the Loop
If a scope terminal appears before the loop terminal but its body contains the loop, it is NOT pruned because the scope terminal itself is not inside the loop.
### Multiple Loops in Sequence
When exiting one loop (reaching its fallthrough) and entering another, `activeLoops` correctly clears the first loop before potentially adding the second.
### Control Flow That Exits Loops (break/return)
The algorithm relies on block ordering and fallthrough IDs. Early exits via break/return don't affect the tracking since we track by fallthrough block ID.
## TODOs
No explicit TODOs in this file. However, the docstring mentions future improvements:
> "Eventually we may integrate more deeply into the runtime so that we can do a single level of reconciliation"
This suggests a potential future feature to support memoization within loops via runtime integration.
## Example
### Fixture: `repro-memoize-for-of-collection-when-loop-body-returns.js`
**Input:**
```javascript
function useHook(nodeID, condition) {
const graph = useContext(GraphContext);
const node = nodeID != null ? graph[nodeID] : null;
for (const key of Object.keys(node?.fields ?? {})) {
if (condition) {
return new Class(node.fields?.[field]); // <-- Scope @4 is here
}
}
return new Class(); // <-- Scope @5 is here (outside loop)
}
```
**Before FlattenReactiveLoopsHIR:**
```
[45] Scope scope @3 [45:72] ... block=bb35 fallthrough=bb36
bb35:
[46] ForOf init=bb6 test=bb7 loop=bb8 fallthrough=bb5
...
[66] Scope scope @4 [66:69] ... block=bb37 fallthrough=bb38 <-- Inside loop
...
[73] Scope scope @5 [73:76] ... block=bb39 fallthrough=bb40 <-- Outside loop
```
**After FlattenReactiveLoopsHIR:**
```
[45] Scope scope @3 [45:72] ... block=bb35 fallthrough=bb36 <-- Unchanged
...
[66] <pruned> Scope scope @4 [66:69] ... block=bb37 fallthrough=bb38 <-- PRUNED!
...
[73] Scope scope @5 [73:76] ... block=bb39 fallthrough=bb40 <-- Unchanged
```
**Final Codegen Result:**
```javascript
function useHook(nodeID, condition) {
const $ = _c(7);
// ... memoized Object.keys call (scope @2)
let t1;
if ($[2] !== condition || $[3] !== node || $[4] !== t0) {
// Scope @3 wraps the loop
t1 = Symbol.for("react.early_return_sentinel");
bb0: for (const key of t0) {
if (condition) {
t1 = new Class(node.fields?.[field]); // Scope @4 was PRUNED - no memoization
break bb0;
}
}
$[2] = condition;
$[3] = node;
$[4] = t0;
$[5] = t1;
} else {
t1 = $[5];
}
// ...
// Scope @5 - memoized (sentinel check)
if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
t2 = new Class();
$[6] = t2;
}
return t2;
}
```
The `new Class(...)` inside the loop has no memoization guards because scope @4 was pruned. The `new Class()` outside the loop retains its memoization via scope @5.

View File

@@ -0,0 +1,143 @@
# flattenScopesWithHooksOrUseHIR
## File
`src/ReactiveScopes/FlattenScopesWithHooksOrUseHIR.ts`
## Purpose
This pass removes (flattens) reactive scopes that transitively contain hook calls or `use()` operator calls. The key insight is that:
1. **Hooks cannot be called conditionally** - wrapping them in a memoized scope would make them conditionally called based on whether the cache is valid
2. **The `use()` operator** - while it can be called conditionally in source code, React requires it to be called consistently if the component needs the returned value. Memoizing a scope containing `use()` would also make it conditionally called.
By running reactive scope inference first (agnostic of hooks), the compiler knows which values "construct together" in the same scope. The pass then removes ALL memoization for scopes containing hook/use calls to ensure they are always executed unconditionally.
## Input Invariants
- HIR must have reactive scope terminals already built (pass runs after `BuildReactiveScopeTerminalsHIR`)
- Blocks are visited in order (the pass iterates through `fn.body.blocks`)
- Scope terminals have a `block` (body of the scope) and `fallthrough` (block after the scope)
- Type inference has run so that `getHookKind()` and `isUseOperator()` can identify hooks and use() calls
## Output Guarantees
- All scopes that transitively contained a hook or `use()` call are either:
- Converted to `LabelTerminal` - if the scope body is trivial (just the hook call and a goto)
- Converted to `PrunedScopeTerminal` - if the scope body contains other instructions besides the hook call
- The `PrunedScopeTerminal` still tracks the original scope information for downstream passes but will not generate memoization code
- The control flow structure is preserved (same blocks, same fallthroughs)
## Algorithm
### Phase 1: Identify Scopes Containing Hook/Use Calls
1. Maintain a stack `activeScopes` of currently "open" reactive scopes
2. Iterate through all blocks in order
3. When entering a block:
- Remove any scopes from `activeScopes` whose fallthrough equals the current block (those scopes have ended)
4. For each instruction in the block:
- If it's a `CallExpression` or `MethodCall` and the callee is a hook or use operator:
- Add all currently active scopes to the `prune` list
- Clear `activeScopes` (these scopes are now marked for pruning)
5. If the block's terminal is a `scope`:
- Push it onto `activeScopes`
### Phase 2: Prune Identified Scopes
For each block ID in `prune`:
1. Get the scope terminal
2. Check if the scope body is trivial (single instruction + goto to fallthrough):
- If trivial: Convert to `LabelTerminal` (will be removed by `PruneUnusedLabels`)
- If non-trivial: Convert to `PrunedScopeTerminal` (preserves scope info but skips memoization)
## Key Data Structures
```typescript
// Stack tracking currently open scopes
activeScopes: Array<{block: BlockId; fallthrough: BlockId}>
// List of block IDs whose scope terminals should be pruned
prune: Array<BlockId>
// Terminal types used
LabelTerminal: {kind: 'label', block, fallthrough, id, loc}
PrunedScopeTerminal: {kind: 'pruned-scope', block, fallthrough, scope, id, loc}
ReactiveScopeTerminal: {kind: 'scope', block, fallthrough, scope, id, loc}
```
## Edge Cases
### Nested Scopes
When a hook is found in an inner scope, ALL enclosing scopes are also pruned (the hook call would become conditional if any outer scope were memoized).
### Method Call Hooks
Handles both `CallExpression` (e.g., `useHook(...)`) and `MethodCall` (e.g., `obj.useHook(...)`).
### Trivial Hook-Only Scopes
If a scope exists just for a hook call (single instruction + goto), it's converted to a `LabelTerminal` which is a simpler structure that gets cleaned up by later passes.
### Multiple Hooks in Sequence
Once the first hook is encountered, all active scopes are pruned and cleared, so subsequent hooks in outer scopes still work correctly.
## TODOs
None explicitly marked in the source file.
## Example
### Fixture: `nested-scopes-hook-call.js`
**Input:**
```javascript
function component(props) {
let x = [];
let y = [];
y.push(useHook(props.foo));
x.push(y);
return x;
}
```
**Before FlattenScopesWithHooksOrUseHIR:**
```
bb0:
[1] Scope @0 [1:22] block=bb6 fallthrough=bb7 // Outer scope for x
bb6:
[2] $22 = Array [] // x = []
[3] StoreLocal x = $22
[4] Scope @1 [4:17] block=bb8 fallthrough=bb9 // Inner scope for y
bb8:
[5] $25 = Array [] // y = []
[6] StoreLocal y = $25
...
[10] $33 = Call useHook(...) // <-- Hook call here!
[11] MethodCall y.push($33)
```
**After FlattenScopesWithHooksOrUseHIR:**
```
bb0:
[1] <pruned> Scope @0 [1:22] block=bb6 fallthrough=bb7 // PRUNED
bb6:
[2] $22 = Array []
[3] StoreLocal x = $22
[4] <pruned> Scope @1 [4:17] block=bb8 fallthrough=bb9 // PRUNED
bb8:
[5] $25 = Array []
[6] StoreLocal y = $25
...
[12] Label block=bb10 fallthrough=bb11 // Hook call converted to label
bb10:
[13] $33 = Call useHook(...)
[14] Goto bb11
...
```
**Final Output (no memoization):**
```javascript
function component(props) {
const x = [];
const y = [];
y.push(useHook(props.foo));
x.push(y);
return x;
}
```
Notice that:
1. Both scope @0 and scope @1 are marked as `<pruned>` because the hook call is inside scope @1, which is inside scope @0
2. The final output has no memoization wrappers - just the raw code

View File

@@ -0,0 +1,158 @@
# propagateScopeDependenciesHIR
## File
`src/HIR/PropagateScopeDependenciesHIR.ts`
## Purpose
The `propagateScopeDependenciesHIR` pass is responsible for computing and assigning the **dependencies** for each reactive scope in the compiled function. Dependencies are the external values that a scope reads, which determine when the scope needs to re-execute. This is a critical step for memoization correctness - the compiler must track exactly which values a scope depends on so it can generate proper cache invalidation checks.
The pass also populates:
- `scope.dependencies` - The set of `ReactiveScopeDependency` objects the scope reads
- `scope.declarations` - Values declared within the scope that are used outside it
## Input Invariants
- Reactive scopes must be established (pass runs after `BuildReactiveScopeTerminalsHIR`)
- The function must be in SSA form
- `InferMutationAliasingRanges` must have run to establish when values are being mutated
- `InferReactivePlaces` marks which identifiers are reactive
- Scope ranges have been aligned and normalized by earlier passes
## Output Guarantees
After this pass completes:
1. Each `ReactiveScope.dependencies` contains the minimal set of dependencies that:
- Were declared before the scope started
- Are read within the scope
- Are not ref values (which are always mutable)
- Are not object methods (which get codegen'd back into object literals)
2. Each `ReactiveScope.declarations` contains identifiers that:
- Are assigned within the scope
- Are used outside the scope (need to be exposed as scope outputs)
3. Property load chains are resolved to their root identifiers with paths (e.g., `props.user.name` becomes `{identifier: props, path: ["user", "name"]}`)
4. Optional chains are handled correctly, distinguishing between `a?.b` and `a.b` access types
## Algorithm
### Phase 1: Build Sidemaps
1. **findTemporariesUsedOutsideDeclaringScope**: Identifies temporaries that are used outside the scope where they were declared (cannot be hoisted/reordered safely)
2. **collectTemporariesSidemap**: Creates a mapping from temporary IdentifierIds to their source `ReactiveScopeDependency`. For example:
```
$0 = LoadLocal 'a'
$1 = PropertyLoad $0.'b'
```
Maps `$1.id` to `{identifier: a, path: [{property: 'b', optional: false}]}`
3. **collectOptionalChainSidemap**: Traverses optional chain blocks to map temporaries within optional chains to their full optional dependency path
4. **collectHoistablePropertyLoads**: Uses CFG analysis to determine which property loads can be safely hoisted
### Phase 2: Collect Dependencies
The `collectDependencies` function traverses the HIR, maintaining a stack of active scopes:
1. **Scope Entry/Exit**: When entering a scope terminal, push a new dependency array. When exiting, propagate collected dependencies to parent scopes if valid.
2. **Instruction Processing**: For each instruction:
- Declare the lvalue with its instruction id and current scope
- Visit operands to record them as potential dependencies
- Handle special cases like `StoreLocal` (tracks reassignments), `Destructure`, `PropertyLoad`, etc.
3. **Dependency Validation** (`#checkValidDependency`):
- Skip ref values (`isRefValueType`)
- Skip object methods (`isObjectMethodType`)
- Only include if declared before scope start
### Phase 3: Derive Minimal Dependencies
For each scope, use `ReactiveScopeDependencyTreeHIR` to:
1. Build a tree from hoistable property loads
2. Add all collected dependencies to the tree
3. Truncate dependencies at their maximal safe-to-evaluate subpath
4. Derive the minimal set (removing redundant nested dependencies)
## Key Data Structures
### ReactiveScopeDependency
```typescript
type ReactiveScopeDependency = {
identifier: Identifier; // Root identifier
reactive: boolean; // Whether the value is reactive
path: DependencyPathEntry[]; // Chain of property accesses
}
```
### DependencyPathEntry
```typescript
type DependencyPathEntry = {
property: PropertyLiteral; // Property name
optional: boolean; // Is this `?.` access?
}
```
### DependencyCollectionContext
Maintains:
- `#declarations`: Map of DeclarationId to {id, scope} recording where each value was declared
- `#reassignments`: Map of Identifier to latest assignment info
- `#scopes`: Stack of currently active ReactiveScopes
- `#dependencies`: Stack of dependency arrays (one per active scope)
- `#temporaries`: Sidemap for resolving property loads
### ReactiveScopeDependencyTreeHIR
A tree structure for efficient dependency deduplication that stores hoistable objects, tracks access types, and computes minimal dependencies.
## Edge Cases
### Values Used Outside Declaring Scope
If a temporary is used outside its declaring scope, it cannot be tracked in the sidemap because reordering the read would be invalid.
### Ref.current Access
Accessing `ref.current` is treated specially - the dependency is truncated to just `ref`.
### Optional Chains
Optional chains like `a?.b?.c` produce different dependency paths than `a.b.c`. The pass distinguishes them and may merge optional loads into unconditional ones when control flow proves the object is non-null.
### Inner Functions
Dependencies from inner functions are collected recursively but with special handling for context variables.
### Phi Nodes
When a value comes from multiple control flow paths, optional chain dependencies from phi operands are also visited.
## TODOs
1. Line 374-375: `// TODO(mofeiZ): understand optional chaining` - More documentation needed for optional chain handling
## Example
### Fixture: `reactive-control-dependency-if.js`
**Input:**
```javascript
function Component(props) {
let x;
if (props.cond) {
x = 1;
} else {
x = 2;
}
return [x];
}
```
**Before PropagateScopeDependenciesHIR:**
```
Scope scope @0 [12:15] dependencies=[] declarations=[] reassignments=[] block=bb9
```
**After PropagateScopeDependenciesHIR:**
```
Scope scope @0 [12:15] dependencies=[x$24:TPrimitive] declarations=[$26_@0] reassignments=[] block=bb9
```
The pass identified that:
- The scope at `[x]` depends on `x$24` (the phi node result from the if/else branches)
- Even though `x` is assigned to constants (1 or 2), its value depends on the reactive control flow condition `props.cond`
- The scope declares `$26_@0` (the array output)

View File

@@ -0,0 +1,180 @@
# buildReactiveFunction
## File
`src/ReactiveScopes/BuildReactiveFunction.ts`
## Purpose
The `buildReactiveFunction` pass converts the compiler's HIR (High-level Intermediate Representation) from a **Control Flow Graph (CFG)** representation to a **tree-based ReactiveFunction** representation that is closer to an AST. This is a critical transformation in the React Compiler pipeline that:
1. **Restores control flow constructs** - Reconstructs `if`, `while`, `for`, `switch`, and other control flow statements from the CFG's basic blocks and terminals
2. **Eliminates phi nodes** - Replaces SSA phi nodes with compound value expressions (ternaries, logical expressions, sequence expressions)
3. **Handles labeled break/continue** - Tracks control flow targets to emit explicit labeled `break` and `continue` statements when needed
4. **Preserves reactive scope information** - Scope terminals are converted to `ReactiveScopeBlock` nodes in the tree
## Input Invariants
- HIR is in SSA form (variables have been renamed with unique identifiers)
- Basic blocks are connected (valid predecessor/successor relationships)
- Each block ends with a valid terminal
- Phi nodes exist at merge points for values from different control flow paths
- Reactive scopes have been constructed (`scope` terminals exist)
- Scope dependencies are computed (`PropagateScopeDependenciesHIR` has run)
## Output Guarantees
- **Tree structure** - The output is a `ReactiveFunction` with a `body: ReactiveBlock` containing a tree of `ReactiveStatement` nodes
- **No CFG structure** - Basic blocks are eliminated; control flow is represented through nested reactive terminals
- **No phi nodes** - Value merges are represented as `ConditionalExpression`, `LogicalExpression`, or `SequenceExpression` values
- **Labels emitted for all control flow** - Every terminal that can be a break/continue target has a label; unnecessary labels are removed by subsequent `PruneUnusedLabels` pass
- **Each block emitted exactly once** - A block cannot be generated twice
- **Scope blocks preserved** - `scope` terminals become `ReactiveScopeBlock` nodes
## Algorithm
### Core Classes
1. **`Driver`** - Traverses blocks and emits ReactiveBlock arrays
2. **`Context`** - Tracks state:
- `emitted: Set<BlockId>` - Which blocks have been generated
- `#scheduled: Set<BlockId>` - Blocks that will be emitted by parent constructs
- `#controlFlowStack: Array<ControlFlowTarget>` - Stack of active break/continue targets
- `scopeFallthroughs: Set<BlockId>` - Fallthroughs for scope blocks
### Traversal Strategy
1. Start at the entry block and call `traverseBlock(entryBlock)`
2. For each block:
- Emit all instructions as `ReactiveInstructionStatement`
- Process the terminal based on its kind
### Terminal Processing
**Simple Terminals:**
- `return`, `throw` - Emit directly as `ReactiveTerminal`
- `unreachable` - No-op
**Control Flow Terminals:**
- `if` - Schedule fallthrough, recursively traverse consequent/alternate, emit `ReactiveIfTerminal`
- `while`, `do-while`, `for`, `for-of`, `for-in` - Use `scheduleLoop()` which tracks continue targets
- `switch` - Process cases in reverse order
- `label` - Schedule fallthrough, traverse inner block
**Value Terminals (expressions that produce values):**
- `ternary`, `logical`, `optional`, `sequence` - Produce `ReactiveValue` compound expressions
**Break/Continue:**
- `goto` with `GotoVariant.Break` - Determine if break is implicit, unlabeled, or labeled
- `goto` with `GotoVariant.Continue` - Determine continue type
**Scope Terminals:**
- `scope`, `pruned-scope` - Schedule fallthrough, traverse inner block, emit as `ReactiveScopeBlock`
## Key Data Structures
### ReactiveFunction
```typescript
type ReactiveFunction = {
loc: SourceLocation;
id: ValidIdentifierName | null;
params: Array<Place | SpreadPattern>;
generator: boolean;
async: boolean;
body: ReactiveBlock;
env: Environment;
directives: Array<string>;
};
```
### ReactiveBlock
```typescript
type ReactiveBlock = Array<ReactiveStatement>;
```
### ReactiveStatement
```typescript
type ReactiveStatement =
| ReactiveInstructionStatement // {kind: 'instruction', instruction}
| ReactiveTerminalStatement // {kind: 'terminal', terminal, label}
| ReactiveScopeBlock // {kind: 'scope', scope, instructions}
| PrunedReactiveScopeBlock; // {kind: 'pruned-scope', ...}
```
### ReactiveValue (for compound expressions)
```typescript
type ReactiveValue =
| InstructionValue // Regular instruction values
| ReactiveLogicalValue // a && b, a || b, a ?? b
| ReactiveSequenceValue // (a, b, c)
| ReactiveTernaryValue // a ? b : c
| ReactiveOptionalCallValue; // a?.b()
```
### ControlFlowTarget
```typescript
type ControlFlowTarget =
| {type: 'if'; block: BlockId; id: number}
| {type: 'switch'; block: BlockId; id: number}
| {type: 'case'; block: BlockId; id: number}
| {type: 'loop'; block: BlockId; continueBlock: BlockId; ...};
```
## Edge Cases
### Nested Control Flow
The scheduling mechanism handles arbitrarily nested control flow by pushing/popping from the control flow stack.
### Value Blocks with Complex Expressions
`SequenceExpression` handles cases where value blocks contain multiple instructions.
### Scope Fallthroughs
Breaks to scope fallthroughs are treated as implicit (no explicit break needed).
### Catch Handlers
Scheduled specially via `scheduleCatchHandler()` to prevent re-emission.
### Unreachable Blocks
The `reachable()` check prevents emitting unreachable blocks.
## TODOs
The code contains several `CompilerError.throwTodo()` calls for unsupported patterns:
1. Optional chaining test blocks must end in `branch`
2. Logical expression test blocks must end in `branch`
3. Support for value blocks within try/catch statements
4. Support for labeled statements combined with value blocks
## Example
### Fixture: `ternary-expression.js`
**Input:**
```javascript
function ternary(props) {
const a = props.a && props.b ? props.c || props.d : (props.e ?? props.f);
const b = props.a ? (props.b && props.c ? props.d : props.e) : props.f;
return a ? b : null;
}
```
**HIR (CFG with many basic blocks):**
The HIR contains 33 basic blocks with `Ternary`, `Logical`, `Branch`, and `Goto` terminals, plus phi nodes at merge points.
**ReactiveFunction Output (Tree):**
```
function ternary(props$62{reactive}) {
[1] $84 = Ternary
Sequence
[2] $66 = Logical
Sequence [...]
&& Sequence [...]
?
Sequence [...] // props.c || props.d
:
Sequence [...] // props.e ?? props.f
[40] StoreLocal a$99 = $98
...
[82] return $145
}
```
The transformation eliminates:
- 33 basic blocks reduced to a single tree
- Phi nodes replaced with nested `Ternary` and `Logical` value expressions
- CFG edges replaced with tree nesting

View File

@@ -0,0 +1,145 @@
# pruneUnusedLabels
## File
`src/ReactiveScopes/PruneUnusedLabels.ts`
## Purpose
The `pruneUnusedLabels` pass optimizes control flow by:
1. **Flattening labeled terminals** where the label is not reachable via a `break` or `continue` statement
2. **Marking labels as implicit** for terminals where the label exists but is never targeted
This pass removes unnecessary labeled blocks that were introduced during compilation but serve no control flow purpose in the final output. JavaScript labeled statements are only needed when there is a corresponding `break label` or `continue label` that targets them.
## Input Invariants
- The input is a `ReactiveFunction` (after conversion from HIR)
- All `break` and `continue` terminals have:
- A `target` (BlockId) indicating which label they jump to
- A `targetKind` that is one of: `'implicit'`, `'labeled'`, or `'unlabeled'`
- Each `ReactiveTerminalStatement` has an optional `label` field containing `id` and `implicit`
- The pass runs after `assertWellFormedBreakTargets` which validates break/continue targets
## Output Guarantees
- Labeled terminals where the label is unreachable are flattened into their parent block
- When flattening, trailing unlabeled `break` statements (that would just fall through) are removed
- Labels that exist but are never targeted have their `implicit` flag set to `true`
- Control flow semantics are preserved - only structurally unnecessary labels are removed
## Algorithm
The pass uses a two-phase approach with a single traversal:
**Phase 1: Collect reachable labels**
```typescript
if ((terminal.kind === 'break' || terminal.kind === 'continue') &&
terminal.targetKind === 'labeled') {
state.add(terminal.target); // Mark this label as reachable
}
```
**Phase 2: Transform terminals**
```typescript
const isReachableLabel = stmt.label !== null && state.has(stmt.label.id);
if (stmt.terminal.kind === 'label' && !isReachableLabel) {
// Flatten: extract block contents, removing trailing unlabeled break
const block = [...stmt.terminal.block];
const last = block.at(-1);
if (last?.kind === 'terminal' && last.terminal.kind === 'break' &&
last.terminal.target === null) {
block.pop(); // Remove trailing break
}
return {kind: 'replace-many', value: block};
} else {
if (!isReachableLabel && stmt.label != null) {
stmt.label.implicit = true; // Mark as implicit
}
return {kind: 'keep'};
}
```
## Edge Cases
### Trailing Break Removal
When flattening a labeled block, if the last statement is an unlabeled break (`target === null`), it is removed since it would just fall through anyway.
### Implicit vs Labeled Breaks
Only breaks with `targetKind === 'labeled'` count toward label reachability. Implicit breaks (fallthrough) and unlabeled breaks don't make a label "used".
### Continue Statements
Both `break` and `continue` with labeled targets mark the label as reachable.
### Non-Label Terminals with Labels
Other terminal types (like `if`, `while`, `for`) can also have labels. If unreachable, these labels are marked implicit but the terminal is not flattened.
## TODOs
None in the source file.
## Example
### Fixture: `unconditional-break-label.js`
**Input:**
```javascript
function foo(a) {
let x = 0;
bar: {
x = 1;
break bar;
}
return a + x;
}
```
**Output (after full compilation):**
```javascript
function foo(a) {
return a + 1;
}
```
The labeled block `bar: { ... }` is removed because after the pass runs, constant propagation and dead code elimination further simplify the code.
### Fixture: `conditional-break-labeled.js`
**Input:**
```javascript
function Component(props) {
const a = [];
a.push(props.a);
label: {
if (props.b) {
break label;
}
a.push(props.c);
}
a.push(props.d);
return a;
}
```
**Output:**
```javascript
function Component(props) {
const $ = _c(5);
let a;
if ($[0] !== props.a || $[1] !== props.b ||
$[2] !== props.c || $[3] !== props.d) {
a = [];
a.push(props.a);
bb0: {
if (props.b) {
break bb0;
}
a.push(props.c);
}
a.push(props.d);
// ... cache updates
} else {
a = $[4];
}
return a;
}
```
The labeled block `bb0: { ... }` is preserved because the `break bb0` inside the conditional targets this label.

View File

@@ -0,0 +1,130 @@
# pruneNonEscapingScopes
## File
`src/ReactiveScopes/PruneNonEscapingScopes.ts`
## Purpose
This pass prunes (removes) reactive scopes whose outputs do not "escape" the component and therefore do not need to be memoized. A value "escapes" in two ways:
1. **Returned from the function** - The value is directly returned or transitively aliased by a return value
2. **Passed to a hook** - Any value passed as an argument to a hook may be stored by React internally (e.g., the closure passed to `useEffect`)
The key insight is that values which never escape the component boundary can be safely recreated on each render without affecting the behavior of consumers.
## Input Invariants
- The input is a `ReactiveFunction` after scope blocks have been identified
- Reactive scopes have been assigned to instructions
- The pass runs after `BuildReactiveFunction` and `PruneUnusedLabels`, before `PruneNonReactiveDependencies`
## Output Guarantees
- **Scopes with non-escaping outputs are removed** - Their instructions are inlined back into the parent scope/function body
- **Scopes with escaping outputs are retained** - Values that escape via return or hook arguments remain memoized
- **Transitive dependencies of escaping scopes are preserved** - If an escaping scope depends on a non-escaping value, that value's scope is also retained to prevent unnecessary invalidation
- **`FinishMemoize` instructions are marked `pruned=true`** - When a scope is pruned, the associated memoization instructions are flagged
## Algorithm
### Phase 1: Build the Dependency Graph
Using `CollectDependenciesVisitor`, build:
- **Identifier nodes** - Each node tracks memoization level, dependencies, scopes, and whether ultimately memoized
- **Scope nodes** - Each scope tracks its dependencies
- **Escaping values** - Identifiers that escape via return or hook arguments
### Phase 2: Classify Memoization Levels
Each instruction value is classified:
- `Memoized`: Arrays, objects, function calls, `new` expressions - always potentially aliasing
- `Conditional`: Conditional/logical expressions, property loads - memoized only if dependencies are memoized
- `Unmemoized`: JSX elements (when `memoizeJsxElements` is false), DeclareLocal
- `Never`: Primitives, LoadGlobal, binary/unary expressions - can be cheaply compared
### Phase 3: Compute Memoized Identifiers
`computeMemoizedIdentifiers()` performs a graph traversal starting from escaping values:
- For each escaping value, recursively visit its dependencies
- Mark values and their scopes based on memoization level
- When marking a scope, force-memoize all its dependencies
### Phase 4: Prune Scopes
`PruneScopesTransform` visits each scope block:
- If any scope output is in the memoized set, keep the scope
- If no outputs are memoized, replace the scope block with its inlined instructions
## Edge Cases
### Interleaved Mutations
```javascript
const a = [props.a]; // independently memoizable, non-escaping
const b = [];
const c = {};
c.a = a; // c captures a, but c doesn't escape
b.push(props.b); // b escapes via return
return b;
```
Here `a` does not directly escape, but it is a dependency of the scope containing `b`. The algorithm correctly identifies that `a`'s scope must be preserved.
### Hook Arguments Escape
Values passed to hooks are treated as escaping because hooks may store references internally.
### JSX Special Handling
JSX elements are marked as `Unmemoized` by default because React.memo() can handle dynamic memoization.
### noAlias Functions
If a function signature indicates `noAlias === true`, its arguments are not treated as escaping.
### Reassignments
When a scope reassigns a variable, the scope is added as a dependency of that variable.
## TODOs
None explicitly in the source file.
## Example
### Fixture: `escape-analysis-non-escaping-interleaved-allocating-dependency.js`
**Input:**
```javascript
function Component(props) {
const a = [props.a];
const b = [];
const c = {};
c.a = a;
b.push(props.b);
return b;
}
```
**Output:**
```javascript
function Component(props) {
const $ = _c(5);
let t0;
if ($[0] !== props.a) {
t0 = [props.a];
$[0] = props.a;
$[1] = t0;
} else {
t0 = $[1];
}
const a = t0; // a is memoized even though it doesn't escape directly
let b;
if ($[2] !== a || $[3] !== props.b) {
b = [];
const c = {}; // c is NOT memoized - it doesn't escape
c.a = a;
b.push(props.b);
$[2] = a;
$[3] = props.b;
$[4] = b;
} else {
b = $[4];
}
return b;
}
```
Key observations:
- `a` is memoized because it's a dependency of the scope containing `b`
- `c` is not separately memoized because it doesn't escape
- `b` is memoized because it's returned

View File

@@ -0,0 +1,138 @@
# pruneNonReactiveDependencies
## File
`src/ReactiveScopes/PruneNonReactiveDependencies.ts`
## Purpose
This pass removes dependencies from reactive scopes that are guaranteed to be **non-reactive** (i.e., their values cannot change between renders). This optimization reduces unnecessary memoization invalidations by ensuring scopes only depend on values that can actually change.
The pass complements `PropagateScopeDependencies`, which infers dependencies without considering reactivity. This subsequent pruning step filters out dependencies that are semantically constant.
## Input Invariants
- The function has been converted to a ReactiveFunction structure
- `InferReactivePlaces` has annotated places with `{reactive: true}` where values can change
- Each `ReactiveScopeBlock` has a `scope.dependencies` set populated by `PropagateScopeDependenciesHIR`
- Type inference has run, so identifiers have type information for `isStableType` checks
## Output Guarantees
- **Non-reactive dependencies removed**: All dependencies in `scope.dependencies` are reactive after this pass
- **Scope outputs marked reactive if needed**: If a scope has any reactive dependencies remaining, all its outputs are marked reactive
- **Stable types remain non-reactive through property loads**: When loading properties from stable types (like `useReducer` dispatch functions), the result is not added to the reactive set
## Algorithm
### Phase 1: Collect Reactive Identifiers
The `collectReactiveIdentifiers` helper builds the initial set of reactive identifiers by:
1. Visiting all places in the ReactiveFunction
2. Adding any place marked `{reactive: true}` to the set
3. For pruned scopes, adding declarations that are not primitives and not stable ref types
### Phase 2: Propagate Reactivity and Prune Dependencies
The main `Visitor` class traverses the ReactiveFunction and:
1. **For Instructions** - Propagates reactivity through data flow:
- `LoadLocal`: If source is reactive, mark the lvalue as reactive
- `StoreLocal`: If source value is reactive, mark both the local variable and lvalue as reactive
- `Destructure`: If source is reactive, mark all pattern operands as reactive (except stable types)
- `PropertyLoad`: If object is reactive AND result is not a stable type, mark result as reactive
- `ComputedLoad`: If object OR property is reactive, mark result as reactive
2. **For Scopes** - Prunes non-reactive dependencies and propagates outputs:
- Delete each dependency from `scope.dependencies` if its identifier is not in the reactive set
- If any dependencies remain after pruning, mark all scope outputs as reactive
### Key Insight: Stable Types
The pass leverages `isStableType` to prevent reactivity from flowing through certain React-provided stable values:
```typescript
function isStableType(id: Identifier): boolean {
return (
isSetStateType(id) || // useState setter
isSetActionStateType(id) || // useActionState setter
isDispatcherType(id) || // useReducer dispatcher
isUseRefType(id) || // useRef result
isStartTransitionType(id) ||// useTransition startTransition
isSetOptimisticType(id) // useOptimistic setter
);
}
```
## Edge Cases
### Unmemoized Values Spanning Hook Calls
A value created before a hook call and mutated after cannot be memoized. However, if it's non-reactive, it still should not appear as a dependency of downstream scopes.
### Stable Types from Reactive Containers
When `useReducer` returns `[state, dispatch]`, `state` is reactive but `dispatch` is stable. The pass correctly handles this.
### Pruned Scopes with Reactive Content
The `CollectReactiveIdentifiers` pass also examines pruned scopes and adds their non-primitive, non-stable-ref declarations to the reactive set.
### Transitive Reactivity Through Scopes
When a scope retains at least one reactive dependency, ALL its outputs become reactive.
## TODOs
None in the source file.
## Example
### Fixture: `unmemoized-nonreactive-dependency-is-pruned-as-dependency.js`
**Input:**
```javascript
function Component(props) {
const x = [];
useNoAlias();
mutate(x);
return <div>{x}</div>;
}
```
**Before PruneNonReactiveDependencies:**
```
scope @2 dependencies=[x$15_@0:TObject<BuiltInArray>] declarations=[$23_@2]
```
**After PruneNonReactiveDependencies:**
```
scope @2 dependencies=[] declarations=[$23_@2]
```
The dependency on `x` is removed because `x` is created locally and therefore non-reactive.
### Fixture: `useReducer-returned-dispatcher-is-non-reactive.js`
**Input:**
```javascript
function f() {
const [state, dispatch] = useReducer();
const onClick = () => {
dispatch();
};
return <div onClick={onClick} />;
}
```
**Generated Code:**
```javascript
function f() {
const $ = _c(1);
const [, dispatch] = useReducer();
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
const onClick = () => {
dispatch();
};
t0 = <div onClick={onClick} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
```
The `onClick` function only captures `dispatch`, which is a stable type. Therefore, `onClick` is non-reactive, and the JSX element can be memoized with zero dependencies.

View File

@@ -0,0 +1,111 @@
# pruneUnusedScopes
## File
`src/ReactiveScopes/PruneUnusedScopes.ts`
## Purpose
This pass converts reactive scopes that have no meaningful outputs into "pruned scopes". A pruned scope is no longer memoized - its instructions are executed unconditionally on every render. This optimization removes unnecessary memoization overhead for scopes that don't produce values that need to be cached.
## Input Invariants
- The input is a `ReactiveFunction` that has already been transformed into reactive scope form
- Scopes have been created and have `declarations`, `reassignments`, and potentially `earlyReturnValue` populated
- The pass is called after:
- `pruneUnusedLabels` - cleans up unnecessary labels
- `pruneNonEscapingScopes` - removes scopes whose outputs don't escape
- `pruneNonReactiveDependencies` - removes non-reactive dependencies from scopes
- Scopes may already be marked as pruned by earlier passes
## Output Guarantees
Scopes that meet ALL of the following criteria are converted to `pruned-scope`:
- No return statement within the scope
- No reassignments (`scope.reassignments.size === 0`)
- Either no declarations (`scope.declarations.size === 0`), OR all declarations "bubbled up" from inner scopes
Pruned scopes:
- Keep their original scope metadata (for debugging/tracking)
- Keep their instructions intact
- Will be executed unconditionally during codegen (no memoization check)
## Algorithm
The pass uses the visitor pattern with `ReactiveFunctionTransform`:
1. **State Tracking**: A `State` object tracks whether a return statement was encountered:
```typescript
type State = {
hasReturnStatement: boolean;
};
```
2. **Terminal Visitor** (`visitTerminal`): Checks if any terminal is a `return` statement
3. **Scope Transform** (`transformScope`): For each scope:
- Creates a fresh state for this scope
- Recursively visits the scope's contents
- Checks pruning criteria:
- `!scopeState.hasReturnStatement` - no early return
- `scope.reassignments.size === 0` - no reassignments
- `scope.declarations.size === 0` OR `!hasOwnDeclaration(scopeBlock)` - no outputs
4. **hasOwnDeclaration Helper**: Determines if a scope has "own" declarations vs declarations propagated from nested scopes
## Edge Cases
### Return Statements
Scopes containing return statements are preserved because early returns need memoization to avoid re-executing the return check on every render.
### Bubbled-Up Declarations
When nested scopes are flattened or merged, their declarations may be propagated to parent scopes. The `hasOwnDeclaration` check ensures that parent scopes with only inherited declarations can still be pruned.
### Reassignments
Scopes with reassignments are kept because the reassignment represents a side effect that needs to be tracked for memoization.
### Already-Pruned Scopes
The pass operates on `ReactiveScopeBlock` (kind: 'scope'), not `PrunedReactiveScopeBlock`. Scopes already pruned by earlier passes are not revisited.
### Interaction with Subsequent Passes
The `MergeReactiveScopesThatInvalidateTogether` pass explicitly handles pruned scopes - it does not merge across them.
## TODOs
None in the source file.
## Example
### Fixture: `prune-scopes-whose-deps-invalidate-array.js`
**Input:**
```javascript
function Component(props) {
const x = [];
useHook();
x.push(props.value);
const y = [x];
return [y];
}
```
What happens:
- The scope for `x` cannot be memoized because `useHook()` is called inside it
- `FlattenScopesWithHooksOrUseHIR` marks scope @0 as `pruned-scope`
- `PruneUnusedScopes` doesn't change it further since it's already pruned
**Output (no memoization for x):**
```javascript
function Component(props) {
const x = [];
useHook();
x.push(props.value);
const y = [x];
return [y];
}
```
### Key Insight
The `pruneUnusedScopes` pass is part of a multi-pass pruning strategy:
1. `FlattenScopesWithHooksOrUseHIR` - Prunes scopes that contain hook/use calls
2. `pruneNonEscapingScopes` - Prunes scopes whose outputs don't escape
3. `pruneNonReactiveDependencies` - Removes non-reactive dependencies
4. **`pruneUnusedScopes`** - Prunes scopes with no remaining outputs
This pass acts as a cleanup for scopes that became "empty" after previous pruning passes removed their outputs.

View File

@@ -0,0 +1,213 @@
# mergeReactiveScopesThatInvalidateTogether
## File
`src/ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts`
## Purpose
This pass is an optimization that reduces memoization overhead in the compiled output by merging reactive scopes that will always invalidate together. The pass operates on the ReactiveFunction representation and works in two main scenarios:
1. **Consecutive Scopes**: When two scopes appear sequentially in the same reactive block with identical dependencies (or where the output of the first scope is the sole input to the second), they are merged into a single scope. This reduces the number of memo cache slots used and eliminates redundant dependency comparisons.
2. **Nested Scopes**: When an inner scope has the same dependencies as its parent scope, the inner scope is flattened into the parent. Since PropagateScopeDependencies propagates dependencies upward, nested scopes can only have equal or fewer dependencies than their parents, never more. When they're equal, the inner scope always invalidates with the parent, making it safe and beneficial to flatten.
## Input Invariants
- The ReactiveFunction has already undergone scope dependency propagation (via `PropagateScopeDependencies`)
- The function has been pruned of unused scopes (via `pruneNonReactiveDependencies` and `pruneUnusedScopes`)
- Scopes have valid `dependencies`, `declarations`, `range`, and `reassignments` fields
- The ReactiveFunction is in a valid structural state with properly formed blocks and instructions
## Output Guarantees
- **Fewer scopes**: Consecutive and nested scopes with identical dependencies are merged
- **Valid scope ranges**: Merged scopes have their `range.end` updated to cover all merged instructions
- **Updated declarations**: Scope declarations are updated to remove any that are no longer used after the merged scope
- **Merged scope tracking**: The `scope.merged` set tracks which scope IDs were merged into each surviving scope
- **Preserved semantics**: Only safe-to-memoize intermediate instructions are absorbed into merged scopes
## Algorithm
The pass operates in multiple phases:
### Phase 1: Find Last Usage
A visitor (`FindLastUsageVisitor`) collects the last usage instruction ID for each declaration:
```typescript
class FindLastUsageVisitor extends ReactiveFunctionVisitor<void> {
lastUsage: Map<DeclarationId, InstructionId> = new Map();
override visitPlace(id: InstructionId, place: Place, _state: void): void {
const previousUsage = this.lastUsage.get(place.identifier.declarationId);
const lastUsage =
previousUsage !== undefined
? makeInstructionId(Math.max(previousUsage, id))
: id;
this.lastUsage.set(place.identifier.declarationId, lastUsage);
}
}
```
### Phase 2: Transform (Nested Scope Flattening)
The `transformScope` method flattens nested scopes with identical dependencies:
```typescript
override transformScope(
scopeBlock: ReactiveScopeBlock,
state: ReactiveScopeDependencies | null,
): Transformed<ReactiveStatement> {
this.visitScope(scopeBlock, scopeBlock.scope.dependencies);
if (
state !== null &&
areEqualDependencies(state, scopeBlock.scope.dependencies)
) {
return {kind: 'replace-many', value: scopeBlock.instructions};
} else {
return {kind: 'keep'};
}
}
```
### Phase 3: Visit Block (Consecutive Scope Merging)
Within `visitBlock`, the pass:
1. First traverses nested blocks recursively
2. Iterates through instructions, tracking merge candidates
3. Determines if consecutive scopes can merge based on:
- Identical dependencies, OR
- Output of first scope is input to second scope (with always-invalidating types)
4. Collects intermediate lvalues and ensures they're only used by the next scope
5. Merges eligible scopes by combining instructions and updating range/declarations
### Key Merging Conditions (`canMergeScopes`):
```typescript
function canMergeScopes(
current: ReactiveScopeBlock,
next: ReactiveScopeBlock,
temporaries: Map<DeclarationId, DeclarationId>,
): boolean {
// Don't merge scopes with reassignments
if (current.scope.reassignments.size !== 0 || next.scope.reassignments.size !== 0) {
return false;
}
// Merge scopes whose dependencies are identical
if (areEqualDependencies(current.scope.dependencies, next.scope.dependencies)) {
return true;
}
// Merge scopes where outputs of previous are inputs of next
// (with always-invalidating type check)
// ...
}
```
### Always-Invalidating Types:
```typescript
export function isAlwaysInvalidatingType(type: Type): boolean {
switch (type.kind) {
case 'Object': {
switch (type.shapeId) {
case BuiltInArrayId:
case BuiltInObjectId:
case BuiltInFunctionId:
case BuiltInJsxId: {
return true;
}
}
break;
}
case 'Function': {
return true;
}
}
return false;
}
```
## Edge Cases
### Terminals
The pass does not merge across terminals (control flow boundaries).
### Pruned Scopes
Merging stops at pruned scopes.
### Reassignments
Scopes containing reassignments cannot be merged (side-effect ordering concerns).
### Intermediate Reassignments
Non-const StoreLocal instructions between scopes prevent merging.
### Safe Intermediate Instructions
Only certain instruction types are allowed between merged scopes: `BinaryExpression`, `ComputedLoad`, `JSXText`, `LoadGlobal`, `LoadLocal`, `Primitive`, `PropertyLoad`, `TemplateLiteral`, `UnaryExpression`, and const `StoreLocal`.
### Lvalue Usage
Intermediate values must be last-used at or before the next scope to allow merging.
### Non-Invalidating Outputs
If a scope's output may not change when inputs change (e.g., `foo(x) { return x < 10 }` returns same boolean for different x values), that scope cannot be a merge candidate for subsequent scopes.
## TODOs
```typescript
/*
* TODO LeaveSSA: use IdentifierId for more precise tracking
* Using DeclarationId is necessary for compatible output but produces suboptimal results
* in cases where a scope defines a variable, but that version is never read and always
* overwritten later.
* see reassignment-separate-scopes.js for example
*/
lastUsage: Map<DeclarationId, InstructionId> = new Map();
```
## Example
### Fixture: `merge-consecutive-scopes-deps-subset-of-decls.js`
**Input:**
```javascript
import {useState} from 'react';
function Component() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
```
**After MergeReactiveScopesThatInvalidateTogether** (from `yarn snap -p merge-consecutive-scopes-deps-subset-of-decls.js -d`):
```
scope @1 [7:24] dependencies=[count$32:TPrimitive] declarations=[$51_@5] reassignments=[] {
[8] $35_@1 = Function @context[setCount$33, count$32] // decrement callback
[10] $41 = JSXText "Decrement"
[12] $42_@2 = JSX <button onClick={$35_@1}>{$41}</button>
[15] $43_@3 = Function @context[setCount$33, count$32] // increment callback
[17] $49 = JSXText "Increment"
[19] $50_@4 = JSX <button onClick={$43_@3}>{$49}</button>
[22] $51_@5 = JSX <div>{$42_@2}{$50_@4}</div>
}
```
All scopes are merged because they share `count` as a dependency. Without merging, this would have separate scopes for each callback and button element.
**Generated Code:**
```javascript
function Component() {
const $ = _c(2);
const [count, setCount] = useState(0);
let t0;
if ($[0] !== count) {
t0 = (
<div>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
$[0] = count;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
```
The merged version uses only 2 cache slots instead of potentially 6-8.

View File

@@ -0,0 +1,143 @@
# pruneAlwaysInvalidatingScopes
## File
`src/ReactiveScopes/PruneAlwaysInvalidatingScopes.ts`
## Purpose
This pass identifies and prunes reactive scopes whose dependencies will *always* invalidate on every render, making memoization pointless. Specifically, it tracks values that are guaranteed to be new allocations (arrays, objects, JSX, new expressions) and checks if those values are used outside of any memoization scope. When a downstream scope depends on such an unmemoized always-invalidating value, the scope is pruned because it would re-execute on every render anyway.
The optimization avoids wasted comparisons in the generated code. Without this pass, the compiler would emit dependency checks for scopes that will never cache-hit, adding runtime overhead with no benefit. By converting these scopes to `pruned-scope` nodes, the codegen emits the instructions inline without memoization guards.
## Input Invariants
- The pass expects a `ReactiveFunction` with scopes already formed
- Scopes should have their `dependencies` populated with the identifiers they depend on
- The pass runs after `MergeReactiveScopesThatInvalidateTogether`
- Hook calls have already caused scope flattening via `FlattenScopesWithHooksOrUseHIR`
## Output Guarantees
- Scopes that depend on unmemoized always-invalidating values are converted to `pruned-scope` nodes
- The `unmemoizedValues` set correctly propagates through `StoreLocal`/`LoadLocal` instructions
- All declarations and reassignments within pruned scopes that are themselves always-invalidating are added to `unmemoizedValues`, enabling cascading pruning of downstream scopes
## Algorithm
The pass uses a `ReactiveFunctionTransform` visitor with two key methods:
### 1. `transformInstruction` - Tracks always-invalidating values:
```typescript
switch (value.kind) {
case 'ArrayExpression':
case 'ObjectExpression':
case 'JsxExpression':
case 'JsxFragment':
case 'NewExpression': {
if (lvalue !== null) {
this.alwaysInvalidatingValues.add(lvalue.identifier);
if (!withinScope) {
this.unmemoizedValues.add(lvalue.identifier); // Key: only if outside a scope
}
}
break;
}
// Also propagates through StoreLocal and LoadLocal
}
```
### 2. `transformScope` - Prunes scopes with unmemoized dependencies:
```typescript
for (const dep of scopeBlock.scope.dependencies) {
if (this.unmemoizedValues.has(dep.identifier)) {
// Propagate unmemoized status to scope outputs
for (const [_, decl] of scopeBlock.scope.declarations) {
if (this.alwaysInvalidatingValues.has(decl.identifier)) {
this.unmemoizedValues.add(decl.identifier);
}
}
return {
kind: 'replace',
value: {
kind: 'pruned-scope',
scope: scopeBlock.scope,
instructions: scopeBlock.instructions,
},
};
}
}
```
## Edge Cases
### Function Calls Not Considered Always-Invalidating
The pass optimistically assumes function calls may return primitives, so `makeArray()` doesn't trigger pruning even though it might return a new array.
### Conditional Allocations
Code like `x = cond ? [] : 42` doesn't trigger pruning because the value might be a primitive.
### Propagation Through Locals
The pass correctly tracks values through `StoreLocal` and `LoadLocal` to handle variable reassignments and loads.
### Cascading Pruning
When a scope is pruned, its always-invalidating outputs become unmemoized, potentially causing downstream scopes to be pruned as well.
## TODOs
None in the source file.
## Example
### Fixture: `prune-scopes-whose-deps-invalidate-array.js`
**Input:**
```javascript
function Component(props) {
const x = [];
useHook();
x.push(props.value);
const y = [x];
return [y];
}
```
**After PruneAlwaysInvalidatingScopes** (from `yarn snap -p prune-scopes-whose-deps-invalidate-array.js -d`):
```
<pruned> scope @0 [1:14] dependencies=[] declarations=[x$21_@0] reassignments=[] {
[2] $20_@0 = Array []
[3] StoreLocal Const x$21_@0 = $20_@0
[4] $23 = LoadGlobal import { useHook }
[6] $24_@1 = Call $23() // Hook flattens scope
[7] break bb9 (implicit)
[8] $25_@0 = LoadLocal x$21_@0
[9] $26 = PropertyLoad $25_@0.push
[10] $27 = LoadLocal props$19
[11] $28 = PropertyLoad $27.value
[12] $29 = MethodCall $25_@0.$26($28)
}
[14] $30 = LoadLocal x$21_@0
<pruned> scope @2 [15:23] dependencies=[x$21_@0:TObject<BuiltInArray>] declarations=[$35_@3] {
[16] $31_@2 = Array [$30]
[18] StoreLocal Const y$32 = $31_@2
[19] $34 = LoadLocal y$32
[21] $35_@3 = Array [$34]
}
[23] return $35_@3
```
Key observations:
- Scope @0 is pruned because the hook call (`useHook()`) flattens it (hook rules prevent memoization around hooks)
- `x` is an `ArrayExpression` created in the pruned scope @0, making it unmemoized
- Scope @2 depends on `x$21_@0` which is unmemoized and always-invalidating (it's an array)
- Therefore, scope @2 is also pruned - cascading pruning
**Generated Code:**
```javascript
function Component(props) {
const x = [];
useHook();
x.push(props.value);
const y = [x];
return [y];
}
```
The output matches the input because all memoization was pruned - the code runs unconditionally on every render.

View File

@@ -0,0 +1,183 @@
# propagateEarlyReturns
## File
`src/ReactiveScopes/PropagateEarlyReturns.ts`
## Purpose
The `propagateEarlyReturns` pass ensures that reactive scopes (memoization blocks) correctly honor the control flow behavior of the original code, particularly when a function returns early from within a reactive scope. Without this transformation, if a component returned early on the previous render and the inputs have not changed, the cached memoization block would be skipped entirely, but the early return would not occur, causing incorrect behavior.
The pass solves this by transforming `return` statements inside reactive scopes into assignments to a temporary variable followed by a labeled `break`. After the reactive scope completes, generated code checks whether the early return sentinel value was replaced with an actual return value; if so, the function returns that value.
## Input Invariants
1. **ReactiveFunction structure**: The input must be a `ReactiveFunction` with scopes already inferred (reactive scope blocks are already established)
2. **Scope earlyReturnValue not set**: The pass expects `scopeBlock.scope.earlyReturnValue === null` for scopes it processes
3. **Return statements within reactive scopes**: The pass specifically targets `return` terminal statements that appear within a `withinReactiveScope` context
## Output Guarantees
1. **Labeled scope blocks**: Top-level reactive scopes containing early returns are wrapped in a labeled block (e.g., `bb14: { ... }`)
2. **Sentinel initialization**: At the start of each such scope, a temporary variable is initialized to `Symbol.for("react.early_return_sentinel")`
3. **Return-to-break transformation**: All `return` statements inside the scope are replaced with:
- An assignment of the return value to the early return temporary
- A `break` to the scope's label
4. **Early return declaration**: The temporary variable is registered as a declaration of the scope so it gets memoized
5. **Post-scope check**: During codegen, an if-statement is added after the scope to check if the temporary differs from the sentinel and return it if so
## Algorithm
The pass uses a visitor pattern with a `ReactiveFunctionTransform` that tracks two pieces of state:
```typescript
type State = {
withinReactiveScope: boolean; // Are we inside a reactive scope?
earlyReturnValue: ReactiveScope['earlyReturnValue']; // Bubble up early return info
};
```
### Key Steps:
1. **visitScope** - When entering a reactive scope:
- Create an inner state with `withinReactiveScope: true`
- Traverse the scope's contents
- If any early returns were found (`earlyReturnValue !== null`):
- If this is the **outermost** scope (parent's `withinReactiveScope` is false):
- Store the early return info on the scope
- Add the temporary as a scope declaration
- Prepend sentinel initialization instructions
- Wrap the original instructions in a labeled block
- Otherwise, propagate the early return info to the parent scope
2. **transformTerminal** - When encountering a `return` inside a reactive scope:
- Create or reuse an early return value identifier
- Replace the return with:
```typescript
[
{kind: 'instruction', /* StoreLocal: reassign earlyReturnValue = returnValue */},
{kind: 'terminal', /* break to earlyReturnValue.label */}
]
```
### Sentinel Initialization Code (synthesized at scope start):
```typescript
// Load Symbol.for and call it with the sentinel string
let t0 = Symbol.for("react.early_return_sentinel");
```
## Edge Cases
### Nested Reactive Scopes
When early returns occur in nested scopes, only the **outermost** scope gets the labeled block wrapper. Inner scopes bubble their early return information up via `parentState.earlyReturnValue`.
### Multiple Early Returns in Same Scope
All returns share the same temporary variable and label. The first return found creates the identifier, subsequent returns reuse it.
### Partial Early Returns
When only some control flow paths return early (e.g., one branch returns, the other falls through), the sentinel check after the scope allows normal execution to continue if no early return occurred.
### Already Processed Scopes
If `scopeBlock.scope.earlyReturnValue !== null` on entry, the pass exits early without modification.
### Returns Outside Reactive Scopes
The pass only transforms returns where `state.withinReactiveScope === true`. Returns outside scopes are left unchanged.
## TODOs
None in the source file.
## Example
### Fixture: `early-return-within-reactive-scope.js`
**Input:**
```javascript
function Component(props) {
let x = [];
if (props.cond) {
x.push(props.a);
return x;
} else {
return makeArray(props.b);
}
}
```
**After PropagateEarlyReturns** (from `yarn snap -p early-return-within-reactive-scope.js -d`):
```
scope @0 [...] earlyReturn={id: #t34$34, label: 14} {
[0] $36 = LoadGlobal(global) Symbol
[0] $37 = PropertyLoad $36.for
[0] $38 = "react.early_return_sentinel"
[0] $35 = MethodCall $36.$37($38)
[0] StoreLocal Let #t34$34{reactive} = $35 // Initialize sentinel
bb14: {
[2] $19_@0 = Array []
[3] StoreLocal Const x$20_@0 = $19_@0
[4] $22{reactive} = LoadLocal props$18
[5] $23{reactive} = PropertyLoad $22.cond
[6] if ($23) {
[7] $24_@0 = LoadLocal x$20_@0
[8] $25 = PropertyLoad $24_@0.push
[9] $26 = LoadLocal props$18
[10] $27 = PropertyLoad $26.a
[11] $28 = MethodCall $24_@0.$25($27)
[12] $29 = LoadLocal x$20_@0
[0] StoreLocal Reassign #t34$34 = $29 // was: return x
[0] break bb14 (labeled)
} else {
[14] $30 = LoadGlobal import { makeArray }
[15] $31 = LoadLocal props$18
[16] $32 = PropertyLoad $31.b
scope @1 [...] {
[18] $33_@1 = Call $30($32)
}
[0] StoreLocal Reassign #t34$34 = $33_@1 // was: return makeArray(props.b)
[0] break bb14 (labeled)
}
}
}
```
Key observations:
- Scope @0 now has `earlyReturn={id: #t34$34, label: 14}`
- Sentinel initialization code is prepended to the scope
- The scope body is wrapped in `bb14: { ... }`
- Both `return x` and `return makeArray(props.b)` are transformed to `StoreLocal Reassign + break bb14`
**Generated Code:**
```javascript
function Component(props) {
const $ = _c(6);
let t0;
if ($[0] !== props.a || $[1] !== props.b || $[2] !== props.cond) {
t0 = Symbol.for("react.early_return_sentinel");
bb0: {
const x = [];
if (props.cond) {
x.push(props.a);
t0 = x;
break bb0;
} else {
let t1;
if ($[4] !== props.b) {
t1 = makeArray(props.b);
$[4] = props.b;
$[5] = t1;
} else {
t1 = $[5];
}
t0 = t1;
break bb0;
}
}
$[0] = props.a;
$[1] = props.b;
$[2] = props.cond;
$[3] = t0;
} else {
t0 = $[3];
}
if (t0 !== Symbol.for("react.early_return_sentinel")) {
return t0;
}
}
```
This transformation ensures that when inputs don't change, the cached return value is used and returned, preserving referential equality and correct early return behavior.

View File

@@ -0,0 +1,203 @@
# promoteUsedTemporaries
## File
`src/ReactiveScopes/PromoteUsedTemporaries.ts`
## Purpose
This pass promotes temporary variables (identifiers with no name) to named variables when they need to be referenced across scope boundaries or in code generation. Temporaries are intermediate values that the compiler creates during lowering; they are typically inlined at their use sites during codegen. However, some temporaries must be emitted as separate declarations - this pass identifies and names them.
The pass ensures that:
1. Scope dependencies and declarations have proper names for codegen
2. Variables referenced across reactive scope boundaries are named
3. JSX tag identifiers get special naming (`T0`, `T1`, etc.)
4. Temporaries with interposing side-effects are promoted to preserve ordering
## Input Invariants
- The ReactiveFunction has undergone scope construction and dependency propagation
- Identifiers may have `name === null` (temporaries) or be named
- Scopes have `dependencies`, `declarations`, and `reassignments` populated
- Pruned scopes are properly marked with `kind: 'pruned-scope'`
## Output Guarantees
- All scope dependencies have non-null names
- All scope declarations have non-null names
- JSX tag temporaries use uppercase naming (`T0`, `T1`, ...)
- Regular temporaries use lowercase naming (`#t{id}`)
- All instances of a promoted identifier share the same name (via DeclarationId tracking)
- Temporaries with interposing mutating instructions are promoted to preserve source ordering
## Algorithm
The pass operates in four phases using visitor classes:
### Phase 1: CollectPromotableTemporaries
Collects information about which temporaries may need promotion:
```typescript
class CollectPromotableTemporaries {
// Tracks pruned scope declarations and whether they're used outside their scope
pruned: Map<DeclarationId, {activeScopes: Array<ScopeId>; usedOutsideScope: boolean}>
// Tracks identifiers used as JSX tags (need uppercase names)
tags: Set<DeclarationId>
}
```
- When visiting a `JsxExpression`, adds the tag identifier to `tags`
- When visiting a `PrunedScope`, records its declarations
- Tracks when pruned declarations are used in different scopes
### Phase 2: PromoteTemporaries
Promotes temporaries that appear in positions requiring names:
```typescript
override visitScope(scopeBlock: ReactiveScopeBlock, state: State): void {
// Promote all dependencies without names
for (const dep of scopeBlock.scope.dependencies) {
if (identifier.name == null) {
promoteIdentifier(identifier, state);
}
}
// Promote all declarations without names
for (const [, declaration] of scopeBlock.scope.declarations) {
if (declaration.identifier.name == null) {
promoteIdentifier(declaration.identifier, state);
}
}
}
```
Also promotes:
- Function parameters without names
- Pruned scope declarations used outside their scope
### Phase 3: PromoteInterposedTemporaries
Handles ordering-sensitive promotion:
```typescript
class PromoteInterposedTemporaries {
// Instructions that emit as statements can interpose between temp defs and uses
// If such an instruction occurs, mark pending temporaries as needing promotion
override visitInstruction(instruction: ReactiveInstruction, state: InterState): void {
// For instructions that become statements (calls, stores, etc.):
if (willBeStatement && !constStore) {
// Mark all pending temporaries as needing promotion
for (const [key, [ident, _]] of state.entries()) {
state.set(key, [ident, true]); // Mark as needing promotion
}
}
}
}
```
This preserves source ordering when side-effects occur between a temporary's definition and use.
### Phase 4: PromoteAllInstancesOfPromotedTemporaries
Ensures all instances of a promoted identifier share the same name:
```typescript
class PromoteAllInstancesOfPromotedTemporaries {
override visitPlace(_id: InstructionId, place: Place, state: State): void {
if (place.identifier.name === null &&
state.promoted.has(place.identifier.declarationId)) {
promoteIdentifier(place.identifier, state);
}
}
}
```
### Naming Convention
```typescript
function promoteIdentifier(identifier: Identifier, state: State): void {
if (state.tags.has(identifier.declarationId)) {
promoteTemporaryJsxTag(identifier); // Uses #T{id} for JSX tags
} else {
promoteTemporary(identifier); // Uses #t{id} for regular temps
}
state.promoted.add(identifier.declarationId);
}
```
## Edge Cases
### JSX Tag Temporaries
JSX tags require uppercase names to be valid JSX syntax. The pass tracks which temporaries are used as JSX tags and uses `T0`, `T1`, etc. instead of `t0`, `t1`.
### Pruned Scope Declarations
Declarations in pruned scopes are only promoted if they're actually used outside the pruned scope, avoiding unnecessary variable declarations.
### Const vs Let Temporaries
The pass tracks const identifiers specially - they don't need promotion for ordering purposes since they can't be mutated by interposing instructions.
### Global Loads
Values loaded from globals (and their property loads) are treated as const-like for promotion purposes.
### Method Call Properties
The property identifier in a method call is treated as const-like to avoid unnecessary promotion.
## TODOs
None in the source file.
## Example
### Fixture: `simple.js`
**Input:**
```javascript
export default function foo(x, y) {
if (x) {
return foo(false, y);
}
return [y * 10];
}
```
**Before PromoteUsedTemporaries:**
```
scope @0 [...] dependencies=[y$14] declarations=[$19_@0]
scope @1 [...] dependencies=[$22] declarations=[$23_@1]
```
**After PromoteUsedTemporaries:**
```
scope @0 [...] dependencies=[y$14] declarations=[#t5$19_@0]
scope @1 [...] dependencies=[#t9$22] declarations=[#t10$23_@1]
```
Key observations:
- `$19_@0` is promoted to `#t5$19_@0` because it's a scope declaration
- `$22` is promoted to `#t9$22` because it's a scope dependency
- `$23_@1` is promoted to `#t10$23_@1` because it's a scope declaration
- The `#t` prefix indicates this is a promoted temporary (later renamed by `renameVariables`)
**Generated Code:**
```javascript
import { c as _c } from "react/compiler-runtime";
export default function foo(x, y) {
const $ = _c(4);
if (x) {
let t0;
if ($[0] !== y) {
t0 = foo(false, y);
$[0] = y;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
const t0 = y * 10;
let t1;
if ($[2] !== t0) {
t1 = [t0];
$[2] = t0;
$[3] = t1;
} else {
t1 = $[3];
}
return t1;
}
```
The promoted temporaries (`#t5`, `#t9`, `#t10`) become the named variables (`t0`, `t1`) in the output after `renameVariables` runs.

View File

@@ -0,0 +1,200 @@
# renameVariables
## File
`src/ReactiveScopes/RenameVariables.ts`
## Purpose
This pass ensures that every named variable in the function has a unique name that doesn't conflict with other variables in the same block scope or with global identifiers. After scope construction and temporary promotion, variables from different source scopes may end up in the same reactive block - this pass resolves any naming conflicts.
The pass also converts the `#t{id}` promoted temporary names into clean output names like `t0`, `t1`, etc.
## Input Invariants
- The ReactiveFunction has been through `promoteUsedTemporaries`
- Variables may have names that conflict with:
- Other variables in the same or ancestor block scope
- Global identifiers referenced by the function
- Promoted temporaries with `#t{id}` or `#T{id}` naming
- The function parameters have names (either from source or promoted)
## Output Guarantees
- Every named variable has a unique name within its scope
- No variable shadows a global identifier referenced by the function
- Promoted temporaries are renamed to `t0`, `t1`, ... (for regular temps)
- Promoted JSX temporaries are renamed to `T0`, `T1`, ... (for JSX tags)
- Conflicting source names get disambiguated with `$` suffix (e.g., `foo$0`, `foo$1`)
- Returns a `Set<string>` of all unique variable names in the function
## Algorithm
### Phase 1: Collect Referenced Globals
Uses `collectReferencedGlobals(fn)` to build a set of all global identifiers referenced by the function. Variable names must not conflict with these.
### Phase 2: Rename with Scope Stack
The `Scopes` class maintains:
```typescript
class Scopes {
#seen: Map<DeclarationId, IdentifierName> = new Map(); // Canonical name for each declaration
#stack: Array<Map<string, DeclarationId>> = [new Map()]; // Block scope stack
#globals: Set<string>; // Global names to avoid
names: Set<ValidIdentifierName> = new Set(); // All assigned names
}
```
### Renaming Logic
```typescript
visit(identifier: Identifier): void {
// Skip unnamed identifiers
if (originalName === null) return;
// If we've already named this declaration, reuse that name
const mappedName = this.#seen.get(identifier.declarationId);
if (mappedName !== undefined) {
identifier.name = mappedName;
return;
}
// Find a unique name
let name = originalName.value;
let id = 0;
// Promoted temporaries start with t0/T0
if (isPromotedTemporary(originalName.value)) {
name = `t${id++}`;
} else if (isPromotedJsxTemporary(originalName.value)) {
name = `T${id++}`;
}
// Increment until we find a unique name
while (this.#lookup(name) !== null || this.#globals.has(name)) {
if (isPromotedTemporary(...)) {
name = `t${id++}`;
} else if (isPromotedJsxTemporary(...)) {
name = `T${id++}`;
} else {
name = `${originalName.value}$${id++}`; // foo$0, foo$1, etc.
}
}
identifier.name = makeIdentifierName(name);
this.#seen.set(identifier.declarationId, identifier.name);
}
```
### Scope Management
```typescript
enter(fn: () => void): void {
this.#stack.push(new Map());
fn();
this.#stack.pop();
}
#lookup(name: string): DeclarationId | null {
// Search from innermost to outermost scope
for (let i = this.#stack.length - 1; i >= 0; i--) {
const entry = this.#stack[i].get(name);
if (entry !== undefined) return entry;
}
return null;
}
```
### Visitor Pattern
```typescript
class Visitor extends ReactiveFunctionVisitor<Scopes> {
override visitBlock(block: ReactiveBlock, state: Scopes): void {
state.enter(() => {
this.traverseBlock(block, state);
});
}
override visitScope(scope: ReactiveScopeBlock, state: Scopes): void {
// Visit scope declarations first
for (const [_, declaration] of scope.scope.declarations) {
state.visit(declaration.identifier);
}
this.traverseScope(scope, state);
}
override visitPlace(id: InstructionId, place: Place, state: Scopes): void {
state.visit(place.identifier);
}
}
```
## Edge Cases
### Shadowed Variables
When the compiler merges scopes that had shadowing in the source:
```javascript
function foo() {
const x = 1;
{
const x = 2; // Shadowed in source
}
}
```
If both `x` declarations end up in the same compiled scope, they become `x` and `x$0`.
### Global Name Conflicts
If a local variable would conflict with a referenced global:
```javascript
function foo() {
const Math = 1; // Conflicts with global Math if used
}
```
The local gets renamed to `Math$0` if `Math` global is referenced.
### Nested Functions
The pass recursively processes nested function expressions, entering a new scope for each function body.
### Pruned Scopes
Pruned scopes don't create a new block scope in the output - the pass traverses their instructions without entering a new scope level.
### DeclarationId Consistency
The pass uses `DeclarationId` to track which identifiers refer to the same variable, ensuring all references get the same renamed name.
## TODOs
None in the source file.
## Example
### Fixture: `simple.js`
**Before RenameVariables:**
```
scope @0 [...] declarations=[#t5$19_@0]
scope @1 [...] dependencies=[#t9$22] declarations=[#t10$23_@1]
```
**After RenameVariables:**
```
scope @0 [...] declarations=[t0$19_@0]
scope @1 [...] dependencies=[t0$22] declarations=[t1$23_@1]
```
Key observations:
- `#t5$19_@0` becomes `t0$19_@0` (first temporary in scope)
- `#t9$22` becomes `t0$22` (first temporary in a different block scope)
- `#t10$23_@1` becomes `t1$23_@1` (second temporary in that block)
- The `#t` prefix is removed and sequential numbering is applied
**Generated Code:**
```javascript
export default function foo(x, y) {
const $ = _c(4);
if (x) {
let t0; // Was #t5
if ($[0] !== y) {
t0 = foo(false, y);
// ...
}
return t0;
}
const t0 = y * 10; // Was #t9, reuses t0 since different block scope
let t1; // Was #t10
// ...
}
```
The pass produces clean, readable output with minimal variable names while avoiding conflicts.

View File

@@ -0,0 +1,286 @@
# codegenReactiveFunction
## File
`src/ReactiveScopes/CodegenReactiveFunction.ts`
## Purpose
This is the final pass that converts the ReactiveFunction representation back into a Babel AST. It generates the memoization code that makes React components and hooks efficient by:
1. Creating the `useMemoCache` call to allocate cache slots
2. Generating dependency comparisons to check if values have changed
3. Emitting conditional blocks that skip computation when cached values are valid
4. Storing computed values in the cache
5. Loading cached values when dependencies haven't changed
## Input Invariants
- The ReactiveFunction has been through all prior passes
- All identifiers that need names have been promoted and renamed
- Reactive scopes have finalized `dependencies`, `declarations`, and `reassignments`
- Early returns have been transformed with sentinel values (via `propagateEarlyReturns`)
- Pruned scopes are marked with `kind: 'pruned-scope'`
- Unique identifiers set is available to avoid naming conflicts
## Output Guarantees
- Returns a `CodegenFunction` with Babel AST `body`
- All reactive scopes become if-else blocks checking dependencies
- The `$` cache array is properly sized with `useMemoCache(n)`
- Each dependency and output gets its own cache slot
- Pruned scopes emit their instructions inline without memoization
- Early returns use the sentinel pattern with post-scope checks
- Statistics are collected: `memoSlotsUsed`, `memoBlocks`, `memoValues`, etc.
## Algorithm
### Entry Point: codegenFunction
```typescript
export function codegenFunction(fn: ReactiveFunction): Result<CodegenFunction, CompilerError> {
const cx = new Context(...);
// Optional: Fast Refresh source hash tracking
if (enableResetCacheOnSourceFileChanges) {
fastRefreshState = { cacheIndex: cx.nextCacheIndex, hash: sha256(source) };
}
const compiled = codegenReactiveFunction(cx, fn);
// Prepend useMemoCache call if any cache slots used
if (cacheCount !== 0) {
body.unshift(
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('$'),
t.callExpression(t.identifier('useMemoCache'), [t.numericLiteral(cacheCount)])
)
])
);
}
return compiled;
}
```
### Context Class
Tracks state during codegen:
```typescript
class Context {
#nextCacheIndex: number = 0; // Allocates cache slots
#declarations: Set<DeclarationId> = new Set(); // Tracks declared variables
temp: Temporaries; // Maps identifiers to their expressions
errors: CompilerError;
get nextCacheIndex(): number {
return this.#nextCacheIndex++; // Returns and increments
}
}
```
### codegenReactiveScope
The core of memoization code generation:
```typescript
function codegenReactiveScope(cx: Context, statements: Array<t.Statement>,
scope: ReactiveScope, block: ReactiveBlock): void {
const changeExpressions: Array<t.Expression> = [];
const cacheStoreStatements: Array<t.Statement> = [];
const cacheLoadStatements: Array<t.Statement> = [];
// 1. Generate dependency checks
for (const dep of scope.dependencies) {
const index = cx.nextCacheIndex;
changeExpressions.push(
t.binaryExpression('!==',
t.memberExpression(t.identifier('$'), t.numericLiteral(index), true),
codegenDependency(cx, dep)
)
);
cacheStoreStatements.push(
t.assignmentExpression('=', $[index], dep)
);
}
// 2. Generate output cache slots
for (const {identifier} of scope.declarations) {
const index = cx.nextCacheIndex;
// Declare variable if not already declared
if (!cx.hasDeclared(identifier)) {
statements.push(t.variableDeclaration('let', [t.variableDeclarator(name, null)]));
}
cacheLoads.push({name, index, value: name});
}
// 3. Build test condition
let testCondition = changeExpressions.reduce((acc, expr) =>
t.logicalExpression('||', acc, expr)
);
// 4. If no dependencies, use sentinel check
if (testCondition === null) {
testCondition = t.binaryExpression('===',
$[firstOutputIndex],
t.callExpression(Symbol.for, ['react.memo_cache_sentinel'])
);
}
// 5. Generate the memoization if-else
statements.push(
t.ifStatement(
testCondition,
computationBlock, // Compute + store in cache
cacheLoadBlock // Load from cache
)
);
}
```
### Generated Structure
For a scope with dependencies `[a, b]` and output `result`:
```javascript
let result;
if ($[0] !== a || $[1] !== b) {
// Computation block
result = compute(a, b);
// Store dependencies
$[0] = a;
$[1] = b;
// Store output
$[2] = result;
} else {
// Load from cache
result = $[2];
}
```
### Early Return Handling
When a scope has an early return (from `propagateEarlyReturns`):
```typescript
// Before scope: initialize sentinel
t0 = Symbol.for("react.early_return_sentinel");
// Scope generates labeled block
bb0: {
// ... computation ...
if (cond) {
t0 = returnValue;
break bb0;
}
}
// After scope: check for early return
if (t0 !== Symbol.for("react.early_return_sentinel")) {
return t0;
}
```
### Pruned Scopes
Pruned scopes emit their instructions inline without memoization:
```typescript
case 'pruned-scope': {
const scopeBlock = codegenBlockNoReset(cx, item.instructions);
statements.push(...scopeBlock.body); // Inline, no memoization
break;
}
```
## Edge Cases
### Zero Dependencies
Scopes with no dependencies use a sentinel value check instead:
```javascript
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
// First render only
}
```
### Fast Refresh / HMR
When `enableResetCacheOnSourceFileChanges` is enabled, the generated code includes a source hash check that resets the cache when the source changes:
```javascript
if ($[0] !== "source_hash_abc123") {
for (let $i = 0; $i < cacheCount; $i++) {
$[$i] = Symbol.for("react.memo_cache_sentinel");
}
$[0] = "source_hash_abc123";
}
```
### Labeled Breaks
Control flow with labeled breaks (for early returns or loop exits) uses `codegenLabel` to generate consistent label names:
```typescript
function codegenLabel(id: BlockId): string {
return `bb${id}`; // e.g., "bb0", "bb1"
}
```
### Nested Functions
Function expressions and object methods are recursively processed with their own contexts.
### FBT/Internationalization
Special handling for FBT operands ensures they're memoized in the same scope for correct internationalization behavior.
## Statistics Collected
```typescript
type CodegenFunction = {
memoSlotsUsed: number; // Total cache slots allocated
memoBlocks: number; // Number of reactive scopes
memoValues: number; // Total memoized values
prunedMemoBlocks: number; // Scopes that were pruned
prunedMemoValues: number; // Values in pruned scopes
hasInferredEffect: boolean;
};
```
## TODOs
None in the source file.
## Example
### Fixture: `simple.js`
**Input:**
```javascript
export default function foo(x, y) {
if (x) {
return foo(false, y);
}
return [y * 10];
}
```
**Generated Code:**
```javascript
import { c as _c } from "react/compiler-runtime";
export default function foo(x, y) {
const $ = _c(4); // Allocate 4 cache slots
if (x) {
let t0;
if ($[0] !== y) { // Check dependency
t0 = foo(false, y); // Compute
$[0] = y; // Store dependency
$[1] = t0; // Store output
} else {
t0 = $[1]; // Load from cache
}
return t0;
}
const t0 = y * 10;
let t1;
if ($[2] !== t0) { // Check dependency
t1 = [t0]; // Compute
$[2] = t0; // Store dependency
$[3] = t1; // Store output
} else {
t1 = $[3]; // Load from cache
}
return t1;
}
```
Key observations:
- `_c(4)` allocates 4 cache slots total
- First scope uses slots 0-1: slot 0 for `y` dependency, slot 1 for `t0` output
- Second scope uses slots 2-3: slot 2 for `t0` (the computed `y * 10`), slot 3 for `t1` (the array)
- Each scope has an if-else structure: compute/store vs load
- The memoization ensures referential equality of the returned array when `y` hasn't changed

View File

@@ -0,0 +1,132 @@
# optimizePropsMethodCalls
## File
`src/Optimization/OptimizePropsMethodCalls.ts`
## Purpose
This pass converts method calls on the props object to regular function calls. Method calls like `props.onClick()` are transformed to `const t0 = props.onClick; t0()`. This normalization enables better analysis and optimization by the compiler.
The transformation is important because method calls have different semantics than regular calls - the receiver (`props`) would normally be passed as `this` to the method. For React props, methods are typically just callback functions where `this` binding doesn't matter, so converting them to regular calls is safe and enables better memoization.
## Input Invariants
- The function has been through type inference
- Props parameters are typed as `TObject<BuiltInProps>`
## Output Guarantees
- All `MethodCall` instructions where the receiver has props type are converted to `CallExpression`
- The method property becomes the callee of the call
- Arguments are preserved exactly
## Algorithm
```typescript
export function optimizePropsMethodCalls(fn: HIRFunction): void {
for (const [, block] of fn.body.blocks) {
for (let i = 0; i < block.instructions.length; i++) {
const instr = block.instructions[i]!;
if (
instr.value.kind === 'MethodCall' &&
isPropsType(instr.value.receiver.identifier)
) {
// Transform: props.onClick(arg)
// To: const t0 = props.onClick; t0(arg)
instr.value = {
kind: 'CallExpression',
callee: instr.value.property, // The method becomes the callee
args: instr.value.args,
loc: instr.value.loc,
};
}
}
}
}
function isPropsType(identifier: Identifier): boolean {
return (
identifier.type.kind === 'Object' &&
identifier.type.shapeId === BuiltInPropsId
);
}
```
## Edge Cases
### Non-Props Method Calls
Method calls on non-props objects are left unchanged:
```javascript
// Unchanged - array.map is not on props
array.map(x => x * 2)
// Unchanged - obj is not props
obj.method()
```
### Props Type Detection
The pass uses type information to identify props:
```javascript
function Component(props) {
// props has type TObject<BuiltInProps>
props.onClick(); // Transformed
}
function Regular(obj) {
// obj has unknown type
obj.onClick(); // Not transformed
}
```
### Nested Props Access
Only direct method calls on props are transformed:
```javascript
props.onClick(); // Transformed
props.nested.onClick(); // Not transformed (receiver is props.nested, not props)
```
### Arrow Function Callbacks
Works with any method on props:
```javascript
props.onChange(value); // Transformed
props.onSubmit(data); // Transformed
props.validate(input); // Transformed
```
## TODOs
None in the source file.
## Example
### Fixture: Using props method
**Input:**
```javascript
function Component(props) {
return <button onClick={() => props.onClick()} />;
}
```
**Before OptimizePropsMethodCalls:**
```
[1] $5 = Function @context[props$1] ...
<<anonymous>>():
[1] $2 = LoadLocal props$1
[2] $3 = PropertyLoad $2.onClick
[3] $4 = MethodCall $2.$3() // Method call on props
[4] Return Void
```
**After OptimizePropsMethodCalls:**
```
[1] $5 = Function @context[props$1] ...
<<anonymous>>():
[1] $2 = LoadLocal props$1
[2] $3 = PropertyLoad $2.onClick
[3] $4 = Call $3() // Now a regular call
[4] Return Void
```
Key observations:
- `MethodCall $2.$3()` becomes `Call $3()`
- The property load (`$3 = PropertyLoad $2.onClick`) is preserved
- The receiver (`$2`) is no longer part of the call
- This enables the compiler to analyze `onClick` as a regular function

View File

@@ -0,0 +1,187 @@
# optimizeForSSR
## File
`src/Optimization/OptimizeForSSR.ts`
## Purpose
This pass applies Server-Side Rendering (SSR) specific optimizations. During SSR, React renders components to HTML strings without mounting them in the DOM. This means:
1. **Effects don't run** - `useEffect` and `useLayoutEffect` are no-ops
2. **Event handlers aren't needed** - There's no DOM to attach handlers to
3. **State is never updated** - Components render once with initial state
4. **Refs aren't attached** - There's no DOM to ref
The pass leverages these SSR characteristics to inline and simplify code, removing unnecessary runtime overhead.
## Input Invariants
- The function has been through type inference
- Hook types are properly identified (useState, useReducer, useEffect, etc.)
- Function types for callbacks are properly inferred
## Output Guarantees
- `useState(initialValue)` is inlined to just `[initialValue, noop]`
- `useReducer(reducer, initialArg, init?)` is inlined to `[init ? init(initialArg) : initialArg, noop]`
- `useEffect` and `useLayoutEffect` calls are removed entirely
- Event handler functions (functions that call setState) are replaced with empty functions
- Ref-typed values are removed from JSX props
## Algorithm
### Phase 1: Identify Inlinable State
```typescript
const inlinedState = new Map<IdentifierId, InstructionValue>();
for (const instr of block.instructions) {
if (isUseStateCall(instr)) {
// Store the initial value for inlining
inlinedState.set(instr.lvalue.id, {
kind: 'ArrayExpression',
elements: [initialValue, noopFunction],
});
}
if (isUseReducerCall(instr)) {
// Compute initial state and store for inlining
const initialState = init ? callInit(initialArg) : initialArg;
inlinedState.set(instr.lvalue.id, {
kind: 'ArrayExpression',
elements: [initialState, noopFunction],
});
}
}
```
### Phase 2: Inline State Hooks
Replace useState/useReducer with their computed initial values:
```typescript
// Before:
$0 = useState(0)
[state, setState] = $0
// After (inlined):
$0 = [0, () => {}]
[state, setState] = $0
```
### Phase 3: Remove Effects
```typescript
if (isUseEffectCall(instr) || isUseLayoutEffectCall(instr)) {
// Remove the instruction entirely
block.instructions.splice(i, 1);
}
```
### Phase 4: Identify and Neuter Event Handlers
```typescript
// Functions that capture and call setState are event handlers
if (capturesSetState(functionExpr)) {
// Replace with empty function
instr.value = {
kind: 'FunctionExpression',
params: originalParams,
body: emptyBody,
};
}
```
### Phase 5: Remove Ref Props
```typescript
if (isJSX(instr) && hasRefProp(instr)) {
// Remove ref={...} from JSX props
removeRefProp(instr.value);
}
```
## Edge Cases
### useState with Function Initializer
When `useState` receives a function initializer, it must be called:
```javascript
// useState(() => expensive())
// SSR: Call the initializer to get the value
const [state] = [expensiveComputation(), noop];
```
### useReducer with Init Function
The optional `init` function is called with `initialArg`:
```javascript
// useReducer(reducer, arg, init)
// SSR: [init(arg), noop]
```
### Nested State Setters
Functions that transitively call setState are also event handlers:
```javascript
function outer() {
function inner() {
setState(x); // inner is event handler
}
inner(); // outer is also event handler
}
```
### Conditional Event Handlers
Event handler detection is conservative - if a function might call setState, it's treated as an event handler.
### Refs in Nested Objects
Only direct `ref` props on JSX are removed:
```javascript
<div ref={myRef} /> // ref removed
<div config={{ref: myRef}} /> // ref NOT removed (nested)
```
## TODOs
None in the source file.
## Example
### Fixture: `ssr/optimize-ssr.js`
**Input:**
```javascript
function Component() {
const [state, setState] = useState(0);
const ref = useRef(null);
const onChange = (e) => {
setState(e.target.value);
};
useEffect(() => {
log(ref.current.value);
});
return <input value={state} onChange={onChange} ref={ref} />;
}
```
**After SSR Optimization:**
```javascript
function Component() {
const $ = _c(1);
// useState inlined to [initialValue, noop]
const [state] = [0, () => {}];
// useRef returns object with current: null
const ref = { current: null };
// Event handler replaced with noop (it calls setState)
const onChange = () => {};
// useEffect removed entirely (no-op on SSR)
// ref prop removed from JSX
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = <input value={state} onChange={onChange} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
```
Key observations:
- `useState(0)` becomes `[0, () => {}]` - no hook call
- `useEffect(...)` is removed entirely
- `onChange` is replaced with empty function since it called `setState`
- `ref={ref}` prop is removed from JSX
- SSR output is simpler and has less runtime overhead

View File

@@ -0,0 +1,224 @@
# outlineJSX
## File
`src/Optimization/OutlineJsx.ts`
## Purpose
This pass outlines nested JSX elements into separate component functions. When a callback function contains JSX, this pass can extract that JSX into a new component, which enables:
1. **Better code splitting** - Outlined components can be lazily loaded
2. **Memoization at component boundaries** - React's reconciliation can skip unchanged subtrees
3. **Reduced closure captures** - Outlined components receive props explicitly
The pass specifically targets JSX within callbacks (like `.map()` callbacks) rather than top-level component returns.
## Input Invariants
- The `enableJsxOutlining` feature flag must be enabled
- The function must be a React component or hook
- JSX must appear within a nested function expression (callback)
## Output Guarantees
- Nested functions containing only JSX returns are extracted as separate components
- The original callback is replaced with a call to the outlined component
- Captured variables become explicit props to the outlined component
- The outlined component is registered with the environment for emission
## Algorithm
### Phase 1: Identify Outlinable JSX
```typescript
function outlineJsxImpl(fn: HIRFunction, outlinedFns: Array<HIRFunction>): void {
for (const [, block] of fn.body.blocks) {
for (const instr of block.instructions) {
if (instr.value.kind === 'FunctionExpression') {
const innerFn = instr.value.loweredFunc.func;
// Check if function only returns JSX
if (canOutline(innerFn)) {
const outlined = createOutlinedComponent(innerFn);
outlinedFns.push(outlined);
replaceWithComponentCall(instr, outlined);
}
}
}
}
}
```
### Phase 2: Check Outlinability
```typescript
function canOutline(fn: HIRFunction): boolean {
// Must have exactly one block with only JSX-related instructions
// Must end with returning JSX
// Must not have complex control flow
return (
fn.body.blocks.size === 1 &&
returnsJSX(fn) &&
!hasComplexControlFlow(fn)
);
}
```
### Phase 3: Create Outlined Component
```typescript
function createOutlinedComponent(fn: HIRFunction): HIRFunction {
// Convert captured context to props
const props = fn.context.map(capture => ({
name: capture.identifier.name,
type: capture.identifier.type,
}));
// Create new component function
return {
...fn,
params: [{kind: 'Identifier', name: 'props', ...}],
context: [], // No captures - all via props
};
}
```
### Phase 4: Replace Original Callback
```typescript
function replaceWithComponentCall(instr: Instruction, outlined: HIRFunction): void {
// Original: items.map(item => <Stringify item={item} />)
// Becomes: items.map(item => <OutlinedComponent item={item} />)
instr.value = {
kind: 'JSX',
tag: {kind: 'LoadGlobal', name: outlined.id},
props: capturedVariablesToProps(instr.value.context),
};
}
```
### Phase 5: Register Outlined Functions
```typescript
export function outlineJSX(fn: HIRFunction): void {
const outlinedFns: Array<HIRFunction> = [];
outlineJsxImpl(fn, outlinedFns);
for (const outlinedFn of outlinedFns) {
fn.env.outlineFunction(outlinedFn, 'Component');
}
}
```
## Edge Cases
### Context Captures
Variables captured by the callback become props:
```javascript
// Before:
items.map(item => <Card key={item.id} user={currentUser} item={item} />)
// After (outlined):
function OutlinedCard(props) {
return <Card key={props.item.id} user={props.currentUser} item={props.item} />;
}
items.map(item => <OutlinedCard currentUser={currentUser} item={item} />)
```
### Complex Control Flow
Callbacks with conditionals or loops are not outlined:
```javascript
// Not outlined - has conditional
items.map(item => item.show ? <Card item={item} /> : null)
```
### Multiple JSX Returns
Only single-JSX-return callbacks are outlined:
```javascript
// Not outlined - multiple potential returns
items.map(item => {
if (item.type === 'a') return <TypeA item={item} />;
return <TypeB item={item} />;
})
```
### Top-Level JSX
Only JSX in nested callbacks is outlined, not component return values:
```javascript
function Component() {
return <div />; // Not outlined - this is the component's return
}
```
### Recursive Outlining
The pass recursively processes outlined components to outline their nested JSX.
## TODOs
None in the source file.
## Example
### Fixture: `outlined-helper.js`
**Input:**
```javascript
// @enableFunctionOutlining
function Component(props) {
return (
<div>
{props.items.map(item => (
<Stringify key={item.id} item={item.name} />
))}
</div>
);
}
```
**After OutlineJSX:**
```
// Outlined component:
function _outlined_Component$1(props) {
return <Stringify key={props.item.id} item={props.item.name} />;
}
// Original component modified:
function Component(props) {
return (
<div>
{props.items.map(item => (
<_outlined_Component$1 item={item} />
))}
</div>
);
}
```
**Generated Code:**
```javascript
function _outlined_Component$1(props) {
const $ = _c(2);
const item = props.item;
let t0;
if ($[0] !== item.id || $[1] !== item.name) {
t0 = <Stringify key={item.id} item={item.name} />;
$[0] = item.id;
$[1] = item.name;
} else {
t0 = $[1];
}
return t0;
}
function Component(props) {
const $ = _c(2);
let t0;
if ($[0] !== props.items) {
t0 = props.items.map((item) => <_outlined_Component$1 item={item} />);
$[0] = props.items;
$[1] = t0;
} else {
t0 = $[1];
}
return <div>{t0}</div>;
}
```
Key observations:
- The map callback JSX is extracted into `_outlined_Component$1`
- The `item` variable becomes a prop instead of a closure capture
- The outlined component gets its own memoization cache
- This enables React to skip re-rendering unchanged list items

View File

@@ -0,0 +1,169 @@
# outlineFunctions
## File
`src/Optimization/OutlineFunctions.ts`
## Purpose
This pass outlines pure function expressions that have no captured context into top-level helper functions. By moving these functions outside the component, they become truly static and can be shared across renders without any memoization overhead.
A function with no captured context is completely self-contained - it only uses its parameters and globals. Such functions don't need to be recreated on each render and can be hoisted to module scope.
## Input Invariants
- The `enableFunctionOutlining` feature flag must be enabled
- Functions must have `context.length === 0` (no captured variables)
- Functions must be anonymous (no `id` property)
- Functions must not be FBT macro operands (tracked by `fbtOperands` parameter)
## Output Guarantees
- Pure function expressions are replaced with `LoadGlobal` of the outlined function
- Outlined functions are registered with the environment for emission
- The original instruction is transformed to load the global
## Algorithm
```typescript
export function outlineFunctions(
fn: HIRFunction,
fbtOperands: Set<IdentifierId>,
): void {
for (const [, block] of fn.body.blocks) {
for (let i = 0; i < block.instructions.length; i++) {
const instr = block.instructions[i]!;
if (
instr.value.kind === 'FunctionExpression' &&
instr.value.loweredFunc.func.context.length === 0 &&
instr.value.loweredFunc.func.id === null &&
!fbtOperands.has(instr.lvalue.identifier.id)
) {
// Outline this function
const outlinedId = fn.env.outlineFunction(
instr.value.loweredFunc.func,
'helper',
);
// Replace with LoadGlobal
instr.value = {
kind: 'LoadGlobal',
binding: {
kind: 'ModuleLocal',
name: outlinedId,
},
loc: instr.value.loc,
};
}
}
}
}
```
## Edge Cases
### Functions with Context
Functions that capture variables are not outlined:
```javascript
function Component(props) {
const x = props.value;
const fn = () => x * 2; // Captures x, not outlined
}
```
### Named Functions
Functions with explicit names are not outlined:
```javascript
const foo = function namedFn() { ... }; // Has id, not outlined
```
### FBT Operands
Functions used as FBT operands cannot be outlined due to translation requirements:
```javascript
<fbt>
Hello <fbt:param name="user">{() => getName()}</fbt:param>
</fbt>
// The function cannot be outlined - FBT needs it inline
```
### Arrow Functions vs Function Expressions
Both arrow functions and function expressions are candidates:
```javascript
const a = () => 1; // Outlined if no context
const b = function() {}; // Outlined if no context
```
### Recursive Functions
Self-referencing functions cannot be outlined (they would have themselves in context):
```javascript
const fib = (n) => n <= 1 ? n : fib(n-1) + fib(n-2); // References self
```
## TODOs
None in the source file.
## Example
### Fixture: `outlined-helper.js`
**Input:**
```javascript
// @enableFunctionOutlining
function Component(props) {
return (
<div>
{props.items.map(item => (
<Stringify key={item.id} item={item.name} />
))}
</div>
);
}
```
**Analysis:**
The map callback `item => <Stringify .../>` has one captured variable: nothing from the component (only uses `item` parameter). However, it receives `item` as a parameter, not from context.
If we have a truly pure helper:
```javascript
// @enableFunctionOutlining
function Component(props) {
const double = (x) => x * 2; // No context, pure
return <div>{double(props.value)}</div>;
}
```
**After OutlineFunctions:**
```
// Outlined to module scope:
function _outlined_double$1(x) {
return x * 2;
}
// In component:
[1] $1 = LoadGlobal _outlined_double$1 // Instead of FunctionExpression
[2] StoreLocal Const double = $1
```
**Generated Code:**
```javascript
function _outlined_double$1(x) {
return x * 2;
}
function Component(props) {
const $ = _c(2);
const double = _outlined_double$1; // Just a reference, no recreation
let t0;
if ($[0] !== props.value) {
t0 = <div>{double(props.value)}</div>;
$[0] = props.value;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
```
Key observations:
- The pure function is hoisted to module scope
- The component just references the outlined function
- No memoization needed for the function itself
- Reduces runtime overhead by avoiding function recreation

View File

@@ -0,0 +1,231 @@
# memoizeFbtAndMacroOperandsInSameScope
## File
`src/ReactiveScopes/MemoizeFbtAndMacroOperandsInSameScope.ts`
## Purpose
This pass ensures that FBT (Facebook Translation) expressions and their operands are memoized within the same reactive scope. FBT is Facebook's internationalization system that requires special handling to ensure translations work correctly.
The key insight is that FBT operands must be computed and frozen together with the FBT call itself. If operands were memoized in separate scopes, the translation system could receive stale operand values when only some inputs change.
## Input Invariants
- The function has been through type inference
- FBT calls (`fbt`, `fbt.c`, `fbt:param`, etc.) are properly identified
- Custom macros are configured in `fn.env.config.customMacros`
- Reactive scope variables have been inferred
## Output Guarantees
- All operands of FBT calls are assigned to the same reactive scope as the FBT call
- The `fbtOperands` set is returned for use by other passes (e.g., `outlineFunctions`)
- Operand scope assignments use either transitive or shallow inlining based on macro definition
## Algorithm
### Phase 1: Collect Macro Kinds
```typescript
const macroKinds = new Map<Macro, MacroDefinition>([
...Array.from(FBT_TAGS.entries()), // Built-in fbt tags
...(fn.env.config.customMacros ?? []).map(([name, def]) => [name, def]),
]);
```
### Phase 2: Populate Macro Tags
```typescript
function populateMacroTags(
fn: HIRFunction,
macroKinds: Map<Macro, MacroDefinition>,
): Map<IdentifierId, MacroDefinition> {
const macroTags = new Map();
for (const instr of allInstructions(fn)) {
if (isLoadGlobal(instr) || isPropertyLoad(instr)) {
const name = getName(instr);
if (macroKinds.has(name)) {
macroTags.set(instr.lvalue.id, macroKinds.get(name));
}
}
}
return macroTags;
}
```
### Phase 3: Merge Macro Arguments
```typescript
function mergeMacroArguments(
fn: HIRFunction,
macroTags: Map<IdentifierId, MacroDefinition>,
macroKinds: Map<Macro, MacroDefinition>,
): Set<IdentifierId> {
const macroValues = new Set<IdentifierId>();
for (const instr of allInstructions(fn)) {
if (isCall(instr) || isMethodCall(instr) || isJSX(instr)) {
const callee = getCallee(instr);
const macroDef = macroTags.get(callee.id);
if (macroDef !== undefined) {
// Mark all operands to be in same scope
for (const operand of getOperands(instr)) {
macroValues.add(operand.id);
// Merge scope to match macro call scope
if (macroDef.inlineLevel === InlineLevel.Transitive) {
mergeScopesTransitively(operand, instr.lvalue);
} else {
mergeScopes(operand, instr.lvalue);
}
}
}
}
}
return macroValues;
}
```
### InlineLevel Types
```typescript
enum InlineLevel {
Shallow, // Only merge direct operands
Transitive, // Merge operands and their dependencies
}
```
## Edge Cases
### Nested FBT Params
FBT params can be nested, and all levels must be in the same scope:
```javascript
<fbt>
Hello <fbt:param name="user">
<fbt:param name="firstName">{user.firstName}</fbt:param>
</fbt:param>
</fbt>
```
### FBT with Complex Expressions
Complex expressions as operands have their entire dependency chain merged:
```javascript
<fbt>
Count: <fbt:param name="count">{items.length * multiplier}</fbt:param>
</fbt>
// Both items.length and multiplier expressions are merged into fbt scope
```
### Custom Macros
User-defined macros can specify their inlining behavior:
```typescript
customMacros: [
['myMacro', { inlineLevel: InlineLevel.Transitive }],
]
```
### Method Calls on FBT
`fbt.param()`, `fbt.plural()`, etc. are handled as method calls:
```javascript
fbt(
fbt.param('count', items.length), // MethodCall on fbt
'description'
)
```
### JSX vs Call Syntax
Both JSX and call syntax for FBT are handled:
```javascript
// JSX syntax
<fbt desc="greeting">Hello</fbt>
// Call syntax
fbt('Hello', 'greeting')
```
## Built-in FBT Tags
The pass recognizes these FBT constructs:
- `fbt` / `fbt.c` - Main translation functions
- `fbt:param` - Parameter substitution
- `fbt:plural` - Plural handling
- `fbt:enum` - Enumeration values
- `fbt:name` - Name parameters
- `fbt:pronoun` - Pronoun handling
- `fbs` - Simple string translation
## TODOs
None in the source file.
## Example
### Fixture: `fbt/fbt-call.js`
**Input:**
```javascript
function Component(props) {
const text = fbt(
`${fbt.param('count', props.count)} items`,
'Number of items'
);
return <div>{text}</div>;
}
```
**Before MemoizeFbtAndMacroOperandsInSameScope:**
```
[1] $18 = LoadGlobal import fbt from 'fbt'
[2] $19 = LoadGlobal import fbt from 'fbt'
[3] $20_@0[3:8] = PropertyLoad $19.param
[4] $21 = "(key) count"
[5] $22 = LoadLocal props$17
[6] $23 = PropertyLoad $22.count
[7] $24_@0[3:8] = MethodCall $19.$20_@0($21, $23) // fbt.param call
[8] $25 = `${$24_@0} items`
[9] $26 = "(description) Number of items"
[10] $27_@1 = Call $18($25, $26) // fbt call
```
**After MemoizeFbtAndMacroOperandsInSameScope:**
```
[1] $18_@1[1:11] = LoadGlobal import fbt from 'fbt' // Merged to @1
[2] $19 = LoadGlobal import fbt from 'fbt'
[3] $20_@0[3:8] = PropertyLoad $19.param
[4] $21 = "(key) count"
[5] $22 = LoadLocal props$17
[6] $23 = PropertyLoad $22.count
[7] $24_@1[1:11] = MethodCall $19.$20_@0($21, $23) // Merged to @1
[8] $25_@1[1:11] = `${$24_@1} items` // Merged to @1
[9] $26_@1[1:11] = "(description) Number of items" // Merged to @1
[10] $27_@1[1:11] = Call $18_@1($25_@1, $26_@1) // Main fbt scope @1
```
**Generated Code:**
```javascript
function Component(props) {
const $ = _c(3);
let t0;
if ($[0] !== props.count) {
// All fbt operands computed in same memoization block
t0 = fbt(
`${fbt.param("count", props.count)} items`,
"Number of items"
);
$[0] = props.count;
$[1] = t0;
} else {
t0 = $[1];
}
const text = t0;
let t1;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <div>{text}</div>;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
}
```
Key observations:
- All FBT-related operations are in the same memoization scope `@1`
- `fbt.param`, template literal, and `fbt` call are memoized together
- This ensures the translation system receives consistent operand values
- The entire translation is recomputed when any operand (`props.count`) changes

View File

@@ -0,0 +1,192 @@
# validateContextVariableLValues
## File
`src/Validation/ValidateContextVariableLValues.ts`
## Purpose
This validation pass ensures that all load/store references to a given named identifier are consistent with the "kind" of that variable (normal local variable or context variable). Context variables are variables that are captured by closures and require special handling for correct closure semantics.
The pass prevents mixing context variable operations (`DeclareContext`, `StoreContext`, `LoadContext`) with local variable operations (`DeclareLocal`, `StoreLocal`, `LoadLocal`, `Destructure`) on the same identifier.
## Input Invariants
- The function has been lowered to HIR
- All instructions have been categorized by kind
- Nested function expressions have been lowered
## Validation Rules
### Rule 1: Consistent Variable Kind
All references to the same identifier must use consistent load/store operations:
- Context variables must only use `DeclareContext`, `StoreContext`, `LoadContext`
- Local variables must only use `DeclareLocal`, `StoreLocal`, `LoadLocal`
**Error (Invariant violation):**
```
Expected all references to a variable to be consistently local or context references
Identifier [place] is referenced as a [kind] variable, but was previously referenced as a [prev.kind] variable
```
### Rule 2: No Destructuring of Context Variables
Context variables cannot be destructured using the `Destructure` instruction.
**Error (Todo):**
```
Support destructuring of context variables
```
### Rule 3: Unhandled Instruction Variants
If an instruction has lvalues that the pass does not handle, it throws a Todo error.
**Error (Todo):**
```
ValidateContextVariableLValues: unhandled instruction variant
Handle '[kind]' lvalues
```
## Algorithm
### Phase 1: Initialize Tracking
```typescript
const identifierKinds: Map<IdentifierId, {place: Place, kind: 'local' | 'context' | 'destructure'}> = new Map();
```
### Phase 2: Visit All Instructions
The pass iterates through all blocks and instructions, categorizing each based on its kind:
```typescript
for (const [, block] of fn.body.blocks) {
for (const instr of block.instructions) {
switch (value.kind) {
case 'DeclareContext':
case 'StoreContext':
visit(identifierKinds, value.lvalue.place, 'context');
break;
case 'LoadContext':
visit(identifierKinds, value.place, 'context');
break;
case 'StoreLocal':
case 'DeclareLocal':
visit(identifierKinds, value.lvalue.place, 'local');
break;
case 'LoadLocal':
visit(identifierKinds, value.place, 'local');
break;
case 'PostfixUpdate':
case 'PrefixUpdate':
visit(identifierKinds, value.lvalue, 'local');
break;
case 'Destructure':
for (const lvalue of eachPatternOperand(value.lvalue.pattern)) {
visit(identifierKinds, lvalue, 'destructure');
}
break;
case 'ObjectMethod':
case 'FunctionExpression':
// Recursively validate nested functions
validateContextVariableLValuesImpl(value.loweredFunc.func, identifierKinds);
break;
}
}
}
```
### Phase 3: Check Consistency
For each place visited, the `visit` function checks if the identifier was previously seen with a different kind:
```typescript
function visit(identifiers, place, kind) {
const prev = identifiers.get(place.identifier.id);
if (prev !== undefined) {
const wasContext = prev.kind === 'context';
const isContext = kind === 'context';
if (wasContext !== isContext) {
// Check for destructuring of context variable
if (prev.kind === 'destructure' || kind === 'destructure') {
CompilerError.throwTodo({
reason: `Support destructuring of context variables`,
...
});
}
// Invariant violation: inconsistent variable kinds
CompilerError.invariant(false, {
reason: 'Expected all references to be consistently local or context references',
...
});
}
}
identifiers.set(place.identifier.id, {place, kind});
}
```
## Edge Cases
### Nested Function Expressions
The validation recursively processes nested function expressions and object methods, sharing the same `identifierKinds` map. This ensures that a variable captured by a nested function is consistently treated as a context variable throughout the entire function hierarchy.
### Destructuring Patterns
Each operand in a destructure pattern is visited individually, marked as 'destructure' kind. If the same identifier was previously used as a context variable, a Todo error is thrown since destructuring of context variables is not yet supported.
### Update Expressions
Both `PostfixUpdate` (e.g., `x++`) and `PrefixUpdate` (e.g., `++x`) are treated as local variable operations.
## TODOs
1. **Destructuring of context variables** - Currently not supported:
```typescript
CompilerError.throwTodo({
reason: `Support destructuring of context variables`,
...
});
```
2. **Unhandled instruction variants** - Some instruction types with lvalues may not be handled:
```typescript
CompilerError.throwTodo({
reason: 'ValidateContextVariableLValues: unhandled instruction variant',
description: `Handle '${value.kind}' lvalues`,
...
});
```
## Example
### Fixture: `error.todo-for-of-loop-with-context-variable-iterator.js`
**Input:**
```javascript
import {useHook} from 'shared-runtime';
function Component(props) {
const data = useHook();
const items = [];
// NOTE: `item` is a context variable because it's reassigned and also referenced
// within a closure, the `onClick` handler of each item
for (let item of props.data) {
item = item ?? {}; // reassignment to force a context variable
items.push(
<div key={item.id} onClick={() => data.set(item)}>
{item.id}
</div>
);
}
return <div>{items}</div>;
}
```
**Error:**
```
Todo: Support non-trivial for..of inits
error.todo-for-of-loop-with-context-variable-iterator.ts:8:2
6 | // NOTE: `item` is a context variable because it's reassigned and also referenced
7 | // within a closure, the `onClick` handler of each item
> 8 | for (let item of props.data) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 9 | item = item ?? {}; // reassignment to force a context variable
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
> 15 | }
| ^^^^ Support non-trivial for..of inits
```
Note: This particular error comes from an earlier pass (lowering), but demonstrates the kind of context variable scenarios that this validation is designed to catch.

View File

@@ -0,0 +1,299 @@
# validateUseMemo
## File
`src/Validation/ValidateUseMemo.ts`
## Purpose
This validation pass ensures that `useMemo()` callbacks follow React's requirements. The pass checks for several common mistakes that developers make when using `useMemo()`:
1. Callbacks should not accept parameters (useMemo callbacks are called with no arguments)
2. Callbacks should not be async or generator functions (must return a value synchronously)
3. Callbacks should not reassign variables declared outside the callback (must be pure)
4. Callbacks should return a value (useMemo is for computing values, not side effects)
5. The result of useMemo should be used (not discarded)
## Input Invariants
- The function has been lowered to HIR
- `useMemo` is either imported directly or accessed via `React.useMemo`
- Function expressions have been lowered with their parameters and async/generator flags preserved
## Validation Rules
### Rule 1: No Parameters
useMemo callbacks must not accept parameters.
**Error:**
```
Error: useMemo() callbacks may not accept parameters
useMemo() callbacks are called by React to cache calculations across re-renders. They should not take parameters. Instead, directly reference the props, state, or local variables needed for the computation.
```
### Rule 2: No Async or Generator Functions
useMemo callbacks must synchronously return a value.
**Error:**
```
Error: useMemo() callbacks may not be async or generator functions
useMemo() callbacks are called once and must synchronously return a value.
```
### Rule 3: No Reassigning Outer Variables
useMemo callbacks cannot reassign variables declared outside the callback.
**Error:**
```
Error: useMemo() callbacks may not reassign variables declared outside of the callback
useMemo() callbacks must be pure functions and cannot reassign variables defined outside of the callback function.
```
### Rule 4: Must Return a Value (when `validateNoVoidUseMemo` is enabled)
useMemo callbacks should return a value.
**Error:**
```
Error: useMemo() callbacks must return a value
This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects.
```
### Rule 5: Result Must Be Used (when `validateNoVoidUseMemo` is enabled)
The result of useMemo should be used somewhere.
**Error:**
```
Error: useMemo() result is unused
This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects.
```
## Algorithm
### Phase 1: Track useMemo References
```typescript
const useMemos = new Set<IdentifierId>();
const react = new Set<IdentifierId>();
const functions = new Map<IdentifierId, FunctionExpression>();
const unusedUseMemos = new Map<IdentifierId, SourceLocation>();
```
The pass tracks:
- Direct `useMemo` imports via `LoadGlobal`
- `React` imports to detect `React.useMemo` pattern
- Function expressions that might be useMemo callbacks
- Unused useMemo results
### Phase 2: Identify useMemo Calls
```typescript
for (const instr of block.instructions) {
switch (value.kind) {
case 'LoadGlobal':
if (value.binding.name === 'useMemo') {
useMemos.add(lvalue.identifier.id);
} else if (value.binding.name === 'React') {
react.add(lvalue.identifier.id);
}
break;
case 'PropertyLoad':
if (react.has(value.object.identifier.id) && value.property === 'useMemo') {
useMemos.add(lvalue.identifier.id);
}
break;
case 'CallExpression':
case 'MethodCall':
// Check if callee is useMemo
const callee = value.kind === 'CallExpression' ? value.callee : value.property;
if (useMemos.has(callee.identifier.id) && value.args.length > 0) {
// Validate the callback
}
break;
}
}
```
### Phase 3: Validate Callback
For each useMemo call, the pass retrieves the callback function expression and validates:
```typescript
const body = functions.get(arg.identifier.id);
// Check for parameters
if (body.loweredFunc.func.params.length > 0) {
errors.push("useMemo() callbacks may not accept parameters");
}
// Check for async/generator
if (body.loweredFunc.func.async || body.loweredFunc.func.generator) {
errors.push("useMemo() callbacks may not be async or generator functions");
}
// Check for context variable reassignment
validateNoContextVariableAssignment(body.loweredFunc.func, errors);
// Check for return value (if config enabled)
if (fn.env.config.validateNoVoidUseMemo) {
if (!hasNonVoidReturn(body.loweredFunc.func)) {
errors.push("useMemo() callbacks must return a value");
}
}
```
### Phase 4: Validate No Context Variable Assignment
```typescript
function validateNoContextVariableAssignment(fn: HIRFunction, errors: CompilerError) {
const context = new Set(fn.context.map(place => place.identifier.id));
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
if (value.kind === 'StoreContext') {
if (context.has(value.lvalue.place.identifier.id)) {
errors.push("Cannot reassign variable");
}
}
}
}
}
```
### Phase 5: Check for Unused Results
```typescript
// Track which useMemo results are referenced
for (const operand of eachInstructionValueOperand(value)) {
unusedUseMemos.delete(operand.identifier.id);
}
// At the end, report any unused useMemos
for (const loc of unusedUseMemos.values()) {
errors.push("useMemo() result is unused");
}
```
### Return Value Helper
```typescript
function hasNonVoidReturn(func: HIRFunction): boolean {
for (const [, block] of func.body.blocks) {
if (block.terminal.kind === 'return') {
if (block.terminal.returnVariant === 'Explicit' ||
block.terminal.returnVariant === 'Implicit') {
return true;
}
}
}
return false;
}
```
## Edge Cases
### React.useMemo vs useMemo
The pass handles both import styles:
```javascript
import {useMemo} from 'react';
useMemo(() => x, [x]);
import React from 'react';
React.useMemo(() => x, [x]);
```
### Immediately Used Results
Results that are used immediately don't trigger the "unused" warning:
```javascript
const x = useMemo(() => compute(), [dep]);
return x; // x is used
```
### Void Return Detection
The pass checks for explicit and implicit returns. A function with only `return;` statements (void returns) will trigger the "must return a value" error.
### VoidUseMemo Errors as Logged Errors
The void useMemo errors (no return value, unused result) are logged via `fn.env.logErrors()` rather than thrown immediately. This allows them to be treated differently (e.g., as warnings) based on configuration.
## TODOs
None in the source file.
## Example
### Fixture: `error.invalid-useMemo-callback-args.js`
**Input:**
```javascript
function component(a, b) {
let x = useMemo(c => a, []);
return x;
}
```
**Error:**
```
Error: useMemo() callbacks may not accept parameters
useMemo() callbacks are called by React to cache calculations across re-renders. They should not take parameters. Instead, directly reference the props, state, or local variables needed for the computation.
error.invalid-useMemo-callback-args.ts:2:18
1 | function component(a, b) {
> 2 | let x = useMemo(c => a, []);
| ^ Callbacks with parameters are not supported
3 | return x;
4 | }
```
### Fixture: `error.invalid-useMemo-async-callback.js`
**Input:**
```javascript
function component(a, b) {
let x = useMemo(async () => {
await a;
}, []);
return x;
}
```
**Error:**
```
Error: useMemo() callbacks may not be async or generator functions
useMemo() callbacks are called once and must synchronously return a value.
error.invalid-useMemo-async-callback.ts:2:18
1 | function component(a, b) {
> 2 | let x = useMemo(async () => {
| ^^^^^^^^^^^^^
> 3 | await a;
| ^^^^^^^^^^^^
> 4 | }, []);
| ^^^^ Async and generator functions are not supported
```
### Fixture: `error.invalid-reassign-variable-in-usememo.js`
**Input:**
```javascript
function Component() {
let x;
const y = useMemo(() => {
let z;
x = [];
z = true;
return z;
}, []);
return [x, y];
}
```
**Error:**
```
Error: useMemo() callbacks may not reassign variables declared outside of the callback
useMemo() callbacks must be pure functions and cannot reassign variables defined outside of the callback function.
error.invalid-reassign-variable-in-usememo.ts:5:4
3 | const y = useMemo(() => {
4 | let z;
> 5 | x = [];
| ^ Cannot reassign variable
6 | z = true;
7 | return z;
8 | }, []);
```

View File

@@ -0,0 +1,330 @@
# validateHooksUsage
## File
`src/Validation/ValidateHooksUsage.ts`
## Purpose
This validation pass ensures that the function honors the [Rules of Hooks](https://react.dev/warnings/invalid-hook-call-warning). Specifically, it validates that:
1. Hooks may only be called unconditionally (not in if statements, loops, etc.)
2. Hooks cannot be used as first-class values (passed around, stored in variables, etc.)
3. Hooks must be the same function on every render (no dynamic hooks)
4. Hooks must be called at the top level, not within nested function expressions
## Input Invariants
- The function has been lowered to HIR
- Global bindings have been resolved and typed
- Nested function expressions have been lowered
## Value Kinds Lattice
The pass uses abstract interpretation with a lattice of value kinds:
```typescript
enum Kind {
Error, // Hook already used in an invalid way (stop reporting)
KnownHook, // Definitely a hook (from LoadGlobal with hook type)
PotentialHook, // Might be a hook (hook-like name but not from global)
Global, // A global value that is not a hook
Local, // A local variable
}
```
The `joinKinds` function merges kinds, with earlier kinds taking precedence:
- `Error` > `KnownHook` > `PotentialHook` > `Global` > `Local`
## Validation Rules
### Rule 1: No Conditional Hook Calls
Hooks must always be called in a consistent order.
**Error:**
```
Error: Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)
```
### Rule 2: No Hooks as First-Class Values
Known hooks may not be referenced as normal values (only called).
**Error:**
```
Error: Hooks may not be referenced as normal values, they must be called. See https://react.dev/reference/rules/react-calls-components-and-hooks#never-pass-around-hooks-as-regular-values
```
### Rule 3: No Dynamic Hooks
Potential hooks (hook-like names from local scope) may change between renders.
**Error:**
```
Error: Hooks must be the same function on every render, but this value may change over time to a different function. See https://react.dev/reference/rules/react-calls-components-and-hooks#dont-dynamically-use-hooks
```
### Rule 4: No Hooks in Nested Functions
Hooks must be called at the top level of a component or custom hook.
**Error:**
```
Error: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)
Cannot call [hookKind] within a function expression
```
## Algorithm
### Phase 1: Compute Unconditional Blocks
```typescript
const unconditionalBlocks = computeUnconditionalBlocks(fn);
```
Determines which blocks are guaranteed to execute on every render (not inside conditionals).
### Phase 2: Initialize Tracking
```typescript
const valueKinds = new Map<IdentifierId, Kind>();
// Initialize parameters
for (const param of fn.params) {
const place = param.kind === 'Identifier' ? param : param.place;
const kind = getKindForPlace(place); // PotentialHook if hook-like name
setKind(place, kind);
}
```
### Phase 3: Track Value Kinds Through Instructions
For each instruction, the pass tracks how hook-ness flows through values:
```typescript
case 'LoadGlobal':
// Globals are the source of KnownHook
if (getHookKind(fn.env, instr.lvalue.identifier) != null) {
setKind(instr.lvalue, Kind.KnownHook);
} else {
setKind(instr.lvalue, Kind.Global);
}
break;
case 'PropertyLoad':
// Hook-like property of Global -> KnownHook
// Hook-like property of Local -> PotentialHook
// Property of KnownHook -> KnownHook (if hook-like name)
const objectKind = getKindForPlace(value.object);
const isHookProperty = isHookName(value.property);
// Determine kind based on object kind and property name
break;
case 'CallExpression':
const calleeKind = getKindForPlace(value.callee);
const isHookCallee = calleeKind === Kind.KnownHook || calleeKind === Kind.PotentialHook;
if (isHookCallee && !unconditionalBlocks.has(block.id)) {
recordConditionalHookError(value.callee);
} else if (calleeKind === Kind.PotentialHook) {
recordDynamicHookUsageError(value.callee);
}
break;
```
### Phase 4: Check for Invalid Hook References
When a `KnownHook` is used as an operand (not as a callee), it's an error:
```typescript
function visitPlace(place: Place): void {
const kind = valueKinds.get(place.identifier.id);
if (kind === Kind.KnownHook) {
recordInvalidHookUsageError(place);
}
}
```
### Phase 5: Validate Nested Function Expressions
Recursively check that nested functions don't call hooks:
```typescript
function visitFunctionExpression(errors: CompilerError, fn: HIRFunction) {
for (const instr of allInstructions(fn)) {
if (isCall(instr)) {
const callee = getCallee(instr);
const hookKind = getHookKind(fn.env, callee.identifier);
if (hookKind != null) {
errors.push({
reason: 'Hooks must be called at the top level...',
description: `Cannot call ${hookKind} within a function expression`,
});
}
}
// Recursively check nested functions
if (isFunctionExpression(instr)) {
visitFunctionExpression(errors, instr.value.loweredFunc.func);
}
}
}
```
### Phi Node Handling
For phi nodes (control flow join points), the pass joins the kinds of all operands:
```typescript
for (const phi of block.phis) {
let kind = isHookName(phi.place.identifier.name) ? Kind.PotentialHook : Kind.Local;
for (const [, operand] of phi.operands) {
const operandKind = valueKinds.get(operand.identifier.id);
if (operandKind !== undefined) {
kind = joinKinds(kind, operandKind);
}
}
valueKinds.set(phi.place.identifier.id, kind);
}
```
## Edge Cases
### Optional Calls
Optional calls like `useHook?.()` are treated as conditional:
```javascript
const result = useHook?.(); // Error: conditional hook call
```
### Property Access on Hooks
Hook-like properties of known hooks are also known hooks:
```javascript
const useFoo = useHook.useFoo; // useFoo is KnownHook
useFoo(); // Must be called unconditionally
```
### Destructuring from Global
Destructuring hook-like names from a global creates known hooks:
```javascript
const {useState} = React; // useState is KnownHook
```
### Hook-Like Names from Local Variables
Hook-like names from local variables are potential hooks:
```javascript
const obj = createObject();
const useFoo = obj.useFoo; // PotentialHook
useFoo(); // Error: dynamic hook
```
### Error Deduplication
The pass deduplicates errors by source location, and once an error is recorded for a place, it's marked as `Kind.Error` to prevent further errors for the same place.
## TODOs
1. **Fixpoint iteration for loops** - The pass currently skips phi operands whose value is unknown (which can occur in loops). A follow-up could expand this to fixpoint iteration:
```typescript
// NOTE: we currently skip operands whose value is unknown
// (which can only occur for functions with loops), we may
// cause us to miss invalid code in some cases. We should
// expand this to a fixpoint iteration in a follow-up.
```
## Example
### Fixture: `rules-of-hooks/error.invalid-hook-if-consequent.js`
**Input:**
```javascript
function Component(props) {
let x = null;
if (props.cond) {
x = useHook();
}
return x;
}
```
**Error:**
```
Error: Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)
error.invalid-hook-if-consequent.ts:4:8
2 | let x = null;
3 | if (props.cond) {
> 4 | x = useHook();
| ^^^^^^^ Hooks must always be called in a consistent order...
5 | }
6 | return x;
```
### Fixture: `rules-of-hooks/error.invalid-hook-as-prop.js`
**Input:**
```javascript
function Component({useFoo}) {
useFoo();
}
```
**Error:**
```
Error: Hooks must be the same function on every render, but this value may change over time to a different function. See https://react.dev/reference/rules/react-calls-components-and-hooks#dont-dynamically-use-hooks
error.invalid-hook-as-prop.ts:2:2
1 | function Component({useFoo}) {
> 2 | useFoo();
| ^^^^^^ Hooks must be the same function on every render...
3 | }
```
### Fixture: `rules-of-hooks/error.invalid-hook-in-nested-function-expression-object-expression.js`
**Input:**
```javascript
function Component() {
'use memo';
const f = () => {
const x = {
outer() {
const g = () => {
const y = {
inner() {
return useFoo();
},
};
return y;
};
},
};
return x;
};
}
```
**Error:**
```
Error: Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)
Cannot call hook within a function expression.
error.invalid-hook-in-nested-function-expression-object-expression.ts:10:21
8 | const y = {
9 | inner() {
> 10 | return useFoo();
| ^^^^^^ Hooks must be called at the top level...
11 | },
12 | };
```
### Fixture: `rules-of-hooks/error.invalid-hook-optionalcall.js`
**Input:**
```javascript
function Component() {
const {result} = useConditionalHook?.() ?? {};
return result;
}
```
**Error:**
```
Error: Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)
error.invalid-hook-optionalcall.ts:2:19
1 | function Component() {
> 2 | const {result} = useConditionalHook?.() ?? {};
| ^^^^^^^^^^^^^^^^^^ Hooks must always be called in a consistent order...
3 | return result;
```

View File

@@ -0,0 +1,221 @@
# validateNoCapitalizedCalls
## File
`src/Validation/ValidateNoCapitalizedCalls.ts`
## Purpose
This validation pass ensures that capitalized functions are not called directly in a component. In React, capitalized functions are conventionally reserved for components, which should be invoked via JSX syntax rather than direct function calls.
Direct calls to capitalized functions can cause issues because:
1. Components may contain hooks, and calling them directly violates the Rules of Hooks
2. The React runtime expects components to be rendered via JSX for proper reconciliation
3. Direct calls bypass React's rendering lifecycle and state management
This validation is opt-in and controlled by the `validateNoCapitalizedCalls` configuration option.
## Input Invariants
- The function has been lowered to HIR
- Global bindings have been resolved
- The `validateNoCapitalizedCalls` configuration option is enabled (via pragma or config)
## Validation Rules
### Rule 1: No Direct Calls to Capitalized Globals
Capitalized global functions (not in the allowlist) cannot be called directly.
**Error:**
```
Error: Capitalized functions are reserved for components, which must be invoked with JSX. If this is a component, render it with JSX. Otherwise, ensure that it has no hook calls and rename it to begin with a lowercase letter. Alternatively, if you know for a fact that this function is not a component, you can allowlist it via the compiler config
[FunctionName] may be a component.
```
### Rule 2: No Direct Method Calls to Capitalized Properties
Capitalized methods on objects cannot be called directly.
**Error:**
```
Error: Capitalized functions are reserved for components, which must be invoked with JSX. If this is a component, render it with JSX. Otherwise, ensure that it has no hook calls and rename it to begin with a lowercase letter. Alternatively, if you know for a fact that this function is not a component, you can allowlist it via the compiler config
[MethodName] may be a component.
```
## Algorithm
### Phase 1: Build Allowlist
```typescript
const ALLOW_LIST = new Set([
...DEFAULT_GLOBALS.keys(), // Built-in globals (Array, Object, etc.)
...(envConfig.validateNoCapitalizedCalls ?? []), // User-configured allowlist
]);
const isAllowed = (name: string): boolean => {
return ALLOW_LIST.has(name);
};
```
### Phase 2: Track Capitalized Globals and Properties
```typescript
const capitalLoadGlobals = new Map<IdentifierId, string>();
const capitalizedProperties = new Map<IdentifierId, string>();
```
### Phase 3: Scan Instructions
```typescript
for (const instr of block.instructions) {
switch (value.kind) {
case 'LoadGlobal':
// Track capitalized globals (excluding CONSTANTS)
if (
value.binding.name !== '' &&
/^[A-Z]/.test(value.binding.name) &&
!(value.binding.name.toUpperCase() === value.binding.name) &&
!isAllowed(value.binding.name)
) {
capitalLoadGlobals.set(lvalue.identifier.id, value.binding.name);
}
break;
case 'CallExpression':
// Check if calling a tracked capitalized global
const calleeName = capitalLoadGlobals.get(value.callee.identifier.id);
if (calleeName != null) {
CompilerError.throwInvalidReact({
reason: 'Capitalized functions are reserved for components...',
description: `${calleeName} may be a component`,
...
});
}
break;
case 'PropertyLoad':
// Track capitalized properties
if (typeof value.property === 'string' && /^[A-Z]/.test(value.property)) {
capitalizedProperties.set(lvalue.identifier.id, value.property);
}
break;
case 'MethodCall':
// Check if calling a tracked capitalized property
const propertyName = capitalizedProperties.get(value.property.identifier.id);
if (propertyName != null) {
errors.push({
reason: 'Capitalized functions are reserved for components...',
description: `${propertyName} may be a component`,
...
});
}
break;
}
}
```
## Edge Cases
### ALL_CAPS Constants
Functions with names that are entirely uppercase (like `CONSTANTS`) are not flagged:
```javascript
const x = MY_CONSTANT(); // Not an error - all caps indicates a constant, not a component
const y = MyComponent(); // Error - PascalCase indicates a component
```
### Built-in Globals
The default globals from `DEFAULT_GLOBALS` are automatically allowlisted:
```javascript
const arr = Array(5); // OK - Array is a built-in
const obj = Object.create(null); // OK - Object is a built-in
```
### User-Configured Allowlist
Users can allowlist specific functions via configuration:
```typescript
validateNoCapitalizedCalls: ['MyUtility', 'SomeFactory']
```
### Method Calls vs Function Calls
Both direct function calls and method calls on objects are checked:
```javascript
MyComponent(); // Error - direct call
someObject.MyComponent(); // Error - method call
```
### Chained Property Access
Only the immediate property being called is checked:
```javascript
a.b.MyComponent(); // Only checks if MyComponent is capitalized
```
## TODOs
None in the source file.
## Example
### Fixture: `error.capitalized-function-call.js`
**Input:**
```javascript
// @validateNoCapitalizedCalls
function Component() {
const x = SomeFunc();
return x;
}
```
**Error:**
```
Error: Capitalized functions are reserved for components, which must be invoked with JSX. If this is a component, render it with JSX. Otherwise, ensure that it has no hook calls and rename it to begin with a lowercase letter. Alternatively, if you know for a fact that this function is not a component, you can allowlist it via the compiler config
SomeFunc may be a component.
error.capitalized-function-call.ts:3:12
1 | // @validateNoCapitalizedCalls
2 | function Component() {
> 3 | const x = SomeFunc();
| ^^^^^^^^^^ Capitalized functions are reserved for components...
4 |
5 | return x;
6 | }
```
### Fixture: `error.capitalized-method-call.js`
**Input:**
```javascript
// @validateNoCapitalizedCalls
function Component() {
const x = someGlobal.SomeFunc();
return x;
}
```
**Error:**
```
Error: Capitalized functions are reserved for components, which must be invoked with JSX. If this is a component, render it with JSX. Otherwise, ensure that it has no hook calls and rename it to begin with a lowercase letter. Alternatively, if you know for a fact that this function is not a component, you can allowlist it via the compiler config
SomeFunc may be a component.
error.capitalized-method-call.ts:3:12
1 | // @validateNoCapitalizedCalls
2 | function Component() {
> 3 | const x = someGlobal.SomeFunc();
| ^^^^^^^^^^^^^^^^^^^^^ Capitalized functions are reserved for components...
4 |
5 | return x;
6 | }
```
### Fixture: `capitalized-function-allowlist.js` (No Error)
**Input:**
```javascript
// @validateNoCapitalizedCalls:["SomeFunc"]
function Component() {
const x = SomeFunc();
return x;
}
```
**Output:**
Compiles successfully because `SomeFunc` is in the allowlist.

View File

@@ -0,0 +1,321 @@
# validateLocalsNotReassignedAfterRender
## File
`src/Validation/ValidateLocalsNotReassignedAfterRender.ts`
## Purpose
This validation pass prevents a category of bugs where a closure captures a binding from one render but does not update when the binding is reassigned in a later render.
When the React Compiler memoizes a function, that function captures bindings at the time of creation. If the function is reused across renders (because its dependencies haven't changed), any reassignments to captured variables will affect the wrong binding version. This can cause inconsistent behavior that's difficult to debug.
The pass detects when:
1. A local variable is reassigned within a function expression
2. That function expression escapes (e.g., passed to useEffect, used as event handler)
3. The reassignment would occur after render completes (in effects or async callbacks)
## Input Invariants
- The function has been lowered to HIR
- Effects have been inferred for all operands (`operand.effect !== Effect.Unknown`)
- Function signatures have been analyzed for `noAlias` properties
## Validation Rules
### Rule 1: No Reassignment After Render
Variables cannot be reassigned in functions that escape to be called after render.
**Error:**
```
Error: Cannot reassign variable after render completes
Reassigning `[variable]` after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead.
```
### Rule 2: No Reassignment in Async Functions
Variables cannot be reassigned within async functions (async functions always execute after render).
**Error:**
```
Error: Cannot reassign variable in async function
Reassigning a variable in an async function can cause inconsistent behavior on subsequent renders. Consider using state instead.
```
## Algorithm
### Phase 1: Track Context Variables
Context variables are variables declared in the outer component/hook that are captured by inner functions:
```typescript
const contextVariables = new Set<IdentifierId>();
// For DeclareContext in the main function, add to tracking
case 'DeclareContext':
if (!isFunctionExpression) {
contextVariables.add(value.lvalue.place.identifier.id);
}
break;
```
### Phase 2: Detect Reassigning Functions
The pass tracks which functions contain reassignments to context variables:
```typescript
const reassigningFunctions = new Map<IdentifierId, Place>();
case 'FunctionExpression':
case 'ObjectMethod':
// Recursively check if the function reassigns context variables
let reassignment = getContextReassignment(
value.loweredFunc.func,
contextVariables,
true, // isFunctionExpression
isAsync || value.loweredFunc.func.async
);
// Also check if any captured functions reassign
if (reassignment === null) {
for (const operand of eachInstructionValueOperand(value)) {
const fromOperand = reassigningFunctions.get(operand.identifier.id);
if (fromOperand !== undefined) {
reassignment = fromOperand;
break;
}
}
}
if (reassignment !== null) {
// If async, error immediately
if (isAsync || value.loweredFunc.func.async) {
throw new CompilerError("Cannot reassign variable in async function");
}
// Otherwise, track this function as reassigning
reassigningFunctions.set(lvalue.identifier.id, reassignment);
}
break;
```
### Phase 3: Detect Reassignment in Function Expression
Within a function expression, a `StoreContext` to a context variable is a reassignment:
```typescript
case 'StoreContext':
if (isFunctionExpression) {
if (contextVariables.has(value.lvalue.place.identifier.id)) {
return value.lvalue.place; // Found a reassignment
}
} else {
// In main function, just track the context variable
contextVariables.add(value.lvalue.place.identifier.id);
}
break;
```
### Phase 4: Propagate Reassignment Through Data Flow
Reassigning functions flow through local/context stores:
```typescript
case 'StoreLocal':
case 'StoreContext':
const reassignment = reassigningFunctions.get(value.value.identifier.id);
if (reassignment !== undefined) {
reassigningFunctions.set(value.lvalue.place.identifier.id, reassignment);
reassigningFunctions.set(lvalue.identifier.id, reassignment);
}
break;
case 'LoadLocal':
const reassignment = reassigningFunctions.get(value.place.identifier.id);
if (reassignment !== undefined) {
reassigningFunctions.set(lvalue.identifier.id, reassignment);
}
break;
```
### Phase 5: Check Escape Points
When a reassigning function is used as an operand with `Effect.Freeze`, it means the function escapes (e.g., passed to a hook, used as a prop):
```typescript
for (const operand of operands) {
const reassignment = reassigningFunctions.get(operand.identifier.id);
if (reassignment !== undefined) {
if (operand.effect === Effect.Freeze) {
// Function escapes - this is an error
return reassignment;
} else {
// Function doesn't escape yet, propagate to lvalues
for (const lval of eachInstructionLValue(instr)) {
reassigningFunctions.set(lval.identifier.id, reassignment);
}
}
}
}
```
### Phase 6: Check Terminal Operands
Reassigning functions used in terminal operands (like return) also escape:
```typescript
for (const operand of eachTerminalOperand(block.terminal)) {
const reassignment = reassigningFunctions.get(operand.identifier.id);
if (reassignment !== undefined) {
return reassignment;
}
}
```
### NoAlias Optimization
For function calls with `noAlias` signatures, only the callee needs to be checked (not all arguments):
```typescript
if (value.kind === 'CallExpression') {
const signature = getFunctionCallSignature(fn.env, value.callee.identifier.type);
if (signature?.noAlias) {
operands = [value.callee]; // Only check the callee
}
}
```
## Edge Cases
### Nested Async Functions
Async functions are always detected as problematic, regardless of nesting level:
```javascript
function Component() {
let x = 0;
const f = async () => {
const g = () => {
x = 1; // Error: in async context
};
};
}
```
### Function Composition
If a reassigning function is captured by another function, that outer function is also marked as reassigning:
```javascript
function Component() {
let x = 0;
const reassign = () => { x = 1; };
const wrapper = () => { reassign(); };
useEffect(wrapper); // Error: wrapper contains reassign
}
```
### NoAlias Functions
Functions with `noAlias` signatures don't let their arguments escape, so passing a reassigning function to them is safe:
```javascript
function Component() {
let x = 0;
const f = () => { x = 1; };
console.log(f); // OK: console.log has noAlias, f doesn't escape
}
```
### Direct Effect Usage
The most common case is passing a reassigning function to useEffect:
```javascript
function Component() {
let local;
const reassign = () => { local = 'new value'; };
useEffect(() => { reassign(); }, []); // Error
}
```
## TODOs
None in the source file.
## Example
### Fixture: `error.invalid-reassign-local-variable-in-effect.js`
**Input:**
```javascript
import {useEffect} from 'react';
function Component() {
let local;
const reassignLocal = newValue => {
local = newValue;
};
const onMount = newValue => {
reassignLocal('hello');
if (local === newValue) {
// Without React Compiler, `reassignLocal` is freshly created
// on each render, capturing a binding to the latest `local`,
// such that invoking reassignLocal will reassign the same
// binding that we are observing in the if condition, and
// we reach this branch
console.log('`local` was updated!');
} else {
// With React Compiler enabled, `reassignLocal` is only created
// once, capturing a binding to `local` in that render pass.
// Therefore, calling `reassignLocal` will reassign the wrong
// version of `local`, and not update the binding we are checking
// in the if condition.
throw new Error('`local` not updated!');
}
};
useEffect(() => {
onMount();
}, [onMount]);
return 'ok';
}
```
**Error:**
```
Error: Cannot reassign variable after render completes
Reassigning `local` after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead.
error.invalid-reassign-local-variable-in-effect.ts:7:4
5 |
6 | const reassignLocal = newValue => {
> 7 | local = newValue;
| ^^^^^ Cannot reassign `local` after render completes
8 | };
9 |
10 | const onMount = newValue => {
```
### Fixture: `error.invalid-reassign-local-variable-in-async-callback.js`
**Input:**
```javascript
function Component() {
let value = null;
const reassign = async () => {
await foo().then(result => {
// Reassigning a local variable in an async function is *always* mutating
// after render, so this should error regardless of where this ends up
// getting called
value = result;
});
};
const onClick = async () => {
await reassign();
};
return <div onClick={onClick}>Click</div>;
}
```
**Error:**
```
Error: Cannot reassign variable in async function
Reassigning a variable in an async function can cause inconsistent behavior on subsequent renders. Consider using state instead.
error.invalid-reassign-local-variable-in-async-callback.ts:8:6
6 | // after render, so this should error regardless of where this ends up
7 | // getting called
> 8 | value = result;
| ^^^^^ Cannot reassign `value`
9 | });
10 | };
```

View File

@@ -0,0 +1,133 @@
# validateNoSetStateInRender
## File
`src/Validation/ValidateNoSetStateInRender.ts`
## Purpose
Validates that a component does not unconditionally call `setState` during render, which would cause an infinite update loop. This pass is conservative and may miss some cases (false negatives) but avoids false positives.
## Input Invariants
- Operates on HIRFunction (pre-reactive scope inference)
- Must run before reactive scope inference
- Uses `computeUnconditionalBlocks` to determine which blocks always execute
## Validation Rules
This pass detects two types of violations:
1. **Unconditional setState in render**: Calling `setState` (or a function that transitively calls setState) in a block that always executes during render.
2. **setState inside useMemo**: Calling `setState` inside a `useMemo` callback, which can cause infinite loops when the memo's dependencies change.
### Error Messages
**For unconditional setState in render:**
```
Error: Cannot call setState during render
Calling setState during render may trigger an infinite loop.
* To reset state when other state/props change, store the previous value in state and update conditionally: https://react.dev/reference/react/useState#storing-information-from-previous-renders
* To derive data from other state/props, compute the derived data during render without using state
```
**For setState in useMemo:**
```
Error: Calling setState from useMemo may trigger an infinite loop
Each time the memo callback is evaluated it will change state. This can cause a memoization dependency to change, running the memo function again and causing an infinite loop. Instead of setting state in useMemo(), prefer deriving the value during render.
```
## Algorithm
1. Compute the set of unconditional blocks using post-dominator analysis
2. Initialize a set `unconditionalSetStateFunctions` to track functions that unconditionally call setState
3. Traverse all blocks and instructions:
- **LoadLocal/StoreLocal**: Propagate setState tracking through variable assignments and loads
- **FunctionExpression/ObjectMethod**: Recursively check if the function unconditionally calls setState. If so, add the function's lvalue to the tracking set
- **StartMemoize/FinishMemoize**: Track when inside a manual memoization block (useMemo/useCallback)
- **CallExpression**: Check if the callee is a setState function or tracked setter:
- If inside a memoize block, emit a useMemo-specific error
- If in an unconditional block, emit a render-time setState error
### Key Helper: `computeUnconditionalBlocks`
Uses post-dominator tree analysis to find blocks that always execute when the function runs. The analysis ignores throw statements since hooks only need consistent ordering for normal execution paths.
## Edge Cases
### Conditional setState is allowed
```javascript
// This is valid - setState is conditional
if (someCondition) {
setState(newValue);
}
```
### Transitive detection through functions
```javascript
// Detected - setTrue unconditionally calls setState
const setTrue = () => setState(true);
setTrue(); // Error here
```
### False negative: setState in data structures
```javascript
// NOT detected - setState stored in array then extracted
const [state, setState] = useState(false);
const x = [setState];
const y = x.pop();
y(); // No error, but will cause infinite loop
```
### Feature flag: enableUseKeyedState
When enabled, the error message suggests using `useKeyedState(initialState, key)` as an alternative pattern for resetting state when dependencies change.
## TODOs
None in source code.
## Example
### Fixture: `error.invalid-unconditional-set-state-in-render.js`
**Input:**
```javascript
// @validateNoSetStateInRender
function Component(props) {
const [x, setX] = useState(0);
const aliased = setX;
setX(1);
aliased(2);
return x;
}
```
**Error:**
```
Found 2 errors:
Error: Cannot call setState during render
Calling setState during render may trigger an infinite loop.
* To reset state when other state/props change, store the previous value in state and update conditionally: https://react.dev/reference/react/useState#storing-information-from-previous-renders
* To derive data from other state/props, compute the derived data during render without using state.
error.invalid-unconditional-set-state-in-render.ts:6:2
4 | const aliased = setX;
5 |
> 6 | setX(1);
| ^^^^ Found setState() in render
7 | aliased(2);
8 |
9 | return x;
Error: Cannot call setState during render
...
error.invalid-unconditional-set-state-in-render.ts:7:2
5 |
6 | setX(1);
> 7 | aliased(2);
| ^^^^^^^ Found setState() in render
```
**Why it fails:** Both `setX(1)` and `aliased(2)` are unconditionally called during render. The pass tracks that `aliased` is assigned from `setX`, so calling `aliased()` is also detected as a setState call.

View File

@@ -0,0 +1,141 @@
# validateNoDerivedComputationsInEffects
## File
`src/Validation/ValidateNoDerivedComputationsInEffects.ts`
## Purpose
Validates that `useEffect` is not used for derived computations that could and should be performed during render. This catches a common anti-pattern where developers use effects to synchronize derived state, which causes unnecessary re-renders and complexity.
See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state
## Input Invariants
- Operates on HIRFunction (pre-reactive scope inference)
- Effect hooks must be identified (`isUseEffectHookType`)
- setState functions must be identified (`isSetStateType`)
## Validation Rules
The pass detects when an effect:
1. Has a dependency array (2nd argument)
2. The effect function only captures the dependencies and setState functions
3. The effect calls setState with a value derived solely from the dependencies
4. The effect has no control flow (loops with back edges)
When detected, it produces:
```
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
```
## Algorithm
1. **Collection Phase**: Traverse all instructions to collect:
- `candidateDependencies`: Map of ArrayExpression identifiers (potential deps arrays)
- `functions`: Map of FunctionExpression identifiers (potential effect callbacks)
- `locals`: Map of LoadLocal sources for identifier resolution
2. **Detection Phase**: When a `useEffect` call is found with 2 arguments:
- Look up the effect function and dependencies array
- Verify all dependency array elements are identifiers
- Call `validateEffect()` on the effect function
3. **Effect Validation** (`validateEffect`):
- Check that the effect only captures dependencies or setState functions
- Check that all dependencies are actually used in the effect
- Skip if any block has a back edge (loop)
- Track data flow through instructions:
- `LoadLocal`: Propagate dependency tracking
- `PropertyLoad`, `BinaryExpression`, `TemplateLiteral`, `CallExpression`, `MethodCall`: Aggregate dependencies from operands
- When `setState` is called with a single argument that depends on ALL effect dependencies, record the location
- If any dependency is used in a terminal operand (control flow), abort validation
- Push errors for all recorded setState locations
### Value Tracking
The pass maintains a `values` map from `IdentifierId` to `Array<IdentifierId>` tracking which effect dependencies each value derives from. When setState is called, if the argument derives from all dependencies, it's flagged as a derived computation.
## Edge Cases
### Allowed: Effects with side effects
```javascript
// Valid - effect captures external values, not just deps
useEffect(() => {
logToServer(firstName);
setFullName(firstName);
}, [firstName]);
```
### Allowed: Effects with loops
```javascript
// Valid - has control flow, not a simple derivation
useEffect(() => {
let result = '';
for (const item of items) {
result += item;
}
setResult(result);
}, [items]);
```
### Allowed: Effects with conditional setState
```javascript
// Valid - setState is conditional on control flow
useEffect(() => {
if (condition) {
setFullName(firstName + lastName);
}
}, [firstName, lastName]);
```
### Not detected: Subset of dependencies
```javascript
// Not flagged - only uses firstName, not lastName
useEffect(() => {
setResult(firstName);
}, [firstName, lastName]);
```
## TODOs
None in source code.
## Example
### Fixture: `error.invalid-derived-computation-in-effect.js`
**Input:**
```javascript
// @validateNoDerivedComputationsInEffects
import {useEffect, useState} from 'react';
function BadExample() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}
```
**Error:**
```
Found 1 error:
Error: Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
error.invalid-derived-computation-in-effect.ts:11:4
9 | const [fullName, setFullName] = useState('');
10 | useEffect(() => {
> 11 | setFullName(firstName + ' ' + lastName);
| ^^^^^^^^^^^ Values derived from props and state should be calculated during render, not in an effect.
12 | }, [firstName, lastName]);
13 |
14 | return <div>{fullName}</div>;
```
**Why it fails:** The effect computes `fullName` purely from `firstName` and `lastName` (the dependencies) and then sets state. This is a derived computation that should be calculated during render:
```javascript
// Correct approach
const fullName = firstName + ' ' + lastName;
```

View File

@@ -0,0 +1,150 @@
# validateNoSetStateInEffects
## File
`src/Validation/ValidateNoSetStateInEffects.ts`
## Purpose
Validates against calling `setState` synchronously in the body of an effect (`useEffect`, `useLayoutEffect`, `useInsertionEffect`), while allowing `setState` in callbacks scheduled by the effect. Synchronous setState in effects triggers cascading re-renders which hurts performance.
See: https://react.dev/learn/you-might-not-need-an-effect
## Input Invariants
- Operates on HIRFunction (pre-reactive scope inference)
- Effect hooks must be identified (`isUseEffectHookType`, `isUseLayoutEffectHookType`, `isUseInsertionEffectHookType`)
- setState functions must be identified (`isSetStateType`)
- Only runs when `outputMode === 'lint'`
## Validation Rules
This pass detects synchronous setState calls within effect bodies:
**Standard error message:**
```
Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended.
```
**Verbose error message** (when `enableVerboseNoSetStateInEffect` is enabled):
Provides more detailed guidance about specific anti-patterns like non-local derived data, derived event patterns, and force update patterns.
## Algorithm
1. **Main function traversal**: Build a map `setStateFunctions` tracking which identifiers are setState functions
2. For each instruction:
- **LoadLocal/StoreLocal**: Propagate setState tracking through variable assignments
- **FunctionExpression**: Check if the function synchronously calls setState by recursively calling `getSetStateCall()`. If so, track the function as a setState-calling function
- **useEffectEvent call**: If the argument is a function that calls setState, track the return value as a setState function
- **useEffect/useLayoutEffect/useInsertionEffect call**: Check if the callback argument is tracked as calling setState. If so, emit an error
3. **`getSetStateCall()` helper**: Recursively analyzes a function to find synchronous setState calls:
- Tracks ref-derived values when `enableAllowSetStateFromRefsInEffects` is enabled
- Propagates setState tracking through local variables
- Returns the Place of the setState call if found, null otherwise
### Ref-derived setState exception
When `enableAllowSetStateFromRefsInEffects` is enabled, the pass allows setState calls where:
- The value being set is derived from a ref (`useRef` or `ref.current`)
- The block containing setState is controlled by a ref-dependent condition
This allows patterns like storing initial layout measurements from refs in state.
## Edge Cases
### Allowed: setState in callbacks
```javascript
// Valid - setState in event callback, not synchronous
useEffect(() => {
const handler = () => {
setState(newValue);
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
```
### Transitive detection
```javascript
// Detected - transitive through function calls
const f = () => setState(value);
const g = () => f();
useEffect(() => {
g(); // Error: calls setState transitively
});
```
### useEffectEvent tracking
```javascript
// Detected - useEffectEvent that calls setState is tracked
const handler = useEffectEvent(() => {
setState(value);
});
useEffect(() => {
handler(); // Error: handler calls setState
});
```
### Allowed: Ref-derived state (with flag)
```javascript
// Valid when enableAllowSetStateFromRefsInEffects is true
const ref = useRef(null);
useEffect(() => {
const width = ref.current.offsetWidth;
setWidth(width); // Allowed - derived from ref
}, []);
```
## TODOs
From the source code:
```typescript
/*
* TODO: once we support multiple locations per error, we should link to the
* original Place in the case that setStateFunction.has(callee)
*/
```
## Example
### Fixture: `invalid-setState-in-useEffect-transitive.js`
**Input:**
```javascript
// @loggerTestOnly @validateNoSetStateInEffects @outputMode:"lint"
import {useEffect, useState} from 'react';
function Component() {
const [state, setState] = useState(0);
const f = () => {
setState(s => s + 1);
};
const g = () => {
f();
};
useEffect(() => {
g();
});
return state;
}
```
**Error:**
```
Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended.
invalid-setState-in-useEffect-transitive.ts:13:4
11 | };
12 | useEffect(() => {
> 13 | g();
| ^ Avoid calling setState() directly within an effect
14 | });
```
**Why it fails:** Even though `setState` is not called directly in the effect, the pass traces through `g()` -> `f()` -> `setState()` and detects that the effect synchronously triggers a state update.

View File

@@ -0,0 +1,167 @@
# validateNoJSXInTryStatement
## File
`src/Validation/ValidateNoJSXInTryStatement.ts`
## Purpose
Validates that JSX is not created within a try block. Developers may incorrectly assume that wrapping JSX in try/catch will catch rendering errors, but React does not immediately render components when JSX is created - JSX is just a description of UI that will be rendered later. Error boundaries should be used instead.
See: https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
## Input Invariants
- Operates on HIRFunction (pre-reactive scope inference)
- Blocks are traversed in order
- Only runs when `outputMode === 'lint'`
## Validation Rules
The pass errors when `JsxExpression` or `JsxFragment` instructions are found within a try block.
**Error message:**
```
Error: Avoid constructing JSX within try/catch
React does not immediately render components when JSX is rendered, so any errors from this component will not be caught by the try/catch. To catch errors in rendering a given component, wrap that component in an error boundary.
```
### Important distinction
- JSX in a **try block**: Error
- JSX in a **catch block** (not nested in outer try): Allowed
- JSX in a **catch block** (nested in outer try): Error
## Algorithm
1. Maintain a stack `activeTryBlocks` of currently active try statement handler block IDs
2. For each block:
- Remove the current block from `activeTryBlocks` if it matches a handler (we've exited the try scope)
- If `activeTryBlocks` is not empty (we're inside a try block):
- Check each instruction for `JsxExpression` or `JsxFragment`
- If found, push an error
- If the block's terminal is a `try` terminal, push its handler block ID to `activeTryBlocks`
### Block tracking with `retainWhere`
The `retainWhere` utility is used to remove the current block from `activeTryBlocks` at the start of each block. When we reach a catch handler block, it gets removed from the active list, allowing JSX in catch blocks (unless there's an outer try).
## Edge Cases
### Allowed: JSX in catch (no outer try)
```javascript
// Valid - catch block is not inside a try
function Component() {
try {
doSomething();
} catch {
return <ErrorMessage />; // OK
}
}
```
### Error: JSX in catch with outer try
```javascript
// Error - catch is inside outer try
function Component() {
try {
try {
doSomething();
} catch {
return <ErrorMessage />; // Error!
}
} catch {
return null;
}
}
```
### Error: JSX assigned in try
```javascript
// Error - JSX creation is in try block
function Component() {
let el;
try {
el = <div />; // Error here
} catch {
return null;
}
return el;
}
```
### Finally blocks
The validation currently has TODOs for handling try/catch/finally properly. Files like `error.todo-invalid-jsx-in-try-with-finally.js` indicate these are known unsupported cases.
## TODOs
Based on fixture naming patterns:
- `error.todo-invalid-jsx-in-try-with-finally.js` - Try blocks with finally clauses
- `error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.js` - Nested try/catch in try with finally
## Example
### Fixture: `invalid-jsx-in-try-with-catch.js`
**Input:**
```javascript
// @loggerTestOnly @validateNoJSXInTryStatements @outputMode:"lint"
function Component(props) {
let el;
try {
el = <div />;
} catch {
return null;
}
return el;
}
```
**Error:**
```
Error: Avoid constructing JSX within try/catch
React does not immediately render components when JSX is rendered, so any errors from this component will not be caught by the try/catch. To catch errors in rendering a given component, wrap that component in an error boundary.
invalid-jsx-in-try-with-catch.ts:5:9
3 | let el;
4 | try {
> 5 | el = <div />;
| ^^^^^^^ Avoid constructing JSX within try/catch
6 | } catch {
7 | return null;
8 | }
```
**Why it fails:** The `<div />` JSX element is created inside a try block. If the developer expects this to catch errors from rendering the div, they will be surprised - the try/catch will only catch errors from creating the JSX object (which is rare), not from React actually rendering it later. The correct approach is to use an error boundary component to catch rendering errors.
### Fixture: `invalid-jsx-in-catch-in-outer-try-with-catch.js`
**Input:**
```javascript
// @loggerTestOnly @validateNoJSXInTryStatements @outputMode:"lint"
import {identity} from 'shared-runtime';
function Component(props) {
let el;
try {
let value;
try {
value = identity(props.foo);
} catch {
el = <div value={value} />;
}
} catch {
return null;
}
return el;
}
```
**Error:**
```
Error: Avoid constructing JSX within try/catch
...
invalid-jsx-in-catch-in-outer-try-with-catch.ts:11:11
9 | value = identity(props.foo);
10 | } catch {
> 11 | el = <div value={value} />;
| ^^^^^^^^^^^^^^^^^^^^^ Avoid constructing JSX within try/catch
```
**Why it fails:** Even though the JSX is in a catch block, that catch block is itself inside an outer try block. The outer try's catch won't catch rendering errors from the JSX any more than the inner try would.

View File

@@ -0,0 +1,180 @@
# validateNoImpureValuesInRender
## File
`src/Validation/ValidateNoImpureValuesInRender.ts`
## Purpose
This validation pass ensures that impure values (values derived from non-deterministic function calls) are not used in render output. Impure values can produce unstable results that update unpredictably when the component re-renders, violating React's requirement that components be pure and idempotent.
The pass tracks values produced by impure functions (like `Date.now()`, `Math.random()`, `performance.now()`) and errors if those values flow into JSX props, component return values, or other render-time contexts.
## Input Invariants
- The function has been through effect inference
- Aliasing effects have been computed on instructions
- `Impure` effects mark values from non-deterministic sources
- `Render` effects mark values used in render context
## Validation Rules
The pass produces errors when:
1. **Impure value in render context**: A value marked with an `Impure` effect flows into a position marked with a `Render` effect
2. **Impure function returns in render**: A function that returns an impure value is called during render
Error messages produced:
- Category: `ImpureValues`
- Reason: "Cannot access impure value during render"
- Description: "Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render."
The error points to two locations:
1. Where the impure value is used in render (e.g., as a JSX prop)
2. Where the impure value originates (e.g., the `Date.now()` call)
## Algorithm
### Phase 1: Infer Impure Values
The pass iterates over all instructions to build a map of which identifiers contain impure values:
```typescript
function inferImpureValues(
fn: HIRFunction,
impure: Map<IdentifierId, ImpureEffect>,
impureFunctions: Map<IdentifierId, ImpuritySignature>,
cache: FunctionCache,
): ImpuritySignature
```
The algorithm uses a fixed-point iteration that propagates impurity through data flow:
1. **Process phi nodes**: If any operand of a phi is impure, the phi result is impure
2. **Process effects**: For each instruction's effects:
- `Impure` effect: Mark the destination identifier as impure
- `Alias/Assign/Capture/CreateFrom/ImmutableCapture`: Propagate impurity from source to destination
- `CreateFunction`: Recursively analyze function expressions
- `Apply`: When calling a function with an impurity signature, propagate impurity to call results
3. **Control flow sensitivity**: The pass also considers control-flow dominators to detect impure values that flow through conditional branches
### Phase 2: Validate Render Effects
After impurity inference converges, the pass validates all `Render` effects:
```typescript
function validateRenderEffect(effect: RenderEffect): void {
const impureEffect = impure.get(effect.place.identifier.id);
if (impureEffect != null) {
// Emit error
}
}
```
### Special Cases
- Values stored in refs (`isUseRefType`) are allowed to be impure since refs are not rendered
- JSX elements are excluded from impurity propagation (`isJsxType`)
## Edge Cases
### Impure Values Through Helper Functions
If a helper function returns an impure value and is called during render, both the call site and the original impure source are reported:
```javascript
function Component() {
const now = () => Date.now(); // Source of impurity
const render = () => {
return <div>{now()}</div>; // Error: impure value in render
};
return <div>{render()}</div>; // Error: impure value in render
}
```
### Indirect Impurity Through Mutation
When an impure value is captured into another value through mutation, the destination becomes impure:
```javascript
function Component() {
const obj = {};
obj.time = Date.now(); // obj becomes impure
return <Foo obj={obj} />; // Error
}
```
### Phi Node Propagation
Impurity propagates through control flow merges:
```javascript
function Component({cond}) {
let x;
if (cond) {
x = Date.now(); // Impure path
} else {
x = 0; // Pure path
}
return <Foo x={x} />; // Error: x may be impure
}
```
## TODOs
From the source file:
```typescript
/**
* TODO: consider propagating impurity for assignments/mutations that
* are controlled by an impure value.
*
* Example: This should error since we know the semantics of array.push,
* it's a definite Mutate and definite Capture, not maybemutate+maybecapture:
*
* let x = [];
* if (Date.now() < START_DATE) {
* x.push(1);
* }
* return <Foo x={x} />
*/
```
## Example
### Fixture: `error.invalid-impure-functions-in-render.js`
**Input:**
```javascript
// @validateNoImpureFunctionsInRender
function Component() {
const date = Date.now();
const now = performance.now();
const rand = Math.random();
return <Foo date={date} now={now} rand={rand} />;
}
```
**Error:**
```
Found 3 errors:
Error: Cannot access impure value during render
Calling an impure function can produce unstable results that update unpredictably
when the component happens to re-render.
(https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:7:20
5 | const now = performance.now();
6 | const rand = Math.random();
> 7 | return <Foo date={date} now={now} rand={rand} />;
| ^^^^ Cannot access impure value during render
8 | }
error.invalid-impure-functions-in-render.ts:4:15
2 |
3 | function Component() {
> 4 | const date = Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
5 | const now = performance.now();
Error: Cannot access impure value during render
...
```
Key observations:
- Each impure function call (`Date.now`, `performance.now`, `Math.random`) produces a separate error
- The error shows both the usage location (in JSX) and the source location (the impure call)
- The pass is enabled via the `@validateNoImpureFunctionsInRender` pragma

View File

@@ -0,0 +1,268 @@
# validateNoRefAccessInRender
## File
`src/Validation/ValidateNoRefAccessInRender.ts`
## Purpose
This validation pass ensures that React refs are not mutated during render. Refs are mutable containers for values that are not needed for rendering. Accessing or mutating `ref.current` during render can cause components to not update as expected because React does not track ref mutations.
The pass validates both direct ref mutations at the component level and ref mutations inside functions that are called during render.
## Input Invariants
- The function has been through type inference
- Ref types are properly identified (`useRef` return values)
- Function expressions have been lowered
## Validation Rules
The pass produces errors for:
1. **Direct ref mutation in render**: Assigning to `ref.current` at the top level of a component
2. **Ref mutation in render helper**: Mutating a ref inside a function that is called during render
3. **Duplicate ref initialization**: Initializing a ref more than once within null-guard blocks
**Exception - Null-guard initialization pattern**: The pass allows a single initialization of `ref.current` inside an `if (ref.current == null)` block. This is a common pattern for lazy initialization:
```javascript
// ALLOWED - null-guard initialization
if (ref.current == null) {
ref.current = expensiveComputation();
}
```
Error messages produced:
- Category: `Refs`
- Reason: "Cannot access refs during render"
- Messages:
- "Cannot update ref during render"
- "Ref is initialized more than once during render"
- "Ref was first initialized here" (for duplicate initialization)
## Algorithm
### Phase 1: Initialize Ref Tracking
Track refs from function parameters and context (captured variables):
```typescript
for (const param of fn.params) {
if (isUseRefType(place.identifier)) {
refs.set(place.identifier.id, {kind: 'Ref', refId: makeRefId()});
}
}
```
### Phase 2: Single Forward Pass
Process all blocks in order, tracking:
- `refs`: Map of identifier IDs to ref information
- `nullables`: Set of identifiers known to be null/undefined
- `guards`: Map of comparison results (e.g., `ref.current == null`)
- `safeBlocks`: Map of blocks where null-guard allows initialization
- `refMutatingFunctions`: Map of function identifiers that mutate refs
### Phase 3: Process Instructions
For each instruction, handle:
```typescript
switch (value.kind) {
case 'PropertyLoad': {
// Track ref.current access
if (objRef?.kind === 'Ref' && value.property === 'current') {
refs.set(lvalue.identifier.id, {kind: 'RefValue', refId: objRef.refId});
}
break;
}
case 'PropertyStore': {
// Check for ref mutation
if (isRef && isCurrentProperty && !isNullGuardInit) {
if (isTopLevel) {
errors.pushDiagnostic(makeRefMutationError(instr.loc));
}
return mutation;
}
break;
}
case 'FunctionExpression': {
// Recursively validate with isTopLevel=false
const mutation = validateFunction(..., false, errors);
if (mutation != null) {
refMutatingFunctions.set(lvalue.identifier.id, mutation);
}
break;
}
case 'CallExpression': {
// Check if calling a ref-mutating function
if (refMutatingFunctions.has(callee.identifier.id) && isTopLevel) {
errors.pushDiagnostic(makeRefMutationError(mutationInfo.loc));
}
break;
}
}
```
### Phase 4: Guard Detection and Propagation
When encountering an `if` terminal with a null-guard condition:
```typescript
if (block.terminal.kind === 'if') {
const guard = guards.get(block.terminal.test.identifier.id);
if (guard != null) {
// For equality checks (==, ===), consequent is safe
// For inequality checks (!=, !==), alternate is safe
const safeBlock = guard.isEquality
? block.terminal.consequent
: block.terminal.alternate;
// Propagate safety through control flow
}
}
```
## Edge Cases
### Null-Guard Initialization Pattern (Allowed)
```javascript
function Component() {
const ref = useRef(null);
if (ref.current == null) {
ref.current = computeValue(); // OK - first initialization
}
return <div />;
}
```
### Duplicate Initialization (Error)
```javascript
function Component() {
const ref = useRef(null);
if (ref.current == null) {
ref.current = value1; // First init - tracked
}
if (ref.current == null) {
ref.current = value2; // Error: duplicate initialization
}
}
```
### Negated Null Check
The pass correctly handles negated null checks:
```javascript
if (ref.current !== null) {
// NOT safe for initialization
} else {
// Safe for initialization (ref.current is null here)
}
```
### Ref Mutation in Called Function
```javascript
function Component(props) {
const ref = useRef(null);
const renderItem = item => {
ref.current = item; // Mutation tracked in function
return <Item item={item} />;
};
// Error: calling function that mutates ref during render
return <List>{props.items.map(renderItem)}</List>;
}
```
### Ref Mutation in Event Handler (Allowed)
```javascript
function Component() {
const ref = useRef(null);
const onClick = () => {
ref.current = value; // OK - not called during render
};
return <button onClick={onClick} />; // onClick is passed, not called
}
```
### Arbitrary Comparison Values (Error)
Only `null` or `undefined` comparisons are recognized as null guards:
```javascript
const DEFAULT_VALUE = 1;
if (ref.current == DEFAULT_VALUE) {
ref.current = 1; // Error: not a null guard
}
```
## TODOs
None in the source file.
## Example
### Fixture: `error.invalid-disallow-mutating-ref-in-render.js`
**Input:**
```javascript
// @validateRefAccessDuringRender
function Component() {
const ref = useRef(null);
ref.current = false;
return <button ref={ref} />;
}
```
**Error:**
```
Found 1 error:
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be
accessed outside of render, such as in event handlers or effects. Accessing a
ref value (the `current` property) during render can cause your component not
to update as expected (https://react.dev/reference/react/useRef).
error.invalid-disallow-mutating-ref-in-render.ts:4:2
2 | function Component() {
3 | const ref = useRef(null);
> 4 | ref.current = false;
| ^^^^^^^^^^^ Cannot update ref during render
5 |
6 | return <button ref={ref} />;
7 | }
```
### Fixture: `error.invalid-ref-in-callback-invoked-during-render.js`
**Input:**
```javascript
// @validateRefAccessDuringRender
function Component(props) {
const ref = useRef(null);
const renderItem = item => {
const current = ref.current;
return <Foo item={item} current={current} />;
};
return <Items>{props.items.map(item => renderItem(item))}</Items>;
}
```
**Error:**
```
Found 1 error:
Error: Cannot access ref value during render
React refs are values that are not needed for rendering...
error.invalid-ref-in-callback-invoked-during-render.ts:6:37
4 | const renderItem = item => {
5 | const current = ref.current;
> 6 | return <Foo item={item} current={current} />;
| ^^^^^^^ Ref value is used during render
7 | };
8 | return <Items>{props.items.map(item => renderItem(item))}</Items>;
error.invalid-ref-in-callback-invoked-during-render.ts:5:20
3 | const ref = useRef(null);
4 | const renderItem = item => {
> 5 | const current = ref.current;
| ^^^^^^^^^^^ Ref is initially accessed
```
Key observations:
- Direct mutation at render level is an immediate error
- Functions that mutate refs are tracked; errors occur when those functions are called at render level
- The null-guard pattern allows a single initialization
- The pass distinguishes between refs (`useRef` return type) and ref values (`.current` property)

View File

@@ -0,0 +1,285 @@
# validateNoFreezingKnownMutableFunctions
## File
`src/Validation/ValidateNoFreezingKnownMutableFunctions.ts`
## Purpose
This validation pass ensures that functions with known mutations (functions that mutate captured local variables) are not passed where a frozen value is expected. Frozen contexts include JSX props, hook arguments, and return values from hooks.
The key insight is that a function which mutates captured variables is effectively a mutable value itself. Unlike a mutable array (which a receiver can choose not to mutate), there is no way for the receiver of a function to prevent the mutation from happening when the function is called. Therefore, passing such functions to props or hooks violates React's expectation that rendered values are immutable.
## Input Invariants
- The function has been through aliasing effect inference
- `aliasingEffects` on FunctionExpression values have been computed
- `Mutate` and `MutateTransitive` effects identify definite mutations to captured variables
## Validation Rules
The pass produces errors when:
1. **Mutable function passed as JSX prop**: A function that mutates a captured variable is passed as a prop to a JSX element
2. **Mutable function passed to hook**: A function that mutates a captured variable is passed as an argument to a hook
3. **Mutable function returned from hook**: A function that mutates a captured variable is returned from a hook
**Exception - Ref mutations**: Functions that mutate refs (`isRefOrRefLikeMutableType`) are allowed, since refs are mutable by design and not tracked for rendering purposes.
Error messages produced:
- Category: `Immutability`
- Reason: "Cannot modify local variables after render completes"
- Description: "This argument is a function which may reassign or mutate [variable] after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead"
- Messages:
- "This function may (indirectly) reassign or modify [variable] after render"
- "This modifies [variable]"
## Algorithm
### Phase 1: Track Context Mutation Effects
The pass maintains a map from identifier IDs to their associated mutation effects:
```typescript
const contextMutationEffects: Map<
IdentifierId,
Extract<AliasingEffect, {kind: 'Mutate'} | {kind: 'MutateTransitive'}>
> = new Map();
```
### Phase 2: Single Forward Pass
Process all blocks in order, handling specific instruction types:
```typescript
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
switch (value.kind) {
case 'LoadLocal': {
// Propagate mutation effect from source to loaded value
const effect = contextMutationEffects.get(value.place.identifier.id);
if (effect != null) {
contextMutationEffects.set(lvalue.identifier.id, effect);
}
break;
}
case 'StoreLocal': {
// Propagate mutation effect to both lvalue and stored variable
const effect = contextMutationEffects.get(value.value.identifier.id);
if (effect != null) {
contextMutationEffects.set(lvalue.identifier.id, effect);
contextMutationEffects.set(value.lvalue.place.identifier.id, effect);
}
break;
}
case 'FunctionExpression': {
// Check function's aliasing effects for context mutations
if (value.loweredFunc.func.aliasingEffects != null) {
const context = new Set(
value.loweredFunc.func.context.map(p => p.identifier.id)
);
for (const effect of value.loweredFunc.func.aliasingEffects) {
if (effect.kind === 'Mutate' || effect.kind === 'MutateTransitive') {
// Mark function as mutable if it mutates a context variable
if (context.has(effect.value.identifier.id) &&
!isRefOrRefLikeMutableType(effect.value.identifier.type)) {
contextMutationEffects.set(lvalue.identifier.id, effect);
}
}
}
}
break;
}
default: {
// Check all operands for freeze effect violations
for (const operand of eachInstructionValueOperand(value)) {
visitOperand(operand); // Check if mutable function is being frozen
}
}
}
}
}
```
### Phase 3: Validate Freeze Effects
When an operand has a `Freeze` effect, check if it's a known mutable function:
```typescript
function visitOperand(operand: Place): void {
if (operand.effect === Effect.Freeze) {
const effect = contextMutationEffects.get(operand.identifier.id);
if (effect != null) {
// Emit error with both usage location and mutation location
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.Immutability,
reason: 'Cannot modify local variables after render completes',
description: `This argument is a function which may reassign or mutate ${variable} after render...`,
})
.withDetails({loc: operand.loc, message: 'This function may...'})
.withDetails({loc: effect.value.loc, message: 'This modifies...'})
);
}
}
}
```
## Edge Cases
### Function Passed as JSX Prop (Error)
```javascript
function Component() {
const cache = new Map();
const fn = () => {
cache.set('key', 'value'); // Mutates captured variable
};
return <Foo fn={fn} />; // Error: fn is frozen but mutates cache
}
```
### Function Passed to Hook (Error)
```javascript
function useFoo() {
const cache = new Map();
useHook(() => {
cache.set('key', 'value'); // Error: function mutates cache
});
}
```
### Function Returned from Hook (Error)
```javascript
function useFoo() {
useHook(); // For hook inference
const cache = new Map();
return () => {
cache.set('key', 'value'); // Error: returned function mutates cache
};
}
```
### Ref Mutation (Allowed)
```javascript
function Component() {
const ref = useRef(null);
const fn = () => {
ref.current = value; // OK: refs are mutable by design
};
return <Foo fn={fn} />; // Allowed
}
```
### Conditional Mutations
The pass only errors on definite mutations (`Mutate`, `MutateTransitive`), not conditional mutations (`MutateConditionally`, `MutateTransitiveConditionally`). However, if a function already has a known mutation effect, conditional mutations will propagate that effect:
```javascript
function Component(cond) {
const cache = new Map();
const fn = () => {
cache.set('a', 1); // Definite mutation
};
const fn2 = fn; // fn2 inherits mutation effect
return <Foo fn={fn2} />; // Error
}
```
### Nested Function Expressions
Mutation effects propagate through assignments:
```javascript
function Component() {
const cache = new Map();
const inner = () => cache.set('key', 'value');
const outer = inner; // outer inherits mutation effect
return <Foo fn={outer} />; // Error
}
```
## TODOs
None in the source file.
## Example
### Fixture: `error.invalid-pass-mutable-function-as-prop.js`
**Input:**
```javascript
// @validateNoFreezingKnownMutableFunctions
function Component() {
const cache = new Map();
const fn = () => {
cache.set('key', 'value');
};
return <Foo fn={fn} />;
}
```
**Error:**
```
Found 1 error:
Error: Cannot modify local variables after render completes
This argument is a function which may reassign or mutate `cache` after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead.
error.invalid-pass-mutable-function-as-prop.ts:7:18
5 | cache.set('key', 'value');
6 | };
> 7 | return <Foo fn={fn} />;
| ^^ This function may (indirectly) reassign or modify `cache` after render
8 | }
9 |
error.invalid-pass-mutable-function-as-prop.ts:5:4
3 | const cache = new Map();
4 | const fn = () => {
> 5 | cache.set('key', 'value');
| ^^^^^ This modifies `cache`
6 | };
7 | return <Foo fn={fn} />;
8 | }
```
### Fixture: `error.invalid-hook-function-argument-mutates-local-variable.js`
**Input:**
```javascript
// @validateNoFreezingKnownMutableFunctions
function useFoo() {
const cache = new Map();
useHook(() => {
cache.set('key', 'value');
});
}
```
**Error:**
```
Found 1 error:
Error: Cannot modify local variables after render completes
This argument is a function which may reassign or mutate `cache` after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead.
error.invalid-hook-function-argument-mutates-local-variable.ts:5:10
3 | function useFoo() {
4 | const cache = new Map();
> 5 | useHook(() => {
| ^^^^^^^
> 6 | cache.set('key', 'value');
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 7 | });
| ^^^^ This function may (indirectly) reassign or modify `cache` after render
8 | }
9 |
error.invalid-hook-function-argument-mutates-local-variable.ts:6:4
4 | const cache = new Map();
5 | useHook(() => {
> 6 | cache.set('key', 'value');
| ^^^^^ This modifies `cache`
7 | });
8 | }
9 |
```
Key observations:
- The pass detects functions that mutate captured local variables (not refs)
- Errors show both where the function is used (frozen) and where the mutation occurs
- The validation prevents inconsistent re-render behavior by catching mutations that happen after render
- The suggestion to "use state instead" guides users toward the correct React pattern

View File

@@ -0,0 +1,376 @@
# validateExhaustiveDependencies
## File
`src/Validation/ValidateExhaustiveDependencies.ts`
## Purpose
This validation pass ensures that manual memoization (useMemo, useCallback) and effect hooks (useEffect, useLayoutEffect) have correct dependency arrays. The pass compares developer-specified dependencies against the actual values referenced within the memoized function or effect callback to detect:
1. **Missing dependencies**: Values used in the function that are not listed in the dependency array, causing the memoized value or effect to update less frequently than expected
2. **Extra dependencies**: Values listed in the dependency array that are not actually used, causing unnecessary re-computation or effect re-runs
3. **Overly precise dependencies**: Dependencies that access deeper property paths than what is actually used (e.g., `x.y.z` when only `x.y` is accessed)
The goal is to ensure that auto-memoization by the compiler will not substantially change program behavior.
## Input Invariants
- The function has been through `StartMemoize` and `FinishMemoize` instruction insertion
- Manual dependency arrays have been parsed and associated with memoization blocks
- Reactive identifiers have been computed
- Optional chaining paths have been analyzed
## Validation Rules
The pass produces errors for:
1. **Missing dependency in useMemo/useCallback**: A reactive value is used but not listed in deps
2. **Extra dependency in useMemo/useCallback**: A value is listed but not used
3. **Missing dependency in useEffect**: A value used in the effect callback is not in the deps array
4. **Extra dependency in useEffect**: A value in deps is not used in the callback
5. **Overly precise dependency**: The manual dep accesses a deeper path than what's actually used
6. **Global as dependency**: Module-level values should not be listed as dependencies
7. **useEffectEvent in dependency array**: Functions from useEffectEvent must not be in deps
**Exception - Optional dependencies**: Non-reactive values of stable types (refs, setState) or primitive types are optional and don't need to be listed.
Error messages produced:
- Categories: `MemoDependencies` or `EffectExhaustiveDependencies`
- Reasons:
- "Found missing memoization dependencies"
- "Found extra memoization dependencies"
- "Found missing/extra memoization dependencies"
- "Found missing effect dependencies"
- "Found extra effect dependencies"
- "Found missing/extra effect dependencies"
- Messages:
- "Missing dependency `{dep}`"
- "Unnecessary dependency `{dep}`"
- "Overly precise dependency `{manual}`, use `{inferred}` instead"
- "Functions returned from `useEffectEvent` must not be included in the dependency array"
- "Values declared outside of a component/hook should not be listed as dependencies"
## Algorithm
### Phase 1: Collect Reactive Identifiers
Scan all instructions to identify which identifiers are reactive:
```typescript
function collectReactiveIdentifiersHIR(fn: HIRFunction): Set<IdentifierId> {
const reactive = new Set<IdentifierId>();
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
for (const lvalue of eachInstructionLValue(instr)) {
if (lvalue.reactive) {
reactive.add(lvalue.identifier.id);
}
}
// ... also check operands
}
}
return reactive;
}
```
### Phase 2: Find Optional Places
Identify places that are within optional chaining expressions:
```typescript
function findOptionalPlaces(fn: HIRFunction): Map<IdentifierId, boolean> {
// Walks through optional terminals to track which identifiers
// are accessed via optional chaining (?.property)
}
```
### Phase 3: Collect Dependencies
The core algorithm processes each block, tracking:
- `temporaries`: Map of identifier IDs to their dependency information
- `locals`: Set of identifiers declared within the current scope
- `dependencies`: Set of inferred dependencies
```typescript
function collectDependencies(
fn: HIRFunction,
temporaries: Map<IdentifierId, Temporary>,
callbacks: {
onStartMemoize: (...) => void;
onFinishMemoize: (...) => void;
onEffect: (...) => void;
},
isFunctionExpression: boolean,
): Temporary {
for (const block of fn.body.blocks.values()) {
// Process phi nodes - merge dependencies from control flow
for (const phi of block.phis) {
// Aggregate dependencies from all operands
}
for (const instr of block.instructions) {
switch (value.kind) {
case 'LoadLocal':
case 'LoadContext':
// Track dependency path through the temporary
break;
case 'PropertyLoad':
// Extend dependency path: x -> x.y
break;
case 'FunctionExpression':
// Recursively collect dependencies from nested function
break;
case 'StartMemoize':
// Begin tracking dependencies for this memo block
break;
case 'FinishMemoize':
// Validate collected dependencies against manual deps
break;
case 'CallExpression':
case 'MethodCall':
// Check for effect hooks and validate their deps
break;
}
}
}
}
```
### Phase 4: Validate Dependencies
Compare inferred dependencies against manual dependencies:
```typescript
function validateDependencies(
inferred: Array<InferredDependency>,
manualDependencies: Array<ManualMemoDependency>,
reactive: Set<IdentifierId>,
...
): CompilerDiagnostic | null {
// Sort and deduplicate inferred dependencies
// For each inferred dep, check if there's a matching manual dep
// For each manual dep, check if it corresponds to an inferred dep
// Report missing and extra dependencies
}
```
### Dependency Matching Rules
- If `x.y.z` is inferred, `x`, `x.y`, or `x.y.z` are valid manual deps
- Optional chaining is handled: `x?.y` inferred can match `x.y` manual (ignoring optionals)
- Stable types (refs, setState) that are non-reactive are optional
- Global values should not be in dependency arrays
- useEffectEvent return values should not be in dependency arrays
## Edge Cases
### Overly Precise Dependency (Error)
```javascript
const a = useMemo(() => {
return x?.y.z?.a;
}, [x?.y.z?.a.b]); // Error: should be [x?.y.z?.a]
```
### Unnecessary Dependencies (Error)
```javascript
const f = useMemo(() => {
return [];
}, [x, y.z, GLOBAL]); // Error: all deps are unnecessary
```
### Reactive Stable Type (Error)
```javascript
const ref1 = useRef(null);
const ref2 = useRef(null);
const ref = z ? ref1 : ref2; // ref is reactive (depends on z)
const cb = useMemo(() => {
return () => ref.current;
}, []); // Error: missing dep 'ref' (reactive even though stable type)
```
### useEffectEvent in Dependencies (Error)
```javascript
const effectEvent = useEffectEvent(() => log(x));
useEffect(() => {
effectEvent();
}, [effectEvent]); // Error: useEffectEvent returns should not be in deps
```
### Effect with Missing and Extra Dependencies (Error)
```javascript
useEffect(() => {
log(x, z);
}, [x, y]); // Error: missing z, extra y
```
### Valid Dependency Specifications
```javascript
// All valid - deps cover or exceed what's used
const b = useMemo(() => x.y.z?.a, [x.y.z.a]); // OK
const d = useMemo(() => x?.y?.[(console.log(y), z?.b)], [x?.y, y, z?.b]); // OK
const e = useMemo(() => { e.push(x); return e; }, [x]); // OK
```
## Configuration
The validation can be configured via compiler options:
```typescript
// For useMemo/useCallback
validateExhaustiveMemoizationDependencies: boolean
// For useEffect and similar
validateExhaustiveEffectDependencies: 'off' | 'all' | 'missing-only' | 'extra-only'
```
The `missing-only` and `extra-only` modes allow validating only one category of errors.
## TODOs
From the source file:
```typescript
/**
* TODO: Invalid, Complex Deps
*
* Handle cases where the user deps were not simple identifiers + property chains.
* We try to detect this in ValidateUseMemo but we miss some cases. The problem
* is that invalid forms can be value blocks or function calls that don't get
* removed by DCE, leaving a structure like:
*
* StartMemoize
* t0 = <value to memoize>
* ...non-DCE'd code for manual deps...
* FinishMemoize decl=t0
*/
```
## Example
### Fixture: `error.invalid-exhaustive-deps.js`
**Input:**
```javascript
// @validateExhaustiveMemoizationDependencies @validateRefAccessDuringRender:false
import {useMemo} from 'react';
function Component({x, y, z}) {
const a = useMemo(() => {
return x?.y.z?.a;
// error: too precise
}, [x?.y.z?.a.b]);
const f = useMemo(() => {
return [];
// error: unnecessary
}, [x, y.z, z?.y?.a, UNUSED_GLOBAL]);
const ref1 = useRef(null);
const ref2 = useRef(null);
const ref = z ? ref1 : ref2;
const cb = useMemo(() => {
return () => ref.current;
// error: ref is a stable type but reactive
}, []);
return <Stringify results={[a, f, cb]} />;
}
```
**Error:**
```
Found 4 errors:
Error: Found missing/extra memoization dependencies
Missing dependencies can cause a value to update less often than it should, resulting in stale UI. Extra dependencies can cause a value to update more often than it should, resulting in performance problems such as excessive renders or effects firing too often.
error.invalid-exhaustive-deps.ts:7:11
5 | function Component({x, y, z}) {
6 | const a = useMemo(() => {
> 7 | return x?.y.z?.a;
| ^^^^^^^^^ Missing dependency `x?.y.z?.a`
8 | // error: too precise
9 | }, [x?.y.z?.a.b]);
error.invalid-exhaustive-deps.ts:9:6
> 9 | }, [x?.y.z?.a.b]);
| ^^^^^^^^^^^ Overly precise dependency `x?.y.z?.a.b`, use `x?.y.z?.a` instead
Inferred dependencies: `[x?.y.z?.a]`
Error: Found extra memoization dependencies
...
error.invalid-exhaustive-deps.ts:31:6
> 31 | }, [x, y.z, z?.y?.a, UNUSED_GLOBAL]);
| ^ Unnecessary dependency `x`
...
| ^^^^^^^^^^^^^ Unnecessary dependency `UNUSED_GLOBAL`. Values declared outside of a component/hook should not be listed as dependencies as the component will not re-render if they change
Inferred dependencies: `[]`
Error: Found missing memoization dependencies
...
error.invalid-exhaustive-deps.ts:37:13
> 37 | return ref.current;
| ^^^ Missing dependency `ref`. Refs, setState functions, and other "stable" values generally do not need to be added as dependencies, but this variable may change over time to point to different values
Inferred dependencies: `[ref]`
```
### Fixture: `error.invalid-exhaustive-effect-deps.js`
**Input:**
```javascript
// @validateExhaustiveEffectDependencies:"all"
import {useEffect} from 'react';
function Component({x, y, z}) {
// error: missing dep - x
useEffect(() => {
log(x);
}, []);
// error: extra dep - y
useEffect(() => {
log(x);
}, [x, y]);
// error: missing dep - z; extra dep - y
useEffect(() => {
log(x, z);
}, [x, y]);
}
```
**Error:**
```
Found 4 errors:
Error: Found missing effect dependencies
Missing dependencies can cause an effect to fire less often than it should.
error.invalid-exhaustive-effect-deps.ts:7:8
> 7 | log(x);
| ^ Missing dependency `x`
Inferred dependencies: `[x]`
Error: Found extra effect dependencies
Extra dependencies can cause an effect to fire more often than it should, resulting in performance problems such as excessive renders and side effects.
error.invalid-exhaustive-effect-deps.ts:13:9
> 13 | }, [x, y]);
| ^ Unnecessary dependency `y`
Inferred dependencies: `[x]`
Error: Found missing/extra effect dependencies
...
error.invalid-exhaustive-effect-deps.ts:17:11
> 17 | log(x, z);
| ^ Missing dependency `z`
error.invalid-exhaustive-effect-deps.ts:18:9
> 18 | }, [x, y]);
| ^ Unnecessary dependency `y`
Inferred dependencies: `[x, z]`
```
Key observations:
- The pass validates both useMemo/useCallback and useEffect dependency arrays
- Dependencies are inferred by analyzing actual value usage within the function
- Optional chaining paths are tracked and included in dependency paths
- Reactive stable types (like conditionally assigned refs) must still be listed
- Globals and useEffectEvent returns should not be in dependency arrays
- The validation provides fix suggestions showing the inferred correct dependencies

View File

@@ -0,0 +1,152 @@
# validatePreservedManualMemoization
## File
`src/Validation/ValidatePreservedManualMemoization.ts`
## Purpose
Validates that all explicit manual memoization (`useMemo`/`useCallback`) from the original source code is accurately preserved in the compiled output. This ensures that values the developer intended to be memoized remain memoized after compilation.
## Input Invariants
- Operates on ReactiveFunction (post-reactive scope inference)
- Manual memoization markers (`StartMemoize`/`FinishMemoize`) are present from earlier passes
- Scopes have been assigned and merged as appropriate
## Validation Rules
This pass validates three conditions:
### 1. Dependencies not mutated later
Validates that dependencies of manual memoization are not mutated after the memoization call:
```
Existing memoization could not be preserved. This dependency may be modified later
```
### 2. Inferred dependencies match source
Validates that the compiler's inferred dependencies match the manually specified dependencies:
```
Existing memoization could not be preserved. The inferred dependencies did not match
the manually specified dependencies, which could cause the value to change more or
less frequently than expected. The inferred dependency was `X`, but the source
dependencies were [Y, Z].
```
### 3. Output value is memoized
Validates that the memoized value actually ends up in a reactive scope:
```
Existing memoization could not be preserved. This value was memoized in source but
not in compilation output
```
## Algorithm
### State Management
The visitor tracks:
- `scopes: Set<ScopeId>` - All completed reactive scopes
- `prunedScopes: Set<ScopeId>` - Scopes that were pruned
- `temporaries: Map<IdentifierId, ManualMemoDependency>` - Temporary variable mappings
- `manualMemoState: ManualMemoBlockState | null` - Current manual memoization context
### ManualMemoBlockState
```typescript
type ManualMemoBlockState = {
reassignments: Map<DeclarationId, Set<Identifier>>; // Track inlined useMemo reassignments
loc: SourceLocation; // Source location for errors
decls: Set<DeclarationId>; // Declarations within the memo block
depsFromSource: Array<ManualMemoDependency> | null; // Original deps from source
manualMemoId: number; // Unique ID for this memoization
};
```
### Processing Flow
1. **On `StartMemoize` instruction:**
- Validate that dependencies' scopes have completed (not mutated later)
- Initialize `manualMemoState` with source dependencies
- Push error if any dependency's scope hasn't completed yet
2. **During memo block (between Start/Finish):**
- Track all declarations made within the block
- Track reassignments for inlined useMemo handling
- Record property loads and temporaries
3. **On scope completion:**
- Validate each scope dependency against source dependencies using `compareDeps()`
- An inferred dependency matches if:
- Root identifiers are the same (same named variable)
- Paths are identical, OR
- Inferred path is more specific (not involving `.current` refs)
4. **On `FinishMemoize` instruction:**
- Validate that the memoized value is in a completed scope
- Handle inlined useMemo with reassignment tracking
- Push error if value is unmemoized
### Dependency Comparison Results
```typescript
enum CompareDependencyResult {
Ok = 0, // Dependencies match
RootDifference = 1, // Different root variables
PathDifference = 2, // Different property paths
Subpath = 3, // Inferred is less specific
RefAccessDifference = 4, // ref.current access differs
}
```
## Edge Cases
### Inlined useMemo Handling
When useMemo is inlined, it produces `let` declarations followed by reassignments. The pass tracks these reassignments to ensure all code paths produce memoized values.
### Ref Access
Special handling for `.current` property access on refs. Since `ref_prev === ref_new` does not imply `ref_prev.current === ref_new.current`, the pass is strict about ref access differences.
### More Specific Dependencies
If the compiler infers a more specific dependency (e.g., `obj.prop.value` instead of `obj`), this is acceptable as long as it doesn't involve ref access.
## TODOs
None found in the source.
## Example
### Fixture: `error.preserve-use-memo-ref-missing-reactive.ts`
**Input:**
```javascript
// @validatePreserveExistingMemoizationGuarantees
import {useCallback, useRef} from 'react';
function useFoo({cond}) {
const ref1 = useRef<undefined | (() => undefined)>();
const ref2 = useRef<undefined | (() => undefined)>();
const ref = cond ? ref1 : ref2;
return useCallback(() => {
if (ref != null) {
ref.current();
}
}, []);
}
```
**Error:**
```
Found 1 error:
Compilation Skipped: Existing memoization could not be preserved
React Compiler has skipped optimizing this component because the existing manual
memoization could not be preserved. The inferred dependencies did not match the
manually specified dependencies, which could cause the value to change more or
less frequently than expected. The inferred dependency was `ref`, but the source
dependencies were []. Inferred dependency not present in source.
error.preserve-use-memo-ref-missing-reactive.ts:9:21
> 9 | return useCallback(() => {
| ^^^^^^^
> 10 | if (ref != null) {
> 11 | ref.current();
> 12 | }
> 13 | }, []);
| ^^^^ Could not preserve existing manual memoization
```
**Why it fails:** The callback uses `ref` which is conditionally assigned based on `cond`. The compiler infers `ref` as a dependency, but the source specifies an empty dependency array `[]`. This mismatch means the memoization cannot be preserved as-is.

View File

@@ -0,0 +1,153 @@
# validateStaticComponents
## File
`src/Validation/ValidateStaticComponents.ts`
## Purpose
Validates that components used in JSX are not created dynamically during render. Components created during render will have their state reset on every re-render because React sees them as new component types each time. This is a common React anti-pattern that causes bugs and poor performance.
## Input Invariants
- Operates on HIRFunction (pre-reactive transformation)
- All instructions and phi nodes are present
- JSX expressions have been lowered to `JsxExpression` instruction values
## Validation Rules
When a JSX element uses a component that was dynamically created during render, the pass produces:
```
Cannot create components during render. Components created during render will reset
their state each time they are created. Declare components outside of render
```
The error includes two locations:
1. Where the component is used in JSX
2. Where the component was originally created
### What constitutes "dynamically created"?
The following instruction kinds mark a value as dynamically created:
- `FunctionExpression` - An inline function definition
- `NewExpression` - A `new` constructor call
- `MethodCall` - A method call that returns a value
- `CallExpression` - A function call that returns a value
## Algorithm
1. Create a `Map<IdentifierId, SourceLocation>` called `knownDynamicComponents` to track identifiers whose values are dynamically created
2. Iterate through all blocks in evaluation order
3. For each block, first process phi nodes:
- If any phi operand is in `knownDynamicComponents`, add the phi result to the map
- This propagates dynamic-ness through control flow joins
4. For each instruction in the block:
- **FunctionExpression, NewExpression, MethodCall, CallExpression**: Add the lvalue to `knownDynamicComponents` with its source location
- **LoadLocal**: If the loaded value is dynamic, mark the lvalue as dynamic
- **StoreLocal**: If the stored value is dynamic, mark both the lvalue and the store target as dynamic
- **JsxExpression**: If the JSX tag is an identifier that is in `knownDynamicComponents`, push a diagnostic error
5. Return the collected errors
### Data Flow Tracking
The pass tracks how dynamic values flow through the program:
- Through variable assignments (`StoreLocal`, `LoadLocal`)
- Through phi nodes (conditional assignments)
- Into JSX component positions
## Edge Cases
### Conditionally Assigned Components
```javascript
function Example({cond}) {
let Component;
if (cond) {
Component = createComponent(); // Dynamic!
} else {
Component = OtherComponent; // Static
}
return <Component />; // Error: Component may be dynamic
}
```
The phi node joins the conditional paths, and since one path is dynamic, the result is considered dynamic.
### Component Returned from Hooks/Functions
```javascript
function Example() {
const Component = useCreateComponent(); // CallExpression - dynamic
return <Component />; // Error
}
```
### Factory Functions
```javascript
function Example() {
const Component = createComponent(); // CallExpression - dynamic
return <Component />; // Error
}
```
### Safe Patterns (No Error)
```javascript
// Component defined outside render
const MyComponent = () => <div />;
function Example() {
return <MyComponent />; // OK - not created during render
}
```
## TODOs
None found in the source.
## Example
### Fixture: `static-components/invalid-dynamically-construct-component-in-render.js`
**Input:**
```javascript
// @validateStaticComponents
function Example(props) {
const Component = createComponent();
return <Component />;
}
```
**Error (from logs):**
```json
{
"kind": "CompileError",
"detail": {
"options": {
"category": "StaticComponents",
"reason": "Cannot create components during render",
"description": "Components created during render will reset their state each time they are created. Declare components outside of render",
"details": [
{
"kind": "error",
"loc": { "start": { "line": 4, "column": 10 } },
"message": "This component is created during render"
},
{
"kind": "error",
"loc": { "start": { "line": 3, "column": 20 } },
"message": "The component is created during render here"
}
]
}
}
}
```
**Why it fails:** The `createComponent()` call creates a new component type on every render. When this component is used in JSX, React will see a different component type each time, causing the component to unmount and remount (losing all state) on every render.
### Fixture: `static-components/invalid-dynamically-constructed-component-function.js`
**Input:**
```javascript
// @validateStaticComponents
function Example(props) {
const Component = () => <div />;
return <Component />;
}
```
**Why it fails:** Even though this looks like a simple component definition, it creates a new function (and thus a new component type) on every render. The fix is to move the component definition outside of `Example`.

View File

@@ -0,0 +1,199 @@
# validateSourceLocations
## File
`src/Validation/ValidateSourceLocations.ts`
## Purpose
**IMPORTANT: This validation is intended for unit tests only, not production use.**
Validates that important source locations from the original code are preserved in the generated AST. This ensures that code coverage instrumentation tools (like Istanbul) can properly map back to the original source code for accurate coverage reports.
## Input Invariants
- Operates on the original Babel AST (`NodePath<FunctionDeclaration | ArrowFunctionExpression | FunctionExpression>`)
- Operates on the generated CodegenFunction output
- Must run after code generation
## Validation Rules
The pass checks that "important" source locations (as defined by Istanbul's instrumentation requirements) are preserved in the generated output.
### Two types of errors:
1. **Missing location:**
```
Important source location missing in generated code. Source location for [NodeType]
is missing in the generated output. This can cause coverage instrumentation to fail
to track this code properly, resulting in inaccurate coverage reports.
```
2. **Wrong node type:**
```
Important source location has wrong node type in generated code. Source location for
[ExpectedType] exists in the generated output but with wrong node type(s): [ActualTypes].
This can cause coverage instrumentation to fail to track this code properly.
```
### Important Node Types
The following node types are considered important for coverage tracking:
```typescript
const IMPORTANT_INSTRUMENTED_TYPES = new Set([
'ArrowFunctionExpression',
'AssignmentPattern',
'ObjectMethod',
'ExpressionStatement',
'BreakStatement',
'ContinueStatement',
'ReturnStatement',
'ThrowStatement',
'TryStatement',
'VariableDeclarator',
'IfStatement',
'ForStatement',
'ForInStatement',
'ForOfStatement',
'WhileStatement',
'DoWhileStatement',
'SwitchStatement',
'SwitchCase',
'WithStatement',
'FunctionDeclaration',
'FunctionExpression',
'LabeledStatement',
'ConditionalExpression',
'LogicalExpression',
'VariableDeclaration',
'Identifier',
]);
```
### Strict Node Types
For these types, both the location AND node type must match:
- `VariableDeclaration`
- `VariableDeclarator`
- `Identifier`
## Algorithm
### Step 1: Collect Important Original Locations
Traverse the original AST and collect locations from nodes whose types are in `IMPORTANT_INSTRUMENTED_TYPES`:
- Skip nodes that are manual memoization calls (`useMemo`/`useCallback`) since the compiler intentionally removes these
- Build a map from location key to `{loc, nodeTypes}`
### Step 2: Collect Generated Locations
Recursively traverse the generated AST (main function body + outlined functions) and collect all locations with their node types.
### Step 3: Validate Preservation
For each important original location:
- If the location is completely missing in generated output, report an error
- For strict node types, verify the specific node type is present
- Handle cases where a generated location has a different node type
### Location Key Format
Locations are compared using a string key:
```typescript
function locationKey(loc: SourceLocation): string {
return `${loc.start.line}:${loc.start.column}-${loc.end.line}:${loc.end.column}`;
}
```
## Edge Cases
### Manual Memoization Removal
The compiler intentionally removes `useMemo` and `useCallback` calls (replacing them with compiler-generated memoization). These are detected and exempted from validation:
```typescript
function isManualMemoization(node: Node): boolean {
// Checks for useMemo/useCallback or React.useMemo/React.useCallback
}
```
### Outlined Functions
The validation also checks locations in outlined functions (functions extracted by the compiler for optimization purposes).
### Multiple Node Types at Same Location
Multiple node types can share the same location (e.g., a `VariableDeclarator` and its `Identifier` child). The pass tracks all node types for each location.
## TODOs
From the file documentation:
> There's one big gotcha with this validation: it only works if the "important" original nodes are not optimized away by the compiler.
>
> When that scenario happens, we should just update the fixture to not include a node that has no corresponding node in the generated AST due to being completely removed during compilation.
## Example
### Fixture: `error.todo-missing-source-locations.js`
**Input:**
```javascript
// @validateSourceLocations
import {useEffect, useCallback} from 'react';
function Component({prop1, prop2}) {
const x = prop1 + prop2;
const y = x * 2;
const arr = [x, y];
const obj = {x, y};
let destA, destB;
if (y > 5) {
[destA, destB] = arr;
}
const [a, b] = arr;
const {x: c, y: d} = obj;
let sound;
if (y > 10) {
sound = 'woof';
} else {
sound = 'meow';
}
useEffect(() => {
if (a > 10) {
console.log(a);
console.log(sound);
console.log(destA, destB);
}
}, [a, sound, destA, destB]);
const foo = useCallback(() => {
return a + b;
}, [a, b]);
function bar() {
return (c + d) * 2;
}
console.log('Hello, world!');
return [y, foo, bar];
}
```
**Error (partial):**
```
Found 25 errors:
Todo: Important source location missing in generated code
Source location for Identifier is missing in the generated output...
error.todo-missing-source-locations.ts:4:9
> 4 | function Component({prop1, prop2}) {
| ^^^^^^^^^
Todo: Important source location missing in generated code
Source location for VariableDeclaration is missing in the generated output...
error.todo-missing-source-locations.ts:9:2
> 9 | let destA, destB;
| ^^^^^^^^^^^^^^^^^
Todo: Important source location missing in generated code
Source location for ExpressionStatement is missing in the generated output...
error.todo-missing-source-locations.ts:11:4
> 11 | [destA, destB] = arr;
| ^^^^^^^^^^^^^^^^^^^^^
```
**Why it fails:** The compiler transforms the code significantly, and many original source locations are not preserved in the output. This causes coverage tools to lose track of which lines were executed.
**Note:** This fixture is prefixed with `error.todo-` indicating this is a known limitation that needs to be addressed.

View File

@@ -0,0 +1,309 @@
# React Compiler Passes Documentation
This directory contains detailed documentation for each pass in the React Compiler pipeline. The compiler transforms React components and hooks to add automatic memoization.
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ COMPILATION PIPELINE │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ PHASE 1: HIR CONSTRUCTION │
│ ┌─────────┐ │
│ │ Babel │──▶ lower ──▶ enterSSA ──▶ eliminateRedundantPhi │
│ │ AST │ │ │
│ └─────────┘ ▼ │
│ ┌──────────┐ │
│ │ HIR │ (Control Flow Graph in SSA Form) │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ PHASE 2: OPTIMIZATION │
│ │
│ constantPropagation ──▶ deadCodeElimination │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ PHASE 3: TYPE & EFFECT INFERENCE │
│ │
│ inferTypes ──▶ analyseFunctions ──▶ inferMutationAliasingEffects │
│ │ │
│ ▼ │
│ inferMutationAliasingRanges ──▶ inferReactivePlaces │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ PHASE 4: REACTIVE SCOPE CONSTRUCTION │
│ │
│ inferReactiveScopeVariables ──▶ alignMethodCallScopes ──▶ alignObjectMethodScopes │
│ │ │
│ ▼ │
│ alignReactiveScopesToBlockScopesHIR ──▶ mergeOverlappingReactiveScopesHIR │
│ │ │
│ ▼ │
│ buildReactiveScopeTerminalsHIR ──▶ flattenReactiveLoopsHIR │
│ │ │
│ ▼ │
│ flattenScopesWithHooksOrUseHIR ──▶ propagateScopeDependenciesHIR │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ PHASE 5: HIR → REACTIVE FUNCTION │
│ │
│ buildReactiveFunction │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ ReactiveFunction │ (Tree Structure) │
│ └───────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ PHASE 6: REACTIVE FUNCTION OPTIMIZATION │
│ │
│ pruneUnusedLabels ──▶ pruneNonEscapingScopes ──▶ pruneNonReactiveDependencies │
│ │ │
│ ▼ │
│ pruneUnusedScopes ──▶ mergeReactiveScopesThatInvalidateTogether │
│ │ │
│ ▼ │
│ pruneAlwaysInvalidatingScopes ──▶ propagateEarlyReturns ──▶ promoteUsedTemporaries │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ PHASE 7: CODE GENERATION │
│ │
│ renameVariables ──▶ codegenReactiveFunction │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Babel AST │ (With Memoization) │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
## Pass Categories
### HIR Construction & SSA (1-3)
| # | Pass | File | Description |
|---|------|------|-------------|
| 1 | [lower](01-lower.md) | `HIR/BuildHIR.ts` | Convert Babel AST to HIR control-flow graph |
| 2 | [enterSSA](02-enterSSA.md) | `SSA/EnterSSA.ts` | Convert to Static Single Assignment form |
| 3 | [eliminateRedundantPhi](03-eliminateRedundantPhi.md) | `SSA/EliminateRedundantPhi.ts` | Remove unnecessary phi nodes |
### Optimization (4-5)
| # | Pass | File | Description |
|---|------|------|-------------|
| 4 | [constantPropagation](04-constantPropagation.md) | `Optimization/ConstantPropagation.ts` | Sparse conditional constant propagation |
| 5 | [deadCodeElimination](05-deadCodeElimination.md) | `Optimization/DeadCodeElimination.ts` | Remove unreferenced instructions |
### Type Inference (6)
| # | Pass | File | Description |
|---|------|------|-------------|
| 6 | [inferTypes](06-inferTypes.md) | `TypeInference/InferTypes.ts` | Constraint-based type unification |
### Mutation/Aliasing Inference (7-10)
| # | Pass | File | Description |
|---|------|------|-------------|
| 7 | [analyseFunctions](07-analyseFunctions.md) | `Inference/AnalyseFunctions.ts` | Analyze nested function effects |
| 8 | [inferMutationAliasingEffects](08-inferMutationAliasingEffects.md) | `Inference/InferMutationAliasingEffects.ts` | Infer mutation/aliasing via abstract interpretation |
| 9 | [inferMutationAliasingRanges](09-inferMutationAliasingRanges.md) | `Inference/InferMutationAliasingRanges.ts` | Compute mutable ranges from effects |
| 10 | [inferReactivePlaces](10-inferReactivePlaces.md) | `Inference/InferReactivePlaces.ts` | Mark reactive places (props, hooks, derived) |
### Reactive Scope Variables (11-12)
| # | Pass | File | Description |
|---|------|------|-------------|
| 11 | [inferReactiveScopeVariables](11-inferReactiveScopeVariables.md) | `ReactiveScopes/InferReactiveScopeVariables.ts` | Group co-mutating variables into scopes |
| 12 | [rewriteInstructionKindsBasedOnReassignment](12-rewriteInstructionKindsBasedOnReassignment.md) | `SSA/RewriteInstructionKindsBasedOnReassignment.ts` | Convert SSA loads to context loads for reassigned vars |
### Scope Alignment (13-15)
| # | Pass | File | Description |
|---|------|------|-------------|
| 13 | [alignMethodCallScopes](13-alignMethodCallScopes.md) | `ReactiveScopes/AlignMethodCallScopes.ts` | Align method call scopes with receivers |
| 14 | [alignObjectMethodScopes](14-alignObjectMethodScopes.md) | `ReactiveScopes/AlignObjectMethodScopes.ts` | Align object method scopes |
| 15 | [alignReactiveScopesToBlockScopesHIR](15-alignReactiveScopesToBlockScopesHIR.md) | `ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts` | Align to control-flow block boundaries |
### Scope Construction (16-18)
| # | Pass | File | Description |
|---|------|------|-------------|
| 16 | [mergeOverlappingReactiveScopesHIR](16-mergeOverlappingReactiveScopesHIR.md) | `HIR/MergeOverlappingReactiveScopesHIR.ts` | Merge overlapping scopes |
| 17 | [buildReactiveScopeTerminalsHIR](17-buildReactiveScopeTerminalsHIR.md) | `HIR/BuildReactiveScopeTerminalsHIR.ts` | Insert scope terminals into CFG |
| 18 | [flattenReactiveLoopsHIR](18-flattenReactiveLoopsHIR.md) | `ReactiveScopes/FlattenReactiveLoopsHIR.ts` | Prune scopes inside loops |
### Scope Flattening & Dependencies (19-20)
| # | Pass | File | Description |
|---|------|------|-------------|
| 19 | [flattenScopesWithHooksOrUseHIR](19-flattenScopesWithHooksOrUseHIR.md) | `ReactiveScopes/FlattenScopesWithHooksOrUseHIR.ts` | Prune scopes containing hooks |
| 20 | [propagateScopeDependenciesHIR](20-propagateScopeDependenciesHIR.md) | `HIR/PropagateScopeDependenciesHIR.ts` | Derive minimal scope dependencies |
### HIR → Reactive Conversion (21)
| # | Pass | File | Description |
|---|------|------|-------------|
| 21 | [buildReactiveFunction](21-buildReactiveFunction.md) | `ReactiveScopes/BuildReactiveFunction.ts` | Convert CFG to tree structure |
### Reactive Function Pruning (22-25)
| # | Pass | File | Description |
|---|------|------|-------------|
| 22 | [pruneUnusedLabels](22-pruneUnusedLabels.md) | `ReactiveScopes/PruneUnusedLabels.ts` | Remove unused labels |
| 23 | [pruneNonEscapingScopes](23-pruneNonEscapingScopes.md) | `ReactiveScopes/PruneNonEscapingScopes.ts` | Remove non-escaping scopes |
| 24 | [pruneNonReactiveDependencies](24-pruneNonReactiveDependencies.md) | `ReactiveScopes/PruneNonReactiveDependencies.ts` | Remove non-reactive dependencies |
| 25 | [pruneUnusedScopes](25-pruneUnusedScopes.md) | `ReactiveScopes/PruneUnusedScopes.ts` | Remove empty scopes |
### Scope Optimization (26-28)
| # | Pass | File | Description |
|---|------|------|-------------|
| 26 | [mergeReactiveScopesThatInvalidateTogether](26-mergeReactiveScopesThatInvalidateTogether.md) | `ReactiveScopes/MergeReactiveScopesThatInvalidateTogether.ts` | Merge co-invalidating scopes |
| 27 | [pruneAlwaysInvalidatingScopes](27-pruneAlwaysInvalidatingScopes.md) | `ReactiveScopes/PruneAlwaysInvalidatingScopes.ts` | Prune always-invalidating scopes |
| 28 | [propagateEarlyReturns](28-propagateEarlyReturns.md) | `ReactiveScopes/PropagateEarlyReturns.ts` | Handle early returns in scopes |
### Codegen Preparation (29-31)
| # | Pass | File | Description |
|---|------|------|-------------|
| 29 | [promoteUsedTemporaries](29-promoteUsedTemporaries.md) | `ReactiveScopes/PromoteUsedTemporaries.ts` | Promote temps to named vars |
| 30 | [renameVariables](30-renameVariables.md) | `ReactiveScopes/RenameVariables.ts` | Ensure unique variable names |
| 31 | [codegenReactiveFunction](31-codegenReactiveFunction.md) | `ReactiveScopes/CodegenReactiveFunction.ts` | Generate final Babel AST |
### Transformations (32-38)
| # | Pass | File | Description |
|---|------|------|-------------|
| 34 | [optimizePropsMethodCalls](34-optimizePropsMethodCalls.md) | `Optimization/OptimizePropsMethodCalls.ts` | Normalize props method calls |
| 35 | [optimizeForSSR](35-optimizeForSSR.md) | `Optimization/OptimizeForSSR.ts` | SSR-specific optimizations |
| 36 | [outlineJSX](36-outlineJSX.md) | `Optimization/OutlineJsx.ts` | Outline JSX to components |
| 37 | [outlineFunctions](37-outlineFunctions.md) | `Optimization/OutlineFunctions.ts` | Outline pure functions |
| 38 | [memoizeFbtAndMacroOperandsInSameScope](38-memoizeFbtAndMacroOperandsInSameScope.md) | `ReactiveScopes/MemoizeFbtAndMacroOperandsInSameScope.ts` | Keep FBT operands together |
### Validation (39-55)
| # | Pass | File | Description |
|---|------|------|-------------|
| 39 | [validateContextVariableLValues](39-validateContextVariableLValues.md) | `Validation/ValidateContextVariableLValues.ts` | Variable reference consistency |
| 40 | [validateUseMemo](40-validateUseMemo.md) | `Validation/ValidateUseMemo.ts` | useMemo callback requirements |
| 41 | [validateHooksUsage](41-validateHooksUsage.md) | `Validation/ValidateHooksUsage.ts` | Rules of Hooks |
| 42 | [validateNoCapitalizedCalls](42-validateNoCapitalizedCalls.md) | `Validation/ValidateNoCapitalizedCalls.ts` | Component vs function calls |
| 43 | [validateLocalsNotReassignedAfterRender](43-validateLocalsNotReassignedAfterRender.md) | `Validation/ValidateLocalsNotReassignedAfterRender.ts` | Variable mutation safety |
| 44 | [validateNoSetStateInRender](44-validateNoSetStateInRender.md) | `Validation/ValidateNoSetStateInRender.ts` | No setState during render |
| 45 | [validateNoDerivedComputationsInEffects](45-validateNoDerivedComputationsInEffects.md) | `Validation/ValidateNoDerivedComputationsInEffects.ts` | Effect optimization hints |
| 46 | [validateNoSetStateInEffects](46-validateNoSetStateInEffects.md) | `Validation/ValidateNoSetStateInEffects.ts` | Effect performance |
| 47 | [validateNoJSXInTryStatement](47-validateNoJSXInTryStatement.md) | `Validation/ValidateNoJSXInTryStatement.ts` | Error boundary usage |
| 48 | [validateNoImpureValuesInRender](48-validateNoImpureValuesInRender.md) | `Validation/ValidateNoImpureValuesInRender.ts` | Impure value isolation |
| 49 | [validateNoRefAccessInRender](49-validateNoRefAccessInRender.md) | `Validation/ValidateNoRefAccessInRender.ts` | Ref access constraints |
| 50 | [validateNoFreezingKnownMutableFunctions](50-validateNoFreezingKnownMutableFunctions.md) | `Validation/ValidateNoFreezingKnownMutableFunctions.ts` | Mutable function isolation |
| 51 | [validateExhaustiveDependencies](51-validateExhaustiveDependencies.md) | `Validation/ValidateExhaustiveDependencies.ts` | Dependency array completeness |
| 53 | [validatePreservedManualMemoization](53-validatePreservedManualMemoization.md) | `Validation/ValidatePreservedManualMemoization.ts` | Manual memo preservation |
| 54 | [validateStaticComponents](54-validateStaticComponents.md) | `Validation/ValidateStaticComponents.ts` | Component identity stability |
| 55 | [validateSourceLocations](55-validateSourceLocations.md) | `Validation/ValidateSourceLocations.ts` | Source location preservation |
## Key Data Structures
### HIR (High-level Intermediate Representation)
The compiler converts source code to HIR for analysis. Key types:
- **HIRFunction**: A function being compiled
- `body.blocks`: Map of BasicBlocks (control flow graph)
- `context`: Captured variables from outer scope
- `params`: Function parameters
- `returns`: The function's return place
- **BasicBlock**: A sequence of instructions with a terminal
- `instructions`: Array of Instructions
- `terminal`: Control flow (return, branch, etc.)
- `phis`: Phi nodes for SSA
- **Instruction**: A single operation
- `lvalue`: The place being assigned to
- `value`: The instruction kind (CallExpression, FunctionExpression, etc.)
- `effects`: Array of AliasingEffects
- **Place**: A reference to a value
- `identifier.id`: Unique IdentifierId
- `effect`: How the place is used (read, mutate, etc.)
### ReactiveFunction
After HIR is analyzed, it's converted to ReactiveFunction:
- Tree structure instead of CFG
- Contains ReactiveScopes that define memoization boundaries
- Each scope has dependencies and declarations
### AliasingEffects
Effects describe data flow and operations:
- **Capture/Alias**: Value relationships
- **Mutate/MutateTransitive**: Mutation tracking
- **Freeze**: Immutability marking
- **Render**: JSX usage context
- **Create/CreateFunction**: Value creation
## Feature Flags
Many passes are controlled by feature flags in `Environment.ts`:
| Flag | Enables Pass |
|------|--------------|
| `enableJsxOutlining` | outlineJSX |
| `enableFunctionOutlining` | outlineFunctions |
| `validateNoSetStateInRender` | validateNoSetStateInRender |
| `enableUseMemoCacheInterop` | Preserves manual memoization |
## Running Tests
```bash
# Run all tests
yarn snap
# Run specific fixture
yarn snap -p <fixture-name>
# Run with debug output (shows all passes)
yarn snap -p <fixture-name> -d
# Compile any file (not just fixtures) and see output
yarn snap compile <path>
# Compile any file with debug output (alternative to yarn snap -d -p when you don't have a fixture)
yarn snap compile --debug <path>
# Minimize a failing test case to its minimal reproduction
yarn snap minimize <path>
# Update expected outputs
yarn snap -u
```
## Further Reading
- [MUTABILITY_ALIASING_MODEL.md](../../src/Inference/MUTABILITY_ALIASING_MODEL.md): Detailed aliasing model docs
- [Pipeline.ts](../../src/Entrypoint/Pipeline.ts): Pass ordering and orchestration
- [HIR.ts](../../src/HIR/HIR.ts): Core data structure definitions

View File

@@ -11,7 +11,6 @@ import {
injectReanimatedFlag,
pipelineUsesReanimatedPlugin,
} from '../Entrypoint/Reanimated';
import validateNoUntransformedReferences from '../Entrypoint/ValidateNoUntransformedReferences';
import {CompilerError} from '..';
const ENABLE_REACT_COMPILER_TIMINGS =
@@ -64,19 +63,12 @@ export default function BabelPluginReactCompiler(
},
};
}
const result = compileProgram(prog, {
compileProgram(prog, {
opts,
filename: pass.filename ?? null,
comments: pass.file.ast.comments ?? [],
code: pass.file.code,
});
validateNoUntransformedReferences(
prog,
pass.filename ?? null,
opts.logger,
opts.environment,
result,
);
if (ENABLE_REACT_COMPILER_TIMINGS === true) {
performance.mark(`${filename}:end`, {
detail: 'BabelPlugin:Program:end',

View File

@@ -12,6 +12,28 @@ import {Err, Ok, Result} from './Utils/Result';
import {assertExhaustive} from './Utils/utils';
import invariant from 'invariant';
// Number of context lines to display above the source of an error
const CODEFRAME_LINES_ABOVE = 2;
// Number of context lines to display below the source of an error
const CODEFRAME_LINES_BELOW = 3;
/*
* Max number of lines for the _source_ of an error, before we abbreviate
* the display of the source portion
*/
const CODEFRAME_MAX_LINES = 10;
/*
* When the error source exceeds the above threshold, how many lines of
* the source should be displayed? We show:
* - CODEFRAME_LINES_ABOVE context lines
* - CODEFRAME_ABBREVIATED_SOURCE_LINES of the error
* - '...' ellipsis
* - CODEFRAME_ABBREVIATED_SOURCE_LINES of the error
* - CODEFRAME_LINES_BELOW context lines
*
* This value must be at least 2 or else we'll cut off important parts of the error message
*/
const CODEFRAME_ABBREVIATED_SOURCE_LINES = 5;
export enum ErrorSeverity {
/**
* An actionable error that the developer can fix. For example, product code errors should be
@@ -284,16 +306,25 @@ export class CompilerError extends Error {
static invariant(
condition: unknown,
options: Omit<CompilerDiagnosticOptions, 'category'>,
options: {
reason: CompilerDiagnosticOptions['reason'];
description?: CompilerDiagnosticOptions['description'];
message?: string | null;
loc: SourceLocation;
},
): asserts condition {
if (!condition) {
const errors = new CompilerError();
errors.pushDiagnostic(
CompilerDiagnostic.create({
reason: options.reason,
description: options.description,
description: options.description ?? null,
category: ErrorCategory.Invariant,
}).withDetails(...options.details),
}).withDetails({
kind: 'error',
loc: options.loc,
message: options.message ?? options.reason,
}),
);
throw errors;
}
@@ -496,7 +527,7 @@ function printCodeFrame(
loc: t.SourceLocation,
message: string,
): string {
return codeFrameColumns(
const printed = codeFrameColumns(
source,
{
start: {
@@ -510,22 +541,36 @@ function printCodeFrame(
},
{
message,
linesAbove: CODEFRAME_LINES_ABOVE,
linesBelow: CODEFRAME_LINES_BELOW,
},
);
const lines = printed.split(/\r?\n/);
if (loc.end.line - loc.start.line < CODEFRAME_MAX_LINES) {
return printed;
}
const pipeIndex = lines[0].indexOf('|');
return [
...lines.slice(
0,
CODEFRAME_LINES_ABOVE + CODEFRAME_ABBREVIATED_SOURCE_LINES,
),
' '.repeat(pipeIndex) + '…',
...lines.slice(
-(CODEFRAME_LINES_BELOW + CODEFRAME_ABBREVIATED_SOURCE_LINES),
),
].join('\n');
}
function printErrorSummary(category: ErrorCategory, message: string): string {
let heading: string;
switch (category) {
case ErrorCategory.AutomaticEffectDependencies:
case ErrorCategory.CapitalizedCalls:
case ErrorCategory.Config:
case ErrorCategory.EffectDerivationsOfState:
case ErrorCategory.EffectSetState:
case ErrorCategory.ErrorBoundaries:
case ErrorCategory.Factories:
case ErrorCategory.FBT:
case ErrorCategory.Fire:
case ErrorCategory.Gating:
case ErrorCategory.Globals:
case ErrorCategory.Hooks:
@@ -537,7 +582,9 @@ function printErrorSummary(category: ErrorCategory, message: string): string {
case ErrorCategory.Suppression:
case ErrorCategory.Syntax:
case ErrorCategory.UseMemo:
case ErrorCategory.VoidUseMemo: {
case ErrorCategory.VoidUseMemo:
case ErrorCategory.MemoDependencies:
case ErrorCategory.EffectExhaustiveDependencies: {
heading = 'Error';
break;
}
@@ -587,14 +634,14 @@ export enum ErrorCategory {
* Checking that useMemos always return a value
*/
VoidUseMemo = 'VoidUseMemo',
/**
* Checking for higher order functions acting as factories for components/hooks
*/
Factories = 'Factories',
/**
* Checks that manual memoization is preserved
*/
PreserveManualMemo = 'PreserveManualMemo',
/**
* Checks for exhaustive useMemo/useCallback dependencies without extraneous values
*/
MemoDependencies = 'MemoDependencies',
/**
* Checks for known incompatible libraries
*/
@@ -615,6 +662,10 @@ export enum ErrorCategory {
* Checks for memoized effect deps
*/
EffectDependencies = 'EffectDependencies',
/**
* Checks for exhaustive and extraneous effect dependencies
*/
EffectExhaustiveDependencies = 'EffectExhaustiveDependencies',
/**
* Checks for no setState in effect bodies
*/
@@ -660,14 +711,6 @@ export enum ErrorCategory {
* Suppressions
*/
Suppression = 'Suppression',
/**
* Issues with auto deps
*/
AutomaticEffectDependencies = 'AutomaticEffectDependencies',
/**
* Issues with `fire`
*/
Fire = 'Fire',
/**
* fbt-specific issues
*/
@@ -732,16 +775,6 @@ export function getRuleForCategory(category: ErrorCategory): LintRule {
function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
switch (category) {
case ErrorCategory.AutomaticEffectDependencies: {
return {
category,
severity: ErrorSeverity.Error,
name: 'automatic-effect-dependencies',
description:
'Verifies that automatic effect dependencies are compiled if opted-in',
preset: LintRulePreset.Off,
};
}
case ErrorCategory.CapitalizedCalls: {
return {
category,
@@ -770,6 +803,16 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
preset: LintRulePreset.Off,
};
}
case ErrorCategory.EffectExhaustiveDependencies: {
return {
category,
severity: ErrorSeverity.Error,
name: 'exhaustive-effect-dependencies',
description:
'Validates that effect dependencies are exhaustive and without extraneous values',
preset: LintRulePreset.Off,
};
}
case ErrorCategory.EffectDerivationsOfState: {
return {
category,
@@ -786,7 +829,9 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
severity: ErrorSeverity.Error,
name: 'set-state-in-effect',
description:
'Validates against calling setState synchronously in an effect, which can lead to re-renders that degrade performance',
'Validates against calling setState synchronously in an effect. ' +
'This can indicate non-local derived data, a derived event pattern, or ' +
'improper external data synchronization.',
preset: LintRulePreset.Recommended,
};
}
@@ -800,17 +845,6 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.Factories: {
return {
category,
severity: ErrorSeverity.Error,
name: 'component-hook-factories',
description:
'Validates against higher order functions defining nested components or hooks. ' +
'Components and hooks should be defined at the module level',
preset: LintRulePreset.Recommended,
};
}
case ErrorCategory.FBT: {
return {
category,
@@ -820,15 +854,6 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
preset: LintRulePreset.Off,
};
}
case ErrorCategory.Fire: {
return {
category,
severity: ErrorSeverity.Error,
name: 'fire',
description: 'Validates usage of `fire`',
preset: LintRulePreset.Off,
};
}
case ErrorCategory.Gating: {
return {
category,
@@ -992,6 +1017,24 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
preset: LintRulePreset.RecommendedLatest,
};
}
case ErrorCategory.MemoDependencies: {
return {
category,
severity: ErrorSeverity.Error,
name: 'memo-dependencies',
description:
'Validates that useMemo() and useCallback() specify comprehensive dependencies without extraneous values. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
/**
* TODO: the "MemoDependencies" rule largely reimplements the "exhaustive-deps" non-compiler rule,
* allowing the compiler to ensure it does not regress change behavior due to different dependencies.
* We previously relied on the source having ESLint suppressions for any exhaustive-deps violations,
* but it's more reliable to verify it within the compiler.
*
* Long-term we should de-duplicate these implementations.
*/
preset: LintRulePreset.Off,
};
}
case ErrorCategory.IncompatibleLibrary: {
return {
category,

View File

@@ -8,6 +8,7 @@
import {NodePath} from '@babel/core';
import * as t from '@babel/types';
import {CompilerError} from '../CompilerError';
import {GeneratedSource} from '../HIR';
import {ProgramContext} from './Imports';
import {ExternalFunction} from '..';
@@ -51,26 +52,12 @@ function insertAdditionalFunctionDeclaration(
CompilerError.invariant(originalFnName != null && compiled.id != null, {
reason:
'Expected function declarations that are referenced elsewhere to have a named identifier',
description: null,
details: [
{
kind: 'error',
loc: fnPath.node.loc ?? null,
message: null,
},
],
loc: fnPath.node.loc ?? GeneratedSource,
});
CompilerError.invariant(originalFnParams.length === compiledParams.length, {
reason:
'Expected React Compiler optimized function declarations to have the same number of parameters as source',
description: null,
details: [
{
kind: 'error',
loc: fnPath.node.loc ?? null,
message: null,
},
],
loc: fnPath.node.loc ?? GeneratedSource,
});
const gatingCondition = t.identifier(
@@ -154,13 +141,7 @@ export function insertGatedFunctionDeclaration(
CompilerError.invariant(compiled.type === 'FunctionDeclaration', {
reason: 'Expected compiled node type to match input type',
description: `Got ${compiled.type} but expected FunctionDeclaration`,
details: [
{
kind: 'error',
loc: fnPath.node.loc ?? null,
message: null,
},
],
loc: fnPath.node.loc ?? GeneratedSource,
});
insertAdditionalFunctionDeclaration(
fnPath,

View File

@@ -19,7 +19,7 @@ import {getOrInsertWith} from '../Utils/utils';
import {ExternalFunction, isHookName} from '../HIR/Environment';
import {Err, Ok, Result} from '../Utils/Result';
import {LoggerEvent, ParsedPluginOptions} from './Options';
import {BabelFn, getReactCompilerRuntimeModule} from './Program';
import {getReactCompilerRuntimeModule} from './Program';
import {SuppressionRange} from './Suppression';
export function validateRestrictedImports(
@@ -84,12 +84,6 @@ export class ProgramContext {
// generated imports
imports: Map<string, Map<string, NonLocalImportSpecifier>> = new Map();
/**
* Metadata from compilation
*/
retryErrors: Array<{fn: BabelFn; error: CompilerError}> = [];
inferredEffectLocations: Set<t.SourceLocation> = new Set();
constructor({
program,
suppressions,
@@ -108,14 +102,7 @@ export class ProgramContext {
}
isHookName(name: string): boolean {
if (this.opts.environment.hookPattern == null) {
return isHookName(name);
} else {
const match = new RegExp(this.opts.environment.hookPattern).exec(name);
return (
match != null && typeof match[1] === 'string' && isHookName(match[1])
);
}
return isHookName(name);
}
hasReference(name: string): boolean {
@@ -257,14 +244,7 @@ export function addImportsToProgram(
reason:
'Encountered conflicting import specifiers in generated program',
description: `Conflict from import ${loweredImport.module}:(${loweredImport.imported} as ${loweredImport.name})`,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
suggestions: null,
loc: GeneratedSource,
},
);
CompilerError.invariant(
@@ -274,13 +254,7 @@ export function addImportsToProgram(
reason:
'Found inconsistent import specifier. This is an internal bug.',
description: `Expected import ${moduleName}:${specifierName} but found ${loweredImport.module}:${loweredImport.imported}`,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
},
);
}

View File

@@ -102,14 +102,25 @@ export type PluginOptions = Partial<{
panicThreshold: PanicThresholdOptions;
/*
/**
* @deprecated
*
* When enabled, Forget will continue statically analyzing and linting code, but skip over codegen
* passes.
*
* NOTE: ignored if `outputMode` is specified
*
* Defaults to false
*/
noEmit: boolean;
/**
* If specified, overrides `noEmit` and controls the output mode of the compiler.
*
* Defaults to null
*/
outputMode: CompilerOutputMode | null;
/*
* Determines the strategy for determining which functions to compile. Note that regardless of
* which mode is enabled, a component can be opted out by adding the string literal
@@ -212,6 +223,17 @@ const CompilationModeSchema = z.enum([
export type CompilationMode = z.infer<typeof CompilationModeSchema>;
const CompilerOutputModeSchema = z.enum([
// Build optimized for SSR, with client features removed
'ssr',
// Build optimized for the client, with auto memoization
'client',
// Lint mode, the output is unused but validations should run
'lint',
]);
export type CompilerOutputMode = z.infer<typeof CompilerOutputModeSchema>;
/**
* Represents 'events' that may occur during compilation. Events are only
* recorded when a logger is set (through the config).
@@ -231,9 +253,7 @@ export type LoggerEvent =
| CompileDiagnosticEvent
| CompileSkipEvent
| PipelineErrorEvent
| TimingEvent
| AutoDepsDecorationsEvent
| AutoDepsEligibleEvent;
| TimingEvent;
export type CompileErrorEvent = {
kind: 'CompileError';
@@ -270,17 +290,6 @@ export type TimingEvent = {
kind: 'Timing';
measurement: PerformanceMeasure;
};
export type AutoDepsDecorationsEvent = {
kind: 'AutoDepsDecorations';
fnLoc: t.SourceLocation;
decorations: Array<t.SourceLocation>;
};
export type AutoDepsEligibleEvent = {
kind: 'AutoDepsEligible';
fnLoc: t.SourceLocation;
depArrayLoc: t.SourceLocation;
};
export type Logger = {
logEvent: (filename: string | null, event: LoggerEvent) => void;
debugLogIRs?: (value: CompilerPipelineValue) => void;
@@ -293,6 +302,7 @@ export const defaultOptions: ParsedPluginOptions = {
logger: null,
gating: null,
noEmit: false,
outputMode: null,
dynamicGating: null,
eslintSuppressionRules: null,
flowSuppressions: true,

View File

@@ -8,7 +8,7 @@
import {NodePath} from '@babel/traverse';
import * as t from '@babel/types';
import prettyFormat from 'pretty-format';
import {Logger, ProgramContext} from '.';
import {CompilerOutputMode, Logger, ProgramContext} from '.';
import {
HIRFunction,
ReactiveFunction,
@@ -24,7 +24,6 @@ import {
pruneUnusedLabelsHIR,
} from '../HIR';
import {
CompilerMode,
Environment,
EnvironmentConfig,
ReactFunctionType,
@@ -35,15 +34,12 @@ import {
dropManualMemoization,
inferReactivePlaces,
inlineImmediatelyInvokedFunctionExpressions,
inferEffectDependencies,
} from '../Inference';
import {
constantPropagation,
deadCodeElimination,
pruneMaybeThrows,
inlineJsxTransform,
} from '../Optimization';
import {instructionReordering} from '../Optimization/InstructionReordering';
import {
CodegenFunction,
alignObjectMethodScopes,
@@ -70,7 +66,6 @@ import {alignReactiveScopesToBlockScopesHIR} from '../ReactiveScopes/AlignReacti
import {flattenReactiveLoopsHIR} from '../ReactiveScopes/FlattenReactiveLoopsHIR';
import {flattenScopesWithHooksOrUseHIR} from '../ReactiveScopes/FlattenScopesWithHooksOrUseHIR';
import {pruneAlwaysInvalidatingScopes} from '../ReactiveScopes/PruneAlwaysInvalidatingScopes';
import pruneInitializationDependencies from '../ReactiveScopes/PruneInitializationDependencies';
import {stabilizeBlockIds} from '../ReactiveScopes/StabilizeBlockIds';
import {
eliminateRedundantPhi,
@@ -81,7 +76,6 @@ import {inferTypes} from '../TypeInference';
import {
validateContextVariableLValues,
validateHooksUsage,
validateMemoizedEffectDependencies,
validateNoCapitalizedCalls,
validateNoRefAccessInRender,
validateNoSetStateInRender,
@@ -90,20 +84,22 @@ import {
} from '../Validation';
import {validateLocalsNotReassignedAfterRender} from '../Validation/ValidateLocalsNotReassignedAfterRender';
import {outlineFunctions} from '../Optimization/OutlineFunctions';
import {lowerContextAccess} from '../Optimization/LowerContextAccess';
import {validateNoSetStateInEffects} from '../Validation/ValidateNoSetStateInEffects';
import {validateNoJSXInTryStatement} from '../Validation/ValidateNoJSXInTryStatement';
import {propagateScopeDependenciesHIR} from '../HIR/PropagateScopeDependenciesHIR';
import {outlineJSX} from '../Optimization/OutlineJsx';
import {optimizePropsMethodCalls} from '../Optimization/OptimizePropsMethodCalls';
import {transformFire} from '../Transform';
import {validateNoImpureFunctionsInRender} from '../Validation/ValidateNoImpureFunctionsInRender';
import {validateStaticComponents} from '../Validation/ValidateStaticComponents';
import {validateNoFreezingKnownMutableFunctions} from '../Validation/ValidateNoFreezingKnownMutableFunctions';
import {inferMutationAliasingEffects} from '../Inference/InferMutationAliasingEffects';
import {inferMutationAliasingRanges} from '../Inference/InferMutationAliasingRanges';
import {validateNoDerivedComputationsInEffects} from '../Validation/ValidateNoDerivedComputationsInEffects';
import {validateNoDerivedComputationsInEffects_exp} from '../Validation/ValidateNoDerivedComputationsInEffects_exp';
import {nameAnonymousFunctions} from '../Transform/NameAnonymousFunctions';
import {optimizeForSSR} from '../Optimization/OptimizeForSSR';
import {validateExhaustiveDependencies} from '../Validation/ValidateExhaustiveDependencies';
import {validateSourceLocations} from '../Validation/ValidateSourceLocations';
export type CompilerPipelineValue =
| {kind: 'ast'; name: string; value: CodegenFunction}
@@ -117,7 +113,7 @@ function run(
>,
config: EnvironmentConfig,
fnType: ReactFunctionType,
mode: CompilerMode,
mode: CompilerOutputMode,
programContext: ProgramContext,
logger: Logger | null,
filename: string | null,
@@ -166,12 +162,7 @@ function runWithEnvironment(
validateContextVariableLValues(hir);
validateUseMemo(hir).unwrap();
if (
env.isInferredMemoEnabled &&
!env.config.enablePreserveExistingManualUseMemo &&
!env.config.disableMemoizationForDebugging &&
!env.config.enableChangeDetectionForDebugging
) {
if (env.enableDropManualMemoization) {
dropManualMemoization(hir).unwrap();
log({kind: 'hir', name: 'DropManualMemoization', value: hir});
}
@@ -203,7 +194,7 @@ function runWithEnvironment(
inferTypes(hir);
log({kind: 'hir', name: 'InferTypes', value: hir});
if (env.isInferredMemoEnabled) {
if (env.enableValidations) {
if (env.config.validateHooksUsage) {
validateHooksUsage(hir).unwrap();
}
@@ -212,15 +203,6 @@ function runWithEnvironment(
}
}
if (env.config.enableFire) {
transformFire(hir);
log({kind: 'hir', name: 'TransformFire', value: hir});
}
if (env.config.lowerContextAccess) {
lowerContextAccess(hir, env.config.lowerContextAccess);
}
optimizePropsMethodCalls(hir);
log({kind: 'hir', name: 'OptimizePropsMethodCalls', value: hir});
@@ -229,21 +211,20 @@ function runWithEnvironment(
const mutabilityAliasingErrors = inferMutationAliasingEffects(hir);
log({kind: 'hir', name: 'InferMutationAliasingEffects', value: hir});
if (env.isInferredMemoEnabled) {
if (env.enableValidations) {
if (mutabilityAliasingErrors.isErr()) {
throw mutabilityAliasingErrors.unwrapErr();
}
}
if (env.outputMode === 'ssr') {
optimizeForSSR(hir);
log({kind: 'hir', name: 'OptimizeForSSR', value: hir});
}
// Note: Has to come after infer reference effects because "dead" code may still affect inference
deadCodeElimination(hir);
log({kind: 'hir', name: 'DeadCodeElimination', value: hir});
if (env.config.enableInstructionReordering) {
instructionReordering(hir);
log({kind: 'hir', name: 'InstructionReordering', value: hir});
}
pruneMaybeThrows(hir);
log({kind: 'hir', name: 'PruneMaybeThrows', value: hir});
@@ -251,14 +232,14 @@ function runWithEnvironment(
isFunctionExpression: false,
});
log({kind: 'hir', name: 'InferMutationAliasingRanges', value: hir});
if (env.isInferredMemoEnabled) {
if (env.enableValidations) {
if (mutabilityAliasingRangeErrors.isErr()) {
throw mutabilityAliasingRangeErrors.unwrapErr();
}
validateLocalsNotReassignedAfterRender(hir);
}
if (env.isInferredMemoEnabled) {
if (env.enableValidations) {
if (env.config.assertValidMutableRanges) {
assertValidMutableRanges(hir);
}
@@ -271,15 +252,20 @@ function runWithEnvironment(
validateNoSetStateInRender(hir).unwrap();
}
if (env.config.validateNoDerivedComputationsInEffects) {
if (
env.config.validateNoDerivedComputationsInEffects_exp &&
env.outputMode === 'lint'
) {
env.logErrors(validateNoDerivedComputationsInEffects_exp(hir));
} else if (env.config.validateNoDerivedComputationsInEffects) {
validateNoDerivedComputationsInEffects(hir);
}
if (env.config.validateNoSetStateInEffects) {
if (env.config.validateNoSetStateInEffects && env.outputMode === 'lint') {
env.logErrors(validateNoSetStateInEffects(hir, env));
}
if (env.config.validateNoJSXInTryStatements) {
if (env.config.validateNoJSXInTryStatements && env.outputMode === 'lint') {
env.logErrors(validateNoJSXInTryStatement(hir));
}
@@ -293,6 +279,16 @@ function runWithEnvironment(
inferReactivePlaces(hir);
log({kind: 'hir', name: 'InferReactivePlaces', value: hir});
if (env.enableValidations) {
if (
env.config.validateExhaustiveMemoizationDependencies ||
env.config.validateExhaustiveEffectDependencies
) {
// NOTE: this relies on reactivity inference running first
validateExhaustiveDependencies(hir).unwrap();
}
}
rewriteInstructionKindsBasedOnReassignment(hir);
log({
kind: 'hir',
@@ -300,11 +296,15 @@ function runWithEnvironment(
value: hir,
});
if (env.isInferredMemoEnabled) {
if (env.config.validateStaticComponents) {
env.logErrors(validateStaticComponents(hir));
}
if (
env.enableValidations &&
env.config.validateStaticComponents &&
env.outputMode === 'lint'
) {
env.logErrors(validateStaticComponents(hir));
}
if (env.enableMemoization) {
/**
* Only create reactive scopes (which directly map to generated memo blocks)
* if inferred memoization is enabled. This makes all later passes which
@@ -406,24 +406,6 @@ function runWithEnvironment(
value: hir,
});
if (env.config.inferEffectDependencies) {
inferEffectDependencies(hir);
log({
kind: 'hir',
name: 'InferEffectDependencies',
value: hir,
});
}
if (env.config.inlineJsxTransform) {
inlineJsxTransform(hir, env.config.inlineJsxTransform);
log({
kind: 'hir',
name: 'inlineJsxTransform',
value: hir,
});
}
const reactiveFunction = buildReactiveFunction(hir);
log({
kind: 'reactive',
@@ -476,15 +458,6 @@ function runWithEnvironment(
value: reactiveFunction,
});
if (env.config.enableChangeDetectionForDebugging != null) {
pruneInitializationDependencies(reactiveFunction);
log({
kind: 'reactive',
name: 'PruneInitializationDependencies',
value: reactiveFunction,
});
}
propagateEarlyReturns(reactiveFunction);
log({
kind: 'reactive',
@@ -534,10 +507,6 @@ function runWithEnvironment(
value: reactiveFunction,
});
if (env.config.validateMemoizedEffectDependencies) {
validateMemoizedEffectDependencies(reactiveFunction).unwrap();
}
if (
env.config.enablePreserveExistingMemoizationGuarantees ||
env.config.validatePreserveExistingMemoizationGuarantees
@@ -554,6 +523,10 @@ function runWithEnvironment(
log({kind: 'ast', name: 'Codegen (outlined)', value: outlined.fn});
}
if (env.config.validateSourceLocations) {
validateSourceLocations(func, ast).unwrap();
}
/**
* This flag should be only set for unit / fixture tests to check
* that Forget correctly handles unexpected errors (e.g. exceptions
@@ -572,7 +545,7 @@ export function compileFn(
>,
config: EnvironmentConfig,
fnType: ReactFunctionType,
mode: CompilerMode,
mode: CompilerOutputMode,
programContext: ProgramContext,
logger: Logger | null,
filename: string | null,

View File

@@ -24,6 +24,7 @@ import {
validateRestrictedImports,
} from './Imports';
import {
CompilerOutputMode,
CompilerReactTarget,
ParsedPluginOptions,
PluginOptions,
@@ -314,13 +315,7 @@ function insertNewOutlinedFunctionNode(
CompilerError.invariant(insertedFuncDecl.isFunctionDeclaration(), {
reason: 'Expected inserted function declaration',
description: `Got: ${insertedFuncDecl}`,
details: [
{
kind: 'error',
loc: insertedFuncDecl.node?.loc ?? null,
message: null,
},
],
loc: insertedFuncDecl.node?.loc ?? GeneratedSource,
});
return insertedFuncDecl;
}
@@ -355,10 +350,6 @@ function isFilePartOfSources(
return false;
}
export type CompileProgramMetadata = {
retryErrors: Array<{fn: BabelFn; error: CompilerError}>;
inferredEffectLocations: Set<t.SourceLocation>;
};
/**
* Main entrypoint for React Compiler.
*
@@ -369,7 +360,7 @@ export type CompileProgramMetadata = {
export function compileProgram(
program: NodePath<t.Program>,
pass: CompilerPass,
): CompileProgramMetadata | null {
): void {
/**
* This is directly invoked by the react-compiler babel plugin, so exceptions
* thrown by this function will fail the babel build.
@@ -382,7 +373,7 @@ export function compileProgram(
* the outlined functions.
*/
if (shouldSkipCompilation(program, pass)) {
return null;
return;
}
const restrictedImportsErr = validateRestrictedImports(
program,
@@ -390,7 +381,7 @@ export function compileProgram(
);
if (restrictedImportsErr) {
handleError(restrictedImportsErr, pass, null);
return null;
return;
}
/*
* Record lint errors and critical errors as depending on Forget's config,
@@ -399,7 +390,15 @@ export function compileProgram(
*/
const suppressions = findProgramSuppressions(
pass.comments,
pass.opts.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS,
/*
* If the compiler is validating hooks rules and exhaustive memo dependencies, we don't need to check
* for React ESLint suppressions
*/
pass.opts.environment.validateExhaustiveMemoizationDependencies &&
pass.opts.environment.validateHooksUsage
? null
: (pass.opts.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS),
// Always bail on Flow suppressions
pass.opts.flowSuppressions,
);
@@ -421,22 +420,23 @@ export function compileProgram(
);
const compiledFns: Array<CompileResult> = [];
// outputMode takes precedence if specified
const outputMode: CompilerOutputMode =
pass.opts.outputMode ?? (pass.opts.noEmit ? 'lint' : 'client');
while (queue.length !== 0) {
const current = queue.shift()!;
const compiled = processFn(current.fn, current.fnType, programContext);
const compiled = processFn(
current.fn,
current.fnType,
programContext,
outputMode,
);
if (compiled != null) {
for (const outlined of compiled.outlined) {
CompilerError.invariant(outlined.fn.outlined.length === 0, {
reason: 'Unexpected nested outlined functions',
description: null,
details: [
{
kind: 'error',
loc: outlined.fn.loc,
message: null,
},
],
loc: outlined.fn.loc,
});
const fn = insertNewOutlinedFunctionNode(
program,
@@ -475,16 +475,11 @@ export function compileProgram(
);
handleError(error, programContext, null);
}
return null;
return;
}
// Insert React Compiler generated functions into the Babel AST
applyCompiledFunctions(program, compiledFns, pass, programContext);
return {
retryErrors: programContext.retryErrors,
inferredEffectLocations: programContext.inferredEffectLocations,
};
}
type CompileSource = {
@@ -514,10 +509,6 @@ function findFunctionsToCompile(
const fnType = getReactFunctionType(fn, pass);
if (pass.opts.environment.validateNoDynamicallyCreatedComponentsOrHooks) {
validateNoDynamicallyCreatedComponentsOrHooks(fn, pass, programContext);
}
if (fnType === null || programContext.alreadyCompiled.has(fn.node)) {
return;
}
@@ -581,6 +572,7 @@ function processFn(
fn: BabelFn,
fnType: ReactFunctionType,
programContext: ProgramContext,
outputMode: CompilerOutputMode,
): null | CodegenFunction {
let directives: {
optIn: t.Directive | null;
@@ -616,18 +608,19 @@ function processFn(
}
let compiledFn: CodegenFunction;
const compileResult = tryCompileFunction(fn, fnType, programContext);
const compileResult = tryCompileFunction(
fn,
fnType,
programContext,
outputMode,
);
if (compileResult.kind === 'error') {
if (directives.optOut != null) {
logError(compileResult.error, programContext, fn.node.loc ?? null);
} else {
handleError(compileResult.error, programContext, fn.node.loc ?? null);
}
const retryResult = retryCompileFunction(fn, fnType, programContext);
if (retryResult == null) {
return null;
}
compiledFn = retryResult;
return null;
} else {
compiledFn = compileResult.compiledFn;
}
@@ -663,17 +656,7 @@ function processFn(
if (programContext.hasModuleScopeOptOut) {
return null;
} else if (programContext.opts.noEmit) {
/**
* inferEffectDependencies + noEmit is currently only used for linting. In
* this mode, add source locations for where the compiler *can* infer effect
* dependencies.
*/
for (const loc of compiledFn.inferredEffectLocations) {
if (loc !== GeneratedSource) {
programContext.inferredEffectLocations.add(loc);
}
}
} else if (programContext.opts.outputMode === 'lint') {
return null;
} else if (
programContext.opts.compilationMode === 'annotation' &&
@@ -693,6 +676,7 @@ function tryCompileFunction(
fn: BabelFn,
fnType: ReactFunctionType,
programContext: ProgramContext,
outputMode: CompilerOutputMode,
):
| {kind: 'compile'; compiledFn: CodegenFunction}
| {kind: 'error'; error: unknown} {
@@ -719,7 +703,7 @@ function tryCompileFunction(
fn,
programContext.opts.environment,
fnType,
'all_features',
outputMode,
programContext,
programContext.opts.logger,
programContext.filename,
@@ -731,52 +715,6 @@ function tryCompileFunction(
}
}
/**
* If non-memo feature flags are enabled, retry compilation with a more minimal
* feature set.
*
* @returns a CodegenFunction if retry was successful
*/
function retryCompileFunction(
fn: BabelFn,
fnType: ReactFunctionType,
programContext: ProgramContext,
): CodegenFunction | null {
const environment = programContext.opts.environment;
if (
!(environment.enableFire || environment.inferEffectDependencies != null)
) {
return null;
}
/**
* Note that function suppressions are not checked in the retry pipeline, as
* they only affect auto-memoization features.
*/
try {
const retryResult = compileFn(
fn,
environment,
fnType,
'no_inferred_memo',
programContext,
programContext.opts.logger,
programContext.filename,
programContext.code,
);
if (!retryResult.hasFireRewrite && !retryResult.hasInferredEffect) {
return null;
}
return retryResult;
} catch (err) {
// TODO: we might want to log error here, but this will also result in duplicate logging
if (err instanceof CompilerError) {
programContext.retryErrors.push({fn, error: err});
}
return null;
}
}
/**
* Applies React Compiler generated functions to the babel AST by replacing
* existing functions in place or inserting new declarations.
@@ -861,84 +799,17 @@ function shouldSkipCompilation(
return false;
}
/**
* Validates that Components/Hooks are always defined at module level. This prevents scope reference
* errors that occur when the compiler attempts to optimize the nested component/hook while its
* parent function remains uncompiled.
*/
function validateNoDynamicallyCreatedComponentsOrHooks(
fn: BabelFn,
pass: CompilerPass,
programContext: ProgramContext,
): void {
const parentNameExpr = getFunctionName(fn);
const parentName =
parentNameExpr !== null && parentNameExpr.isIdentifier()
? parentNameExpr.node.name
: '<anonymous>';
const validateNestedFunction = (
nestedFn: NodePath<
t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression
>,
): void => {
if (
nestedFn.node === fn.node ||
programContext.alreadyCompiled.has(nestedFn.node)
) {
return;
}
if (nestedFn.scope.getProgramParent() !== nestedFn.scope.parent) {
const nestedFnType = getReactFunctionType(nestedFn as BabelFn, pass);
const nestedFnNameExpr = getFunctionName(nestedFn as BabelFn);
const nestedName =
nestedFnNameExpr !== null && nestedFnNameExpr.isIdentifier()
? nestedFnNameExpr.node.name
: '<anonymous>';
if (nestedFnType === 'Component' || nestedFnType === 'Hook') {
CompilerError.throwDiagnostic({
category: ErrorCategory.Factories,
reason: `Components and hooks cannot be created dynamically`,
description: `The function \`${nestedName}\` appears to be a React ${nestedFnType.toLowerCase()}, but it's defined inside \`${parentName}\`. Components and Hooks should always be declared at module scope`,
details: [
{
kind: 'error',
message: 'this function dynamically created a component/hook',
loc: parentNameExpr?.node.loc ?? fn.node.loc ?? null,
},
{
kind: 'error',
message: 'the component is created here',
loc: nestedFnNameExpr?.node.loc ?? nestedFn.node.loc ?? null,
},
],
});
}
}
nestedFn.skip();
};
fn.traverse({
FunctionDeclaration: validateNestedFunction,
FunctionExpression: validateNestedFunction,
ArrowFunctionExpression: validateNestedFunction,
});
}
function getReactFunctionType(
fn: BabelFn,
pass: CompilerPass,
): ReactFunctionType | null {
const hookPattern = pass.opts.environment.hookPattern;
if (fn.node.body.type === 'BlockStatement') {
const optInDirectives = tryFindDirectiveEnablingMemoization(
fn.node.body.directives,
pass.opts,
);
if (optInDirectives.unwrapOr(null) != null) {
return getComponentOrHookLike(fn, hookPattern) ?? 'Other';
return getComponentOrHookLike(fn) ?? 'Other';
}
}
@@ -959,13 +830,13 @@ function getReactFunctionType(
}
case 'infer': {
// Check if this is a component or hook-like function
return componentSyntaxType ?? getComponentOrHookLike(fn, hookPattern);
return componentSyntaxType ?? getComponentOrHookLike(fn);
}
case 'syntax': {
return componentSyntaxType;
}
case 'all': {
return getComponentOrHookLike(fn, hookPattern) ?? 'Other';
return getComponentOrHookLike(fn) ?? 'Other';
}
default: {
assertExhaustive(
@@ -1007,10 +878,7 @@ function hasMemoCacheFunctionImport(
return hasUseMemoCache;
}
function isHookName(s: string, hookPattern: string | null): boolean {
if (hookPattern !== null) {
return new RegExp(hookPattern).test(s);
}
function isHookName(s: string): boolean {
return /^use[A-Z0-9]/.test(s);
}
@@ -1019,16 +887,13 @@ function isHookName(s: string, hookPattern: string | null): boolean {
* containing a hook name.
*/
function isHook(
path: NodePath<t.Expression | t.PrivateName>,
hookPattern: string | null,
): boolean {
function isHook(path: NodePath<t.Expression | t.PrivateName>): boolean {
if (path.isIdentifier()) {
return isHookName(path.node.name, hookPattern);
return isHookName(path.node.name);
} else if (
path.isMemberExpression() &&
!path.node.computed &&
isHook(path.get('property'), hookPattern)
isHook(path.get('property'))
) {
const obj = path.get('object').node;
const isPascalCaseNameSpace = /^[A-Z].*/;
@@ -1169,19 +1034,18 @@ function getComponentOrHookLike(
node: NodePath<
t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
>,
hookPattern: string | null,
): ReactFunctionType | null {
const functionName = getFunctionName(node);
// Check if the name is component or hook like:
if (functionName !== null && isComponentName(functionName)) {
let isComponent =
callsHooksOrCreatesJsx(node, hookPattern) &&
callsHooksOrCreatesJsx(node) &&
isValidComponentParams(node.get('params')) &&
!returnsNonNode(node);
return isComponent ? 'Component' : null;
} else if (functionName !== null && isHook(functionName, hookPattern)) {
} else if (functionName !== null && isHook(functionName)) {
// Hooks have hook invocations or JSX, but can take any # of arguments
return callsHooksOrCreatesJsx(node, hookPattern) ? 'Hook' : null;
return callsHooksOrCreatesJsx(node) ? 'Hook' : null;
}
/*
@@ -1191,7 +1055,7 @@ function getComponentOrHookLike(
if (node.isFunctionExpression() || node.isArrowFunctionExpression()) {
if (isForwardRefCallback(node) || isMemoCallback(node)) {
// As an added check we also look for hook invocations or JSX
return callsHooksOrCreatesJsx(node, hookPattern) ? 'Component' : null;
return callsHooksOrCreatesJsx(node) ? 'Component' : null;
}
}
return null;
@@ -1217,7 +1081,6 @@ function callsHooksOrCreatesJsx(
node: NodePath<
t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
>,
hookPattern: string | null,
): boolean {
let invokesHooks = false;
let createsJsx = false;
@@ -1228,7 +1091,7 @@ function callsHooksOrCreatesJsx(
},
CallExpression(call) {
const callee = call.get('callee');
if (callee.isExpression() && isHook(callee, hookPattern)) {
if (callee.isExpression() && isHook(callee)) {
invokesHooks = true;
}
},
@@ -1423,15 +1286,7 @@ export function getReactCompilerRuntimeModule(
typeof target.runtimeModule === 'string',
{
reason: 'Expected target to already be validated',
description: null,
details: [
{
kind: 'error',
loc: null,
message: null,
},
],
suggestions: null,
loc: GeneratedSource,
},
);
return target.runtimeModule;

View File

@@ -78,7 +78,7 @@ export function filterSuppressionsThatAffectFunction(
export function findProgramSuppressions(
programComments: Array<t.Comment>,
ruleNames: Array<string>,
ruleNames: Array<string> | null,
flowSuppressions: boolean,
): Array<SuppressionRange> {
const suppressionRanges: Array<SuppressionRange> = [];
@@ -89,7 +89,7 @@ export function findProgramSuppressions(
let disableNextLinePattern: RegExp | null = null;
let disablePattern: RegExp | null = null;
let enablePattern: RegExp | null = null;
if (ruleNames.length !== 0) {
if (ruleNames != null && ruleNames.length !== 0) {
const rulePattern = `(${ruleNames.join('|')})`;
disableNextLinePattern = new RegExp(
`eslint-disable-next-line ${rulePattern}`,
@@ -163,14 +163,7 @@ export function suppressionsToCompilerError(
): CompilerError {
CompilerError.invariant(suppressionRanges.length !== 0, {
reason: `Expected at least suppression comment source range`,
description: null,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
});
const error = new CompilerError();
for (const suppressionRange of suppressionRanges) {

View File

@@ -1,341 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {NodePath} from '@babel/core';
import * as t from '@babel/types';
import {CompilerError, EnvironmentConfig, Logger} from '..';
import {getOrInsertWith} from '../Utils/utils';
import {Environment, GeneratedSource} from '../HIR';
import {DEFAULT_EXPORT} from '../HIR/Environment';
import {CompileProgramMetadata} from './Program';
import {
CompilerDiagnostic,
CompilerDiagnosticOptions,
ErrorCategory,
} from '../CompilerError';
function throwInvalidReact(
options: CompilerDiagnosticOptions,
{logger, filename}: TraversalState,
): never {
logger?.logEvent(filename, {
kind: 'CompileError',
fnLoc: null,
detail: new CompilerDiagnostic(options),
});
CompilerError.throwDiagnostic(options);
}
function isAutodepsSigil(
arg: NodePath<t.ArgumentPlaceholder | t.SpreadElement | t.Expression>,
): boolean {
// Check for AUTODEPS identifier imported from React
if (arg.isIdentifier() && arg.node.name === 'AUTODEPS') {
const binding = arg.scope.getBinding(arg.node.name);
if (binding && binding.path.isImportSpecifier()) {
const importSpecifier = binding.path.node as t.ImportSpecifier;
if (importSpecifier.imported.type === 'Identifier') {
return (importSpecifier.imported as t.Identifier).name === 'AUTODEPS';
}
}
return false;
}
// Check for React.AUTODEPS member expression
if (arg.isMemberExpression() && !arg.node.computed) {
const object = arg.get('object');
const property = arg.get('property');
if (
object.isIdentifier() &&
object.node.name === 'React' &&
property.isIdentifier() &&
property.node.name === 'AUTODEPS'
) {
return true;
}
}
return false;
}
function assertValidEffectImportReference(
autodepsIndex: number,
paths: Array<NodePath<t.Node>>,
context: TraversalState,
): void {
for (const path of paths) {
const parent = path.parentPath;
if (parent != null && parent.isCallExpression()) {
const args = parent.get('arguments');
const maybeCalleeLoc = path.node.loc;
const hasInferredEffect =
maybeCalleeLoc != null &&
context.inferredEffectLocations.has(maybeCalleeLoc);
/**
* Error on effect calls that still have AUTODEPS in their args
*/
const hasAutodepsArg = args.some(isAutodepsSigil);
if (hasAutodepsArg && !hasInferredEffect) {
const maybeErrorDiagnostic = matchCompilerDiagnostic(
path,
context.transformErrors,
);
/**
* Note that we cannot easily check the type of the first argument here,
* as it may have already been transformed by the compiler (and not
* memoized).
*/
throwInvalidReact(
{
category: ErrorCategory.AutomaticEffectDependencies,
reason:
'Cannot infer dependencies of this effect. This will break your build!',
description:
'To resolve, either pass a dependency array or fix reported compiler bailout diagnostics' +
(maybeErrorDiagnostic ? ` ${maybeErrorDiagnostic}` : ''),
details: [
{
kind: 'error',
message: 'Cannot infer dependencies',
loc: parent.node.loc ?? GeneratedSource,
},
],
},
context,
);
}
}
}
}
function assertValidFireImportReference(
paths: Array<NodePath<t.Node>>,
context: TraversalState,
): void {
if (paths.length > 0) {
const maybeErrorDiagnostic = matchCompilerDiagnostic(
paths[0],
context.transformErrors,
);
throwInvalidReact(
{
category: ErrorCategory.Fire,
reason: '[Fire] Untransformed reference to compiler-required feature.',
description:
'Either remove this `fire` call or ensure it is successfully transformed by the compiler' +
(maybeErrorDiagnostic != null ? ` ${maybeErrorDiagnostic}` : ''),
details: [
{
kind: 'error',
message: 'Untransformed `fire` call',
loc: paths[0].node.loc ?? GeneratedSource,
},
],
},
context,
);
}
}
export default function validateNoUntransformedReferences(
path: NodePath<t.Program>,
filename: string | null,
logger: Logger | null,
env: EnvironmentConfig,
compileResult: CompileProgramMetadata | null,
): void {
const moduleLoadChecks = new Map<
string,
Map<string, CheckInvalidReferenceFn>
>();
if (env.enableFire) {
/**
* Error on any untransformed references to `fire` (e.g. including non-call
* expressions)
*/
for (const module of Environment.knownReactModules) {
const react = getOrInsertWith(moduleLoadChecks, module, () => new Map());
react.set('fire', assertValidFireImportReference);
}
}
if (env.inferEffectDependencies) {
for (const {
function: {source, importSpecifierName},
autodepsIndex,
} of env.inferEffectDependencies) {
const module = getOrInsertWith(moduleLoadChecks, source, () => new Map());
module.set(
importSpecifierName,
assertValidEffectImportReference.bind(null, autodepsIndex),
);
}
}
if (moduleLoadChecks.size > 0) {
transformProgram(path, moduleLoadChecks, filename, logger, compileResult);
}
}
type TraversalState = {
shouldInvalidateScopes: boolean;
program: NodePath<t.Program>;
logger: Logger | null;
filename: string | null;
transformErrors: Array<{fn: NodePath<t.Node>; error: CompilerError}>;
inferredEffectLocations: Set<t.SourceLocation>;
};
type CheckInvalidReferenceFn = (
paths: Array<NodePath<t.Node>>,
context: TraversalState,
) => void;
function validateImportSpecifier(
specifier: NodePath<t.ImportSpecifier>,
importSpecifierChecks: Map<string, CheckInvalidReferenceFn>,
state: TraversalState,
): void {
const imported = specifier.get('imported');
const specifierName: string =
imported.node.type === 'Identifier'
? imported.node.name
: imported.node.value;
const checkFn = importSpecifierChecks.get(specifierName);
if (checkFn == null) {
return;
}
if (state.shouldInvalidateScopes) {
state.shouldInvalidateScopes = false;
state.program.scope.crawl();
}
const local = specifier.get('local');
const binding = local.scope.getBinding(local.node.name);
CompilerError.invariant(binding != null, {
reason: 'Expected binding to be found for import specifier',
description: null,
details: [
{
kind: 'error',
loc: local.node.loc ?? null,
message: null,
},
],
});
checkFn(binding.referencePaths, state);
}
function validateNamespacedImport(
specifier: NodePath<t.ImportNamespaceSpecifier | t.ImportDefaultSpecifier>,
importSpecifierChecks: Map<string, CheckInvalidReferenceFn>,
state: TraversalState,
): void {
if (state.shouldInvalidateScopes) {
state.shouldInvalidateScopes = false;
state.program.scope.crawl();
}
const local = specifier.get('local');
const binding = local.scope.getBinding(local.node.name);
const defaultCheckFn = importSpecifierChecks.get(DEFAULT_EXPORT);
CompilerError.invariant(binding != null, {
reason: 'Expected binding to be found for import specifier',
description: null,
details: [
{
kind: 'error',
loc: local.node.loc ?? null,
message: null,
},
],
});
const filteredReferences = new Map<
CheckInvalidReferenceFn,
Array<NodePath<t.Node>>
>();
for (const reference of binding.referencePaths) {
if (defaultCheckFn != null) {
getOrInsertWith(filteredReferences, defaultCheckFn, () => []).push(
reference,
);
}
const parent = reference.parentPath;
if (
parent != null &&
parent.isMemberExpression() &&
parent.get('object') === reference
) {
if (parent.node.computed || parent.node.property.type !== 'Identifier') {
continue;
}
const checkFn = importSpecifierChecks.get(parent.node.property.name);
if (checkFn != null) {
getOrInsertWith(filteredReferences, checkFn, () => []).push(parent);
}
}
}
for (const [checkFn, references] of filteredReferences) {
checkFn(references, state);
}
}
function transformProgram(
path: NodePath<t.Program>,
moduleLoadChecks: Map<string, Map<string, CheckInvalidReferenceFn>>,
filename: string | null,
logger: Logger | null,
compileResult: CompileProgramMetadata | null,
): void {
const traversalState: TraversalState = {
shouldInvalidateScopes: true,
program: path,
filename,
logger,
transformErrors: compileResult?.retryErrors ?? [],
inferredEffectLocations:
compileResult?.inferredEffectLocations ?? new Set(),
};
path.traverse({
ImportDeclaration(path: NodePath<t.ImportDeclaration>) {
const importSpecifierChecks = moduleLoadChecks.get(
path.node.source.value,
);
if (importSpecifierChecks == null) {
return;
}
const specifiers = path.get('specifiers');
for (const specifier of specifiers) {
if (specifier.isImportSpecifier()) {
validateImportSpecifier(
specifier,
importSpecifierChecks,
traversalState,
);
} else {
validateNamespacedImport(
specifier as NodePath<
t.ImportNamespaceSpecifier | t.ImportDefaultSpecifier
>,
importSpecifierChecks,
traversalState,
);
}
}
},
});
}
function matchCompilerDiagnostic(
badReference: NodePath<t.Node>,
transformErrors: Array<{fn: NodePath<t.Node>; error: CompilerError}>,
): string | null {
for (const {fn, error} of transformErrors) {
if (fn.isAncestor(badReference)) {
return error.toString();
}
}
return null;
}

View File

@@ -46,14 +46,7 @@ export function raiseUnificationErrors(
if (errs.length === 0) {
CompilerError.invariant(false, {
reason: 'Should not have array of zero errors',
description: null,
details: [
{
kind: 'error',
loc,
message: null,
},
],
loc,
});
} else if (errs.length === 1) {
CompilerError.throwInvalidJS({

View File

@@ -151,15 +151,7 @@ export type LinearId = number & {
export function makeLinearId(id: number): LinearId {
CompilerError.invariant(id >= 0 && Number.isInteger(id), {
reason: 'Expected LinearId id to be a non-negative integer',
description: null,
details: [
{
kind: 'error',
loc: null,
message: null,
},
],
suggestions: null,
loc: GeneratedSource,
});
return id as LinearId;
}
@@ -172,15 +164,7 @@ export type TypeParameterId = number & {
export function makeTypeParameterId(id: number): TypeParameterId {
CompilerError.invariant(id >= 0 && Number.isInteger(id), {
reason: 'Expected TypeParameterId to be a non-negative integer',
description: null,
details: [
{
kind: 'error',
loc: null,
message: null,
},
],
suggestions: null,
loc: GeneratedSource,
});
return id as TypeParameterId;
}
@@ -202,15 +186,7 @@ export type VariableId = number & {
export function makeVariableId(id: number): VariableId {
CompilerError.invariant(id >= 0 && Number.isInteger(id), {
reason: 'Expected VariableId id to be a non-negative integer',
description: null,
details: [
{
kind: 'error',
loc: null,
message: null,
},
],
suggestions: null,
loc: GeneratedSource,
});
return id as VariableId;
}
@@ -417,14 +393,7 @@ function convertFlowType(flowType: FlowType, loc: string): ResolvedType {
} else {
CompilerError.invariant(false, {
reason: `Unsupported property kind ${prop.kind}`,
description: null,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
});
}
}
@@ -493,14 +462,7 @@ function convertFlowType(flowType: FlowType, loc: string): ResolvedType {
} else {
CompilerError.invariant(false, {
reason: `Unsupported property kind ${prop.kind}`,
description: null,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
});
}
}
@@ -519,14 +481,7 @@ function convertFlowType(flowType: FlowType, loc: string): ResolvedType {
} else {
CompilerError.invariant(false, {
reason: `Unsupported property kind ${prop.kind}`,
description: null,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
});
}
}
@@ -539,14 +494,7 @@ function convertFlowType(flowType: FlowType, loc: string): ResolvedType {
}
CompilerError.invariant(false, {
reason: `Unsupported class instance type ${flowType.def.type.kind}`,
description: null,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
});
}
case 'Fun':
@@ -605,14 +553,7 @@ function convertFlowType(flowType: FlowType, loc: string): ResolvedType {
} else {
CompilerError.invariant(false, {
reason: `Unsupported component props type ${propsType.type.kind}`,
description: null,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
});
}
@@ -765,14 +706,7 @@ export class FlowTypeEnv implements ITypeEnv {
// TODO: use flow-js only for web environments (e.g. playground)
CompilerError.invariant(env.config.flowTypeProvider != null, {
reason: 'Expected flowDumpTypes to be defined in environment config',
description: null,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
});
let stdout: any;
if (source === lastFlowSource) {

View File

@@ -38,28 +38,14 @@ export function assertConsistentIdentifiers(fn: HIRFunction): void {
CompilerError.invariant(instr.lvalue.identifier.name === null, {
reason: `Expected all lvalues to be temporaries`,
description: `Found named lvalue \`${instr.lvalue.identifier.name}\``,
details: [
{
kind: 'error',
loc: instr.lvalue.loc,
message: null,
},
],
suggestions: null,
loc: instr.lvalue.loc,
});
CompilerError.invariant(!assignments.has(instr.lvalue.identifier.id), {
reason: `Expected lvalues to be assigned exactly once`,
description: `Found duplicate assignment of '${printPlace(
instr.lvalue,
)}'`,
details: [
{
kind: 'error',
loc: instr.lvalue.loc,
message: null,
},
],
suggestions: null,
loc: instr.lvalue.loc,
});
assignments.add(instr.lvalue.identifier.id);
for (const operand of eachInstructionLValue(instr)) {
@@ -89,14 +75,7 @@ function validate(
CompilerError.invariant(identifier === previous, {
reason: `Duplicate identifier object`,
description: `Found duplicate identifier object for id ${identifier.id}`,
details: [
{
kind: 'error',
loc: loc ?? GeneratedSource,
message: null,
},
],
suggestions: null,
loc: loc ?? GeneratedSource,
});
}
}

View File

@@ -18,14 +18,7 @@ export function assertTerminalSuccessorsExist(fn: HIRFunction): void {
description: `Block bb${successor} does not exist for terminal '${printTerminal(
block.terminal,
)}'`,
details: [
{
kind: 'error',
loc: (block.terminal as any).loc ?? GeneratedSource,
message: null,
},
],
suggestions: null,
loc: (block.terminal as any).loc ?? GeneratedSource,
});
return successor;
});
@@ -39,26 +32,14 @@ export function assertTerminalPredsExist(fn: HIRFunction): void {
CompilerError.invariant(predBlock != null, {
reason: 'Expected predecessor block to exist',
description: `Block ${block.id} references non-existent ${pred}`,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
});
CompilerError.invariant(
[...eachTerminalSuccessor(predBlock.terminal)].includes(block.id),
{
reason: 'Terminal successor does not reference correct predecessor',
description: `Block bb${block.id} has bb${predBlock.id} as a predecessor, but bb${predBlock.id}'s successors do not include bb${block.id}`,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
},
);
}

View File

@@ -131,13 +131,7 @@ export function recursivelyTraverseItems<T, TContext>(
CompilerError.invariant(disjoint || nested, {
reason: 'Invalid nesting in program blocks or scopes',
description: `Items overlap but are not nested: ${maybeParentRange.start}:${maybeParentRange.end}(${currRange.start}:${currRange.end})`,
details: [
{
kind: 'error',
loc: GeneratedSource,
message: null,
},
],
loc: GeneratedSource,
});
if (disjoint) {
exit(maybeParent, context);

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