Compare commits

..

294 Commits

Author SHA1 Message Date
Joe Savona
d58c07b563 [compiler] New mutability/aliasing model
Squashed, review-friendly version of the stack from https://github.com/facebook/react/pull/33488.

This is new version of our mutability and inference model, designed to replace the core algorithm for determining the sets of instructions involved in constructing a given value or set of values. The new model replaces InferReferenceEffects, InferMutableRanges (and all of its subcomponents), and parts of AnalyzeFunctions. The new model does not use per-Place effect values, but in order to make this drop-in the end _result_ of the inference adds these per-Place effects.

I'll write up a larger document on the model, first i'm doing some housekeeping to rebase the PR.
2025-06-18 09:53:47 -07:00
Joe Savona
631b1cfc74 [compiler] Remove unnecessary fixture
This is covered by iife-inline-ternary
2025-06-18 09:53:45 -07:00
Sebastian Markbåge
e1dc03492e Expose cacheSignal() alongside cache() (#33557)
This was really meant to be there from the beginning. A `cache()`:ed
entry has a life time. On the server this ends when the render finishes.
On the client this ends when the cache of that scope gets refreshed.

When a cache is no longer needed, it should be possible to abort any
outstanding network requests or other resources. That's what
`cacheSignal()` gives you. It returns an `AbortSignal` which aborts when
the cache lifetime is done based on the same execution scope as a
`cache()`ed function - i.e. `AsyncLocalStorage` on the server or the
render scope on the client.

```js
import {cacheSignal} from 'react';
async function Component() {
  await fetch(url, { signal: cacheSignal() });
}
```

For `fetch` in particular, a patch should really just do this
automatically for you. But it's useful for other resources like database
connections.

Another reason it's useful to have a `cacheSignal()` is to ignore any
errors that might have triggered from the act of being aborted. This is
just a general useful JavaScript pattern if you have access to a signal:

```js
async function getData(id, signal) {
  try {
     await queryDatabase(id, { signal });
  } catch (x) {
     if (!signal.aborted) {
       logError(x); // only log if it's a real error and not due to cancellation
     }
     return null;
  }
}
```

This just gets you a convenient way to get to it without drilling
through so a more idiomatic code in React might look something like.

```js
import {cacheSignal} from "react";

async function getData(id) {
  try {
     await queryDatabase(id);
  } catch (x) {
     if (!cacheSignal()?.aborted) {
       logError(x);
     }
     return null;
  }
}
```

If it's called outside of a React render, we normally treat any cached
functions as uncached. They're not an error call. They can still load
data. It's just not cached. This is not like an aborted signal because
then you couldn't issue any requests. It's also not like an infinite
abort signal because it's not actually cached forever. Therefore,
`cacheSignal()` returns `null` when called outside of a React render
scope.

Notably the `signal` option passed to `renderToReadableStream` in both
SSR (Fizz) and RSC (Flight Server) is not the same instance that comes
out of `cacheSignal()`. If you abort the `signal` passed in, then the
`cacheSignal()` is also aborted with the same reason. However, the
`cacheSignal()` can also get aborted if the render completes
successfully or fatally errors during render - allowing any outstanding
work that wasn't used to clean up. In the future we might also expand on
this to give different
[`TaskSignal`](https://developer.mozilla.org/en-US/docs/Web/API/TaskSignal)
to different scopes to pass different render or network priorities.

On the client version of `"react"` this exposes a noop (both for
Fiber/Fizz) due to `disableClientCache` flag but it's exposed so that
you can write shared code.
2025-06-17 17:04:40 -04:00
Jordan Brown
90bee81902 [compiler] Do not inline IIFEs in value blocks (#33548)
As discussed in chat, this is a simple fix to stop introducing labels
inside expressions.

The useMemo-with-optional test was added in
d70b2c2c4e
and crashes for the same reason- an unexpected label as a value block
terminal.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33548).
* __->__ #33548
* #33546
2025-06-16 21:53:50 -04:00
Jordan Brown
75e78d243f [compiler] Add repro for IIFE in ternary causing a bailout (#33546)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33546).
* #33548
* __->__ #33546
2025-06-16 21:53:27 -04:00
Jan Kassens
5d24c64cc9 Remove feature flag enableDO_NOT_USE_disableStrictPassiveEffect (#33524) 2025-06-16 12:22:47 -04:00
lauren
6b7e207cab [ci] Don't skip experimental prerelease incorrectly (#33527)
Previously the experimental workflow relied on the canary one running
first to avoid race conditions. However, I didn't account for the fact
that the canary one can now be skipped.
2025-06-13 15:29:59 -04:00
lauren
d60f77a533 [ci] Update prerelease workflows to allow publishing specific packages (#33525)
It may be useful at times to publish only specific packages as an
experimental tag. For example, if we need to cherry pick some fixes for
an old release, we can first do so by creating that as an experimental
release just for that package to allow for quick testing by downstream
projects.

Similar to .github/workflows/runtime_releases_from_npm_manual.yml I
added three options (`dry`, `only_packages`, `skip_packages`) to
`runtime_prereleases.yml` which both the manual and nightly workflows
reuse. I also added a discord notification when the manual workflow is
run.
2025-06-13 14:22:55 -04:00
James Friend
12bc60f509 [devtools] Added minimum indent size to Component Tree (#33517)
## Summary

The devtools Components tab's component tree view currently has a
behavior where the indentation of each level of the tree scales based on
the available width of the view. If the view is narrow or component
names are long, all indentation showing the hierarchy of the tree scales
down with the view width until there is no indentation at all. This
makes it impossible to see the nesting of the tree, making the tree view
much less useful. With long component names and deep hierarchies this
issue is particularly egregious. For comparison, the Chrome Dev Tools
Elements panel uses a fixed indentation size, so it doesn't suffer from
this issue.

This PR adds a minimum pixel value for the indentation width, so that
even when the window is narrow some indentation will still be visible,
maintaining the visual representation of the component tree hierarchy.

Alternatively, we could match the behavior of the Chrome Dev Tools and
just use a constant indentation width.

## How did you test this change?

- tests (yarn test-build-devtools)
- tested in browser:
- added an alternate left/right split pane layout to
react-devtools-shell to test with
(https://github.com/facebook/react/pull/33516)
- tested resizing the tree view in different layout modes

### before this change:



https://github.com/user-attachments/assets/470991f1-dc05-473f-a2cb-4f7333f6bae4

with a long component name:



https://github.com/user-attachments/assets/1568fc64-c7d7-4659-bfb1-9bfc9592fb9d





### after this change:




https://github.com/user-attachments/assets/f60bd7fc-97f6-4680-9656-f0db3d155411

with a long component name:


https://github.com/user-attachments/assets/6ac3f58c-42ea-4c5a-9a52-c3b397f37b45
2025-06-13 15:28:31 +01:00
James Friend
ed023cfc73 [devtools-shell] layout options for testing (#33516)
## Summary

This PR adds a 'Layout' selector to the devtools shell main example, as
well as a resizable split pane, allowing more realistic testing of how
the devtools behaves when used in a vertical or horizontal layout and at
different sizes (e.g. when resizing the Chrome Dev Tools pane).

## How did you test this change?



https://github.com/user-attachments/assets/81179413-7b46-47a9-bc52-4f7ec414e8be
2025-06-13 15:25:04 +01:00
Sebastian "Sebbie" Silbermann
a00ca6f6b5 [Fizz] Delay detachment of completed boundaries until reveal (#33511) 2025-06-11 21:24:24 +02:00
lauren
888ea60d8e [compiler][repro] Postfix operator is incorrectly compiled (#33508)
This bug was reported via our wg and appears to only affect values
created as a ref.

Currently, postfix operators used in a callback gets compiled to:

```js
modalId.current = modalId.current + 1; // 1
const id = modalId.current; // 1
return id;
```

which is semantically incorrect. The postfix increment operator should
return the value before incrementing. In other words something like this
should have been compiled instead:

```js
const id = modalId.current; // 0
modalId.current = modalId.current + 1; // 1
return id;
```

This bug does not trigger when the incremented value is a plain
primitive, instead there is a TODO bailout.
2025-06-11 14:40:42 -04:00
Jan Kassens
b7e2de632b Stringify context as SomeContext instead of SomeContext.Provider (#33507)
This matches the change in React 19 to use `<SomeContext>` as the
preferred way to provide a context.
2025-06-11 12:08:04 -04:00
Sebastian Markbåge
ff93c4448c [Flight] Track Debug Info from Synchronously Unwrapped Promises (#33485)
Stacked on #33482.

There's a flaw with getting information from the execution context of
the ping. For the soft-deprecated "throw a promise" technique, this is a
bit unreliable because you could in theory throw the same one multiple
times. Similarly, a more fundamental flaw with that API is that it
doesn't allow for tracking the information of Promises that are already
synchronously able to resolve.

This stops tracking the async debug info in the case of throwing a
Promise and only when you render a Promise. That means some loss of data
but we should just warn for throwing a Promise anyway.

Instead, this also adds support for tracking `use()`d thenables and
forwarding `_debugInfo` from then. This is done by extracting the info
from the Promise after the fact instead of in the resolve so that it
only happens once at the end after the pings are done.

This also supports passing the same Promise in multiple places and
tracking the debug info at each location, even if it was already
instrumented with a synchronous value by the time of the second use.
2025-06-11 12:07:10 -04:00
Jan Kassens
6c86e56a0f Remove feature flag enableRenderableContext (#33505)
The flag is fully rolled out.
2025-06-11 11:53:04 -04:00
Sebastian Markbåge
56408a5b12 [Flight] Emit timestamps only in forwards advancing time in debug info (#33482)
Previously you weren't guaranteed to have only advancing time entries,
you could jump back in time, but now it omits unnecessary duplicates and
clamps automatically if you emit a previous time entry to enforce
forwards order only.

The reason I didn't do this originally is because `await` can jump in
the order because we're trying to encode a graph into a flat timeline
for simplicity of the protocol and consumers.

```js
async function a() {
  await fetch1();
  await fetch2();
}

async function b() {
  await fetch3();
}

async function foo() {
  const p = a();
  await b();
  return p;
}
```

This can effectively create two parallel sequences:

```
--1.................----2.......--
------3......---------------------
```

This can now be flattened to either:

```
--1.................3---2.......--
```

Or:

```
------3......1......----2.......--
```

Depending on which one we visit first. Regardless, information is lost.

I'd say that the second one is worse encoding of this scenario because
it pretends that we weren't waiting for part of the timespan that we
were. To solve this I think we should probably make `emitAsyncSequence`
create a temporary flat list and then sort it by start time before
emitting.

Although we weren't actually blocked since there was some CPU time that
was able to proceed to get to 3. So maybe the second one is actually
better. If we wanted that consistently we'd have to figure out what the
intersection was.

---------

Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
2025-06-10 11:03:20 -04:00
Sebastian Markbåge
c38e268978 [Fiber] Fix hydration of useId in SuspenseList (#33491)
Includes #31412.

The issue is that `pushTreeFork` stores some global state when reconcile
children. This gets popped by `popTreeContext` in `completeWork`.
Normally `completeWork` returns its own `Fiber` again if it wants to do
a second pass which will call `pushTreeFork` again in the next pass.
However, `SuspenseList` doesn't return itself, it returns the next child
to work on.

The fix is to keep track of the count and push it again it when we
return the next child to attempt.

There are still some outstanding issues with hydration. Like the
backwards test still has the wrong behavior in it because it hydrates
backwards and so it picks up the DOM nodes in reverse order.
`tail="hidden"` also doesn't work correctly.

There's also another issue with `useId` and `AsyncIterable` in
SuspenseList when there's an unknown number of children. We don't
support those showing one at a time yet though so it's not an issue yet.
To fix it we need to add variable total count to the `useId` algorithm.
E.g. by falling back to varint encoding.

---------

Co-authored-by: Rick Hanlon <rickhanlonii@fb.com>
Co-authored-by: Ricky <rickhanlonii@gmail.com>
2025-06-09 19:37:49 -04:00
Ruslan Lesiutin
80c03eb7e0 refactor[devtools]: update css for settings and support css variables in shadow dom scnenario (#33487)
## Summary

Minor changes around css and styling of Settings dialog.

1. `:root` selector was updated to `:is(:root, :host)` to make css
variables available on Shadow Root
2. CSS tweaks around Settings dialog: removed references to deleted
styles, removed unused styles, ironed out styling for cases when input
styles are enhanced by user agent stylesheet

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

## How did you test this change?

| Before | After |
|--------|--------|
| ![Screenshot 2025-06-09 at 15 35
55](https://github.com/user-attachments/assets/1ac5d002-744b-4b10-9501-d4f2a7c827d2)
| ![Screenshot 2025-06-09 at 15 26
12](https://github.com/user-attachments/assets/8cc07cda-99a5-4930-973b-b139b193e349)
|
| ![Screenshot 2025-06-09 at 15 36
02](https://github.com/user-attachments/assets/1af4257c-928d-4ec6-a614-801cc1936f4b)
| ![Screenshot 2025-06-09 at 15 26
25](https://github.com/user-attachments/assets/7a3a0f7c-5f3d-4567-a782-dd37368a15ae)
|
| ![Screenshot 2025-06-09 at 15 36
05](https://github.com/user-attachments/assets/a1e00381-2901-4e22-b1c6-4a3f66ba78c9)
| ![Screenshot 2025-06-09 at 15 26
30](https://github.com/user-attachments/assets/bdefce68-cbb5-4b88-b44c-a74f28533f7d)
|
| ![Screenshot 2025-06-09 at 15 36
12](https://github.com/user-attachments/assets/4eda6234-0ef0-40ca-ad9d-5990a2b1e8b4)
| ![Screenshot 2025-06-09 at 15 26
37](https://github.com/user-attachments/assets/5cac305e-fd29-460c-b0b8-30e477b8c26e)
|
2025-06-09 18:25:19 +01:00
Wesley LeMahieu
b6c0aa8814 [compiler]: fix link compiler & 4 broken tests from path containing spaces (#33409)
## Summary

Problem #1: Running the `link-compiler.sh` bash script via `"prebuild"`
script fails if a developer has cloned the `react` repo into a folder
that contains _any_ spaces. 3 tests fail because of this.

<img width="1003" alt="fail-1"
src="https://github.com/user-attachments/assets/1fbfa9ce-4f84-48d7-b49c-b6e967b8c7ca"
/>
<img width="1011" alt="fail-2"
src="https://github.com/user-attachments/assets/0a8c6371-a2df-4276-af98-38f4784cf0da"
/>
<img width="1027" alt="fail-3"
src="https://github.com/user-attachments/assets/1c4f4429-800c-4b44-b3da-a59ac85a16b9"
/>

For example, my current folder is:
`/Users/wes/Development/Open Source Contributions/react`

The link compiler error returns:
`./scripts/react-compiler/link-compiler.sh: line 15: cd:
/Users/wes/Development/Open: No such file or directory`

Problem #2: 1 test in `ReactChildren-test.js` fails due the existing
stack trace regex which should be lightly revised.

`([^(\[\n]+)[^\n]*/g` is more robust for stack traces: it captures the
function/class name (with dots) and does not break on spaces in file
paths.
`([\S]+)[^\n]*/g` is simpler but breaks if there are spaces and doesn't
handle dotted names well.

Additionally, we trim the whitespace off the name to resolve extra
spaces breaking this test as well:

```
-     in div (at **)
+     in div  (at **)
```

<img width="987" alt="fail-4"
src="https://github.com/user-attachments/assets/56a673bc-513f-4458-95b2-224129c77144"
/>

All of the above tests pass if I hyphenate my local folder:
`/Users/wes/Development/Open-Source-Contributions/react`

I selfishly want to keep spaces in my folder names. 🫣

## How did you test this change?

**npx yarn prebuild**

Before:
<img width="896" alt="Screenshot at Jun 01 11-42-56"
src="https://github.com/user-attachments/assets/4692775c-1e5c-4851-9bd7-e12ed5455e47"
/>

After:
<img width="420" alt="Screenshot at Jun 01 11-43-42"
src="https://github.com/user-attachments/assets/4e303c00-02b7-4540-ba19-927b2d7034fb"
/>

**npx yarn test**
**npx yarn test
./packages/react/src/\_\_tests\_\_/ReactChildren-test.js**
**npx yarn test -r=xplat --env=development --variant=true --ci
--shard=3/5**

Before:
<img width="438" alt="before"
src="https://github.com/user-attachments/assets/f5eedb22-18c3-4124-a04b-daa95c0f7652"
/>

After:
<img width="439" alt="after"
src="https://github.com/user-attachments/assets/a94218ba-7c6a-4f08-85d3-57540e9d0029"
/>

<img width="650" alt="Screenshot at Jun 02 18-03-39"
src="https://github.com/user-attachments/assets/3eae993c-a56b-46c8-ae02-d249cb053fe7"
/>

<img width="685" alt="Screenshot at Jun 03 12-53-47"
src="https://github.com/user-attachments/assets/5b2caa33-d3dc-4804-981d-52cb10b6226f"
/>
2025-06-09 08:40:27 -07:00
Sebastian Markbåge
428ab82001 [Flight] Simulate fetch to third party in fixture (#33484)
This adds some I/O to go get the third party thing to test how it
overlaps.

With #33482, this is what it looks like. The await gets cut off when the
third party component starts rendering. I.e. after the latency to start.

<img width="735" alt="Screenshot 2025-06-08 at 5 42 46 PM"
src="https://github.com/user-attachments/assets/f68d9a84-05a1-4125-b3f0-8f3e4eaaa5c1"
/>

This doesn't fully simulate everything because it should actually also
simulate each chunk of the stream coming back too. We could wrap the
ReadableStream to simulate that. In that scenario, it would probably get
some awaits on the chunks at the end too.
2025-06-09 10:04:40 -04:00
Jordan Brown
4df098c4c2 [compiler] Don't include useEffectEvent values in autodeps (#33450)
Summary: useEffectEvent values are not meant to be added to the dep
array
2025-06-09 09:26:45 -04:00
Hendrik Liebau
95bcf87e6b Format ReactNativeAttributePayloadFabric.js with Prettier (#33486)
The prettier check for this file is currently failing on `main`, after
#32119 was merged.
2025-06-09 12:42:10 +01:00
Hanno J. Gödecke
911dbd9e34 feat(ReactNative): prioritize attribute config process function to allow processing function props (#32119)
## Summary

In react-native props that are passed as function get converted to a
boolean (`true`). This is the default pattern for event handlers in
react-native.
However, there are reasons for why you might want to opt-out of this
behavior, and instead, pass along the actual function as the prop.
Right now, there is no way to do this, and props that are functions
always get set to `true`.
The `ViewConfig` attributes already have the API for a `process`
function. I simply moved the check for the process function up, so if a
ViewConfig's prop attribute configured a process function this is always
called first.
This provides an API to opt out of the default behavior. 

This is the accompanied PR for react-native:

- https://github.com/facebook/react-native/pull/48777

## 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 modified the code manually in a template react-native app and
confirmed its working. This is a code path you only need in very special
cases, thus it's a bit hard to provide a test for this. I recorded a
video where you can see that the changes are active and the prop is
being passed as native value.

For this I created a custom native component with a view config that
looked like this:

```js
const viewConfig = {
  uiViewClassName: 'CustomView',
  bubblingEventTypes: {},
  directEventTypes: {},
  validAttributes: {
    nativeProp: {
      process: (nativeProp) => {
		// Identity function that simply returns the prop function callback
        // to opt out of this prop being set to `true` as its a function
        return nativeProp
      },
    },
  },
}
```



https://github.com/user-attachments/assets/493534b2-a508-4142-a760-0b1b24419e19

Additionally I made sure that this doesn't conflict with any existing
view configs in react native. In general, this shouldn't be a breaking
change, as for existing view configs it didn't made a difference if you
simply set `myProp: true` or `myProp: { process: () => {...} }` because
as soon as it was detected that the prop is a function the config
wouldn't be used (which is what this PR fixes).
Probably everyone, including the react-native core components use
`myProp: true` for callback props, so this change should be fine.
2025-06-09 10:55:28 +01:00
Hendrik Liebau
c0b5a0cad3 [Flight] Use Web Streams APIs for 3rd-party component in Flight fixture (#33481) 2025-06-08 06:33:25 +02:00
Hendrik Liebau
e4b88ae4c6 [Flight] Add Web Streams APIs to unbundled Node entries for Webpack (#33480) 2025-06-07 23:39:25 +02:00
Sebastian Markbåge
6c8bcdaf1b [Flight] Clarify Semantics for Awaiting Cached Data (#33438)
Technically the async call graph spans basically all the way back to the
start of the app potentially, but we don't want to include everything.
Similarly we don't want to include everything from previous components
in every child component. So we need some heuristics for filtering out
data.

We roughly want to be able to inspect is what might contribute to a
Suspense loading sequence even if it didn't this time e.g. due to a race
condition.

One flaw with the previous approach was that awaiting a cached promise
in a sibling that happened to finish after another sibling would be
excluded. However, in a different race condition that might end up being
used so I wanted to include an empty "await" in that scenario to have
some association from that component.

However, for data that resolved fully before the request even started,
it's a little different. This can be things that are part of the start
up sequence of the app or externally cached data. We decided that this
should be excluded because it doesn't contribute to the loading sequence
in the expected scenario. I.e. if it's cached. Things that end up being
cache misses would still be included. If you want to test externally
cached data misses, then it's up to you or the framework to simulate
those. E.g. by dropping the cache. This also helps free up some noise
since static / cached data can be excluded in visualizations.

I also apply this principle to forwarding debug info. If you reuse a
cached RSC payload, then the Server Component render time and its awaits
gets clamped to the caller as if it has zero render/await time. The I/O
entry is still back dated but if it was fully resolved before we started
then it's completely excluded.
2025-06-07 17:26:36 -04:00
Sebastian Markbåge
b367b60927 [Flight] Add "use ..." boundary after the change instead of before it (#33478)
I noticed that the ThirdPartyComponent in the fixture was showing the
wrong stack and the `"use third-party"` is in the wrong location.

<img width="628" alt="Screenshot 2025-06-06 at 11 22 11 PM"
src="https://github.com/user-attachments/assets/f0013380-d79e-4765-b371-87fd61b3056b"
/>

When creating the initial JSX inside the third party server, we should
make sure that it has no owner. In a real cross-server environment you
get this by default by just executing in different context. But since
the fixture example is inside the same AsyncLocalStorage as the parent
it already has an owner which gets transferred. So we should make sure
that were we create the JSX has no owner to simulate this.

When we then parse a null owner on the receiving side, we replace its
owner/stack with the owner/stack of the call to `createFrom...` to
connect them. This worked fine with only two environments. The bug was
that when we did this and then transferred the result to a third
environment we took the original parsed stack trace. We should instead
parse a new one from the replaced stack in the current environment.

The second bug was that the `"use third-party"` badge ends up in the
wrong place when we do this kind of thing. Because the stack of the
thing entering the new environment is the call to `createFrom...` which
is in the old environment even though the component itself executes in
the new environment. So to see if there's a change we should be
comparing the current environment of the task to the owner's environment
instead of the next environment after the task.

After:

<img width="494" alt="Screenshot 2025-06-07 at 1 13 28 AM"
src="https://github.com/user-attachments/assets/e2e870ba-f125-4526-a853-bd29f164cf09"
/>
2025-06-07 11:28:57 -04:00
Sebastian Markbåge
9666605abf [Flight] Add Web Stream support to the Flight Server in Node (#33474)
This needs some tweaks to the implementation and a conversion but simple
enough.

---------

Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
2025-06-07 10:40:09 -04:00
Sebastian Markbåge
65ec57df37 [Fizz] Add Web Streams to Fizz Node entry point (#33475)
New take on #33441.

This uses a wrapper instead of a separate bundle.
2025-06-06 20:16:43 -04:00
Sebastian "Sebbie" Silbermann
b3d5e90786 [Fizz] Include unit of threshold in rel=expect deopt error (#33476) 2025-06-07 02:11:33 +02:00
Sebastian Markbåge
280ff6fed2 [Flight] Add Web Stream support to the Flight Client in Node (#33473)
This effectively lets us consume Web Streams in a Node build. In fact
the Node entry point is now just adding Node stream APIs.

For the client, this is simple because the configs are not actually
stream type specific. The server is a little trickier.
2025-06-06 17:14:15 -04:00
Sebastian Markbåge
82f3684c63 Revert Node Web Streams (#33472)
Reverts #33457, #33456 and #33442.

There are too many issues with wrappers, lazy init, stateful modules,
duplicate instantiation of async_hooks and duplication of code.

Instead, we'll just do a wrapper polyfill that uses Node Streams
internally.

I kept the client indirection files that I added for consistency with
the server though.
2025-06-06 16:26:36 -04:00
Josh Story
142aa0744d [Fizz] Support deeply nested Suspense inside fallback (#33467)
When deeply nested Suspense boundaries inside a fallback of another
boundary resolve it is possible to encounter situations where you either
attempt to flush an aborted Segment or you have a boundary without any
root segment. We intended for both of these conditions to be impossible
to arrive at legitimately however it turns out in this situation you
can. The fix is two-fold

1. allow flushing aborted segments by simply skipping them. This does
remove some protection against future misconfiguraiton of React because
it is no longer an invariant that you hsould never attempt to flush an
aborted segment but there are legitimate cases where this can come up
and simply omitting the segment is fine b/c we know that the user will
never observe this. A semantically better solution would be to avoid
flushing boudaries inside an unneeded fallback but to do this we would
need to track all boundaries inside a fallback or create back pointers
which add to memory overhead and possibly make GC harder to do
efficiently. By flushing extra we're maintaining status quo and only
suffer in performance not with broken semantics.

2. when queuing completed segments allow for queueing aborted segments
and if we are eliding the enqueued segment allow for child segments that
are errored to be enqueued too. This will mean that we can maintain the
invariant that a boundary must have a root segment the first time we
flush it, it just might be aborted (see point 1 above).

This change has two seemingly similar test cases to exercise this fix.
The reason we need both is that when you have empty segments you hit
different code paths within Fizz and so each one (without this fix)
triggers a different error pathway.

This change also includes a fix to our tests where we were not
appropriately setting CSPnonce back to null at the start of each test so
in some contexts scripts would not run for some tests
2025-06-06 11:59:15 -07:00
Sebastian Markbåge
6ccf328499 [Fizz] Shorten throttle to hit a specific target metric (#33463)
Adding throttling or delaying on images, can obviously impact metrics.
However, it's all in the name of better actual user experience overall.
(Note that it's not strictly worse even for metric. Often it's actually
strictly better due to less work being done overall thanks to batching.)

Metrics can impact things like search ranking but I believe this is on a
curve. If you're already pretty good, then a slight delay won't suddenly
make you rank in a completely different category. Similarly, if you're
already pretty bad then a slight delay won't make it suddenly way worse.
It's still in the same realm. It's just one weight of many. I don't
think this will make a meaningful practical impact and if it does,
that's probably a bug in the weights that will get fixed.

However, because there's a race to try to "make everything green" in
terms of web vitals, if you go from green to yellow only because of some
throttling or suspensey images, it can feel bad. Therefore this
implements a heuristic where if the only reason we'd miss a specific
target is because of throttling or suspensey images, then we shorten the
timeout to hit the metric. This is a worse user experience because it
can lead to extra flashing but feeling good about "green" matters too.

If you then have another reveal that happens to be the largest
contentful paint after that, then that's throttled again so that it
doesn't become flashy after that. If you've already missed the deadline
then you're not going to hit your metric target anyway. It can affect
average but not median.

This is mainly about LCP. It doesn't affect FCP since that doesn't have
a throttle. If your LCP is the same as your FCP then it also doesn't
matter.

We assume that `performance.now()`'s zero point starts at the "start of
the navigation" which makes this simple. Even if we used the
`PerformanceNavigationTiming` API it would just tell us the same thing.

This only implements for Fizz since these metrics tend to currently only
by tracked for initial loads, but with soft navs tracking we could
consider implementing the same for Fiber throttles.
2025-06-06 14:01:15 -04:00
lauren
a374e0ec87 [ci] Fix missing permissions for stale job (#33466)
Missed these the last time.
2025-06-06 13:32:51 -04:00
Sebastian Markbåge
ab859e31be [Flight] Build Node.js Web Streams builds for Turbopack and Parcel (#33457)
Same as #33456 and #33442 but for Turbopack and Parcel.
2025-06-06 11:07:40 -04:00
Sebastian Markbåge
e8d15fa19e [Flight] Build node-webstreams version of bundled webpack server (#33456)
Follow up to #33442. This is the bundled version.

To keep type check passes from exploding and the maintainance of the
annoying `paths: []` list small, this doesn't add this to flow type
checks. We might miss some config but every combination should already
be covered by other one passes.

I also don't add any jest tests because to test these double export
entry points we need conditional importing to cover builds and
non-builds which turns out to be difficult for the Flight builds so
these aren't covered by any basic build tests.

This approach is what I'm going for, for the other bundlers too.
2025-06-06 11:07:15 -04:00
Sebastian Markbåge
d177272802 [Fizz] Error and deopt from rel=expect for large documents without boundaries (#33454)
We want to make sure that we can block the reveal of a well designed
complete shell reliably. In the Suspense model, client transitions don't
have any way to implicitly resolve. This means you need to use Suspense
or SuspenseList to explicitly split the document. Relying on implicit
would mean you can't add a Suspense boundary later where needed. So we
highly encourage the use of them around large content.

However, if you have constructed a too large shell (e.g. by not adding
any Suspense boundaries at all) then that might take too long to render
on the client. We shouldn't punish users (or overzealous metrics
tracking tools like search engines) in that scenario.

This opts out of render blocking if the shell ends up too large to be
intentional and too slow to load. Instead it deopts to showing the
content split up in arbitrary ways (browser default). It only does this
for SSR, and not client navs so it's not reliable.

In fact, we issue an error to `onError`. This error is recoverable in
that the document is still produced. It's up to your framework to decide
if this errors the build or just surface it for action later.

What should be the limit though? There's a trade off here. If this limit
is too low then you can't fit a reasonably well built UI within it
without getting errors. If it's too high then things that accidentally
fall below it might take too long to load.

I came up with 512kB of uncompressed shell HTML. See the comment in code
for the rationale for this number. TL;DR: Data and theory indicates that
having this much content inside `rel="expect"` doesn't meaningfully
change metrics. Research of above-the-fold content on various websites
indicate that this can comfortable fit all of them which should be
enough for any intentional initial paint.
2025-06-06 10:29:48 -04:00
Sebastian Markbåge
22b929156c [Fizz] Suspensey Images for View Transition Reveals (#33433)
Block the view transition on suspensey images Up to 500ms just like the
client.

We can't use `decode()` because a bug in Chrome where those are blocked
on `startViewTransition` finishing we instead rely on sync decoding but
also that the image is live when it's animating in and we assume it
doesn't start visible.

However, we can block the View Transition from starting on the `"load"`
or `"error"` events.

The nice thing about blocking inside `startViewTransition` is that we
have already done the layout so we can only wait on images that are
within the viewport at this point. We might want to do that in Fiber
too. If many image doesn't have fixed size but need to load first, they
can all end up in the viewport. We might consider only doing this for
images that have a fixed size or only a max number that doesn't have a
fixed size.
2025-06-06 10:14:13 -04:00
Ricky
a3be6829c6 [tests] remove pretest compiler script (#33452)
This shouldn't be needed now that the lint rule was move
2025-06-06 09:16:58 -04:00
Hendrik Liebau
b1759882c0 [Flight] Bypass caches in Flight fixture if requested (#33445) 2025-06-06 06:42:58 +02:00
Timothy Yung
dddcae7a11 Enable the enableEagerAlternateStateNodeCleanup Feature Flag (#33447)
## Summary

Enables the `enableEagerAlternateStateNodeCleanup` feature flag for all
variants, while maintaining the `__VARIANT__` for the internal React
Native flavor for backtesting reasons.

## How did you test this change?

```
$ yarn test
```
2025-06-05 14:22:35 -07:00
Hendrik Liebau
43714eb4e9 Do not notify Discord for draft pull requests (#33446)
When I added the `ready_for_review` event in #32344, no notifications
for opened draft PRs were sent due to some other condition. This is not
the case anymore, so we need to exclude draft PRs from triggering a
notification when the workflow is run because of an `opened` event. This
event is still needed because the `ready_for_review` event only fires
when an existing draft PR is converted to a non-draft state. It does not
trigger for pull requests that are opened directly as ready-for-review.
2025-06-05 15:08:57 -04:00
Sebastian Markbåge
a5110b22f0 [Flight] Add a Node.js Web Streams bundle for unbundled client/server for Webpack (#33442)
Like #33441 but for Flight.

This is just one of the many combinations needed. I'm just starting with
one.
2025-06-05 14:29:02 -04:00
Hendrik Liebau
b4477d3800 [Flight] Add a cached 3rd-party component to the Flight fixture (#33443)
This should allow us to visualize what
https://github.com/facebook/react/pull/33438 is trying to convey.

An uncached 3rd-party component is displayed like this in the dev tools:

<img width="1072" alt="Screenshot 2025-06-05 at 12 57 32"
src="https://github.com/user-attachments/assets/d418ae23-d113-4dc9-98b8-ab426710454a"
/>

However, when the component is restored from a cache, it looks like
this:

<img width="1072" alt="Screenshot 2025-06-05 at 12 56 56"
src="https://github.com/user-attachments/assets/a0e34379-d8c0-4b14-8b54-b5c06211232b"
/>

The `Server Components ⚛` track is missing completely here, and the
`Loading profile...` phase also took way longer than without caching the
3rd-party component.

On `main`, the `Server Components ⚛` track is not missing:

<img width="1072" alt="Screenshot 2025-06-05 at 14 31 20"
src="https://github.com/user-attachments/assets/c35e405d-27ca-4b04-a34c-03bd959a7687"
/>

The cached 3rd-party component starts before the current render, and is
also not excluded here, which is of course expected without #33438.
2025-06-05 17:19:54 +02:00
Sebastian Markbåge
93f1668045 [Fizz] Add Node Web Streams bundle for SSR (#33441)
We highly recommend using Node Streams in Node.js because it's much
faster and it is less likely to cause issues when chained in things like
compression algorithms that need explicit flushing which the Web Streams
ecosystem doesn't have a good solution for. However, that said, people
want to be able to use the worse option for various reasons.

The `.edge` builds aren't technically intended for Node.js. A Node.js
environments needs to be patched in various ways to support it. It's
also less optimal since it can't use [Node.js exclusive
features](https://github.com/facebook/react/pull/33388) and have to use
[the lowest common
denominator](https://github.com/facebook/react/pull/27399) such as JS
implementations instead of native.

This adds a Web Streams build of Fizz but exclusively for Node.js so
that in it we can rely on Node.js modules. The main difference compared
to Edge is that SSR now uses `createHash` from the `"crypto"` module and
imports `TextEncoder` from `"util"`. We use `setImmediate` instead of
`setTimeout`.

The public API is just `react-dom/server` which in Node.js automatically
imports `react-dom/server.node` which re-exports the legacy bundle, Node
Streams bundle and Node Web Streams bundle. The main downside is if your
bundler isn't smart to DCE this barrel file.

With Flight the difference is larger but that's a bigger lift.
2025-06-05 10:50:41 -04:00
Sebastian Markbåge
37054867c1 [Flight] Forward debugInfo from awaited instrumented Promises (#33415)
Stacked on #33403.

When a Promise is coming from React such as when it's passed from
another environment, we should forward the debug information from that
environment. We already do that when rendered as a child.

This makes it possible to also `await promise` and have the information
from that instrumented promise carry through to the next render.

This is a bit tricky because the current protocol is that we have to
read it from the Promise after it resolves so it has time to be assigned
to the promise. `async_hooks` doesn't pass us the instance (even though
it has it) when it gets resolved so we need to keep it around. However,
we have to be very careful because if we get this wrong it'll cause a
memory leak since we retain things by `asyncId` and then manually listen
for `destroy()` which can only be called once a Promise is GC:ed, which
it can't be if we retain it. We have to therefore use a `WeakRef` in
case it never resolves, and then read the `_debugInfo` when it resolves.
We could maybe install a setter or something instead but that's also
heavy.

The other issues is that we don't use native Promises in
ReactFlightClient so our instrumented promises aren't picked up by the
`async_hooks` implementation and so we never get a handle to our
thenable instance. To solve this we can create a native wrapper only in
DEV.
2025-06-04 00:49:03 -04:00
Sebastian Markbåge
d742611ce4 Replace Implicit Options on SuspenseList with Explicit Options (#33424)
We want to change the defaults for `revealOrder` and `tail` on
SuspenseList. This is an intermediate step to allow experimental users
to upgrade.

To explicitly specify these options I added `revealOrder="independent"`
and `tail="visible"`.

I then added warnings if `undefined` or `null` is passed. You must now
always explicitly specify them. However, semantics are still preserved
for now until the next step.

We also want to change the rendering order of the `children` prop for
`revealOrder="backwards"`. As an intermediate step I first added
`revealOrder="unstable_legacy-backwards"` option. This will only be
temporary until all users can switch to the new `"backwards"` semantics
once we flip it in the next step.

I also clarified the types that the directional props requires iterable
children but not iterable inside of those. Rows with multiple items can
be modeled as explicit fragments.
2025-06-03 17:40:30 -04:00
Sebastian Markbåge
1540081725 [Flight] Encode Async I/O Tasks using the Enclosing Line/Column (#33403)
Stacked on #33402.

There's a bug in Chrome Performance tracking which uses the enclosing
line/column instead of the callsite in stacks.

For our fake eval:ed functions that represents functions on the server,
we can position the enclosing function body at the position of the
callsite to simulate getting the right line.

Unfortunately, that doesn't give us exactly the right callsite when it's
used for other purposes that uses the callsite like console logs and
error reporting and stacks inside breakpoints. So I don't think we want
to always do this.

For ReactAsyncInfo/ReactIOInfo, the only thing we're going to use the
fake task for is the Performance tracking, so it doesn't have any
downsides until Chrome fixes the bug and we'd have to revert it.
Therefore this PR uses that techniques only for those entries.

We could do this for Server Components too but we're going to use those
for other things too like console logs. I don't think it's worth
duplicating the Task objects. That would also make it inconsistent with
Client Components.

For Client Components, we could in theory also generate fake evals but
that would be way slower since there's so many of them and currently we
rely on the native implementation for those. So doesn't seem worth
fixing.

But since we can at least fix it for RSC I/O/awaits we can do this hack.
2025-06-03 17:30:31 -04:00
Sebastian Markbåge
9cc74fec74 [Flight] Emit the time we awaited something inside a Server Component (#33402)
Stacked on #33400. 

<img width="1261" alt="Screenshot 2025-06-01 at 10 27 47 PM"
src="https://github.com/user-attachments/assets/a5a73ee2-49e0-4851-84ac-e0df6032efb5"
/>

This is emitted with the start/end time and stack of the "await". Which
may be different than the thing that started the I/O.

These awaits aren't quite as simple as just every await since you can
start a sequence in parallel there can actually be multiple overlapping
awaits and there can be CPU work interleaved with the await on the same
component.

```js
function getData() {
  await fetch(...);
  await fetch(...);
}
const promise = getData();
doWork();
await promise;
```

This has two "I/O" awaits but those are actually happening in parallel
with `doWork()`.

Since these also could have started before we started rendering this
sequence (e.g. a component) we have to clamp it so that we don't
consider awaits that start before the component.

What we're conceptually trying to convey is the time this component was
blocked due to that I/O resource. Whether it's blocked from completing
the last result or if it's blocked from issuing a waterfall request.
2025-06-03 17:29:41 -04:00
Sebastian Markbåge
157ac578de [Flight] Include env in ReactAsyncInfo and ReactIOInfo (#33400)
Stacked on #33395.

This lets us keep track of which environment this was fetched and
awaited.

Currently the IO and await is in the same environment. It's just kept
when forwarded. Once we support forwarding information from a Promise
fetched from another environment and awaited in this environment then
the await can end up being in a different environment.

There's a question of when the await is inside Flight itself such as
when you return a promise fetched from another environment whether that
should mean that the await is in the current environment. I don't think
so since the original stack trace is the best stack trace. It's only if
you `await` it in user space in this environment first that this might
happen and even then it should only be considered if there wasn't a
better await earlier or if reading from the other environment was itself
I/O.

The timing of *when* we read `environmentName()` is a little interesting
here too.
2025-06-03 17:28:46 -04:00
Sebastian Markbåge
45da4e055d [Flight] Track Owner on AsyncInfo and IOInfo (#33395)
Stacked on #33394.

This lets us create async stack traces to the owner that was in context
when the I/O was started or awaited.

<img width="615" alt="Screenshot 2025-06-01 at 12 31 52 AM"
src="https://github.com/user-attachments/assets/6ff5a146-33d6-4a4b-84af-1b57e73047d4"
/>

This owner might not be the immediate closest parent where the I/O was
awaited.
2025-06-03 16:12:26 -04:00
Sebastian Markbåge
d8919a0a68 [Flight] Log "Server Requests" Track (#33394)
Stacked on #33392.

This adds another track to the Performance Track called `"Server
Requests"`.

<img width="1015" alt="Screenshot 2025-06-01 at 12 02 14 AM"
src="https://github.com/user-attachments/assets/c4d164c4-cfdf-4e14-9a87-3f011f65fd20"
/>

This logs the flat list of I/O awaited on by Server Components. There
will be other views that are more focused on what data blocks a specific
Component or Suspense boundary but this is just the list of all the I/O
basically so you can get an overview of those waterfalls without the
noise of all the Component trees and rendering. It's similar to what the
"Network" track is on the client.

I've been going back and forth on what to call this track but I went
with `"Server Requests"` for now. The idea is that the name should
communicate that this is something that happens on the server and is a
pairing with the `"Server Components"` track. Although we don't use that
feature, since it's missing granularity, it's also similar to "Server
Timings".
2025-06-03 15:31:12 -04:00
Sebastian "Sebbie" Silbermann
2e9f8cd3e0 Clear bundler cache before bundling fixtures (#33426) 2025-06-03 21:10:13 +02:00
Sebastian Markbåge
65a46c7eeb [Flight] Track the function name that was called for I/O entries (#33392)
Stacked on #33390.

The stack trace doesn't include the thing you called when calling into
ignore listed content. We consider the ignore listed content
conceptually the abstraction that you called that's interesting.

This extracts the name of the first ignore listed function that was
called from user space. For example `"fetch"`. So we can know what kind
of request this is.

This could be enhanced and tweaked with heuristics in the future. For
example, when you create a Promise yourself and call I/O inside of it
like my `delay` examples, then we use that Promise as the I/O node but
its stack doesn't have the actual I/O performed. It might be better to
use the inner I/O node in that case. E.g. `setTimeout`. Currently I pick
the name from the first party code instead - in my example `delay`.

Another case that could be improved is the case where your whole
component is third-party. In that case we still log the I/O but it has
no context about what kind of I/O since the whole stack is ignored it
just gets the component name for example. We could for example look at
the first name that is in a different package than the package name of
the ignored listed component. So if
`node_modules/my-component-library/index.js` calls into
`node_modules/mysql/connection.js` then we could use the name from the
inner.
2025-06-03 15:04:28 -04:00
Sebastian Markbåge
3fb17d16a4 [Flight] Encode ReactIOInfo as its own row type (#33390)
Stacked on #33388.

This encodes the I/O entries as their own row type (`"J"`). This makes
it possible to parse them directly without first parsing the debug info
for each component. E.g. if you're just interested in logging the I/O
without all the places it was awaited.

This is not strictly necessary since the debug info is also readily
available without parsing the actual trees. (That's how the Server
Components Performance Track works.) However, we might want to exclude
this information in profiling builds while retaining some limited form
of I/O tracking.

It also allows for logging side-effects that are not awaited if we
wanted to.
2025-06-03 14:16:34 -04:00
Sebastian Markbåge
acee65d6d0 [Flight] Track Awaits on I/O as Debug Info (#33388)
This lets us track what data each Server Component depended on. This
will be used by Performance Track and React DevTools.

We use Node.js `async_hooks`. This has a number of downside. It is
Node.js specific so this feature is not available in other runtimes
until something equivalent becomes available. It's [discouraged by
Node.js docs](https://nodejs.org/api/async_hooks.html#async-hooks). It's
also slow which makes this approach only really viable in development
mode. At least with stack traces. However, it's really the only solution
that gives us the data that we need.

The [Diagnostic
Channel](https://nodejs.org/api/diagnostics_channel.html) API is not
sufficient. Not only is many Node.js built-in APIs missing but all
libraries like databases are also missing. Were as `async_hooks` covers
pretty much anything async in the Node.js ecosystem.

However, even if coverage was wider it's not actually showing the
information we want. It's not enough to show the low level I/O that is
happening because that doesn't provide the context. We need the stack
trace in user space code where it was initiated and where it was
awaited. It's also not each low level socket operation that we want to
surface but some higher level concept which can span a sequence of I/O
operations but as far as user space is concerned.

Therefore this solution is anchored on stack traces and ignore listing
to determine what the interesting span is. It is somewhat
Promise-centric (and in particular async/await) because it allows us to
model an abstract span instead of just random I/O. Async/await points
are also especially useful because this allows Async Stacks to show the
full sequence which is not supported by random callbacks. However, if no
Promises are involved we still to our best to show the stack causing
plain I/O callbacks.

Additionally, we don't want to track all possible I/O. For example,
side-effects like logging that doesn't affect the rendering performance
doesn't need to be included. We only want to include things that
actually block the rendering output. We also need to track which data
blocks each component so that we can track which data caused a
particular subtree to suspend.

We can do this using `async_hooks` because we can track the graph of
what resolved what and then spawned what.

To track what suspended what, something has to resolve. Therefore it
needs to run to completion before we can show what it was suspended on.
So something that never resolves, won't be tracked for example.

We use the `async_hooks` in `ReactFlightServerConfigDebugNode` to build
up an `ReactFlightAsyncSequence` graph that collects the stack traces
for basically all I/O and Promises allocated in the whole app. This is
pretty heavy, especially the stack traces, but it's because we don't
know which ones we'll need until they resolve. We don't materialize the
stacks until we need them though.

Once they end up pinging the Flight runtime, we collect which current
executing task that pinged the runtime and then log the sequence that
led up until that runtime into the RSC protocol. Currently we only
include things that weren't already resolved before we started rendering
this task/component, so that we don't log the entire history each time.

Each operation is split into two parts. First a `ReactIOInfo` which
represents an I/O operation and its start/end time. Basically the start
point where it was start. This is basically represents where you called
`new Promise()` or when entering an `async function` which has an
implied Promise. It can be started in a different component than where
it's awaited and it can be awaited in multiple places. Therefore this is
global information and not associated with a specific Component.

The second part is `ReactAsyncInfo`. This represents where this I/O was
`await`:ed or `.then()` called. This is associated with a point in the
tree (usually the Promise that's a direct child of a Component). Since
you can have multiple different I/O awaited in a sequence technically it
forms a dependency graph but to simplify the model these awaits as
flattened into the `ReactDebugInfo` list. Basically it contains each
await in a sequence that affected this part from unblocking.

This means that the same `ReactAsyncInfo` can appear in mutliple
components if they all await the same `ReactIOInfo` but the same Promise
only appears once.

Promises that are only resolved by other Promises or immediately are not
considered here. Only if they're resolved by an I/O operation. We pick
the Promise basically on the border between user space code and ignored
listed code (`node_modules`) to pick the most specific span but abstract
enough to not give too much detail irrelevant to the current audience.
Similarly, the deepest `await` in user space is marked as the relevant
`await` point.

This feature is only available in the `node` builds of React. Not if you
use the `edge` builds inside of Node.js.

---------

Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>
2025-06-03 14:14:40 -04:00
Sebastian Markbåge
1ae0a845bd Use underscore instead of « » for useId algorithm (#33422)
Alternative to #33421. The difference is that this also adds an
underscore between the "R" and the ID.

The reason we wanted to use special characters is because we use the
full spectrum of A-Z 0-9 in our ID generation so we can basically
collide with any common word (or anyone using a similar algorithm,
base64 or even base16). It's a little less likely that someone would put
`_R_` specifically unless you generate like two IDs separated by
underscore.


![9w2ogt](https://github.com/user-attachments/assets/21b2d2ac-1a3a-4657-ba0b-1616e49dfdee)
2025-06-03 11:30:17 -04:00
Jorge Cabiedes
2b4064eb9b [mcp] Add MCP tool to print out the component tree of the currently open React App (#33305)
## Summary

This tool leverages DevTools to get the component tree from the
currently open React App. This gives realtime information to agents
about the state of the app.

## How did you test this change?

Tested integration with Claude Desktop
2025-06-02 21:42:34 -07:00
Ricky
3531b26729 [scripts] Switch back to flow parser for prettier (#33414)
Prettier 3.3 (which we're on) should support modern flow features
according to https://prettier.io/blog/2024/06/01/3.3.0
2025-06-03 00:00:28 -04:00
Sebastian "Sebbie" Silbermann
4a1f29079c [Fizz] Add Owner Stacks when render is aborted (#32735) 2025-06-02 19:27:49 +02:00
mofeiZ
526dd340b3 [compiler][patch] Emit unary expressions instead of negative numbers (#33383)
This is a babel bug + edge case.

Babel compact mode produces invalid JavaScript (i.e. parse error) when
given a `NumericLiteral` with a negative value.

See https://codesandbox.io/p/devbox/5d47fr for repro.
2025-06-02 11:43:45 -04:00
Wesley LeMahieu
ee76351917 fix typo in compiler validation filename (#33345)
## Summary

While investigating the root cause of #33208, I noticed a clear typo for
one of the validation files.

## How did you test this change?

Inside `/react/compiler/packages/babel-plugin-react-compiler` I ran the
test script successfully:

<img width="415" alt="Screenshot at May 22 16-43-06"
src="https://github.com/user-attachments/assets/3fe8c5e1-37ce-4a31-b35e-7e323e57cd9d"
/>
2025-05-30 16:31:16 -07:00
Pieter De Baets
8b55eb4e72 Cleanup props diffing experiments (#33381)
## Summary

We completed testing on these internally, so can cleanup the separate
fast and slow paths and remove the `enableShallowPropDiffing` flag which
we're not pursuing.

## How did you test this change?

```
yarn test ReactNativeAttributePayloadFabric
```
2025-05-30 17:17:59 +01:00
Mateusz Burzyński
14094f80cb Allow nonce to be used on hoistable styles (#32461)
fixes https://github.com/facebook/react/issues/32449

This is my first time touching this code. There are multiple systems in
place here and I wouldn't be surprised to learn that this has to be
handled in some other areas too. I have found some other style-related
code areas but I had no time yet to double-check them.

cc @gnoff
2025-05-29 08:17:10 -07:00
Sebastian "Sebbie" Silbermann
5717f1933f [react-dom] Enforce small gap between completed navigation and default Transition indicator (#33354) 2025-05-28 19:46:12 +02:00
Ricky
b07717d857 [devtools] upgrade json5 (#33358) 2025-05-28 10:31:09 -04:00
Jan Kassens
283f87f083 Revert "enableViewTransition in www" (#33362)
We need to do some more testing here.

Reverts facebook/react#33357
2025-05-27 17:17:45 -04:00
mofeiZ
f9ae0a4c2e [compiler][gating] Custom opt out directives (experimental option) (#33328)
Adding an experimental / unstable compiler config to enable custom
opt-out directives
2025-05-27 12:02:29 -04:00
Jan Kassens
f702620cea [fb-www] ship enableViewTransition (#33357) 2025-05-27 11:23:27 -04:00
Sebastian Markbåge
c0464aedb1 [Fizz] Block on Suspensey Fonts during reveal (#33342)
This is the same technique we do for the client except we don't check
whether this is newly created font loading to keep code small.

Unfortunately, we can't use this technique for Suspensey images. They'll
need to block before we call `startViewTransition` in a separate
refactor. This is due to a bug in Chrome where `img.decode()` doesn't
resolve until `startViewTransition` does.
2025-05-23 13:26:02 -04:00
Sebastian Markbåge
6a1dfe3777 Disable moveBefore experiment (#33348)
There seems to be some bugs still to work out in Chrome. See #33187.

Additionally, since you can't really rely on this function existing
across browsers, it's hard to depend on its behavior anyway. In fact,
you now have a source of inconsistent behaviors across browsers to deal
with.

Ideally it would also be more widely spread in fake DOM implementations
like JSDOM so that we can use it unconditionally. #33177.

We still want to enable this since it's a great feature but maybe not
until it's more widely available cross-browsers with fewer bugs.
2025-05-23 13:25:13 -04:00
Jordan Brown
99efc627a5 [eslint] Add an option to require dependencies on effect hooks (#33344)
Summary:

To prepare for automatic effect dependencies, some codebases may want to
codemod
existing useEffect calls with no deps to include an explicit undefined
second argument
in order to preserve the "run on every render" behavior. In sufficiently
large codebases,
this may require a temporary enforcement period where all effects
provide an explicit
dependencies argument.

Outside of migration, relying on a component to render can lead to real
bugs,
especially when working with memoization.
2025-05-23 10:09:41 -04:00
0xFango
bfaeb4a461 Fix incorrect use of NoLanes in executionContext check (#33170)
## Summary

This PR fixes a likely incorrect condition in the
`scheduleUpdateOnFiber` function inside `ReactFiberWorkLoop.js`.

Previously, the code checked:

```js
(executionContext & RenderContext) !== NoLanes
````

However, `NoLanes` is part of the lane priority system, not the
execution context flags. The intent here seems to be to detect whether
the current execution context includes `RenderContext`, which should be
compared against `NoContext`, not `NoLanes`.

This fix replaces `NoLanes` with `NoContext` for semantic correctness
and consistency with other checks throughout the codebase.

**Fixes
[[#33169](https://github.com/facebook/react/issues/33169)](https://github.com/facebook/react/issues/33169)**

---

## How did you test this change?

I ran the following commands to validate correctness and ensure nothing
was broken:

* `yarn lint`
* `yarn linc`
* `yarn test`
* `yarn test --prod`
* `yarn flow`
* `yarn prettier`

All checks passed. Since this is a minor internal logic fix and doesn't
change public behavior or APIs, no additional tests are necessary at
this time.
2025-05-22 22:02:39 -04:00
Christoph Nakazawa
3e9db65fc3 Fix typo in error message. (#33313)
## Summary

I am writing code that isn't so good, so I saw this error message many
times. It appears to have a typo. This PR fixes the typo.

## How did you test this change?

Ran the tests
2025-05-22 16:18:23 -04:00
mofeiZ
0d072884f9 [compiler] Inferred effect dependencies now include optional chains (#33326)
Inferred effect dependencies now include optional chains.

This is a temporary solution while
https://github.com/facebook/react/pull/32099 and its followups are
worked on. Ideally, we should model reactive scope dependencies in the
IR similarly to `ComputeIR` -- dependencies should be hoisted and all
references rewritten to use the hoisted dependencies.

`
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33326).
* __->__ #33326
* #33325
* #32286
2025-05-22 16:14:49 -04:00
mofeiZ
abf9fd559d [compiler] Add reactive flag on scope dependencies (#33325)
When collecting scope dependencies, mark each dependency with `reactive:
true | false`. This prepares for later PRs
https://github.com/facebook/react/pull/33326 and
https://github.com/facebook/react/pull/32099 which rewrite scope
dependencies into instructions.

Note that some reactive objects may have non-reactive properties, but we
do not currently track this.

Technically, state[0] is reactive and state[1] is not. Currently, both
would be marked as reactive.
```js
const state = useState();
```
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33325).
* #33326
* __->__ #33325
* #32286
2025-05-22 16:14:05 -04:00
mofeiZ
13f20044f3 [compiler] Prepare HIRBuilder to be used by later passes (#32286)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32286).
* #33326
* #33325
* __->__ #32286
2025-05-22 16:13:50 -04:00
Sebastian Markbåge
8ce15b0f56 [Fizz] Apply View Transition Name and Class to SSR:ed View Transitions (#33332)
Stacked on #33330.

This walks the element tree to activate the various classes under
different scenarios. There are some edge case things that are a little
different since we can't express every scenario without virtual nodes.

The main thing that's still missing though is avoiding animating updates
if it can be contained to a layout or enter/exit/share if they're out of
the viewport. I.e. layout stuff.
2025-05-22 10:40:28 -04:00
Sebastian Markbåge
91ac1fea1a [Fizz] Pass batch as argument to revealCompletedBoundaries (#33330)
Follow up to #33293.

This solves a race condition when boundaries are added to the batch
after the `startViewTransition` call.

This doesn't matter yet but it will once we start assigning names before
the `startViewTransition` call.

A possible alternative solution might be to ensure the names are added
synchronously in the event that adds to the batch. It's possible to keep
adding to a batch until the snapshot has happened.
2025-05-22 10:25:13 -04:00
Sebastian Markbåge
08064ea671 [Fizz] Make ViewTransition enter/exit/share null the same as none (#33331)
I believe that these mean the same thing. We don't have to emit the
attribute if it's `none` for these cases because if there is no matching
scenario we won't apply the animation in this case.

The only case where we have to emit `none` in the attribute is for
`vt-update` because those can block updates from propagating upwards.
2025-05-22 10:21:28 -04:00
Sebastian Markbåge
99781d605b [Fizz] Track boundaries in future rows as postponed (#33329)
Follow up to #33321.

We can mark boundaries that were blocked in the prerender as postponed
but without anything to replayed inside them. That way they're not
emitted in the prerender but is unblocked when replayed.

Technically this does some unnecessary replaying of the path to the
otherwise already completed boundary but it simplifies our model by just
marking the boundary as needing replaying.
2025-05-22 10:20:13 -04:00
mofeiZ
459a2c4298 [compiler][gating] Experimental directive based gating (#33149)
Adds `dynamicGating` as an experimental option for testing rollout DX at
Meta. If specified, this enables dynamic gating which matches `use memo
if(...)` directives.

#### Example usage
Input file
```js
// @dynamicGating:{"source":"myModule"}
export function MyComponent() {
  'use memo if(isEnabled)';
   return <div>...</div>;
}
```
Compiler output
```js
import {isEnabled} from 'myModule';
export const MyComponent = isEnabled()
  ? <optimized version>
  : <original version>;
```
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33149).
* __->__ #33149
* #33148
2025-05-21 17:23:29 -04:00
Sebastian Markbåge
1c43d0aed7 Unify serverAct helpers (#33327)
This uses the richer `serverAct` helper that we already use in other
tests.

This avoids using the `Scheduler`. We don't use that package on the
server so it doesn't make sense to simulate going through it.
Additionally, we really should be getting rid of it on the client too to
favor `postTask` polyfills.
2025-05-21 16:13:54 -04:00
Jack Pope
1835b3f7d9 New children notify fragment instances in Fabric (#33093)
When a new child of a fragment instance is inserted, we need to notify
the instance to keep any relevant tracking up to date. For example, we
automatically observe the new child with any active
IntersectionObserver.

For mutable renderers (DOM), we reuse the existing traversal in
`commitPlacement` that does the insertions for HostComponents. Immutable
renderers (Fabric) exit this path before the traversal though, so
currently we can't notify the fragment instances.

Here I've created a separate traversal in `commitPlacement`,
specifically for immutable renders when `enableFragmentRefs` is on.
2025-05-21 15:47:47 -04:00
Sebastian Markbåge
f4041aa388 [Fizz] Unblock SuspenseList when prerendering (#33321)
There's an interesting case when a SuspenseList is partially prerendered
but some of the completed boundaries are blocked by rows to be resumed.

This handles it but just unblocking the future rows to avoid stalling.

However, the correct semantics will need special handling in the
postponed state.
2025-05-21 15:31:22 -04:00
Jack Pope
3710c4d4f9 Prevent errors from comment node roots with enableViewTransition (#33205)
We have many cases internally where the `containerInstance` resolves to
a comment node. `restoreRootViewTransitionName` is called when
`enableViewTransition` is on, even without introducing a
`<ViewTransition />`. So that means it can crash pages because
`containerInstance.style` is `undefined` just by turning on the flag.

This skips cancel/restore of root view transition name if a comment node is the root.
2025-05-21 13:57:35 -04:00
Sebastian Markbåge
2388481283 [Fizz] Set keyPath for SuspenseList (#33320)
I missed setting the `keyPath` because the `renderChildrenArray` that
this is forked from doesn't need to set a path but since this is
rendered from the `SuspenseList` element it needs it.
2025-05-20 21:08:47 -04:00
Sebastian Markbåge
9c7b10e22e [Fizz] Clean up row that was blocked by an aborted boundary (#33318)
Fixes a bug that we caused us to hang after an abort because we didn't
manage the ref count correctly.
2025-05-20 20:31:16 -04:00
Sebastian Markbåge
50389e1792 [Fizz] Hoist hoistables to each row and transfer the dependencies to future rows (#33312)
Stacked on #33311.

When a row contains Suspense boundaries that themselves depend on CSS,
they will not resolve until the CSS has loaded on the client. We need
future rows in a list to be blocked until this happens. We could do
something in the runtime but a simpler approach is to just add those CSS
dependencies to all those boundaries as well.

To do this, we first hoist the HoistableState from a completed boundary
onto its parent row. Then when the row finishes do we hoist it onto the
next row and onto any boundaries within that row.
2025-05-20 14:48:51 -04:00
Sebastian Markbåge
99aa685cef [Fizz] Support SuspenseList revealOrder="together" (#33311)
Stacked on #33308.

For "together" mode, we can be a self-blocking row that adds all its
boundaries to the blocked set, but there's no parent row that unblocks
it.

A particular quirk of this mode is that it's not enough to just unblock
them all on the server together. Because if one boundary downloads all
its html and then issues a complete instruction it'll appear before the
others while streaming in. What we actually want is to reveal them all
in a single batch.

This implementation takes a short cut by unblocking the rows in
`flushPartialBoundary`. That ensures that all the segments of every
boundary has a chance to flush before we start emitting any of the
complete boundary instructions. Once the last one unblocks, all the
complete boundary instructions are queued. Ideally this would be a
single `<script>` tag so that they can't be split up even if we get a
chunk containing some of them.

~A downside of this approach is that we always outline these boundaries.
We could inline them if they all complete before the parent flushes.
E.g. by checking if the row is blocked only by its own boundaries and if
all the boundaries would fit without getting outlined, then we can
inline them all at once.~ I went ahead and did this because it solves an
issue with `renderToString` where it doesn't support the script runtime
so it can only handle this if inlined.
2025-05-20 14:42:05 -04:00
Jan Kassens
d38c7e10d3 Remove leftover Rust script (#33314)
For now we removed Rust from the codebase, remove this leftover script.

Also remove some dupes and Rust related files from `.gitignore`.
2025-05-20 12:20:51 -04:00
Sebastian Markbåge
c4676e72a6 [Fizz] Handle nested SuspenseList (#33308)
Follow up to #33306.

If we're nested inside a SuspenseList and we have a row, then we can
point our last row to block the parent row and unblock the parent when
the last child unblocks.
2025-05-20 09:39:46 -04:00
Sebastian Markbåge
4c6967be29 [Fiber] Support AsyncIterable children in SuspenseList (#33299)
We support AsyncIterable (more so when it's a cached form like in coming
from Flight) as children.

This fixes some warnings and bugs when passed to SuspenseList.

Ideally SuspenseList with `tail="hidden"` should support unblocking
before the full result has resolved but that's an optimization on top.
We also might want to change semantics for this for
`revealOrder="backwards"` so it becomes possible to stream items in
reverse order.
2025-05-20 09:39:25 -04:00
Joseph Savona
c6c2a52ad8 [compiler] Fix error message for custom hooks (#33310)
We were printing "Custom" instead of "hook".
2025-05-19 15:29:58 -07:00
Sebastian Markbåge
5dc1b212c3 [Fizz] Support basic SuspenseList forwards/backwards revealOrder (#33306)
Basically we track a `SuspenseListRow` on the task. These keep track of
"pending tasks" that block the row. A row is blocked by:

- First itself completing rendering.
- A previous row completing.
- Any tasks inside the row and before the Suspense boundary inside the
row. This is mainly because we don't yet know if we'll discover more
SuspenseBoundaries.
- Previous row's SuspenseBoundaries completing.

If a boundary might get outlined, then we can't consider it completed
until we have written it because it determined whether other future
boundaries in the row can finish.

This is just handling basic semantics. Features not supported yet that
need follow ups later:

- CSS dependencies of previous rows should be added as dependencies of
future row's suspense boundary. Because otherwise if the client is
blocked on CSS then a previous row could be blocked but the server
doesn't know it.
- I need a second pass on nested SuspenseList semantics.
- `revealOrder="together"`
- `tail="hidden"`/`tail="collapsed"`. This needs some new runtime
semantics to the Fizz runtime and to allow the hydration to handle
missing rows in the HTML. This should also be future compatible with
AsyncIterable where we don't know how many rows upfront.
- Need to double check resuming semantics.

---------

Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>
2025-05-19 15:16:42 -04:00
Jan Kassens
a3abf5f2f8 [eslint-plugin-react-hooks] add experimental_autoDependenciesHooks option (#33294) 2025-05-19 15:08:30 -04:00
Sebastian Markbåge
462d08f9ba Move SuspenseListProps into a shared/ReactTypes (#33298)
So they can be shared by server. Incorporates the types from definitely
typed too.
2025-05-17 20:00:56 -04:00
Sebastian Markbåge
6060367ef8 [Fizz] Wrap revealCompletedBoundaries in a ViewTransitions aware version (#33293)
When needed.

For the external runtime we always include this wrapper.

For others, we only include it if we have an ViewTransitions affecting.
If we discover the ViewTransitions late, then we can upgrade an already
emitted instruction.

This doesn't yet do anything useful with it, that's coming in a follow
up. This is just the mechanism for how it gets installed.
2025-05-17 18:18:24 -04:00
Sebastian Markbåge
c250b7d980 [Fizz] Should be considered complete inside onShellReady callback (#33295)
We decremented `allPendingTasks` after invoking `onShellReady`. Which
means that in that scope it wasn't considered fully complete.

Since the pattern for flushing in Node.js is to start piping in
`onShellReady` and that's how you can get sync behavior, this led us to
think that we had more work left to do. For example we emitted the
`writeShellTimeInstruction` in this scenario before.
2025-05-16 14:53:40 -04:00
Jan Kassens
4448b18760 [eslint-plugin-react-hooks] fix exhaustive deps lint rule with component syntax (#33182) 2025-05-15 12:51:18 -04:00
Ricky
4a45ba92c4 [sync] Fix noop for xplat (#33214)
Noop detection for xplat syncs broke because `eslint-plugin-react-hooks`
uses versions like:

- `0.0.0-experimental-d85f86cf-20250514`

But xplat expects them to be of the form:

- `19.2.0-native-fb-63d664b2-20250514`

This PR fixes the noop by ignoring
`eslint-plugin-react-hooks/package.json` changes. This means we won't
create a sync if only that package.json changes, but that should be rare
and we can follow up with better detection if needed.

[Example failed
action](https://github.com/facebook/react/actions/runs/15032346805/job/42247414406):

<img width="1031" alt="Screenshot 2025-05-15 at 11 31 17 AM"
src="https://github.com/user-attachments/assets/d902079c-1afe-4e18-af1d-25e60e28929e"
/>

I believe the regression was caused by
https://github.com/facebook/react/pull/33104
2025-05-15 12:12:51 -04:00
lauren
08cb2d7ee7 [ci] Log author_association (#33213)
For debugging purposes, log author_association
2025-05-15 11:49:56 -04:00
lauren
203df2c940 [compiler] Update changelog for 19.1.0-rc.2 (#33207)
Update the changelog.
2025-05-15 10:34:11 -04:00
Sebastian Markbåge
65b5aae010 [Fizz] Add vt- prefix attributes to annotate <ViewTransition> in HTML (#33206)
Stacked on #33194 and #33200.

When Suspense boundaries reveal during streaming, the Fizz runtime will
be responsible for animating the reveal if necessary (not in this PR).
However, for the future runtime to know what to do it needs to know
about the `<ViewTransition>` configuration to apply.

Ofc, these are virtual nodes that disappear from the HTML. We could
model them as comments like we do with other virtual nodes like Suspense
and Activity. However, that doesn't let us target them with
querySelector and CSS (for no-JS transitions). We also don't have to
model every ViewTransition since not every combination can happen using
only the server runtime. So instead this collapses `<ViewTransition>`
and applies the configuration to the inner DOM nodes.

```js
<ViewTransition name="hi">
  <div />
  <div />
</ViewTransition>
```

Becomes:

```html
<div vt-name="hi" vt-update="auto"></div>
<div vt-name="hi_1" vt-update="auto"></div>
```

I use `vt-` prefix as opposed to `data-` to keep these virtual
attributes away from user specific ones but we're effectively claiming
this namespace.

There are four triggers `vt-update`, `vt-enter`, `vt-exit` and
`vt-share`. The server resolves which ones might apply to this DOM node.
The value represents the class name (after resolving
view-transition-type mappings) or `"auto"` if no specific class name is
needed but this is still a trigger.

The value can also be `"none"`. This is different from missing because
for example an `vt-update="none"` will block mutations inside it from
triggering the boundary where as a missing `vt-update` would bubble up
to be handled by a parent.

`vt-name` is technically only necessary when `vt-share` is specified to
find a pair. However, since an explicit name can also be used to target
specific CSS selectors, we include it even for other cases.

We want to exclude as many of these annotations as possible.

`vt-enter` can only affect the first DOM node inside a Suspense
boundary's content since the reveal would cause it to enter but nothing
deeper inside. Similarly `vt-exit` can only affect the first DOM node
inside a fallback. So for every other case we can exclude them. (For
future MPA ViewTransitions of the whole document it might also be
something we annotate to children inside the `<body>` as well.) Ideally
we'd only include `vt-enter` for Suspense boundaries that actually
flushed a fallback but since we prepare all that content earlier it's
hard to know.

`vt-share` can be anywhere inside an fallback or content. Technically we
don't have to include it outside the root most Suspense boundary or for
boundaries that are inlined into the root shell. However, this is tricky
to detect. It would also not be correct for future MPA ViewTransitions
because in that case the shared scenario can affect anything in the two
documents so it needs to be in every node everywhere which is
effectively what we do. If a `share` class is specified but it has no
explicit name, we can exclude it since it can't match anything.

`vt-update` is only necessary if something below or a sibling might
update like a Suspense boundary. However, since we don't know when
rendering a segment if it'll later asynchronously add a Suspense
boundary later we have to assume that anywhere might have a child. So
these are always included. We collapse to use the inner most one when
directly nested though since that's the one that ends up winning.

There are some weird edge cases that can't be fully modeled by the lack
of virtual nodes.
2025-05-15 01:04:10 -04:00
Sebastian Markbåge
3f67d0857e [Fizz] Track whether we're in a fallback on FormatContext (#33194)
Removes the `isFallback` flag on Tasks and tracks it on the
formatContext instead.

Less memory and avoids passing and tracking extra arguments to all the
pushStartInstance branches that doesn't need it.

We'll need to be able to track more Suspense related contexts on this
for View Transitions anyway.
2025-05-15 00:06:06 -04:00
Sebastian Markbåge
96eb84e493 Claim the useId name space for every auto named ViewTransition (#33200)
This is a partial revert of #33094. It's true that we don't need the
server and client ViewTransition names to line up. However the server
does need to be able to generate deterministic names for itself. The
cheapest way to do that is using the useId algorithm. When it's used by
the server, the client needs to also materialize an ID even if it
doesn't use it.
2025-05-14 17:52:41 -04:00
Sebastian Markbåge
63d664b220 Don't consider Portals animating unless they're wrapped in a ViewTransition (#33191)
And that doesn't disable with `update="none"`.

The principle here is that we want the content of a Portal to animate if
other things are animating with it but if other things aren't animating
then we don't.
2025-05-14 17:50:56 -04:00
Jan Kassens
d85f86cf01 Delete stray file (#33199)
Not sure where this was coming from.
2025-05-14 11:27:36 -04:00
Sebastian Markbåge
3a5b326d81 [Fiber] Trigger default indicator for isomorphic async actions with no root associated (#33190)
Stacked on #33160, #33162, #33186 and #33188.

We have a special case that's awkward for default indicators. When you
start a new async Transition from `React.startTransition` then there's
not yet any associated root with the Transition because you haven't
necessarily `setState` on anything yet until the promise resolves.
That's what `entangleAsyncAction` handles by creating a lane that
everything entangles with until all async actions are done.

If there are no sync updates before the end of the event, we should
trigger a default indicator until either the async action completes
without update or if it gets entangled with some roots we should keep it
going until those roots are done.
2025-05-13 16:10:28 -04:00
Sebastian Markbåge
59440424d0 Implement Navigation API backed default indicator for DOM renderer (#33162)
Stacked on #33160.

By default, if `onDefaultTransitionIndicator` is not overridden, this
will trigger a fake Navigation event using the Navigation API. This is
intercepted to create an on-going navigation until we complete the
Transition. Basically each default Transition is simulated as a
Navigation.

This triggers the native browser loading state (in Chrome at least). So
now by default the browser spinner spins during a Transition if no other
loading state is provided. Firefox and Safari hasn't shipped Navigation
API yet and even in the flag Safari has, it doesn't actually trigger the
native loading state.

To ensures that you can still use other Navigations concurrently, we
don't start our fake Navigation if there's one on-going already.
Similarly if our fake Navigation gets interrupted by another. We wait
for on-going ones to finish and then start a new fake one if we're
supposed to be still pending.

There might be other routers on the page that might listen to intercept
Navigation Events. Typically you'd expect them not to trigger a refetch
when navigating to the same state. However, if they want to detect this
we provide the `"react-transition"` string in the `info` field for this
purpose.
2025-05-13 16:00:38 -04:00
Sebastian Markbåge
b480865db0 [Fiber] Always flush Default priority in the microtask if a Transition was scheduled (#33186)
Stacked on #33160.

The purpose of this is to avoid calling `onDefaultTransitionIndicator`
when a Default priority update acts as the loading indicator, but still
call it when unrelated Default updates happens nearby.

When we schedule Default priority work that gets batched with other
events in the same frame more or less. This helps optimize by doing less
work. However, that batching means that we can't separate work from one
setState from another. If we would consider all Default priority work in
a frame when determining whether to show the default we might never show
it in cases like when you have a recurring timer updating something.

This instead flushes the Default priority work eagerly along with the
sync work at the end of the event, if this event scheduled any
Transition work. This is then used to determine if the default indicator
needs to be shown.
2025-05-13 15:52:44 -04:00
Sebastian Markbåge
62d3f36ea7 [Fiber] Trigger default transition indicator if needed (#33160)
Stacked on #33159.

This implements `onDefaultTransitionIndicator`.

The sequence is:

1) In `markRootUpdated` we schedule Transition updates as needing
`indicatorLanes` on the root. This tracks the lanes that currently need
an indicator to either start or remain going until this lane commits.
2) Track mutations during any commit. We use the same hook that view
transitions use here but instead of tracking it just per view transition
scope, we also track a global boolean for the whole root.
3) If a sync/default commit had any mutations, then we clear the
indicator lane for the `currentEventTransitionLane`. This requires that
the lane is still active while we do these commits. See #33159. In other
words, a sync update gets associated with the current transition and it
is assumed to be rendering the loading state for that corresponding
transition so we don't need a default indicator for this lane.
4) At the end of `processRootScheduleInMicrotask`, right before we're
about to enter a new "event transition lane" scope, it is no longer
possible to render any more loading states for the current transition
lane. That's when we invoke `onDefaultTransitionIndicator` for any roots
that have new indicator lanes.
5) When we commit, we remove the finished lanes from `indicatorLanes`
and once that reaches zero again, then we can clean up the default
indicator. This approach means that you can start multiple different
transitions while an indicator is still going but it won't stop/restart
each time. Instead, it'll wait until all are done before stopping.

Follow ups:

- [x] Default updates are currently not enough to cancel because those
aren't flush in the same microtask. That's unfortunate. #33186
- [x] Handle async actions before the setState. Since these don't
necessarily have a root this is tricky. #33190
- [x] Disable for `useDeferredValue`. ~Since it also goes through
`markRootUpdated` and schedules a Transition lane it'll get a default
indicator even though it probably shouldn't have one.~ EDIT: Turns out
this just works because it doesn't go through `markRootUpdated` when
work is left behind.
- [x] Implement built-in DOM version by default. #33162
2025-05-13 15:45:11 -04:00
Sebastian Markbåge
0cac32d60d [Fiber] Stash the entangled async action lane on currentEventTransitionLane (#33188)
When we're entangled with an async action lane we use that lane instead
of the currentEventTransitionLane. Conversely, if we start a new async
action lane we reuse the currentEventTransitionLane.

So they're basically supposed to be in sync but they're not if you
resolve the async action and then schedule new stuff in the same event.
Then you end up with two transitions in the same event with different
lanes.

By stashing it like this we fix that but it also gives us an opportunity
to check just the currentEventTransitionLane to see if this event
scheduled any regular Transition updates or Async Transitions.
2025-05-13 15:20:59 -04:00
Sebastian Markbåge
676f0879f3 Reset currentEventTransitionLane after flushing sync work (#33159)
This keeps track of the transition lane allocated for this event. I want
to be able to use the current one within sync work flushing to know
which lane needs its loading indicator cleared.

It's also a bit weird that transition work scheduled inside sync updates
in the same event aren't entangled with other transitions in that event
when `flushSync` is.

Therefore this moves it to reset after flushing.

It should have no impact. Just splitting it out into a separate PR for
an abundance of caution.

The only thing this might affect would be if the React internals throws
and it doesn't reset after. But really it doesn't really have to reset
and they're all entangled anyway.
2025-05-13 15:18:02 -04:00
Sebastian Markbåge
997c7bc930 [DevTools] Get source location from structured callsites in prepareStackTrace (#33143)
When we get the source location for "View source for this element" we
should be using the enclosing function of the callsite of the child. So
that we don't just point to some random line within the component.

This is similar to the technique in #33136.

This technique is now really better than the fake throw technique, when
available. So I now favor the owner technique. The only problem it's
only available in DEV and only if it has a child that's owned (and not
filtered).

We could implement this same technique for the error that's thrown in
the fake throwing solution. However, we really shouldn't need that at
all because for client components we should be able to call
`inspect(fn)` at least in Chrome which is even better.
2025-05-13 12:39:10 -04:00
Sebastian Markbåge
b94603b955 [Fizz] Gate rel="expect" behind enableFizzBlockingRender (#33183)
Enabled in experimental channel.

We know this is critical semantics to enforce at the HTML level since if
you don't then you can't add explicit boundaries after the fact.
However, this might have to go in a major release to allow for
upgrading.
2025-05-13 10:17:53 -04:00
Jenny Steele
2bcf06b692 [ReactFlightWebpackPlugin] Add support for .mjs file extension (#33028)
## Summary
Our builds generate files with a `.mjs` file extension. These are
currently filtered out by `ReactFlightWebpackPlugin` so I am updating it
to support this file extension.

This fixes https://github.com/facebook/react/issues/33155

## How did you test this change?
I built the plugin with this change and used `yalc` to test it in my
project. I confirmed the expected files now show up in
`react-client-manifest.json`
2025-05-12 21:16:15 -04:00
Samuel Susla
5d04d73274 Add eager alternate.stateNode cleanup (#33161)
This is a fix for a problem where React retains shadow nodes longer than
it needs to. The behaviour is shown in React Native test:
https://github.com/facebook/react-native/blob/main/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js#L169

# Problem
When React commits a new shadow tree, old shadow nodes are stored inside
`fiber.alternate.stateNode`. This is not cleared up until React clones
the node again. This may be problematic if mutation deletes a subtree,
in that case `fiber.alternate.stateNode` will retain entire subtree
until next update. In case of image nodes, this means retaining entire
images.

So when React goes from revision A: `<View><View /></View>` to revision
B: `<View />`, `fiber.alternate.stateNode` will be pointing to Shadow
Node that represents revision A..


![image](https://github.com/user-attachments/assets/076b677e-d152-4763-8c9d-4f923212b424)


# Fix
To fix this, this PR adds a new feature flag
`enableEagerAlternateStateNodeCleanup`. When enabled,
`alternate.stateNode` is proactively pointed towards finishedWork's
stateNode, releasing resources sooner.

I have verified this fixes the issue [demonstrated by React Native
tests](https://github.com/facebook/react-native/blob/main/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js#L169).
All existing React tests pass when the flag is enabled.
2025-05-12 17:39:20 +01:00
mofeiZ
3820740a7f [compiler][entrypoint] Fix edgecases for noEmit and opt-outs (#33148)
Title
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33148).
* #33149
* __->__ #33148
2025-05-09 13:37:49 -04:00
mofeiZ
5069e18060 [compiler][be] Make program traversal more readable (#33147)
React Compiler's program traversal logic is pretty lengthy and complex
as we've added a lot of features piecemeal. `compileProgram` is 300+
lines long and has confusing control flow (defining helpers inline,
invoking visitors, mutating-asts-while-iterating, mutating global
`ALREADY_COMPILED` state).

- Moved more stuff to `ProgramContext`
- Separated `compileProgram` into a bunch of helpers

Tested by syncing this stack to a Meta codebase and observing no
compilation output changes (D74487851, P1806855669, P1806855379)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33147).
* #33149
* #33148
* __->__ #33147
2025-05-09 13:23:08 -04:00
Sebastian Markbåge
21fdf308a1 Use a shared noop function from shared/noop (#33154)
Stacked on #33150.

We use `noop` functions in a lot of places as place holders. I don't
think there's any real optimizations we get from having separate
instances. This moves them to use a common instance in `shared/noop`.
2025-05-08 21:33:18 -04:00
Jack Pope
4ca97e4891 Clean up enableSiblingPrerendering flag (#32319) 2025-05-08 20:49:23 -04:00
Sebastian Markbåge
9b79292ae7 Add plumbing for onDefaultTransitionIndicator (#33150)
This just adds the options at the root and wire it up to the root but it
doesn't do anything yet.
2025-05-08 20:42:50 -04:00
Niklas Mollenhauer
ac06829246 feat(compiler): Implement constant propagation for template literals (#33139)
New take on #29716

## Summary
Template literals consisting entirely of constant values will be inlined
to a string literal, effectively replacing the backticks with a double
quote.

This is done primarily to make the resulting instruction a string
literal, so it can be processed further in constant propatation. So this
is now correctly simplified to `true`:
```js
`` === "" // now true
`a${1}` === "a1" // now true
```

If a template string literal can only partially be comptime-evaluated,
it is not that useful for dead code elimination or further constant
folding steps and thus, is left as-is in that case. Same is true if the
literal contains an array, object, symbol or function.

## How did you test this change?

See added tests.
2025-05-08 09:24:22 -07:00
mofeiZ
38ef6550a8 [compiler][playground][tests] Standardize more pragmas (#33146)
(Almost) all pragmas are now one of the following:
- `@...TestOnly`: custom pragma for test fixtures
- `@<configName>` | `@<configName>:true`: enables with either true or a
default enabled value
- `@<configName>:<json value>`
2025-05-08 11:26:53 -04:00
mofeiZ
b629a865fb [compiler][be] Move test pragma to separate file (#33145)
`Environment.ts` is getting complex so let's separate test / playground
parsing logic from it
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33145).
* #33146
* __->__ #33145
2025-05-08 11:24:44 -04:00
mofeiZ
fbe7bc21b9 [compiler][be] repro edge cases for noEmit and module opt-outs (#33144)
see test fixtures
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33144).
* #33146
* #33145
* __->__ #33144
2025-05-08 11:18:16 -04:00
Dawid Małecki
9518f11856 Root import types from react-native in ReactNativeTypes (#33063) 2025-05-08 12:12:35 +01:00
Ruslan Lesiutin
557a64795c React DevTools 6.1.1 -> 6.1.2 (#33142)
Patch release to mitigate https://github.com/facebook/react/issues/32659

Essentially just 6.1.1 with:
* Restore all Transitions for Tree updates
([eps1lon](https://github.com/eps1lon) in
[#33042](https://github.com/facebook/react/pull/33042))
* Restore "double-click to view owners tree" functionality
([eps1lon](https://github.com/eps1lon) in
[#33039](https://github.com/facebook/react/pull/33039))
2025-05-08 08:01:17 +01:00
Jack Pope
8a8df5dbdd Add dispatchEvent to fragment instances (#32813)
`fragmentInstance.dispatchEvent(evt)` calls `element.dispatchEvent(evt)`
on the fragment's host parent. This mimics bubbling if the
`fragmentInstance` could receive an event itself.

If the parent is disconnected, there is a dev warning and no event is
dispatched.
2025-05-07 14:00:59 -04:00
Niklas Mollenhauer
946da518eb feat(compiler): implement constant folding for unary minus (#33140)
## Summary
`-constant` is represented as a `UnaryExpression` node that is currently
not part of constant folding. If the operand is a constant number, the
node is folded to `constant * -1`. This also coerces `-0` to `0`,
resulting in `0 === -0` being folded to `true`.

## How did you test this change?
See attached tests
2025-05-07 10:15:11 -07:00
Sebastian Markbåge
a437c99ff7 [Flight] Clarify that location field is a FunctionLocation not a CallSite (#33141)
Follow up to #33136.

This clarifies in the types where the conversion happens from a CallSite
which we use to simulate getting the enclosing line/col to a
FunctionLocation which doesn't represent a CallSite but actually just
the function which only has an enclosing line/col.
2025-05-07 13:02:41 -04:00
Jack Pope
4206fe4982 Allow fragment refs to attempt focus/focusLast on nested host children (#33058)
This enables `focus` and `focusLast` methods on FragmentInstances to
search nested host components, depth first. Attempts focus on each child
and bails if one is successful. Previously, only the first level of host
children would attempt focus.

Now if we have an example like

```
component MenuItem() {
  return (<div><a>{...}</a></div>)
}

component Menu() {
  return <Fragment>{items.map(i => <MenuItem i={i} />)}</Fragment>
}
```
We can target focus on the first or last a tag, rather than checking
each wrapping div and then noop.
2025-05-07 12:47:28 -04:00
Sebastian Markbåge
4a702865dd [Flight] Encode enclosing line/column numbers and use it to align the fake function (#33136)
Stacked on #33135.

This encodes the line/column of the enclosing function as part of the
stack traces. When that information is available.

I adjusted the fake function code generation so that the beginning of
the arrow function aligns with these as much as possible.

This ensures that when the browser tries to look up the line/column of
the enclosing function, such as for getting the function name, it gets
the right one. If we can't get the enclosing line/column, then we encode
it at the beginning of the file. This is likely to get a miss in the
source map identifiers, which means that the function name gets
extracted from the runtime name instead which is better.

Another thing where this is used is the in the Performance Track.
Ideally that would be fixed by
https://issues.chromium.org/u/1/issues/415968771 but the enclosing
information is useful for other things like the function name resolution
anyway.

We can also use this for the "View source for this element" in React
DevTools.
2025-05-07 12:34:55 -04:00
Sebastian Markbåge
0ff1d13b80 [Flight] Parse Stack Trace from Structured CallSite if available (#33135)
This is first step to include more enclosing line/column in the parsed
data.

We install our own `prepareStackTrace` to collect structured callsite
data and only fall back to parsing the string if it was already
evaluated or if `prepareStackTrace` doesn't work in this environment.

We still mirror the default V8 format for encoding the function name
part. A lot of this is covered by tests already.
2025-05-07 11:43:37 -04:00
YongSeok Jang (장용석)
53c9f81049 [DevTools] Use Popover API for TraceUpdates highlighting (#32614)
## Summary

When using React DevTools to highlight component updates, the highlights
would sometimes appear behind elements that use the browser's
[top-layer](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer)
(such as `<dialog>` elements or components using the Popover API). This
made it difficult to see which components were updating when they were
inside or behind top-layer elements.

This PR fixes the issue by using the Popover API to ensure that
highlighting appears on top of all content, including elements in the
top-layer. The implementation maintains backward compatibility with
browsers that don't support the Popover API.

## How did you test this change?

I tested this change in the following ways:

1. Manually tested in Chrome (which supports the Popover API) with:
- Created a test application with React components inside `<dialog>`
elements and custom elements using the Popover API
- Verified that component highlighting appears above these elements when
they update
- Confirmed that highlighting displays correctly for nested components
within top-layer elements

2. Verified backward compatibility:
- Tested in browsers without Popover API support to ensure fallback
behavior works correctly
- Confirmed that no errors occur and highlighting still functions as
before

3. Ran the React DevTools test suite:
   - All tests pass successfully
   - No regressions were introduced

[demo-page](https://devtools-toplayer-demo.vercel.app/)
[demo-repo](https://github.com/yongsk0066/devtools-toplayer-demo)

### AS-IS

https://github.com/user-attachments/assets/dc2e1281-969f-4f61-82c3-480153916969

### TO-BE

https://github.com/user-attachments/assets/dd52ce35-816c-42f0-819b-0d5d0a8a21e5
2025-05-07 15:48:17 +01:00
Jack Pope
e5a8de81e5 Add compareDocumentPosition to fragment instances (#32722)
This adds `compareDocumentPosition(otherNode)` to fragment instances.

The semantics implemented are meant to match typical element
positioning, with some fragment specifics. See the unit tests for all
expectations.

- An element preceding a fragment is `Node.DOCUMENT_POSITION_PRECEDING`
- An element after a fragment is `Node.DOCUMENT_POSITION_FOLLOWING`
- An element containing the fragment is
`Node.DOCUMENT_POSITION_PRECEDING` and
`Node.DOCUMENT_POSITION_CONTAINING`
- An element within the fragment is
`Node.DOCUMENT_POSITION_CONTAINED_BY`
- An element compared against an empty fragment will result in
`Node.DOCUMENT_POSITION_DISCONNECTED` and
`Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC`

Since we assume a fragment instances target children are DOM siblings
and we want to compare the full fragment as a pseudo container, we can
compare against the first target child outside of handling the special
cases (empty fragments and contained elements).
2025-05-06 13:01:40 -04:00
Jorge Cabiedes
7a2c7045ae [mcp] Add proper web-vitals metric collection (#33109)
Multiple things here:
- Improve the mean calculation for metrics so we don't report 0 when
web-vitals fail to be retrieved
- improve ui chaos monkey to use puppeteer APIs since only those trigger
INP/CLS metrics since we need emulated mouse clicks
- Add logic to navigate to a temp page after render since some
web-vitals metrics are only calculated when the page is backgrounded
- Some readability improvements
2025-05-06 08:50:40 -07:00
Sebastian Markbåge
845d93742f Remove useId semantics from View Transition name generation (#33094)
Originally I thought it was important that SSR used the same View
Transition name as the client so that the Fizz runtime could emit those
names and then the client could pick up and take over. However, I no
longer believe that approach is feasible. Instead, the names can be
generated only during that particular animation.

Therefore we can simplify the auto name assignment to not have to
consider the hydration.
2025-05-06 10:33:03 -04:00
Sebastian Markbåge
54a50729cc [Fiber] Replay events between commits (#33130)
Stacked on #33129. Flagged behind `enableHydrationChangeEvent`.

If you type into a controlled input before hydration and something else
rerenders like a setState in an effect, then the controlled input will
reset to whatever React thought it was. Even with event replaying that
this is stacked on, if the second render happens before event replaying
has fired in a separate task.

We don't want to flush inside the commit phase because then things like
flushSync in these events wouldn't work since they're inside the commit
stack.

This flushes all event replaying between renders by flushing it at the
end of `flushSpawned` work. We've already committed at that point and is
about to either do subsequent renders or yield to event loop for passive
effects which could have these events fired anyway. This just ensures
that they've already happened by the time subsequent renders fire. This
means that there's now a type of event that fire between sync render
passes.
2025-05-06 00:23:27 -04:00
Sebastian Markbåge
587cb8f896 [Fiber] Replay onChange Events if input/textarea/select has changed before hydration (#33129)
This fixes a long standing issue that controlled inputs gets out of sync
with the browser state if it's changed before we hydrate.

This resolves the issue by replaying the change events (click, input and
change) if the value has changed by the time we commit the hydration.
That way you can reflect the new value in state to bring it in sync. It
does this whether controlled or uncontrolled.

The idea is that this should be ok to replay because it's similar to the
continuous events in that it doesn't replay a sequence but only reflects
the current state of the tree.

Since this is a breaking change I added it behind
`enableHydrationChangeEvent` flag.

There is still an additional issue remaining that I intend to address in
a follow up. If a `useLayoutEffect` triggers an sync rerender on
hydration (always a bad idea) then that can rerender before we have had
a chance to replay the change events. If that renders through a input
then that input will always override the browser value with the
controlled value. Which will reset it before we've had a change to
update to the new value.
2025-05-06 00:10:05 -04:00
Matt Carroll
79586c7eb6 Add test for multiple form submissions (#33059)
Test for #30041 and #33055
2025-05-05 14:47:47 -07:00
Jack Pope
edf550b679 Ship enableFabricCompleteRootInCommitPhase (#33064)
This was shipped internally. Cleaning up the flag.
2025-05-05 13:36:44 -04:00
Sebastian "Sebbie" Silbermann
b9cfa0d308 [Flight] Prevent serialized size leaking across requests (#33121) 2025-05-05 18:30:33 +02:00
mofeiZ
c129c2424b [compiler][repro] Nested fbt test fixture (#32779)
Ideally we should detect and bail out on this case to avoid babel build
failures.
2025-05-05 11:52:45 -04:00
mofeiZ
0c1575cee8 [compiler][bugfix] Bail out when a memo block declares hoisted fns (#32765)
Note that bailing out adds false positives for hoisted functions whose
only references are within other functions. For example, this rewrite
would be safe.
```js
// source program
  function foo() {
    return bar();
  }
  function bar() {
    return 42;
  }

// compiler output
let bar;
if (/* deps changed */) {
  function foo() {
    return bar();
  }
  bar = function bar() {
    return 42;
  }
}
```
These false positives are difficult to detect because any maybe-call of
foo before the definition of bar would be invalid.

Instead of bailing out, we should rewrite hoisted function declarations
to the following form.
```js
let bar$0;
if (/* deps changed */) {
  // All references within the declaring memo block
  // or before the function declaration should use
  // the original identifier `bar`
  function foo() {
    return bar();
  }
  function bar() {
    return 42;
  }
  bar$0 = bar;
}
// All references after the declaring memo block
// or after the function declaration should use
// the rewritten declaration `bar$0`
```
2025-05-05 11:45:58 -04:00
Sebastian Markbåge
52ea641449 [Flight] Don't increase serializedSize for every recursive pass (#33123)
I noticed that we increase this in the recursive part of the algorithm.
This would mean that we'd count a key more than once if it has Server
Components inside it recursively resolving. This moves it out to where
we enter from toJSON. Which is called once per JSON entry (and therefore
once per key).
2025-05-05 11:37:39 -04:00
Stephen Zhou
3ec88e797f [eslint-plugin-react-hooks] update doc url for rules of hooks (#33118) 2025-05-05 17:37:06 +02:00
Sebastian "Sebbie" Silbermann
0ca8420f9d [Flight] Use valid CSS selectors in useId format (#33099) 2025-05-04 13:47:32 +02:00
Joe Savona
0db8db178c [compiler] Validate against mutable functions being frozen
This revisits a validation I built a while ago, trying to make it more strict this time to ensure that it's high-signal.

We detect function expressions which are *known* mutable — they definitely can modify a variable defined outside of the function expression itself (modulo control flow). This uses types to look for known Store and Mutate effects only, and disregards mutations of effects. Any such function passed to a location with a Freeze effect is reported as a validation error.

This is behind a flag and disabled by default. If folks agree this makes sense to revisit, i'll test out internally and we can consider enabling by default.

ghstack-source-id: 075a731444ce95e52dbd5ea3be85c16d428927f5
Pull Request resolved: https://github.com/facebook/react/pull/33079
2025-05-03 09:15:32 +09:00
Joe Savona
8570116bd1 [compiler] Fix for uncalled functions that are known-mutable
If a function captures a mutable value but never gets called, we don't infer a mutable range for that function. This means that we also don't alias the function with its mutable captures.

This case is tricky, because we don't generally know for sure what is a mutation and what may just be a normal function call. For example:

```js
hook useFoo() {
  const x = makeObject();
  return () => {
    return readObject(x); // could be a mutation!
  }
}
```

If we pessimistically assume that all such cases are mutations, we'd have to group lots of memo scopes together unnecessarily. However, if there is definitely a mutation:

```js
hook useFoo(createEntryForKey) {
  const cache = new WeakMap();
  return (key) => {
    let entry = cache.get(key);
    if (entry == null) {
      entry = createEntryForKey(key);
      cache.set(key, entry); // known mutation!
    }
    return entry;
  }
}
```

Then we have to ensure that the function and its mutable captures alias together and end up in the same scope. However, aliasing together isn't enough if the function and operands all have empty mutable ranges (end = start + 1).

This pass finds function expressions and object methods that have an empty mutable range and known-mutable operands which also don't have a mutable range, and ensures that the function and those operands are aliased together *and* that their ranges are updated to end after the function expression. This is sufficient to ensure that a reactive scope is created for the alias set.

NOTE: The alternative is to reject these cases. If we do that we'd also want to similarly disallow cases like passing a mutable function to a hook.

ghstack-source-id: 5d8158246a320e80d8da3f0e395ac1953d8920a2
Pull Request resolved: https://github.com/facebook/react/pull/33078
2025-05-03 09:15:32 +09:00
Joe Savona
4f1d2ddf95 [compiler] Add types for WeakMap, WeakSet, and reanimated shared values
Building on mofeiz's recent work to type constructors. Also, types for reanimated values which are useful in the next PR.

ghstack-source-id: 1c81e213a11337ac7e9c85a429ecf3f1d1adef66
Pull Request resolved: https://github.com/facebook/react/pull/33077
2025-05-03 09:15:32 +09:00
Joe Savona
73d7e816b7 [compiler] ValidatePreservedManualMemoization reports detailed errors
This pass didn't previously report the precise difference btw inferred/manual dependencies unless a debug flag was set. But the error message is really good (nice job mofeiz): the only catch is that in theory the inferred dep could be a temporary that can't trivially be reported to the user.

But the messages are really useful for quickly verifying why the compiler couldn't preserve memoization. So here we switch to outputting a detailed message about the discrepancy btw inferred/manual deps so long as the inferred dep root is a named variable. I also slightly adjusted the message to handle the case where there is no diagnostic, which can occur if there were no manual deps but the compiler inferred a dependency.

ghstack-source-id: 534f6f1fec0855e05e85077eba050eb2ba254ef8
Pull Request resolved: https://github.com/facebook/react/pull/33095
2025-05-03 09:09:34 +09:00
Joe Savona
ac2cae5245 [compiler] Fix for string attribute values with emoji
If a JSX attribute value is a string that contains unicode or other characters that need special escaping, we wrap the attribute value in an expression container. However, our unicode to detect this only handled the basic unicode character plane, not the "astral" plane which includes emojis.

This PR updates the regex to detect such extended characters and also use an expression container.

ghstack-source-id: 6d9c8e4dd22285077108e2fa53d66154d1b781fb
Pull Request resolved: https://github.com/facebook/react/pull/33096
2025-05-03 09:07:50 +09:00
Jan Kassens
66de8e5a99 [eslint-plugin-react-hooks] move eslint plugin once again (#33110) 2025-05-02 18:14:56 -04:00
lauren
26ecc98a00 [mcp] Prompt improvements for smarter tool composition (#33108)
Our runtime results are still bogus, but so far this tweak let's the llm
use the tools a little more intelligently.

example:
---

```js
import React from 'react';

function App() {
  const [count, setCount] = React.useState(0);
  const [data, setData] = React.useState([]);

  React.useEffect(() => {
    const processData = () => {
      console.log("Processing data...");
      const newData = [];
      for (let i = 0; i < 1000000000; i++) {
        newData.push({ id: i, value: Math.random() * count });
      }
      return newData;
    };

    const processedData = processData();
    setData(processedData);
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Data Length: {data.length}</p>
      <ul>
        {data.slice(0, 5).map((item) => (
          <li key={item.id}>Item {item.id}: {item.value.toFixed(2)}</li>
        ))}
      </ul>
    </div>
  );
};
```
why is this component slow? use the react tools to figure this out.

> I'll analyze why this component is slow using the React tools. Let's
first evaluate the performance of the original code.
>
[review-react-runtime ...]
>
> The performance measurement tool timed out, which is a strong
indication of just how severe the performance issue is in this
component. Let's now try to compile the code to see if React Compiler
can identify any issues:
>
[compile...]
>
> Now I understand the issue. Let's analyze why this component > is
extremely slow:
> Main Performance Issues:
>
> Massive Data Processing: The component is trying to create an array
with a billion (1,000,000,000) objects in the processData function. This
is an enormous amount of data that will:
>
> Consume massive amounts of memory
> Take an extremely long time to process
> Likely crash the browser
>
>
> Unnecessary Recalculation: The effect depends on [count], meaning
every time the count changes, it will reprocess this enormous dataset.
> Inefficient Data Handling: Even though only 5 items are displayed, the
component processes a billion items.
>
> Let's fix these issues:
>
[compile...]
>
> Now let's check if our modified code performs better:
>
[review-react-runtime...]
>
> Excellent! The performance has dramatically improved. Now let's check
the React documentation to make sure we're following the best practices:
>
[query-react-dev-docs...]

(hit the maximum length conversation limit because i'm on a free plan)
2025-05-02 17:50:39 -04:00
Jan Kassens
f0ca53d133 [eslint-plugin-react-hooks] another CI update... (#33107)
We currently created a nested directory, this should remove that.

See:

https://github.com/facebook/react/tree/builds/facebook-fbsource/compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react/eslint-plugin-react-hooks/eslint-plugin-react-hooks
2025-05-02 17:05:56 -04:00
lauren
e39b380a21 [mcp] Fix unresolved imports (#33105)
We need to explicitly import the modules so they'll be inlined correctly
into the bundle.
2025-05-02 16:54:17 -04:00
Jan Kassens
9de0304ad7 Add missing copyright header (#33106)
This made the build fail since there was no file header comment.
2025-05-02 16:52:17 -04:00
Jan Kassens
0d695bea10 [eslint-plugin-react-hooks] update fbsource build (#33104)
In order to sync the lint rules directly to internal, include the eslint
plugin in the build output for fbsource.
2025-05-02 16:03:06 -04:00
Jan Kassens
4c4a57c4f9 [eslint-plugin-react-hooks] updates for component syntax (#33089)
Adds support for Flow's component and hook syntax.
[docs](https://flow.org/en/docs/react/component-syntax/)
2025-05-02 15:04:45 -04:00
lauren
dc2b11817b [mcp] Refactor (#33085)
Just some cleanup. Mainly, we now take the number of iterations as an
argument. Everything else is just code movement and small tweaks.
2025-05-02 14:15:12 -04:00
lauren
b5450b0738 [mcp] Update prompts (#33084)
Some tweaks to the prompt to provide more context on how to use them.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33084).
* #33085
* __->__ #33084
* #33083
2025-05-02 14:06:20 -04:00
lauren
f150c046ec [mcp] Move to /tools (#33083)
Moves to a tools directory.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33083).
* #33085
* #33084
* __->__ #33083
2025-05-02 14:06:11 -04:00
lauren
12b094d2f6 [mcp] Update plugins (#33082)
Adds typescript support.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33082).
* #33085
* #33084
* #33083
* __->__ #33082
* #33101
2025-05-02 13:56:45 -04:00
lauren
e5f0315efa [mcp] Fix package.json (#33101)
Since we use esbuild we need to correctly move dependencies that are
required at runtime into `dependencies` and other packages that are only
used in development in to `devDependencies`. This ensures the correct
packages are included in the build.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33101).
* #33085
* #33084
* #33083
* #33082
* __->__ #33101
2025-05-02 13:56:01 -04:00
Sebastian Markbåge
f739642745 [Fizz] Always load the external runtime if one is provided (#33091)
Because we now decided whether to outline in the flushing phase, when
we're writing the preamble we don't yet know if we will make that
decision so we don't know if it's safe to omit the external runtime.

However, if you are providing an external runtime it's probably a pretty
safe bet you're streaming something dynamically that's likely to need it
so we can always include it.

The main thing is that this makes it hard to test it because it affects
our tests in ways it wouldn't otherwise so we have to add a bunch of
conditions.
2025-05-01 18:14:42 -04:00
Sebastian Markbåge
0ed6ceb9f6 [Fizz] Add "Queued" Status to SSR:ed Suspense Boundaries (#33087)
Stacked on #33076.

This fixes a bug where we used the "complete" status but the
DOMContentLoaded event. This checks for not "loading" instead.

We also add a new status where the boundary has been marked as complete
by the server but has not yet flushed either due to being throttled,
suspended on CSS or animating.
2025-05-01 16:11:54 -04:00
Sebastian Markbåge
ee7fee8f88 [Fizz] Batch Suspense Boundary Reveal with Throttle (#33076)
Stacked on #33073.

React semantics is that Suspense boundaries reveal with a throttle
(300ms). That helps avoid flashing reveals when a stream reveals many
individual steps back to back. It can also improve overall performance
by batching the layout and paint work that has to happen at each step.

Unfortunately we never implemented this for SSR streaming - only for
client navigations. This is highly noticeable on very dynamic sites with
lots of Suspense boundaries. It can look good with a client nav but feel
glitchy when you reload the page or initial load.

This fixes the Fizz runtime to be throttled and reveals batched into a
single paint at a time. We do this by first tracking the last paint
after the complete (this will be the first paint if `rel="expect"` is
respected). Then in the `completeBoundary` operation we queue the
operation and then flush it all into a throttled batch.

Another motivation is that View Transitions need to operate as a batch
and individual steps get queued in a sequence so it's extra important to
include as much content as possible in each animated step. This will be
done in a follow up for SSR View Transitions.
2025-05-01 16:09:37 -04:00
Sebastian Markbåge
ee077b6ccd [Fizz] Don't handle errors in completeBoundary instruction (#33073)
Stacked on #33066 and #33068.

Currently we're passing `errorDigest` to `completeBoundary` if there is
a client side error (only CSS loading atm). This only exists because of
`completeBoundaryWithStyles`. Normally if there's a server-side error
we'd emit the `clientRenderBoundary` instruction instead. This adds
unnecessary code to the common case where all styles are in the head.
This is about to get worse with batching because client render shouldn't
be throttled but complete should be.

The first commit moves the client render logic inline into
`completeBoundaryWithStyles` so we only pay for it when styles are used.

However, the approach I went with in the second commit is to reuse the
`$RX` instruction instead (`clientRenderBoundary`). That way if you have
both it ends up being amortized. However, it does mean we have to emit
the `$RX` (along with the `$RC` helper if any
`completeBoundaryWithStyles` instruction is needed.
2025-05-01 15:44:17 -04:00
Sebastian Markbåge
bb57fa7351 [Fizz] Share code between inline and external runtime (#33066)
Stacked on #33065.

The runtime is about to be a lot more complicated so we need to start
sharing some more code.

The problem with sharing code is that we want the inline runtime to as
much as possible be isolated in its scope using only a few global
variables to refer across runtimes.

A problem with Closure Compiler is that it refuses to inline functions
if they have closures inside of them. Which makes sense because of how
VMs work it can cause memory leaks. However, in our cases this doesn't
matter and code size matters more. So we can't use many clever tricks.

So this just favors writing the source in the inline form. Then we add
an extra compiler pass to turn those global variables into local
variables in the external runtime.
2025-05-01 14:25:10 -04:00
Joe Savona
e9db3cc2d4 [compiler] PruneNonEscapingScopes understands terminal operands
We weren't treating terminal operands as eligible for memoization in PruneNonEscapingScopes, which meant that they could end up un-memoized. Terminal operands can also be compound ReactiveValues like SequenceExpressions, so part of the fix is to make sure we don't just recurse into compound values but record the full aliasing information we would for top-level instructions.

Still WIP, this needs to handle terminals other than for..of.

ghstack-source-id: 09a29230514e3bc95d1833cd4392de238fabbeda
Pull Request resolved: https://github.com/facebook/react/pull/33062
2025-05-01 12:41:27 +09:00
Jorge Cabiedes
d8074cbc79 [mcp] Make tool more reliable and fix integration issues with babel (#33074)
## Summary

Fix babel presets, and add a bit more context to the tool so that it is
more reliable

## How did you test this change?

Manually tested the mcp integrated with claude desktop
2025-04-30 15:42:00 -07:00
Sebastian Markbåge
71797c871b [Fizz] Ignore error if content node is gone (#33068)
We normally expect the segment to exist whatever the client does while
streaming. However, when hydration errors at the root of the shell for a
whole document render, then we clear nodes from body which can include
our segments. We don't need them anymore because we switched to client
rendering.

It triggers an error accessing parent node which can safely be ignored.
This just helps avoid confusion in this scenario.

This also covers up the error in #33067. Which doesn't actually cause
any visible problems other than error logging. However, ideally we
wouldn't emit completeBoundary instructions if the boundary is inside a
cancelled fallback.
2025-04-30 17:51:39 -04:00
mofeiZ
9d795d3808 [compiler][bugfix] expand StoreContext to const / let / function variants (#32747)
```js
function Component() {
  useEffect(() => {
    let hasCleanedUp = false;
    document.addEventListener(..., () => hasCleanedUp ? foo() : bar());
    // effect return values shouldn't be typed as frozen
    return () => {
      hasCleanedUp = true;
    }
  };
}
```
### Problem
`PruneHoistedContexts` currently strips hoisted declarations and
rewrites the first `StoreContext` reassignment to a declaration. For
example, in the following example, instruction 0 is removed while a
synthetic `DeclareContext let` is inserted before instruction 1.

```js
// source
const cb = () => x; // reference that causes x to be hoisted

let x = 4;
x = 5;

// React Compiler IR
[0] DeclareContext HoistedLet 'x'
...
[1] StoreContext reassign 'x' = 4
[2] StoreContext reassign 'x' = 5
```

Currently, we don't account for `DeclareContext let`. As a result, we're
rewriting to insert duplicate declarations.
```js
// source
const cb = () => x; // reference that causes x to be hoisted

let x;
x = 5;

// React Compiler IR
[0] DeclareContext HoistedLet 'x'
...
[1] DeclareContext Let 'x'
[2] StoreContext reassign 'x' = 5
```

### Solution

Instead of always lowering context variables to a DeclareContext
followed by a StoreContext reassign, we can keep `kind: 'Const' | 'Let'
| 'Reassign' | etc` on StoreContext.
Pros:
- retain more information in HIR, so we can codegen easily `const` and
`let` context variable declarations back
- pruning hoisted `DeclareContext` instructions is simple.

Cons:
- passes are more verbose as we need to check for both `DeclareContext`
and `StoreContext` declarations

~(note: also see alternative implementation in
https://github.com/facebook/react/pull/32745)~

### Testing
Context variables are tricky. I synced and diffed changes in a large
meta codebase and feel pretty confident about landing this. About 0.01%
of compiled files changed. Among these changes, ~25% were [direct
bugfixes](https://www.internalfb.com/phabricator/paste/view/P1800029094).
The [other
changes](https://www.internalfb.com/phabricator/paste/view/P1800028575)
were primarily due to changed (corrected) mutable ranges from
https://github.com/facebook/react/pull/33047. I tried to represent most
interesting changes in new test fixtures

`
2025-04-30 17:18:58 -04:00
mofeiZ
12f4cb85c5 [compiler][bugfix] Returned functions are not always frozen (#33047)
Fixes an edge case in React Compiler's effects inference model.

Returned values should only be typed as 'frozen' if they are (1) local
and (2) not a function expression which may capture and mutate this
function's outer context. See test fixtures for details
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33047).
* #32765
* #32747
* __->__ #33047
2025-04-30 15:50:54 -04:00
Jorge Cabiedes
90a124a980 [mdn] Initial experiment for adding performance tool (#33045)
## Summary
Add a way for the agent to get some data on the performance of react
code

## How did you test this change?
Tested function independently and directly with claude desktop app

---------

Co-authored-by: Sebastian "Sebbie" Silbermann <sebastian.silbermann@vercel.com>
2025-04-30 12:44:05 -07:00
Sebastian Markbåge
49ea8bf569 [Flight] Defer Elements if the parent chunk is too large (#33030)
Same principle as #33029 but for Flight.

We pretty aggressively create separate rows for things in Flight (every
Server Component that's an async function create a microtask). However,
sync Server Components and just plain Host Components are not. Plus we
should ideally ideally inline more of the async ones in the same way
Fizz does.

This means that we can create rows that end up very large. Especially if
all the data is already available. We can't show the parent content
until the whole thing loads on the client.

We don't really know where Suspense boundaries are for Flight but any
Element is potentially a point that can be split.

This heuristic counts roughly how much we've serialized to block the
current chunk and once a limit is exceeded, we start deferring all
Elements. That way they get outlined into future chunks that are later
in the stream. Since they get replaced by Lazy references the parent can
potentially get unblocked.

This can help if you're trying to stream a very large document with a
client nav for example.
2025-04-30 14:21:28 -04:00
Sebastian Markbåge
9a52ad9fd9 [Fizz] Remove globals from external runtime (#33065)
We never emit any inline functions when we use external runtime so this
global shouldn't be needed.
2025-04-30 14:21:14 -04:00
Sebastian "Sebbie" Silbermann
fa8e3a251e [devtools] Restore all Transitions for Tree updates (#33042) 2025-04-30 19:51:40 +02:00
Jack Pope
408d055a3b Add Fragment Refs to Fabric with intersection observer support (#33056)
Adds Fragment Ref support to RN through the Fabric config, starting with
`observeUsing`/`unobserveUsing`. This is mostly a copy from the
implementation on DOM, and some of it can likely be shared in the future
but keeping it separate for now and we can refactor as we add more
features.

Added a basic test with Fabric, but testing specific methods requires so
much mocking that it doesn't seem valuable here.

I built Fabric and ran on the Catalyst app internally to test with
intersection observers end to end.
2025-04-30 10:47:18 -04:00
Sebastian "Sebbie" Silbermann
fbf29ccaa3 [devtools] Restore "double-click to view owners tree" functionality (#33039) 2025-04-30 11:11:33 +02:00
Sebastian Markbåge
62960c67c8 Run Component Track Logs in the console.createTask() of the Fiber (#32809)
Stacked on #32736.

That way you can find the owner stack of each component that rerendered
for context.

In addition to the JSX callsite tasks that we already track, I also
added tracking of the first `setState` call before rendering.

We then run the "Update" entries in that task. That way you can find the
callsite of the first setState and therefore the "cause" of a render
starting by selecting the "Update" track.

Unfortunately this is blocked on bugs in Chrome that makes it so that
these stacks are not reliable in the Performance tab. It basically just
doesn't work.
2025-04-29 22:17:17 -04:00
Sebastian Markbåge
cd4e4d7599 Use console.timeStamp instead of performance.measure in Component Performance Track (#32736)
This is a new extension that Chrome added to the existing
`console.timeStamp` similar to the extensions added to
`performance.measure`. This one should be significantly faster because
it doesn't have the extra object indirection, it doesn't return a
`PerformanceMeasure` entry and doesn't register itself with the global
system of entries.

I also use `performance.measure` in DEV for errors since we can attach
the error to the `properties` extension which doesn't exist for
`console.timeStamp`.

A downside of using this API is that there's no programmatic API for the
site itself to collect its own logs from React. Which the previous
allowed us to use the standard `performance.getEntries()` for. The
recommendation instead will be for the site to patch `console.timeStamp`
if it wants to collect measurements from React just like you're
recommended to patch `console.error` or `fetch` or whatever to collect
other instrumentation metrics.

This extension works in Chrome canary but it doesn't yet work fully in
Chrome stable. We might want to wait until it has propagated to Chrome
to stable. It should be in Chrome 136.
2025-04-29 21:40:10 -04:00
Sebastian Markbåge
18212ca960 [Fizz] Outline if a boundary would add too many bytes to the next completion (#33029)
Follow up to #33027.

This enhances the heuristic so that we accumulate the size of the
currently written boundaries. Starting from the size of the root (minus
preamble) for the shell.

This ensures that if you have many small boundaries they don't all
continue to get inlined. For example, you can wrap each paragraph in a
document in a Suspense boundary to regain document streaming
capabilities if that's what you want.

However, one consideration is if it's worth producing a fallback at all.
Maybe if it's like `null` it's free but if it's like a whole alternative
page, then it's not. It's possible to have completely useless Suspense
boundaries such as when you nest several directly inside each other. So
this uses a limit of at least 500 bytes of the content itself for it to
be worth outlining at all. It also can't be too small because then for
example a long list of paragraphs can never be outlined.

In the fixture I straddle this limit so some paragraphs are too small to
be considered. An unfortunate effect of that is that you can end up with
some of them not being outlined which means that they appear out of
order. SuspenseList is supposed to address that but it's unfortunate.

The limit is still fairly high though so it's unlikely that by default
you'd start outlining anything within the viewport at all. I had to
reduce the `progressiveChunkSize` by an order of magnitude in my fixture
to try it out properly.
2025-04-29 19:13:28 -04:00
Sebastian Markbåge
88b9767404 Hack to recover from reading the wrong Fiber (#33055)
`requestFormReset` incorrectly tries to get the current dispatch queue
from the Fiber. However, the Fiber might be the workInProgress which is
an inconsistent state.

This hack just tries the other Fiber if it detects one of the known
inconsistent states but there can be more.

Really we should stash the dispatch queue somewhere stateful which is
effectively what `setState` does by binding it to the closure.
2025-04-29 13:36:19 -04:00
Pieter De Baets
0038c501a3 [react-native] Pull up enableFastAddPropertiesInDiffing check (#33043)
## Summary

We don't need the isArray check for this experiment, as
`fastAddProperties` already does the same. Also renaming
slowAddProperties to make it clearer we can fully remove this codepath
once fastAddProperties is fully rolled out.

## How did you test this change?

```
yarn test packages/react-native-renderer -r=xplat --variant=true
```
2025-04-29 11:10:18 +01:00
Sebastian Markbåge
5dc00d6b2b [Fizz] Reset Instructions on ResumableState (#33046)
When we end up creating an incomplete state in the shell we end up not
flushing anything. As a hack, in this case we need to reset the
ResumableState because some of the ResumableState is still relevant
(e.g. any preloads that went into headers) but some of the
ResumableState needs to be reset since they assume that what we produced
actually flushed.

We didn't reset the instructions state but we haven't actually flushed
any of the instructions so it needs to reset.
2025-04-28 15:50:06 -04:00
Sebastian "Sebbie" Silbermann
c498bfce8b [devtools] Allow inspecting cause, name, message, stack of Errors in props (#33023) 2025-04-26 07:20:57 +02:00
Sebastian Markbåge
8e9a5fc6c1 [Fizz] Enable the progressiveChunkSize option (#33027)
Since the very beginning we have had the `progressiveChunkSize` option
but we never actually took advantage of it because we didn't count the
bytes that we emitted. This starts counting the bytes by taking a pass
over the added chunks each time a segment completes.

That allows us to outline a Suspense boundary to stream in late even if
it is already loaded by the time that back-pressure flow and in a
`prerender`. Meaning it gets inserted with script.

The effect can be seen in the fixture where if you have large HTML
content that can block initial paint (thanks to
[`rel="expect"`](https://github.com/facebook/react/pull/33016) but also
nested Suspense boundaries). Before this fix, the paint would be blocked
until the large content loaded. This lets us paint the fallback first in
the case that the raw bytes of the content takes a while to download.

You can set it to `Infinity` to opt-out. E.g. if you want to ensure
there's never any scripts. It's always set to `Infinity` in
`renderToHTML` and the legacy `renderToString`.

One downside is that if we might choose to outline a boundary, we need
to let its fallback complete.

We don't currently discount the size of the fallback but really just
consider them additive even though in theory the fallback itself could
also add significant size or even more than the content. It should maybe
really be considered the delta but that would require us to track the
size of the fallback separately which is tricky.

One problem with the current heuristic is that we just consider the size
of the boundary content itself down to the next boundary. If you have a
lot of small boundaries adding up, it'll never kick in. I intend to
address that in a follow up.
2025-04-25 16:10:53 -04:00
mofeiZ
89e8875ec4 [compiler] Fallback for inferred effect dependencies (#32984)
When effect dependencies cannot be inferred due to memoization-related
bailouts or unexpected mutable ranges (which currently often have to do
with writes to refs), fall back to traversing the effect lambda itself.

This fallback uses the same logic as PropagateScopeDependencies:
1. Collect a sidemap of loads and property loads
2. Find hoistable accesses from the control flow graph. Note that here,
we currently take into account the mutable ranges of instructions (see
`mutate-after-useeffect-granular-access` fixture)
3. Collect the set of property paths accessed by the effect
4. Merge to get the set of minimal dependencies
2025-04-25 15:44:39 -04:00
mofeiZ
2d0a5e399f [compiler] Patch for reactive refs in inferred effect dependencies (#32991)
Inferred effect dependencies and inlined jsx (both experimental
features) rely on `InferReactivePlaces` to determine their dependencies.


Since adding type inference for phi nodes
(https://github.com/facebook/react/pull/30796), we have been incorrectly
inferring stable-typed value blocks (e.g. `props.cond ? setState1 :
setState2`) as non-reactive. This fix patches InferReactivePlaces
instead of adding a new pass since we want non-reactivity propagated
correctly
2025-04-25 15:42:40 -04:00
mofeiZ
0c28a09eef [ci] Reduce non-deterministic builds for eslint-plugin-react-hooks (#33026)
See https://github.com/rollup/plugins/issues/1425

Currently, `@babel/helper-string-parser/lib/index.js` is either emitted
as a wrapped esmodule or inline depending on the ordering of async
functions in `rollup/commonjs`. Specifically,
`@babel/types/lib/definitions/core.js` is cyclic (i.e. transitively
depends upon itself), but sometimes
`@babel/helper-string-parser/lib/index.js` is emitted before this is
realized.


A relatively straightforward patch is to wrap all modules (see
https://github.com/rollup/plugins/issues/1425#issuecomment-1465626736).
This only regresses `eslint-plugin-react-hooks` bundle size by ~1.8% and
is safer (see
https://github.com/rollup/plugins/blob/master/packages/commonjs/README.md#strictrequires)

> The default value of true will wrap all CommonJS files in functions
which are executed when they are required for the first time, preserving
NodeJS semantics. This is the safest setting and should be used if the
generated code does not work correctly with "auto". Note that
strictRequires: true can have a small impact on the size and performance
of generated code, but less so if the code is minified.

(note that we're on an earlier version of `@rollup/commonjs` which does
not default to `strictRequires: true`)
2025-04-25 14:26:59 -04:00
Sebastian Markbåge
143d3e1b89 [Fizz] Emit link rel="expect" to block render before the shell has fully loaded (#33016)
The semantics of React is that anything outside of Suspense boundaries
in a transition doesn't display until it has fully unsuspended. With SSR
streaming the intention is to preserve that.

We explicitly don't want to support the mode of document streaming
normally supported by the browser where it can paint content as tags
stream in since that leads to content popping in and thrashing in
unpredictable ways. This should instead be modeled explictly by nested
Suspense boundaries or something like SuspenseList.

After the first shell any nested Suspense boundaries are only revealed,
by script, once they're fully streamed in to the next boundary. So this
is already the case there. However, for the initial shell we have been
at the mercy of browser heuristics for how long it decides to stream
before the first paint.

Chromium now has [an API explicitly for this use
case](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#stabilizing_page_state_to_make_cross-document_transitions_consistent)
that lets us model the semantics that we want. This is always important
but especially so with MPA View Transitions.

After this a simple document looks like this:

```html
<!DOCTYPE html>
<html>
  <head>
     <link rel="expect" href="#«R»" blocking="render"/>
  </head>
  <body>
    <p>hello world</p>
    <script src="bootstrap.js" id="«R»" async=""></script>
    ...
  </body>
</html>
```

The `rel="expect"` tag indicates that we want to wait to paint until we
have streamed far enough to be able to paint the id `"«R»"` which
indicates the shell.

Ideally this `id` would be assigned to the root most HTML element in the
body. However, this is tricky in our implementation because there can be
multiple and we can render them out of order.

So instead, we assign the id to the first bootstrap script if there is
one since these are always added to the end of the shell. If there isn't
a bootstrap script then we emit an empty `<template
id="«R»"></template>` instead as a marker.

Since we currently put as much as possible in the shell if it's loaded
by the time we render, this can have some negative effects for very
large documents. We should instead apply the heuristic where very large
Suspense boundaries get outlined outside the shell even if they're
immediately available. This means that even prerenders can end up with
script tags.

We only emit the `rel="expect"` if you're rendering a whole document.
I.e. if you rendered either a `<html>` or `<head>` tag. If you're
rendering a partial document, then we don't really know where the
streaming parts are anyway and can't provide such guarantees. This does
apply whether you're streaming or not because we still want to block
rendering until the end, but in practice any serialized state that needs
hydrate should still be embedded after the completion id.
2025-04-25 11:52:28 -04:00
Sebastian Markbåge
693803a9bb Rename Suspense unstable_name to name (#33014)
This was only used by Transition Tracing which isn't really used
anywhere.

However, we want to start using it for other DevTools.
2025-04-24 16:53:34 -04:00
lauren
24dfad3abb [compiler] Add changelog (#32983)
Adds CHANGELOG.md.

This entry contains changes from the very first beta
`19.0.0-beta-9ee70a1-20241017` to `19.1.0-rc.1`.
2025-04-24 16:20:02 -04:00
lauren
bb74190c26 [mcp] Convert docs resource to tool (#33009)
Seems to work better as a tool. Also it now returns plaintext instead of
markdown.
2025-04-24 14:57:44 -04:00
lauren
5010364d34 [chore] Update caniuse-lite (#33013)
silence annoying warnings

```
npx update-browserslist-db@latest
```
2025-04-24 13:50:03 -04:00
lauren
9938f83ca2 [compiler] Emit CompileSkip before CompileSuccess event (#33012)
Previously the CompileSuccess event would emit first before CompileSkip,
so the lsp's codelens would incorrectly flag skipped components/hooks
(via 'use no memo') as being optimized.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33012).
* __->__ #33012
* #33011
* #33010
2025-04-24 13:30:36 -04:00
lauren
2af218a728 [forgive][ez] Tweak logging (#33011)
Just some tweaks

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33011).
* #33012
* __->__ #33011
* #33010
2025-04-24 13:30:16 -04:00
lauren
b06bb35ce9 [forgive] Don't look up user babel configs (#33010)
Projects with existing babel config files may confuse the LSP, so
explictly opt out of looking them up.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33010).
* #33012
* #33011
* __->__ #33010
2025-04-24 13:29:56 -04:00
Sebastian "Sebbie" Silbermann
197d6a0403 [devtools] 1st class support of used Thenables (#32989)
Co-authored-by: Ruslan Lesiutin <rdlesyutin@gmail.com>
2025-04-24 13:46:31 +02:00
lauren
ad09027c16 [compiler] Add missing copyrights (#33004)
`yarn copyright`
2025-04-23 22:04:44 -04:00
lauren
8b9629c810 [compiler] Fix copyright script (#33003)
Don't try to open directories
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33003).
* #33004
* __->__ #33003
* #33002

---------

Co-authored-by: Jordan Brown <jmbrown@meta.com>
2025-04-23 21:55:24 -04:00
lauren
3a5335676f [forgive] Polish decorations (#33002)
Polishes up decorations.

Co-authored-by: Jordan Brown <jmbrown@meta.com>
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33002).
* #33004
* #33003
* __->__ #33002

Co-authored-by: Jordan Brown <jmbrown@meta.com>
2025-04-23 21:55:15 -04:00
lauren
b75af04670 [forgive] Don't crash if we couldn't compile (#33001)
Compiler shouldn't crash Forgive if it can't compile (eg parse error due
to being mid-typing).

Co-authored-by: Jordan Brown <jmbrown@meta.com>
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33001).
* #33002
* __->__ #33001
* #33000

---------

Co-authored-by: Jordan Brown <jmbrown@meta.com>
2025-04-23 21:32:11 -04:00
lauren
f765082996 [forgive] Add code action to remove dependency array (#33000)
Adds a new codeaction event in the compiler and handler in forgive. This
allows you to remove a dependency array when you're editing a range that
is within an autodep eligible function.

Co-authored-by: Jordan Brown <jmbrown@meta.com>
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33000).
* #33002
* #33001
* __->__ #33000

Co-authored-by: Jordan Brown <jmbrown@meta.com>
2025-04-23 21:31:57 -04:00
lauren
7b21c46489 [forgive] Refactor inferred deps (#32999)
Refactor.

Co-authored-by: Jordan Brown <jmbrown@meta.com>
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32999).
* #33002
* #33001
* #33000
* __->__ #32999

Co-authored-by: Jordan Brown <jmbrown@meta.com>
2025-04-23 21:27:22 -04:00
lauren
e25e8c7575 [forgive] Hacky first pass at adding decorations for inferred deps (#32998)
Draws basic decorations for inferred deps on hover.

Co-authored-by: Jordan Brown <jmbrown@meta.com>
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32998).
* #33002
* #33001
* #33000
* #32999
* __->__ #32998

Co-authored-by: Jordan Brown <jmbrown@meta.com>
2025-04-23 21:21:44 -04:00
lauren
cd7d236682 [forgive] Emit AutoDepsDecoration event when inferring effect deps (#32997)
Emits a new event for decorating inferred effect dependencies.

Co-authored-by: Jordan Brown <jmbrown@meta.com>
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32997).
* #33002
* #33001
* #33000
* #32999
* #32998
* __->__ #32997
* #32996

---------

Co-authored-by: Jordan Brown <jmbrown@meta.com>
2025-04-23 20:51:38 -04:00
lauren
71d0896a4a [forgive] Log inferEffectDependencies (#32996)
This was missed earlier.


Co-authored-by: Jordan Brown <jmbrown@meta.com>
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32996).
* #33002
* #33001
* #33000
* #32999
* #32998
* #32997
* __->__ #32996

Co-authored-by: Jordan Brown <jmbrown@meta.com>
2025-04-23 20:49:25 -04:00
Hendrik Liebau
914319ae59 [Flight] Don't hang forever when prerendering a rejected promise (#32953) 2025-04-23 11:02:43 +02:00
Sebastian Markbåge
3ef31d196a Implement Partial Hydration for Activity (#32863)
Stacked on #32862 and #32842.

This means that Activity boundaries now act as boundaries which can have
their effects mounted independently. Just like Suspense boundaries, we
hydrate the outer content first and then start hydrating the content in
an Offscreen lane. Flowing props or interacting with the content
increases the priority just like Suspense boundaries.

This skips emitting even the comments for `<Activity mode="hidden">` so
we don't hydrate those. Instead those are deferred to a later client
render.

The implementation are just forked copies of the SuspenseComponent
branches and then carefully going through each line and tweaking it.

The main interesting bit is that, unlike Suspense, Activity boundaries
don't have fallbacks so all those branches where you might commit a
suspended tree disappears. Instead, if something suspends while
hydration, we can just leave the dehydrated content in place. However,
if something does suspend during client rendering then it should bubble
up to the parent. Therefore, we have to be careful to only
pushSuspenseHandler when hydrating. That's really the main difference.

This just uses the existing basic Activity tests but I've started work
on port all of the applicable Suspense tests in SelectiveHydration-test
and PartialHydration-test to Activity versions.
2025-04-22 21:00:30 -04:00
Sebastian Markbåge
17f88c80ed Implement ActivityInstance in FiberConfigDOM (#32842)
Stacked on #32851 and #32900.

This implements the equivalent Configs for ActivityInstance as we have
for SuspenseInstance. These can be implemented as comments but they
don't have to be and can be implemented differently in the renderer.

This seems like a lot duplication but it's actually ends mostly just
calling the same methods underneath and the wrappers compiles out.

This doesn't leave the Activity dehydrated yet. It just hydrates into it
immediately.
2025-04-22 19:44:14 -04:00
Sebastian Markbåge
3fbd6b7b50 Set hidden Offscreen to the shellBoundary regardless of previous state (#32844)
I think this was probably just copy-paste from the Suspense path.

It shouldn't matter what the previous state of an Offscreen boundary
was. What matters is that it's now hidden and therefore if it suspends,
we can just leave it as is without the tree becoming inconsistent.
2025-04-22 19:39:09 -04:00
Sebastian Markbåge
ebf7318e87 Hide/unhide the content of dehydrated suspense boundaries if they resuspend (#32900)
Found this bug while working on Activity. There's a weird edge case when
a dehydrated Suspense boundary is a direct child of another Suspense
boundary which is hydrated but then it resuspends without forcing the
inner one to hydrate/delete.

It used to just leave that in place because hiding/unhiding didn't deal
with dehydrated fragments.

Not sure this is really worth fixing.
2025-04-22 19:29:12 -04:00
Hendrik Liebau
620c838fb6 Build react-server-dom-webpack for codesandbox (#32990)
This allows us to test Flight changes in a codesandbox.

[Example](https://codesandbox.io/p/devbox/zkjk7y)
2025-04-22 22:20:21 +02:00
lauren
7213509649 [compiler] Only append hash and date for experimental releases (#32981)
No need to append these for non experimental/beta releases.
2025-04-21 15:10:51 -04:00
lauren
4c54da77fb [ci] Change to string type (#32980)
to no one's surprise, the `number` type appears to be cursed in GH
actions for workflow dispatch. switch to string
2025-04-21 14:56:51 -04:00
lauren
efd890422d [compiler] Fix version name in publish script (#32979)
Add ability to specify an optional tagVersion which is appended to the
version name + tag, eg

19.1.0-rc.1
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32979).
* __->__ #32979
* #32978
2025-04-21 14:43:20 -04:00
lauren
b303610c33 [eprh] Bump stable version (#32978)
https://www.npmjs.com/package/eslint-plugin-react-hooks/v/6.0.0 was just
released, so we can bump this now.
2025-04-21 14:36:13 -04:00
lauren
fea92d8462 [ci] Remove compiler weekly release (#32977)
No longer needed.
2025-04-21 13:47:50 -04:00
Sebastian "Sebbie" Silbermann
bc6184dd99 [devtools] Fix "View source" for sources with URLs that aren't normalized (#32951) 2025-04-17 21:56:05 +02:00
lauren
ce578f9c59 [compiler] Update publish tags (#32952)
Adds missing tag.
2025-04-17 13:13:50 -04:00
lauren
45d942f94a [mcp] Also emit bailout messages with no loc (#32937)
Not every bailout will contain a loc (could be synthetic)
2025-04-17 13:11:55 -04:00
Jordan Brown
b8bedc267f [compiler][autodeps/fire] Do not include fire functions in autodep arrays (#32532)
Summary: We landed on not including fire functions in dep arrays. They
aren't needed because all values returned from the useFire hook call
will read from the same ref. The linter will error if you include a
fired function in an explicit dep array.

Test Plan: yarn snap --watch

--
2025-04-17 13:03:19 -04:00
lauren
4a36d3eab7 [ci] Only label on PR open (#32936)
No reason to label it every update, only do it once when it's first
opened.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32936).
* __->__ #32936
* #32935
2025-04-16 18:01:55 -04:00
lauren
2ddf8caa9d [ci] Fix check_access again (#32935)
I can see the value being output and set correctly but not sure why it's
skipping the 2nd job.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32935).
* #32936
* __->__ #32935
2025-04-16 18:00:25 -04:00
lauren
95ff37f5f5 [mcp] Iterate on prompt (#32932)
v2
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32932).
* __->__ #32932
* #32931
* #32930
* #32929
* #32928
2025-04-16 17:49:25 -04:00
lauren
3c75bf21dd [mcp] Fix bailout loc (#32931)
Use the correct loc line numbers and not [Object:object]
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32931).
* #32932
* __->__ #32931
* #32930
* #32929
* #32928
2025-04-16 17:49:15 -04:00
lauren
3e04b2a214 [mcp] Refine passes returned (#32930)
Adds some new options to request the HIR, ReactiveFunction passes
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32930).
* #32932
* #32931
* __->__ #32930
* #32929
* #32928
2025-04-16 17:49:04 -04:00
lauren
fc21d5a7db [mcp] Dedupe docs (#32929)
Previously the resource would return a bunch of dupes because the
algolia results would return multiple hashes (headings) for the same
url.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32929).
* #32932
* #32931
* #32930
* __->__ #32929
* #32928
2025-04-16 17:48:53 -04:00
lauren
35ab8ffef7 [mcp] Add inspect script (#32928)
Uses https://github.com/modelcontextprotocol/inspector to inspect and
debug the mcp server.

`yarn workspace react-mcp-server dev` will build the server in watch
mode and launch the inspector. Default address is http://127.0.0.1:6274.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32928).
* #32932
* #32931
* #32930
* #32929
* __->__ #32928
2025-04-16 17:48:38 -04:00
lauren
68013725ac [ci] Fix check_access fr (#32934)
💀
2025-04-16 17:39:24 -04:00
lauren
bf39780a06 [ci] Fix check_access output (#32933)
the joy of yml
2025-04-16 17:27:09 -04:00
Sebastian Markbåge
b04254fdce Don't try to hydrate a hidden Offscreen tree (#32862)
I found a bug even before the Activity hydration stuff.

If we're hydrating an Offscreen boundary in its "hidden" state it won't
have any content to hydrate so will trigger hydration errors (which are
then eaten by the Offscreen boundary itself). Leaving it not prewarmed.

This doesn't happen in the simple case because we'd be hydrating at a
higher priority than Offscreen at the root, and those are deferred to
Offscreen by not having higher priority. However, we've hydrating at the
Offscreen priority, which we do inside Suspense boundaries, then it
tries to hydrate against an empty set.

I ended up moving this to the Activity boundary in a future PR since
it's the SSR side that decided where to not render something and it only
has a concept of Activity, no Offscreen.


1dc05a5e22 (diff-d5166797ebbc5b646a49e6a06a049330ca617985d7a6edf3ad1641b43fde1ddfR1111)
2025-04-15 17:43:42 -04:00
Sebastian Markbåge
539bbdbd86 Warn if you pass a hidden prop to Activity (#32916)
Since `hidden` is a prop on arbitrary DOM elements it's a common mistake
to think that it would also work that way on `<Activity>` but it
doesn't. In fact, we even had this mistakes in our own tests.

Maybe there's an argument that we should actually just support it but we
also have more modes planned.

So this adds a warning. It should also already be covered by TypeScript.
2025-04-15 17:17:22 -04:00
lauren
e71d4205ae [ci] Don't run some checks for non-members/collaborators (#32918)
There's really no need to even run the workflow for non-members or
collaborators for the labeling and discord notification workflows. We
can exit early.
2025-04-15 13:02:16 -04:00
lauren
2ed34eba0d Update @playwright/test (#32917)
Routine update.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32917).
* #32918
* __->__ #32917
2025-04-15 12:52:43 -04:00
Jorge (Hezi) Cohen
707b3fc6b2 [DevTools] Make Toggle hover state more visible (#32914)
This change adds a background color to Toggles to make them easier to
see. This is especially important when DevTools are not in focus, and
it's harder to see.

Test plan:
1. `yarn build:chrome:local`
2. Inspect components 
3. Hover over "Select an Element in page to inspect it"
4. Observe background change
2025-04-15 11:20:29 +01:00
Piotr Tomczewski
7ff4d057b6 [DevTools] feat: show changed hooks names in the Profiler tab (#31398)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

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

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

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

## Summary

This PR adds support for displaying the names of changed hooks directly
in the Profiler tab, making it easier to identify specific updates.

A `HookChangeSummary` component has been introduced to show these hook
names, with a `displayMode` prop that toggles between `“compact”` for
tooltips and `“detailed”` for more in-depth views. This keeps tooltip
summaries concise while allowing for a full breakdown where needed.

This functionality also respects the `“Always parse hook names from
source”` setting from the Component inspector, as it uses the same
caching mechanism already in place for the Components tab. Additionally,
even without hook names parsed, the Profiler will now display hook types
(like `State`, `Callback`, etc.) based on data from `inspectedElement`.

To enable this across the DevTools, `InspectedElementContext` has been
moved higher in the component tree, allowing it to be shared between the
Profiler and Components tabs. This update allows hook name data to be
reused across tabs without duplication.

Additionally, a `getAlreadyLoadedHookNames` helper function was added to
efficiently access cached hook names, reducing the need for repeated
fetching when displaying changes.

These changes improve the ability to track specific hook updates within
the Profiler tab, making it clearer to see what’s changed.

### Before
Previously, the Profiler tab displayed only the IDs of changed hooks, as
shown below:
<img width="350" alt="Screenshot 2024-11-01 at 12 02 21_cropped"
src="https://github.com/user-attachments/assets/7a5f5f67-f1c8-4261-9ba3-1c76c9a88af3">

### After (without hook names parsed)
When hook names aren’t parsed, custom hooks and hook types are displayed
based on the inspectedElement data:
<img width="350" alt="Screenshot 2024-11-01 at 12 03 09_cropped"
src="https://github.com/user-attachments/assets/ed857a6d-e6ef-4e5b-982c-bf30c2d8a7e2">

### After (with hook names parsed)
Once hook names are fully parsed, the Profiler tab provides a complete
breakdown of specific hooks that have changed:
<img width="350" alt="Screenshot 2024-11-01 at 12 03 14_cropped"
src="https://github.com/user-attachments/assets/1ddfcc35-7474-4f4d-a084-f4e9f993a5bf">

This should resolve #21856 🎉
2025-04-15 11:10:00 +01:00
lauren
08075929f2 [compiler] Init react-mcp-server (#32859)
Just trying this out as a small hack for fun. Nothing serious is
planned.

Inits an MCP server that has 1 assistant prompt and two capabilities.
2025-04-14 18:39:00 -04:00
lauren
4eea4fcf41 [compiler] Update rimraf (#32868)
Just updating the compiler workspace package.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32868).
* #32859
* __->__ #32868
2025-04-14 15:15:14 -04:00
lauren
58e9a4b74f Upgrade node.js to 20 LTS (#32855)
Try to upgrade our node version.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32855).
* #32861
* #32860
* #32859
* __->__ #32855
2025-04-14 12:52:02 -04:00
Jordan Eldredge
39cad7afc4 Remove redundant __DEV__ condition (#32810)
It used to be that in `__DEV__` we wrapped this `renderWithHooks`,
`checkDidRenderIdHook` pair in calls to `setIsRendering()`. However,
that dev-only bookkeeping was removed in
https://github.com/facebook/react/pull/29206 leaving this redundant
check which runs identical code in dev and in prod.

## Test Plan

* Manually confirm both cases are the same
* GitHub CI tests
2025-04-11 14:39:36 -07:00
Sebastian Markbåge
1d6c8168db Clear Update flag for unchanged trees in the beginning of the commit phase (#32849)
We use the Update flag to track if a View Transition had any mutations
or relayout. Unlike the other usage of it, this is just temporary state
during the commit phase.

Normally the flags gets used in the render phase and we reset it when we
rerender but in the case of "nested" updates, those trees didn't update.
We're only looking for relayouts. So we need to manually reset it before
we start using it.

We probably shouldn't abuse the Update flag for this and instead use
something like temporary state on ViewTransitionState.
2025-04-11 10:54:06 -04:00
Sebastian Markbåge
961b625ab5 Try not. Do... or do not. Hydrate Suspense Boundaries. (#32851)
Assertively claim a SuspenseInstance. We already know we're hydrating.

If there's no match, it throws anyway. So there's no other code path.
2025-04-11 10:52:23 -04:00
Sebastian Markbåge
8a3c5e1a8d Emit Preamble Contribution inline instead of the end of a boundary (#32850)
This lets us write them early in the render phase.

This should be safe because even if we write them deeply, then they
still can't be wrapped by a element because then they'd no longer be in
the document scope anymore. They end up flat in the body and so when we
search the content we'll discover them.
2025-04-10 19:42:03 -04:00
Rubén Norte
5e9b48778c [RN] Map Fabric priorities to reconciler priorities correctly (#32847)
## Summary

This fixes how we map priorities between Fabric and the React
reconciler. At the moment, we're only considering default and discrete
priorities, when there's a larger range of priorities available.

In Fabric, we'll test supporting additional priorities soon. For that
test to do something useful, we need the new priorities to be mapped to
reconciler priorities correctly, which is what this change is done.

> [!IMPORTANT]
> At the moment, this is a no-op because Fabric is only reporting
default and discrete event priorities.

## How did you test this change?

Will test e2e on React Native on top of
https://github.com/facebook/react-native/pull/50627

The changes are gated in React Native, so we'll use that feature flag to
test this.
2025-04-10 17:35:15 +01:00
Sebastian Markbåge
c44e4a2505 Move Built-in Props Types to React Types (#32841)
Stacked on #32838.

We don't always type the Props of built-ins. This adds typing for most
of the built-ins.

When we did type them, we used to put it in the `ReactFiber...Component`
files but any public API like this can be implemented in other renderers
too such as Fizz. So I moved them to `shared/ReactTypes` which is where
we put other public API types (that are not already built-in to Flow).
That way Fizz can import them and assert properly when it accesses the
props.
2025-04-09 22:44:44 -04:00
Sebastian Markbåge
31ecc9804a Clarify that there's three different kinds of OffscreenProps (#32838)
ActivityProps - Public API
LegacyHiddenProps - Public Legacy API
OffscreenProps - Internal implementation detail
2025-04-09 22:22:45 -04:00
lauren
ff697fc58b [eprh] Temporarily disable ref access in render validation (#32839)
This rule currently has a few false positives, so let's disable it for
now (just in the eslint rule, it's still enabled in the compiler) while
we iterate on it.
2025-04-09 14:49:31 -04:00
Sebastian "Sebbie" Silbermann
096dd7385d Send notification to Discord if automated publish fails (#32840) 2025-04-09 19:14:28 +02:00
michael faith
717584167b docs(eslint-plugin-react-hooks): add 6.0 documentation (#32513)
This change adds the details for using the 6.0+ version of the flat
recommended config.

Co-authored-by: lauren <poteto@users.noreply.github.com>
2025-04-09 12:42:23 -04:00
Sebastian Markbåge
3fbfb9baaf Emit Activity boundaries as comments in Fizz (#32834)
Uses `&` for Activity as opposed to `$` for Suspense. This will be used
to delimitate which nodes we can skip hydrating.

This isn't used on the client yet. It's just a noop on the client
because it's just an unknown comment. This just adds the SSR parts.
2025-04-09 10:59:52 -04:00
Andrew Clark
8571249eb8 Add unstable_Activity to server entrypoint (#32833)
Activity is a client component, but you should still be able to import
it and render it from a Server Component. Same as what we do with other
types like Suspense and ViewTransition.
2025-04-09 01:49:27 -04:00
Sebastian Markbåge
8da36d0508 Enable Suspensey Images inside <ViewTransition> subtrees (#32820)
Even if the `enableSuspenseyImages` flag is off.

Started View Transitions already wait for Suspensey Fonts and this is
another Suspensey feature that is even more important for View
Transitions - even though we eventually want it all the time. So this
uses `<ViewTransition>` as an early opt-in for that tree into Suspensey
Images, which we can ship in a minor.

If you're doing an update inside a ViewTransition then we're eligible to
start a ViewTransition in any Transition that might suspend. Even if
that doesn't end up animating after all, we still consider it Suspensey.
We could try to suspend inside the startViewTransition but that's not
how it would work with `enableSuspenseyImages` on and we can't do that
for startGestureTransition.

Even so we still need some opt-in to trigger the Suspense fallback even
before we know whether we'll animate or not. So the simple solution is
just that `<ViewTransition>` opts in the whole subtree into Suspensey
Images in general.

In this PR I disable `enableSuspenseyImages` in experimental so that we
can instead test the path that only enables it inside `<ViewTransition>`
tree since that's the path that would next graduate to a minor.
2025-04-08 17:55:15 -04:00
Sebastian Markbåge
ea05b750a5 Allow Passing Blob/File/MediaSource/MediaStream to src of <img>, <video> and <audio> (#32828)
Behind the `enableSrcObject` flag. This is revisiting a variant of what
was discussed in #11163.

Instead of supporting the [`srcObject`
property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/srcObject)
as a separate name, this adds an overload of `src` to allow objects to
be passed. The DOM needs to add separate properties for the object forms
since you read back but it doesn't make sense for React's write-only API
to do that. Similar to how we'll like add an overload for
`popoverTarget` instead of calling it `popoverTargetElement` and how
`style` accepts an object and it's not `styleObject={{...}}`.

There are a number of reason to revisit this.

- It's just way more convenient to have this built-in and it makes
conceptual sense. We typically support declarative APIs and polyfill
them when necessary.
- RSC supports Blobs and by having it built-in you don't need a Client
Component wrapper to render it where as doing it with effects would
require more complex wrappers. By picking Blobs over base64,
client-navigations can use the more optimized binary encoding in the RSC
protocol.
- The timing aspect of coordinating it with Suspensey images and image
decoding is a bit tricky to get right because if you set it in an effect
it's too late because you've already rendered it.
- SSR gets complicated when done in user space because you have to
handle both branches. Likely with `useSyncExternalStore`.
- By having it built-in we could optimize the payloads shared between
RSC payloads embedded in the HTML and data URLs.

This does not support objects for `<source src>` nor `<img srcset>`.
Those don't really have equivalents in the DOM neither. They're mainly
for picking an option when you don't know programmatically. However, for
this use case you're really better off picking a variant before
generating the blobs.

We may support Response objects in the future too as per
https://github.com/whatwg/fetch/issues/49
2025-04-08 12:11:41 -04:00
Hendrik Liebau
3366146796 Fix Failed to execute 'measure' on 'Performance' error (#32823)
When `startTime` still has its initial value of `-1.1` we must not call
`logComponentMount`. This can occur when rendering a `'next/dynamic'`
component with `{ssr: false}` in a client component, for example.
Unfortunately, I didn't manage to reproduce this scenario in a unit
test.
2025-04-07 10:13:34 -04:00
Sebastian Markbåge
365c031fd2 Workaround against display: inline bug in Safari (#32822)
Safari has a bug where if you put a block element inside an inline
element and the inline element has a `view-transition-name` assigned it
finds it as duplicate names.

https://bugs.webkit.org/show_bug.cgi?id=290923

This adds a warning if we detect this scenario in dev mode.

For the case where it renders into a single block, we can model this by
making the parent either `block` or `inline-block` automatically to fix
the issue. So we do that to automatically cover simple cases like
`<a><div>...</div></a>`. This unfortunately causes layout/styling thrash
so we might want to delete it once the bug has been fixed in enough
Safari versions.
2025-04-07 10:08:07 -04:00
Jason Zhang
a9d63f3f97 fix: incorrect type in getTypeSymbol (#32825)
`getTypeSymbol` also returns string
2025-04-07 10:51:28 +01:00
Andrew Clark
6a7650c75c [Bugfix] Infinite uDV loop in popstate event (#32821)
Found a bug that occurs during a specific combination of very subtle
implementation details.

It occurs sometimes (not always) when 1) a transition is scheduled
during a popstate event, and 2) as a result, a new value is passed to an
already-mounted useDeferredValue hook.

The fix is relatively straightforward, and I found it almost
immediately; it took a bit longer to figure out exactly how the scenario
occurred in production and create a test case to simulate it.

Rather than couple the test to the implementation details, I've chosen
to keep it as high-level as possible so that it doesn't break if the
details change. In the future, it might not be trigger the exact set of
internal circumstances anymore, but it could be useful for catching
similar bugs because it represents a realistic real world situation —
namely, switching tabs repeatedly in an app that uses useDeferredValue.
2025-04-05 00:49:28 -04:00
Sebastian Markbåge
efb22d8850 Add Suspensey Images behind a Flag (#32819)
We've known we've wanted this for many years and most of the
implementation was already done for Suspensey CSS. This waits to commit
until images have decoded by default or up to 500ms timeout (same as
suspensey fonts).

It only applies to Transitions, Retries (Suspense), Gesture Transitions
(flag) and Idle (doesn't exist). Sync updates just commit immediately.

`<img loading="lazy" src="..." />` opts out since you explicitly want it
to load lazily in that case.

`<img onLoad={...} src="..." />` also opts out since that implies you're
ok with managing your own reveal.

In the future, we may add an opt in e.g. `<img blocking="render"
src="..." />` that opts into longer timeouts and re-suspends even sync
updates. Perhaps also triggering error boundaries on errors.

The rollout for this would have to go in a major and we may have to
relax the default timeout to not delay too much by default. However, we
can also make this part of `enableViewTransition` so that if you opt-in
by using View Transitions then those animations will suspend on images.
That we could ship in a minor.
2025-04-04 14:54:05 -04:00
Sebastian Markbåge
540cd65252 Log Mount/Unmount/Reconnect/Disconnect in the Component Track (#32816)
Stacked on #32815.

To be able to differentiate mounted subtrees from updated subtrees. This
adds a yellow entry above the component subtree that mounted. This is
added both to the render phase, mutation effect phase, layout effect
phase and passive effect phase.

<img width="962" alt="Screenshot 2025-04-03 at 10 41 02 PM"
src="https://github.com/user-attachments/assets/13777347-07e8-458c-9127-8675ef08b54f"
/>

Ideally we could probably give an annotation to the component instead of
adding a whole other line which is also a color that's kind of
distracting. However, not all components are included and keeping track
of which one is the first one below is kind of annoying. Adding a marker
to all components is kind of noisy. So this is a compromise. It's only
one per depth so it won't make it too deep even on larger trees.

If this is an unmount, those are added to the mutation effect phase for
the layout unmounts and passive unmount effect phase. Since these never
have a render, they're not in the render phase.

<img width="1010" alt="Screenshot 2025-04-03 at 11 05 57 PM"
src="https://github.com/user-attachments/assets/ab39f27e-13be-4281-94fa-9391bb293fd2"
/>

For showing / hiding `<Activity>` the terminology "Reconnect" and
"Disconnect" is used instead.
2025-04-03 23:33:29 -04:00
Sebastian Markbåge
c0f08ae74a Fix Bugs Measuring Performance Track for Effects (#32815)
This fixes two bugs with commit phase effect tracking.

I missed, or messed up the rebase for, deletion effects when a subtree
was deleted and for passive disconnects when a subtree was hidden.

The other bug is that when I started using self time
(componentEffectDuration) for color and for determining whether to
bother logging an entry, I didn't consider that the component with
effects can have children which end up resetting this duration before we
log. Which lead to most effects not having their components logged since
they almost always have children.

We don't necessarily have to push/pop but we have to store at least one
thing on the stack unfortunately. That's because we have to do the
actual log after the children to get the right end time. So might as
well use the push/pop strategy like the rest of them.
2025-04-03 23:33:14 -04:00
Sebastian Markbåge
b10cb4c01e [DevTools] Release and aquire host instances when they're cloned in persistent mode (#32812)
In persistent mode they can change when they're closned and so we need
to release the old copy and acquire the new copy.
2025-04-03 10:06:04 -04:00
Ruslan Lesiutin
f0c767e2a2 feat[devtools]: display native tag for host components for Native (#32762)
Native only. Displays the native tag for Native Host components inside a
badge, when user inspects the component.

Only displaying will be supported for now, because in order to get
native tags indexable, they should be part of the bridge operations,
which is technically a breaking change that requires significantly more
time investment.

The text will only be shown when user hovers over the badge.
![Screenshot 2025-03-26 at 19 46
40](https://github.com/user-attachments/assets/787530cf-c5e5-4b85-8e2a-15b006a3d783)
2025-04-02 22:44:38 +01:00
Sebastian Markbåge
b2f6365745 Minor Tweak to Performance Track (#32808)
Rename "Suspended" commit to "Suspended on CSS" since that's the only
reason for this particular branch. This will not hold true because with
suspended images and with view transitions those can also be the reason.
So in the future we need to add those.

Only log "Blocked" in the components track if we yield for 3ms or
longer. It's common to have like 1-2ms yield times for various reasons
going on which is not worth the noise to consider "blocking".

Rename "Blocked" to "Update" in the Blocking/Transition tracks. This is
when a setState happens and with stack traces it's where you should look
for the stack trace of the setState. So we want to indicate that this is
the "Update".

I only added the "Blocked" part if we're blocked for more than 5ms
before we can start rendering - indicating that some other track was
working at the same time and preventing us from rendering.
2025-04-02 17:01:10 -04:00
Matt Carroll
b81c92be62 Delete CHANGELOG-canary.md (#32807)
This is no longer being used or updated
2025-04-02 16:05:43 -04:00
Sebastian Markbåge
040f8286e9 Follow through all the phases when an error happens during snapshotting (#32803)
This can happen for example if you have duplicate names in the "old"
state. This errors the transition before the updateCallback is invoked
so we haven't yet applied mutations etc.

This runs through those phases after the error to get us back to a
consistent state.
2025-04-02 10:49:44 -04:00
Sebastian Markbåge
450f8df886 Clarify that the transitionLanes used by Transition Tracing is a LaneMap (#32800)
We have a high level concept for this used elsewhere.

We should use this for `transitionTypes` too:


https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactInternalTypes.js#L285

As mentioned in #32797 we could also just use the `transitionLanes`
since the `types` are also on the `Transition` objects. If we always
stored this set.
2025-04-01 18:18:33 -04:00
Sebastian Markbåge
7a728dffd1 Don't shadow EventListenerOptionsOrUseCapture and FocusOptions types (#32801)
These are built-in to Flow.
2025-04-01 14:22:57 -04:00
Sebastian Markbåge
e5dd82a79d Warn for using a React owned node as a Container if it also has text content (#32774)
The problem with setting both `children` or `dangerouslySetInnerHTML`
and also using a ref on a DOM node to either manually append children or
using it as a Container for `createRoot` or `createPortal` is that it's
ambiguous which children should win. Ideally you use one of the four
options to control children. Meaning that ideally you always use a leaf
container for refs like this.

Unfortunately it's very common to use a React owned thing with children
as a Container of a Portal. For example `document.body` can have both
regular React children and be used as a Portal container. This isn't
really fully supported and has some undefined behavior like relative
order isn't guaranteed but still very common.

It is extra bad if the children are a `string`/`number` or if
`dangerouslySetInnerHTML` is set. Because then when ever that reactively
updates it'll clear out any manually added DOM nodes. When this happens
isn't guaranteed. It's always happening as far as the reactivity is
concerned. See https://github.com/facebook/react/issues/31600

Therefore, we should warn for this specific pattern. This still allows
non-text children as a compromise even though that behavior is also
somewhat undefined.
2025-04-01 12:11:29 -04:00
Sebastian Markbåge
731ae3e0ad Solidify addTransitionType Semantics (#32797)
Stacked on #32793.

This is meant to model the intended semantics of `addTransitionType`
better. The previous hack just consumed all transition types when any
root committed so it could steal them from other roots. Really each root
should get its own set. Really each transition lane should get its own
set.

We can't implement the full ideal semantics yet because 1) we currently
entangle transition lanes 2) we lack `AsyncContext` on the client so for
async actions we can't associate a `addTransitionType` call to a
specific `startTransition`.

This starts by modeling Transition Types to be stored on the Transition
instance. Conceptually they belong to the Transition instance of that
`startTransition` they belong to. That instance is otherwise mostly just
used for Transition Tracing but it makes sense that those would be able
to be passed the Transition Types for that specific instance.

Nested `startTransition` need to get entangled. So that this
`addTransitionType` can be associated with the `setState`:

```js
startTransition(() => {
  startTransition(() => {
    addTransitionType(...)
  });
  setState(...);
});
```

Ideally we'd probably just use the same Transition instance itself since
these are conceptually all part of one entangled one. But transition
tracing uses multiple names and start times. Unclear what we want to do
with that. So I kept separate instances but shared `types` set.

Next I collect the types added during a `startTransition` to any root
scheduled with a Transition. This should really be collected one set per
Transition lane in a `LaneMap`. In fact, the information would already
be there if Transition Tracing was always enabled because it tracks all
Transition instances per lane. For now I just keep track of one set for
all Transition lanes. Maybe we should only add it if a `setState` was
done on this root in this particular `startTransition` call rather
having already scheduled any Transition earlier.

While async transitions are entangled, we don't know if there will be a
startTransition+setState on a new root in the future. Therefore, we
collect all transition types while this is happening and if a new root
gets startTransition+setState they get added to that root.

```js
startTransition(async () => {
  addTransitionType(...)
  await ...;
  setState(...);
});
```
2025-04-01 12:11:19 -04:00
Sebastian Markbåge
deca96520f Warn if addTransitionType is called when there are no pending Actions (#32793)
Stacked on #32792.

It's tricky to associate a specific `addTransitionType` call to a
specific `startTransition` call because we don't have `AsyncContext` in
browsers yet. However, we can keep track if there are any async
transitions running at all, and if not, warn. This should cover most
cases.

This also errors when inside a React render which might be a legit way
to associate a Transition Type to a specific render (e.g. based on props
changing) but we want to be a more conservative about allowing that yet.
If we wanted to support calling it in render, we might want to set which
Transition object is currently rendering but it's still tricky if the
render has `async function` components. So it might at least be
restricted to sync components (like Hooks).
2025-04-01 12:10:10 -04:00
Sebastian Markbåge
0b1a9e90c5 Support addTransitionType in startGestureTransition (#32792)
Stacked on #32788.

Normally we track `addTransitionType` globally because of the async gap
that can happen in Actions where we lack AsyncContext to associate it
with a particular Transition. This unfortunately also means it's
possible to call outside of `startTransition` which is something we want
to warn for.

We need to be able to distinguish whether `addTransitionType` is for a
regular Transition or a Gesture Transition though.

Since `startGestureTransition` is only synchronous we can track it
within that execution scope and move it to a separate set. Since we know
for sure which call owns it we can properly associate it with that
specific provider's `ScheduledGesture`.

This does not yet handle calling `addTransitionType` inside the render
phase of a gesture. That would currently still be associated with the
next Transition instead.
2025-04-01 12:08:55 -04:00
Sebastian Markbåge
8b2046d0ce Get rid of the directional gesture options (#32788)
Stacked on #32786.

`startGestureTransition` doesn't have a concept of two directions. It's
just a start and end range now.
2025-04-01 12:07:07 -04:00
Sebastian Markbåge
d20c2802b4 Adjust range start/end based on the duration and delay of the animation (#32790)
When different animations in a View Transition have different durations,
we shouldn't stretch them out to run the full range of swipe. Because
then they wouldn't line up the same way as when played using plain time.

This adjusts the range start/end to be what it would've been when played
by time. Except since we are playing animations in reverse, the
animation-delay is actually applied from the range end and then the
duration from there to get closer to the start.

Reverse the range if the original animation was reversed.

Interestingly, the range it takes can be adjusted by what is in the
viewport since if a long duration animation is excluded then everything
else adjusts too.

I left some todos too. We really should also handle if the original
animation has multiple iterations. Currently we only play those once.
2025-04-01 11:44:44 -04:00
Sebastian Markbåge
0a7cf20b22 Remove useSwipeTransition (#32786)
Stacked on #32785.

This is now replaced by `startGestureTransition` added in #32785.

I also renamed the flag from `enableSwipeTransition` to
`enableGestureTransition` to correspond to the new name.
2025-04-01 11:43:33 -04:00
Sebastian Markbåge
b286430c8a Add startGestureTransition API (#32785)
Stacked on #32783. This will replace [the `useSwipeTransition`
API](https://github.com/facebook/react/pull/32373).

Instead, of a special Hook, you can make updates to `useOptimistic`
Hooks within the `startGestureTransition` scope.

```
import {unstable_startGestureTransition as startGestureTransition} from 'react';

const cancel = startGestureTransition(timeline, () => {
  setOptimistic(...);
}, options);
```

There are some downsides to this like you can't define two directions as
once and there's no "standard" direction protocol. It's instead up to
libraries to come up with their own conventions (although we can suggest
some).

The convention is still that a gesture recognizer has two props `action`
and `gesture`. The `gesture` prop is a Gesture concept which now behaves
more like an Action but 1) it can't be async 2) it shouldn't have
side-effects. For example you can't call `setState()` in it except on
`useOptimistic` since those can be reverted if needed. The `action` is
invoked with whatever side-effects you want after the gesture fulfills.

This is isomorphic and not associated with a specific renderer nor root
so it's a bit more complicated.

To implement this I unify with the `ReactSharedInternal.T` property to
contain a regular Transition or a Gesture Transition (the `gesture`
field). The benefit of this unification means that every time we
override this based on some scope like entering `flushSync` we also
override the `startGestureTransition` scope. We just have to be careful
when we read it to check the `gesture` field to know which one it is.
(E.g. I error for setState / requestFormReset.)

The other thing that's unique is the `cancel` return value to know when
to stop the gesture. That cancellation is no longer associated with any
particular Hook. It's more associated with the scope of the
`startGestureTransition`. Since the schedule of whether a particular
gesture has rendered or committed is associated with a root, we need to
somehow associate any scheduled gestures with a root.

We could track which roots we update inside the scope but instead, I
went with a model where I check all the roots and see if there's a
scheduled gesture matching the timeline. This means that you could
"retain" a gesture across roots. Meaning this wouldn't cancel until both
are cancelled:

```
const cancelA = startGestureTransition(timeline, () => {
  setOptimisticOnRootA(...);
}, options);

const cancelB = startGestureTransition(timeline, () => {
  setOptimisticOnRootB(...);
}, options);
```

It's more like it's a global transition than associated with the roots
that were updated.

Optimistic updates mostly just work but I now associate them with a
specific "ScheduledGesture" instance since we can only render one at a
time and so if it's not the current one, we leave it for later.

Clean up of optimistic updates is now lazy rather than when we cancel.
Allowing the cancel closure not to have to be associated with each
particular update.
2025-03-31 20:05:50 -04:00
Sebastian Markbåge
d3b8ff6e58 Unify BatchConfigTransition and Transition types (#32783)
This is some overdue refactoring. The two types never made sense. It
also should be defined by isomorphic since it defines how it should be
used by renderers rather than isomorphic depending on Fiber.

Clean up hidden classes to be consistent.

Fix missing name due to wrong types. I choose not to invoke the
transition tracing callbacks if there's no name since the name is
required there.
2025-03-31 19:59:07 -04:00
Jack Pope
a7fa8702ee Remove v19 beta specific issue template (#32795)
This was a template for the 19 beta. Since 19 has been stable for a
while now, we can clean this up. Any bug report for React 19 should use
the standard bug report template.
2025-03-31 15:53:17 -04:00
Sebastian Markbåge
95671b4eb3 Mark the root as animating if any Portal mutates or resizes (#32772)
Portals and `<ViewTransition>` are tricky because they leave the React
tree. You might think of a Portal's container conceptually as also being
part of a React tree but that's not quite how they're modeled today.
They're more like their own roots. So instead, of trying to find a
conceptual place in the React tree we treat Portals as their own root.

We have two ways of tracking whether an update to a ViewTransition
boundary has occurred. Either a DOM mutation has happened within it, or
a resize of a child has caused it to potentially relayout its parent.
Normally that just follows the tree structure of React, but not when
it's a Portal.

When it's a Portal we don't know which DOM parent it might have
affected. For all we know it's at the root (and in fact, in most cases
that's where Portals go).

With this PR we mark the root as having been affected by a mutation or
resize. This means that the whole document will animate and we can't
optimize away from it. This ensures that a mutation to the root of a
Portal doesn't go unanimated with other things are animating such as its
parent.

You can regain this optimization by adding a `<ViewTransition>` boundary
directly inside the Portal itself so it owns its own animation. If that
DOM node is also absolutely positioned it doesn't leak.

Conversely this also means that a mutation inside a Portal doesn't
affect its React parent so it won't trigger its parent's animation if
this was the only thing animating. That could be unfortunate if this
container is actually inside the same React parent. However, because
this would have been an update we would've marked it for "maybe
animating" and updates can't only get their animations cancelled if the
root is cancelled, in practice this will actually animate anyway.
2025-03-31 15:13:11 -04:00
Matt Carroll
6377903074 Update 19.1 changelog to remove confusing owner stack sentance 2025-03-28 14:55:28 -07:00
Matt Carroll
095ce8a311 Fix changelog Owner Stack spelling consistency 2025-03-28 14:30:13 -07:00
Matt Carroll
18a11339c3 Update 19.1 changelog to add owner stack context 2025-03-28 14:02:11 -07:00
Matt Carroll
d726d692ed Add changelog for 19.1.0 (#32781) 2025-03-28 13:21:29 -07:00
lauren
50c5cdb653 Bump next prerelease version numbers (#32782)
Updates the version numbers in the prerelease channels.
2025-03-28 16:20:04 -04:00
mofeiZ
deb7859bb0 [compiler][snap] Fix test filter + watch mode (#32780)
Accidentally broke this when migrating our test runner to use the
bundled build https://github.com/facebook/react/pull/32758

The fix is pretty simple. File watcher should listen for changes in
`packages/babel-plugin-react-compiler` instead of `cwd`, which is now
`packages/snap`.
2025-03-28 16:03:08 -04:00
lauren
1825990c56 [release] Don't lookup build-info.json when updating version numbers (#32778)
From what we can see, `build-info.json` is a vestigal file that we were
previously including in builds but are no longer since 2022 (see
https://github.com/facebook/react/pull/23257, which removes
`build-info.json` which would have broken
scripts/release/build-release-locally-commands/add-build-info-json.js).

Since this file is no longer built, instead of looking it up we default
to the `version` that was passed in as an argument to
scripts/release/prepare-release-from-npm.js. Since `version` is what is
pulled from npm, there should only be 1 consistent version for all the
packages that are pulled. Therefore, only 1 version (eg canary) needs to
be replaced to the new stable version.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32778).
* __->__ #32778
* #32777
2025-03-28 14:35:29 -04:00
lauren
1de32a5e75 [release] Also split the onlyPackages param (#32777)
I missed this the last time.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32777).
* #32778
* __->__ #32777
2025-03-28 14:35:18 -04:00
Rodrigo Faria
ef4bc8b4f9 feat(babel-plugin-react-compiler): support satisfies operator (#32742)
Solve https://github.com/facebook/react/pull/29818

---------

Co-authored-by: Rodrigo Faria <rodrigo.faria@cartrack.com>
2025-03-28 11:10:32 -04:00
mofeiZ
8039f1b2a0 [compiler] Fix inferEffectDependencies lint false positives (#32769)
Currently, inferred effect dependencies are considered a
"compiler-required" feature. This means that untransformed callsites
should escalate to a build error.

`ValidateNoUntransformedReferences` iterates 'special effect' callsites
and checks that the compiler was able to successfully transform them.
Prior to this PR, this relied on checking the number of arguments passed
to this special effect.

This obviously doesn't work with `noEmit: true`, which is used for our
eslint plugin (this avoids mutating the babel program as other linters
run with the same ast). This PR adds a set of `babel.SourceLocation`s to
do best effort matching in this mode.
2025-03-27 12:18:50 -04:00
1006 changed files with 53723 additions and 11507 deletions

View File

@@ -1,10 +1,11 @@
{
"packages": ["packages/react", "packages/react-dom", "packages/scheduler"],
"packages": ["packages/react", "packages/react-dom", "packages/react-server-dom-webpack", "packages/scheduler"],
"buildCommand": "download-build-in-codesandbox-ci",
"node": "18",
"publishDirectory": {
"react": "build/oss-experimental/react",
"react-dom": "build/oss-experimental/react-dom",
"react-server-dom-webpack": "build/oss-experimental/react-server-dom-webpack",
"scheduler": "build/oss-experimental/scheduler"
},
"sandboxes": ["new"],

View File

@@ -496,6 +496,7 @@ module.exports = {
'packages/react-devtools-shared/src/devtools/views/**/*.js',
'packages/react-devtools-shared/src/hook.js',
'packages/react-devtools-shared/src/backend/console.js',
'packages/react-devtools-shared/src/backend/fiber/renderer.js',
'packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js',
'packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js',
],
@@ -504,6 +505,7 @@ module.exports = {
__IS_FIREFOX__: 'readonly',
__IS_EDGE__: 'readonly',
__IS_NATIVE__: 'readonly',
__IS_INTERNAL_MCP_BUILD__: 'readonly',
__IS_INTERNAL_VERSION__: 'readonly',
chrome: 'readonly',
},
@@ -559,6 +561,7 @@ module.exports = {
ConsoleTask: 'readonly', // TOOD: Figure out what the official name of this will be.
ReturnType: 'readonly',
AnimationFrameID: 'readonly',
WeakRef: 'readonly',
// For Flow type annotation. Only `BigInt` is valid at runtime.
bigint: 'readonly',
BigInt: 'readonly',
@@ -579,6 +582,7 @@ module.exports = {
JSONValue: 'readonly',
JSResourceReference: 'readonly',
MouseEventHandler: 'readonly',
NavigateEvent: 'readonly',
PropagationPhases: 'readonly',
PropertyDescriptor: 'readonly',
React$AbstractComponent: 'readonly',
@@ -608,6 +612,7 @@ module.exports = {
TimeoutID: 'readonly',
WheelEventHandler: 'readonly',
FinalizationRegistry: 'readonly',
Exclude: 'readonly',
Omit: 'readonly',
Keyframe: 'readonly',
PropertyIndexedKeyframes: 'readonly',
@@ -615,6 +620,8 @@ module.exports = {
GetAnimationsOptions: 'readonly',
Animatable: 'readonly',
ScrollTimeline: 'readonly',
EventListenerOptionsOrUseCapture: 'readonly',
FocusOptions: 'readonly',
spyOnDev: 'readonly',
spyOnDevAndProd: 'readonly',
@@ -632,5 +639,6 @@ module.exports = {
AsyncLocalStorage: 'readonly',
async_hooks: 'readonly',
globalThis: 'readonly',
navigation: 'readonly',
},
};

View File

@@ -1,18 +0,0 @@
---
name: "⚛React 19 beta issue"
about: Report a issue with React 19 beta.
title: '[React 19]'
labels: 'React 19'
---
## Summary
<!--
Please provide a CodeSandbox (https://codesandbox.io/s/new), a link to a
repository on GitHub, or provide a minimal code example that reproduces the
problem. You may provide a screenshot of the application if you think it is
relevant to your bug report. Here are some tips for providing a minimal
example: https://stackoverflow.com/help/mcve.
-->

View File

@@ -10,7 +10,21 @@ on:
permissions: {}
jobs:
check_access:
if: ${{ github.event.pull_request.draft == false }}
runs-on: ubuntu-latest
outputs:
is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }}
steps:
- run: echo ${{ github.event.pull_request.author_association }}
- name: Check is member or collaborator
id: check_is_member_or_collaborator
if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }}
run: echo "is_member_or_collaborator=true" >> "$GITHUB_OUTPUT"
check_maintainer:
if: ${{ needs.check_access.outputs.is_member_or_collaborator == 'true' || needs.check_access.outputs.is_member_or_collaborator == true }}
needs: [check_access]
uses: facebook/react/.github/workflows/shared_check_maintainer.yml@main
permissions:
# Used by check_maintainer

View File

@@ -16,6 +16,9 @@ on:
version_name:
required: true
type: string
tag_version:
required: false
type: string
secrets:
NPM_TOKEN:
required: true
@@ -55,4 +58,4 @@ jobs:
- name: Publish packages to npm
run: |
cp ./scripts/release/ci-npmrc ~/.npmrc
scripts/release/publish.js --frfr --ci --versionName=${{ inputs.version_name }} --tag ${{ inputs.dist_tag }}
scripts/release/publish.js --frfr --ci --versionName=${{ inputs.version_name }} --tag=${{ inputs.dist_tag }} ${{ inputs.tag_version && format('--tagVersion={0}', inputs.tag_version) || '' }}

View File

@@ -14,6 +14,9 @@ on:
version_name:
required: true
type: string
tag_version:
required: false
type: string
permissions: {}
@@ -29,5 +32,6 @@ jobs:
release_channel: ${{ inputs.release_channel }}
dist_tag: ${{ inputs.dist_tag }}
version_name: ${{ inputs.version_name }}
tag_version: ${{ inputs.tag_version }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,23 +0,0 @@
name: (Compiler) Publish Prereleases Weekly
on:
schedule:
# At 10 minutes past 9:00 on Mon
- cron: 10 9 * * 1
permissions: {}
env:
TZ: /usr/share/zoneinfo/America/Los_Angeles
jobs:
publish_prerelease_beta:
name: Publish to beta channel
uses: facebook/react/.github/workflows/compiler_prereleases.yml@main
with:
commit_sha: ${{ github.sha }}
release_channel: beta
dist_tag: beta
version_name: '19.0.0'
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -280,6 +280,35 @@ jobs:
if: steps.node_modules.outputs.cache-hit != 'true'
- run: yarn test ${{ matrix.params }} --ci --shard=${{ matrix.shard }}
# Hardcoded to improve parallelism
test-linter:
name: Test eslint-plugin-react-hooks
needs: [runtime_compiler_node_modules_cache]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
cache-dependency-path: |
yarn.lock
compiler/yarn.lock
- name: Restore cached node_modules
uses: actions/cache@v4
id: node_modules
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-
- run: yarn install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
- run: ./scripts/react-compiler/build-compiler.sh && ./scripts/react-compiler/link-compiler.sh
- run: yarn workspace eslint-plugin-react-hooks test
# ----- BUILD -----
build_and_lint:
name: yarn build and lint

View File

@@ -132,9 +132,9 @@ jobs:
mkdir ./compiled/facebook-www/__test_utils__
mv build/__test_utils__/ReactAllWarnings.js ./compiled/facebook-www/__test_utils__/ReactAllWarnings.js
# Move eslint-plugin-react-hooks into eslint-plugin-react-hooks
# Copy eslint-plugin-react-hooks
mkdir ./compiled/eslint-plugin-react-hooks
mv build/oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js \
cp build/oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js \
./compiled/eslint-plugin-react-hooks/index.js
# Move unstable_server-external-runtime.js into facebook-www
@@ -167,6 +167,12 @@ jobs:
rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js
rm $RENDERER_FOLDER/ReactNativeRenderer-{dev,prod,profiling}.js
# Copy eslint-plugin-react-hooks
# NOTE: This is different from www, here we include the full package
# including package.json to include dependencies in fbsource.
mkdir "$BASE_FOLDER/tools"
cp -r build/oss-experimental/eslint-plugin-react-hooks "$BASE_FOLDER/tools"
# Move React Native version file
mv build/facebook-react-native/VERSION_NATIVE_FB ./compiled-rn/VERSION_NATIVE_FB
@@ -326,10 +332,10 @@ jobs:
git --no-pager diff -U0 --cached | grep '^[+-]' | head -n 100
echo "===================="
# Ignore REVISION or lines removing @generated headers.
if git diff --cached ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" > /dev/null; then
if git diff --cached ':(exclude)*REVISION' ':(exclude)*/eslint-plugin-react-hooks/package.json' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" > /dev/null; then
echo "Changes detected"
echo "===== Changes ====="
git --no-pager diff --cached ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" | head -n 50
git --no-pager diff --cached ':(exclude)*REVISION' ':(exclude)*/eslint-plugin-react-hooks/package.json' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" | head -n 50
echo "==================="
echo "should_commit=true" >> "$GITHUB_OUTPUT"
else

View File

@@ -10,7 +10,21 @@ on:
permissions: {}
jobs:
check_access:
if: ${{ github.event.pull_request.draft == false }}
runs-on: ubuntu-latest
outputs:
is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }}
steps:
- run: echo ${{ github.event.pull_request.author_association }}
- name: Check is member or collaborator
id: check_is_member_or_collaborator
if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }}
run: echo "is_member_or_collaborator=true" >> "$GITHUB_OUTPUT"
check_maintainer:
if: ${{ needs.check_access.outputs.is_member_or_collaborator == 'true' || needs.check_access.outputs.is_member_or_collaborator == true }}
needs: [check_access]
uses: facebook/react/.github/workflows/shared_check_maintainer.yml@main
permissions:
# Used by check_maintainer

View File

@@ -13,7 +13,25 @@ on:
dist_tag:
required: true
type: string
enableFailureNotification:
description: 'Whether to notify the team on Discord when the release fails. Useful if this workflow is called from an automation.'
required: false
type: boolean
only_packages:
description: Packages to publish (space separated)
type: string
skip_packages:
description: Packages to NOT publish (space separated)
type: string
dry:
required: true
description: Dry run instead of publish?
type: boolean
default: true
secrets:
DISCORD_WEBHOOK_URL:
description: 'Discord webhook URL to notify on failure. Only required if enableFailureNotification is true.'
required: false
GH_TOKEN:
required: true
NPM_TOKEN:
@@ -54,7 +72,41 @@ jobs:
if: steps.node_modules.outputs.cache-hit != 'true'
- run: yarn --cwd scripts/release install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
- run: cp ./scripts/release/ci-npmrc ~/.npmrc
- run: |
GH_TOKEN=${{ secrets.GH_TOKEN }} scripts/release/prepare-release-from-ci.js --skipTests -r ${{ inputs.release_channel }} --commit=${{ inputs.commit_sha }}
cp ./scripts/release/ci-npmrc ~/.npmrc
scripts/release/publish.js --ci --tags ${{ inputs.dist_tag }}
- name: Check prepared files
run: ls -R build/node_modules
- if: '${{ inputs.only_packages }}'
name: 'Publish ${{ inputs.only_packages }}'
run: |
scripts/release/publish.js \
--ci \
--skipTests \
--tags=${{ inputs.dist_tag }} \
--onlyPackages=${{ inputs.only_packages }} ${{ (inputs.dry && '') || '\'}}
${{ inputs.dry && '--dry'}}
- if: '${{ inputs.skip_packages }}'
name: 'Publish all packages EXCEPT ${{ inputs.skip_packages }}'
run: |
scripts/release/publish.js \
--ci \
--skipTests \
--tags=${{ inputs.dist_tag }} \
--skipPackages=${{ inputs.skip_packages }} ${{ (inputs.dry && '') || '\'}}
${{ inputs.dry && '--dry'}}
- if: '${{ !(inputs.skip_packages && inputs.only_packages) }}'
name: 'Publish all packages'
run: |
scripts/release/publish.js \
--ci \
--tags=${{ inputs.dist_tag }} ${{ (inputs.dry && '') || '\'}}
${{ inputs.dry && '--dry'}}
- name: Notify Discord on failure
if: failure() && inputs.enableFailureNotification == true
uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
embed-author-name: "GitHub Actions"
embed-title: 'Publish of $${{ inputs.release_channel }} release failed'
embed-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}

View File

@@ -5,6 +5,25 @@ on:
inputs:
prerelease_commit_sha:
required: true
only_packages:
description: Packages to publish (space separated)
type: string
skip_packages:
description: Packages to NOT publish (space separated)
type: string
dry:
required: true
description: Dry run instead of publish?
type: boolean
default: true
experimental_only:
type: boolean
description: Only publish to the experimental tag
default: false
force_notify:
description: Force a Discord notification?
type: boolean
default: false
permissions: {}
@@ -12,8 +31,26 @@ env:
TZ: /usr/share/zoneinfo/America/Los_Angeles
jobs:
notify:
if: ${{ inputs.force_notify || inputs.dry == false || inputs.dry == 'false' }}
runs-on: ubuntu-latest
steps:
- name: Discord Webhook Action
uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
embed-author-name: ${{ github.event.sender.login }}
embed-author-url: ${{ github.event.sender.html_url }}
embed-author-icon-url: ${{ github.event.sender.avatar_url }}
embed-title: "⚠️ Publishing ${{ inputs.experimental_only && 'EXPERIMENTAL' || 'CANARY & EXPERIMENTAL' }} release ${{ (inputs.dry && ' (dry run)') || '' }}"
embed-description: |
```json
${{ toJson(inputs) }}
```
embed-url: https://github.com/facebook/react/actions/runs/${{ github.run_id }}
publish_prerelease_canary:
if: ${{ !inputs.experimental_only }}
name: Publish to Canary channel
uses: facebook/react/.github/workflows/runtime_prereleases.yml@main
permissions:
@@ -33,6 +70,9 @@ jobs:
# downstream consumers might still expect that tag. We can remove this
# after some time has elapsed and the change has been communicated.
dist_tag: canary,next
only_packages: ${{ inputs.only_packages }}
skip_packages: ${{ inputs.skip_packages }}
dry: ${{ inputs.dry }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -48,10 +88,15 @@ jobs:
# different versions of the same package, even if they use different
# dist tags.
needs: publish_prerelease_canary
# Ensures the job runs even if canary is skipped
if: always()
with:
commit_sha: ${{ inputs.prerelease_commit_sha }}
release_channel: experimental
dist_tag: experimental
only_packages: ${{ inputs.only_packages }}
skip_packages: ${{ inputs.skip_packages }}
dry: ${{ inputs.dry }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -21,7 +21,10 @@ jobs:
commit_sha: ${{ github.sha }}
release_channel: stable
dist_tag: canary,next
enableFailureNotification: true
dry: false
secrets:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -40,6 +43,9 @@ jobs:
commit_sha: ${{ github.sha }}
release_channel: experimental
dist_tag: experimental
enableFailureNotification: true
dry: false
secrets:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -2,6 +2,7 @@ name: (Shared) Label Core Team PRs
on:
pull_request_target:
types: [opened]
permissions: {}
@@ -11,7 +12,20 @@ env:
SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1
jobs:
check_access:
runs-on: ubuntu-latest
outputs:
is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }}
steps:
- run: echo ${{ github.event.pull_request.author_association }}
- name: Check is member or collaborator
id: check_is_member_or_collaborator
if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }}
run: echo "is_member_or_collaborator=true" >> "$GITHUB_OUTPUT"
check_maintainer:
if: ${{ needs.check_access.outputs.is_member_or_collaborator == 'true' || needs.check_access.outputs.is_member_or_collaborator == true }}
needs: [check_access]
uses: facebook/react/.github/workflows/shared_check_maintainer.yml@main
permissions:
# Used by check_maintainer

View File

@@ -6,7 +6,10 @@ on:
- cron: '0 * * * *'
workflow_dispatch:
permissions: {}
permissions:
# https://github.com/actions/stale/tree/v9/?tab=readme-ov-file#recommended-permissions
issues: write
pull-requests: write
env:
TZ: /usr/share/zoneinfo/America/Los_Angeles

2
.nvmrc
View File

@@ -1 +1 @@
v18.20.1
v20.19.0

View File

@@ -3,13 +3,12 @@
const {esNextPaths} = require('./scripts/shared/pathsByLanguageVersion');
module.exports = {
plugins: ['prettier-plugin-hermes-parser'],
bracketSpacing: false,
singleQuote: true,
bracketSameLine: true,
trailingComma: 'es5',
printWidth: 80,
parser: 'hermes',
parser: 'flow',
arrowParens: 'avoid',
overrides: [
{

View File

@@ -1,18 +0,0 @@
## March 22, 2024 (18.3.0-canary-670811593-20240322)
## React
- Added `useActionState` to replace `useFormState` and added `pending` value ([#28491](https://github.com/facebook/react/pull/28491)).
## October 5, 2023 (18.3.0-canary-546178f91-20231005)
### React
- Added support for async functions to be passed to `startTransition`.
- `useTransition` now triggers the nearest error boundary instead of a global error.
- Added `useOptimistic`, a new Hook for handling optimistic UI updates. It optimistically updates the UI before receiving confirmation from a server or external source.
### React DOM
- Added support for passing async functions to the `action` prop on `<form>`. When the function passed to `action` is marked with [`'use server'`](https://react.dev/reference/react/use-server), the form is [progressively enhanced](https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement).
- Added `useFormStatus`, a new Hook for checking the submission state of a form.
- Added `useFormState`, a new Hook for updating state upon form submission. When the function passed to `useFormState` is marked with [`'use server'`](https://react.dev/reference/react/use-server), the update is [progressively enhanced](https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement).

View File

@@ -1,3 +1,50 @@
## 19.1.0 (March 28, 2025)
### Owner Stack
An Owner Stack is a string representing the components that are directly responsible for rendering a particular component. You can log Owner Stacks when debugging or use Owner Stacks to enhance error overlays or other development tools. Owner Stacks are only available in development builds. Component Stacks in production are unchanged.
* An Owner Stack is a development-only stack trace that helps identify which components are responsible for rendering a particular component. An Owner Stack is distinct from a Component Stacks, which shows the hierarchy of components leading to an error.
* The [captureOwnerStack API](https://react.dev/reference/react/captureOwnerStack) is only available in development mode and returns a Owner Stack, if available. The API can be used to enhance error overlays or log component relationships when debugging. [#29923](https://github.com/facebook/react/pull/29923), [#32353](https://github.com/facebook/react/pull/32353), [#30306](https://github.com/facebook/react/pull/30306),
[#32538](https://github.com/facebook/react/pull/32538), [#32529](https://github.com/facebook/react/pull/32529), [#32538](https://github.com/facebook/react/pull/32538)
### React
* Enhanced support for Suspense boundaries to be used anywhere, including the client, server, and during hydration. [#32069](https://github.com/facebook/react/pull/32069), [#32163](https://github.com/facebook/react/pull/32163), [#32224](https://github.com/facebook/react/pull/32224), [#32252](https://github.com/facebook/react/pull/32252)
* Reduced unnecessary client rendering through improved hydration scheduling [#31751](https://github.com/facebook/react/pull/31751)
* Increased priority of client rendered Suspense boundaries [#31776](https://github.com/facebook/react/pull/31776)
* Fixed frozen fallback states by rendering unfinished Suspense boundaries on the client. [#31620](https://github.com/facebook/react/pull/31620)
* Reduced garbage collection pressure by improving Suspense boundary retries. [#31667](https://github.com/facebook/react/pull/31667)
* Fixed erroneous “Waiting for Paint” log when the passive effect phase was not delayed [#31526](https://github.com/facebook/react/pull/31526)
* Fixed a regression causing key warnings for flattened positional children in development mode. [#32117](https://github.com/facebook/react/pull/32117)
* Updated `useId` to use valid CSS selectors, changing format from `:r123:` to `«r123»`. [#32001](https://github.com/facebook/react/pull/32001)
* Added a dev-only warning for null/undefined created in useEffect, useInsertionEffect, and useLayoutEffect. [#32355](https://github.com/facebook/react/pull/32355)
* Fixed a bug where dev-only methods were exported in production builds. React.act is no longer available in production builds. [#32200](https://github.com/facebook/react/pull/32200)
* Improved consistency across prod and dev to improve compatibility with Google Closure Complier and bindings [#31808](https://github.com/facebook/react/pull/31808)
* Improve passive effect scheduling for consistent task yielding. [#31785](https://github.com/facebook/react/pull/31785)
* Fixed asserts in React Native when passChildrenWhenCloningPersistedNodes is enabled for OffscreenComponent rendering. [#32528](https://github.com/facebook/react/pull/32528)
* Fixed component name resolution for Portal [#32640](https://github.com/facebook/react/pull/32640)
* Added support for beforetoggle and toggle events on the dialog element. #32479 [#32479](https://github.com/facebook/react/pull/32479)
### React DOM
* Fixed double warning when the `href` attribute is an empty string [#31783](https://github.com/facebook/react/pull/31783)
* Fixed an edge case where `getHoistableRoot()` didnt work properly when the container was a Document [#32321](https://github.com/facebook/react/pull/32321)
* Removed support for using HTML comments (e.g. `<!-- -->`) as a DOM container. [#32250](https://github.com/facebook/react/pull/32250)
* Added support for `<script>` and `<template>` tags to be nested within `<select>` tags. [#31837](https://github.com/facebook/react/pull/31837)
* Fixed responsive images to be preloaded as HTML instead of headers [#32445](https://github.com/facebook/react/pull/32445)
### use-sync-external-store
* Added `exports` field to `package.json` for `use-sync-external-store` to support various entrypoints. [#25231](https://github.com/facebook/react/pull/25231)
### React Server Components
* Added `unstable_prerender`, a new experimental API for prerendering React Server Components on the server [#31724](https://github.com/facebook/react/pull/31724)
* Fixed an issue where streams would hang when receiving new chunks after a global error [#31840](https://github.com/facebook/react/pull/31840), [#31851](https://github.com/facebook/react/pull/31851)
* Fixed an issue where pending chunks were counted twice. [#31833](https://github.com/facebook/react/pull/31833)
* Added support for streaming in edge environments [#31852](https://github.com/facebook/react/pull/31852)
* Added support for sending custom error names from a server so that they are available in the client for console replaying. [#32116](https://github.com/facebook/react/pull/32116)
* Updated the server component wire format to remove IDs for hints and console.log because they have no return value [#31671](https://github.com/facebook/react/pull/31671)
* 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.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.

View File

@@ -18,7 +18,7 @@
//
// 0.0.0-experimental-241c4467e-20200129
const ReactVersion = '19.1.0';
const ReactVersion = '19.2.0';
// The label used by the @canary channel. Represents the upcoming release's
// stability. Most of the time, this will be "canary", but we may temporarily
@@ -33,7 +33,7 @@ const canaryChannelLabel = 'canary';
const rcNumber = 0;
const stablePackages = {
'eslint-plugin-react-hooks': '5.2.0',
'eslint-plugin-react-hooks': '6.1.0',
'jest-react': '0.17.0',
react: ReactVersion,
'react-art': ReactVersion,
@@ -42,12 +42,12 @@ const stablePackages = {
'react-server-dom-turbopack': ReactVersion,
'react-server-dom-parcel': ReactVersion,
'react-is': ReactVersion,
'react-reconciler': '0.32.0',
'react-refresh': '0.17.0',
'react-reconciler': '0.33.0',
'react-refresh': '0.18.0',
'react-test-renderer': ReactVersion,
'use-subscription': '1.11.0',
'use-sync-external-store': '1.5.0',
scheduler: '0.26.0',
'use-subscription': '1.12.0',
'use-sync-external-store': '1.6.0',
scheduler: '0.27.0',
};
// These packages do not exist in the @canary or @latest channel, only

14
compiler/.gitignore vendored
View File

@@ -1,28 +1,14 @@
.DS_Store
.spr.yml
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
node_modules
.watchmanconfig
.watchman-cookie-*
dist
.vscode
!packages/playground/.vscode
.spr.yml
testfilter.txt
bundle-oss.sh
# forgive
*.vsix
.vscode-test

65
compiler/CHANGELOG.md Normal file
View File

@@ -0,0 +1,65 @@
## 19.1.0-rc.2 (May 14, 2025)
## babel-plugin-react-compiler
* Fix for string attribute values with emoji [#33096](https://github.com/facebook/react/pull/33096) by [@josephsavona](https://github.com/josephsavona)
## 19.1.0-rc.1 (April 21, 2025)
## eslint-plugin-react-hooks
* Temporarily disable ref access in render validation [#32839](https://github.com/facebook/react/pull/32839) by [@poteto](https://github.com/poteto)
* Fix type error with recommended config [#32666](https://github.com/facebook/react/pull/32666) by [@niklasholm](https://github.com/niklasholm)
* Merge rule from eslint-plugin-react-compiler into `react-hooks` plugin [#32416](https://github.com/facebook/react/pull/32416) by [@michaelfaith](https://github.com/michaelfaith)
* Add dev dependencies for typescript migration [#32279](https://github.com/facebook/react/pull/32279) by [@michaelfaith](https://github.com/michaelfaith)
* Support v9 context api [#32045](https://github.com/facebook/react/pull/32045) by [@michaelfaith](https://github.com/michaelfaith)
* Support eslint 8+ flat plugin syntax out of the box for eslint-plugin-react-compiler [#32120](https://github.com/facebook/react/pull/32120) by [@orta](https://github.com/orta)
## babel-plugin-react-compiler
* Support satisfies operator [#32742](https://github.com/facebook/react/pull/32742) by [@rodrigofariow](https://github.com/rodrigofariow)
* Fix inferEffectDependencies lint false positives [#32769](https://github.com/facebook/react/pull/32769) by [@mofeiZ](https://github.com/mofeiZ)
* Fix hoisting of let declarations [#32724](https://github.com/facebook/react/pull/32724) by [@mofeiZ](https://github.com/mofeiZ)
* Avoid failing builds when import specifiers conflict or shadow vars [#32663](https://github.com/facebook/react/pull/32663) by [@mofeiZ](https://github.com/mofeiZ)
* Optimize components declared with arrow function and implicit return and `compilationMode: 'infer'` [#31792](https://github.com/facebook/react/pull/31792) by [@dimaMachina](https://github.com/dimaMachina)
* Validate static components [#32683](https://github.com/facebook/react/pull/32683) by [@josephsavona](https://github.com/josephsavona)
* Hoist dependencies from functions more conservatively [#32616](https://github.com/facebook/react/pull/32616) by [@mofeiZ](https://github.com/mofeiZ)
* Implement NumericLiteral as ObjectPropertyKey [#31791](https://github.com/facebook/react/pull/31791) by [@dimaMachina](https://github.com/dimaMachina)
* Avoid bailouts when inserting gating [#32598](https://github.com/facebook/react/pull/32598) by [@mofeiZ](https://github.com/mofeiZ)
* Stop bailing out early for hoisted gated functions [#32597](https://github.com/facebook/react/pull/32597) by [@mofeiZ](https://github.com/mofeiZ)
* Add shape for Array.from [#32522](https://github.com/facebook/react/pull/32522) by [@mofeiZ](https://github.com/mofeiZ)
* Patch array and argument spread mutability [#32521](https://github.com/facebook/react/pull/32521) by [@mofeiZ](https://github.com/mofeiZ)
* Make CompilerError compatible with reflection [#32539](https://github.com/facebook/react/pull/32539) by [@poteto](https://github.com/poteto)
* Add simple walltime measurement [#32331](https://github.com/facebook/react/pull/32331) by [@poteto](https://github.com/poteto)
* Improve error messages for unhandled terminal and instruction kinds [#32324](https://github.com/facebook/react/pull/32324) by [@inottn](https://github.com/inottn)
* Handle TSInstantiationExpression in lowerExpression [#32302](https://github.com/facebook/react/pull/32302) by [@inottn](https://github.com/inottn)
* Fix invalid Array.map type [#32095](https://github.com/facebook/react/pull/32095) by [@mofeiZ](https://github.com/mofeiZ)
* Patch for JSX escape sequences in @babel/generator [#32131](https://github.com/facebook/react/pull/32131) by [@mofeiZ](https://github.com/mofeiZ)
* `JSXText` emits incorrect with bracket [#32138](https://github.com/facebook/react/pull/32138) by [@himself65](https://github.com/himself65)
* Validation against calling impure functions [#31960](https://github.com/facebook/react/pull/31960) by [@josephsavona](https://github.com/josephsavona)
* Always target node [#32091](https://github.com/facebook/react/pull/32091) by [@poteto](https://github.com/poteto)
* Patch compilationMode:infer object method edge case [#32055](https://github.com/facebook/react/pull/32055) by [@mofeiZ](https://github.com/mofeiZ)
* Generate ts defs [#31994](https://github.com/facebook/react/pull/31994) by [@poteto](https://github.com/poteto)
* Relax react peer dep requirement [#31915](https://github.com/facebook/react/pull/31915) by [@poteto](https://github.com/poteto)
* Allow type cast expressions with refs [#31871](https://github.com/facebook/react/pull/31871) by [@josephsavona](https://github.com/josephsavona)
* Add shape for global Object.keys [#31583](https://github.com/facebook/react/pull/31583) by [@mofeiZ](https://github.com/mofeiZ)
* Optimize method calls w props receiver [#31775](https://github.com/facebook/react/pull/31775) by [@josephsavona](https://github.com/josephsavona)
* Fix dropped ref with spread props in InlineJsxTransform [#31726](https://github.com/facebook/react/pull/31726) by [@jackpope](https://github.com/jackpope)
* Support for non-declatation for in/of iterators [#31710](https://github.com/facebook/react/pull/31710) by [@mvitousek](https://github.com/mvitousek)
* Support for context variable loop iterators [#31709](https://github.com/facebook/react/pull/31709) by [@mvitousek](https://github.com/mvitousek)
* Replace deprecated dependency in `eslint-plugin-react-compiler` [#31629](https://github.com/facebook/react/pull/31629) by [@rakleed](https://github.com/rakleed)
* Support enableRefAsProp in jsx transform [#31558](https://github.com/facebook/react/pull/31558) by [@jackpope](https://github.com/jackpope)
* Fix: ref.current now correctly reactive [#31521](https://github.com/facebook/react/pull/31521) by [@mofeiZ](https://github.com/mofeiZ)
* Outline JSX with non-jsx children [#31442](https://github.com/facebook/react/pull/31442) by [@gsathya](https://github.com/gsathya)
* Outline jsx with duplicate attributes [#31441](https://github.com/facebook/react/pull/31441) by [@gsathya](https://github.com/gsathya)
* Store original and new prop names [#31440](https://github.com/facebook/react/pull/31440) by [@gsathya](https://github.com/gsathya)
* Stabilize compiler output: sort deps and decls by name [#31362](https://github.com/facebook/react/pull/31362) by [@mofeiZ](https://github.com/mofeiZ)
* Bugfix for hoistable deps for nested functions [#31345](https://github.com/facebook/react/pull/31345) by [@mofeiZ](https://github.com/mofeiZ)
* Remove compiler runtime-compat fixture library [#31430](https://github.com/facebook/react/pull/31430) by [@poteto](https://github.com/poteto)
* Wrap inline jsx transform codegen in conditional [#31267](https://github.com/facebook/react/pull/31267) by [@jackpope](https://github.com/jackpope)
* Check if local identifier is a hook when resolving globals [#31384](https://github.com/facebook/react/pull/31384) by [@poteto](https://github.com/poteto)
* Handle member expr as computed property [#31344](https://github.com/facebook/react/pull/31344) by [@gsathya](https://github.com/gsathya)
* Fix to ref access check to ban ref?.current [#31360](https://github.com/facebook/react/pull/31360) by [@mvitousek](https://github.com/mvitousek)
* InlineJSXTransform transforms jsx inside function expressions [#31282](https://github.com/facebook/react/pull/31282) by [@josephsavona](https://github.com/josephsavona)
## Other
* Add shebang to banner [#32225](https://github.com/facebook/react/pull/32225) by [@Jeremy-Hibiki](https://github.com/Jeremy-Hibiki)
* remove terser from react-compiler-runtime build [#31326](https://github.com/facebook/react/pull/31326) by [@henryqdineen](https://github.com/henryqdineen)

View File

@@ -1,5 +1,5 @@
import { c as _c } from "react/compiler-runtime"; // 
        @compilationMode(all)
        @compilationMode:"all"
function nonReactFn() {
  const $ = _c(1);
  let t0;

View File

@@ -1,4 +1,4 @@
// @compilationMode(infer)
// @compilationMode:"infer"
function nonReactFn() {
  return {};
}

View File

@@ -92,7 +92,7 @@ function useFoo(propVal: {+baz: number}) {
},
{
name: 'compilationMode-infer',
input: `// @compilationMode(infer)
input: `// @compilationMode:"infer"
function nonReactFn() {
return {};
}
@@ -101,7 +101,7 @@ function nonReactFn() {
},
{
name: 'compilationMode-all',
input: `// @compilationMode(all)
input: `// @compilationMode:"all"
function nonReactFn() {
return {};
}

View File

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

View File

@@ -8,8 +8,8 @@ set -eo pipefail
HERE=$(pwd)
cd ../../packages/react-compiler-runtime && yarn --silent link && cd $HERE
cd ../../packages/babel-plugin-react-compiler && yarn --silent link && cd $HERE
cd ../../packages/react-compiler-runtime && yarn --silent link && cd "$HERE"
cd ../../packages/babel-plugin-react-compiler && yarn --silent link && cd "$HERE"
yarn --silent link babel-plugin-react-compiler
yarn --silent link react-compiler-runtime

View File

@@ -781,12 +781,12 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@playwright/test@^1.42.1":
version "1.47.2"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.47.2.tgz#dbe7051336bfc5cc599954214f9111181dbc7475"
integrity sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==
"@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==
dependencies:
playwright "1.47.2"
playwright "1.51.1"
"@rtsao/scc@^1.1.0":
version "1.1.0"
@@ -1249,14 +1249,14 @@ camelcase-css@^2.0.1:
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001579:
version "1.0.30001669"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz#fda8f1d29a8bfdc42de0c170d7f34a9cf19ed7a3"
integrity sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==
version "1.0.30001715"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz"
integrity sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==
caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001663:
version "1.0.30001664"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001664.tgz#d588d75c9682d3301956b05a3749652a80677df4"
integrity sha512-AmE7k4dXiNKQipgn7a2xg558IRqPN3jMQY/rOsbxDhrd0tyChwbITBfiwtnqz8bi2M5mIWbxAYBvk7W7QBUS2g==
version "1.0.30001715"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz"
integrity sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==
chalk@^2.4.2:
version "2.4.2"
@@ -3008,17 +3008,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.47.2:
version "1.47.2"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.47.2.tgz#7858da9377fa32a08be46ba47d7523dbd9460a4e"
integrity sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==
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@1.47.2:
version "1.47.2"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.47.2.tgz#155688aa06491ee21fb3e7555b748b525f86eb20"
integrity sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==
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==
dependencies:
playwright-core "1.47.2"
playwright-core "1.51.1"
optionalDependencies:
fsevents "2.3.2"

View File

@@ -37,7 +37,7 @@
"prettier": "^3.3.3",
"prettier-plugin-hermes-parser": "^0.26.0",
"prompt-promise": "^1.0.3",
"rimraf": "^5.0.10",
"rimraf": "^6.0.1",
"to-fast-properties": "^2.0.0",
"tsup": "^8.4.0",
"typescript": "^5.4.3",
@@ -45,7 +45,6 @@
"yargs": "^17.7.2"
},
"resolutions": {
"rimraf": "5.0.10",
"@babel/types": "7.26.3"
},
"packageManager": "yarn@1.22.22"

View File

@@ -18,8 +18,9 @@ import {
import {getOrInsertWith} from '../Utils/utils';
import {ExternalFunction, isHookName} from '../HIR/Environment';
import {Err, Ok, Result} from '../Utils/Result';
import {CompilerReactTarget} from './Options';
import {getReactCompilerRuntimeModule} from './Program';
import {LoggerEvent, PluginOptions} from './Options';
import {BabelFn, getReactCompilerRuntimeModule} from './Program';
import {SuppressionRange} from './Suppression';
export function validateRestrictedImports(
path: NodePath<t.Program>,
@@ -52,32 +53,65 @@ export function validateRestrictedImports(
}
}
type ProgramContextOptions = {
program: NodePath<t.Program>;
suppressions: Array<SuppressionRange>;
opts: PluginOptions;
filename: string | null;
code: string | null;
hasModuleScopeOptOut: boolean;
};
export class ProgramContext {
/* Program and environment context */
/**
* Program and environment context
*/
scope: BabelScope;
opts: PluginOptions;
filename: string | null;
code: string | null;
reactRuntimeModule: string;
hookPattern: string | null;
suppressions: Array<SuppressionRange>;
hasModuleScopeOptOut: boolean;
/*
* This is a hack to work around what seems to be a Babel bug. Babel doesn't
* consistently respect the `skip()` function to avoid revisiting a node within
* a pass, so we use this set to track nodes that we have compiled.
*/
alreadyCompiled: WeakSet<object> | Set<object> = new (WeakSet ?? Set)();
// known generated or referenced identifiers in the program
knownReferencedNames: Set<string> = new Set();
// generated imports
imports: Map<string, Map<string, NonLocalImportSpecifier>> = new Map();
constructor(
program: NodePath<t.Program>,
reactRuntimeModule: CompilerReactTarget,
hookPattern: string | null,
) {
this.hookPattern = hookPattern;
/**
* Metadata from compilation
*/
retryErrors: Array<{fn: BabelFn; error: CompilerError}> = [];
inferredEffectLocations: Set<t.SourceLocation> = new Set();
constructor({
program,
suppressions,
opts,
filename,
code,
hasModuleScopeOptOut,
}: ProgramContextOptions) {
this.scope = program.scope;
this.reactRuntimeModule = getReactCompilerRuntimeModule(reactRuntimeModule);
this.opts = opts;
this.filename = filename;
this.code = code;
this.reactRuntimeModule = getReactCompilerRuntimeModule(opts.target);
this.suppressions = suppressions;
this.hasModuleScopeOptOut = hasModuleScopeOptOut;
}
isHookName(name: string): boolean {
if (this.hookPattern == null) {
if (this.opts.environment.hookPattern == null) {
return isHookName(name);
} else {
const match = new RegExp(this.hookPattern).exec(name);
const match = new RegExp(this.opts.environment.hookPattern).exec(name);
return (
match != null && typeof match[1] === 'string' && isHookName(match[1])
);
@@ -179,6 +213,12 @@ export class ProgramContext {
});
return Err(error);
}
logEvent(event: LoggerEvent): void {
if (this.opts.logger != null) {
this.opts.logger.logEvent(this.filename, event);
}
}
}
function getExistingImports(

View File

@@ -37,6 +37,14 @@ const PanicThresholdOptionsSchema = z.enum([
]);
export type PanicThresholdOptions = z.infer<typeof PanicThresholdOptionsSchema>;
const DynamicGatingOptionsSchema = z.object({
source: z.string(),
});
export type DynamicGatingOptions = z.infer<typeof DynamicGatingOptionsSchema>;
const CustomOptOutDirectiveSchema = z
.nullable(z.array(z.string()))
.default(null);
type CustomOptOutDirective = z.infer<typeof CustomOptOutDirectiveSchema>;
export type PluginOptions = {
environment: EnvironmentConfig;
@@ -65,6 +73,28 @@ export type PluginOptions = {
*/
gating: ExternalFunction | null;
/**
* If specified, this enables dynamic gating which matches `use memo if(...)`
* directives.
*
* Example usage:
* ```js
* // @dynamicGating:{"source":"myModule"}
* export function MyComponent() {
* 'use memo if(isEnabled)';
* return <div>...</div>;
* }
* ```
* This will emit:
* ```js
* import {isEnabled} from 'myModule';
* export const MyComponent = isEnabled()
* ? <optimized version>
* : <original version>;
* ```
*/
dynamicGating: DynamicGatingOptions | null;
panicThreshold: PanicThresholdOptions;
/*
@@ -98,7 +128,7 @@ export type PluginOptions = {
* provided rules will skip compilation. To disable this feature (never bailout of compilation
* even if the default ESLint is suppressed), pass an empty array.
*/
eslintSuppressionRules?: Array<string> | null | undefined;
eslintSuppressionRules: Array<string> | null | undefined;
flowSuppressions: boolean;
/*
@@ -106,7 +136,12 @@ export type PluginOptions = {
*/
ignoreUseNoForget: boolean;
sources?: Array<string> | ((filename: string) => boolean) | null;
/**
* Unstable / do not use
*/
customOptOutDirectives: CustomOptOutDirective;
sources: Array<string> | ((filename: string) => boolean) | null;
/**
* The compiler has customized support for react-native-reanimated, intended as a temporary workaround.
@@ -182,7 +217,9 @@ export type LoggerEvent =
| CompileDiagnosticEvent
| CompileSkipEvent
| PipelineErrorEvent
| TimingEvent;
| TimingEvent
| AutoDepsDecorationsEvent
| AutoDepsEligibleEvent;
export type CompileErrorEvent = {
kind: 'CompileError';
@@ -219,6 +256,16 @@ 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;
@@ -232,6 +279,7 @@ export const defaultOptions: PluginOptions = {
logger: null,
gating: null,
noEmit: false,
dynamicGating: null,
eslintSuppressionRules: null,
flowSuppressions: true,
ignoreUseNoForget: false,
@@ -239,6 +287,7 @@ export const defaultOptions: PluginOptions = {
return filename.indexOf('node_modules') === -1;
},
enableReanimatedCheck: true,
customOptOutDirectives: null,
target: '19',
} as const;
@@ -280,6 +329,40 @@ export function parsePluginOptions(obj: unknown): PluginOptions {
}
break;
}
case 'dynamicGating': {
if (value == null) {
parsedOptions[key] = null;
} else {
const result = DynamicGatingOptionsSchema.safeParse(value);
if (result.success) {
parsedOptions[key] = result.data;
} else {
CompilerError.throwInvalidConfig({
reason:
'Could not parse dynamic gating. Update React Compiler config to fix the error',
description: `${fromZodError(result.error)}`,
loc: null,
suggestions: null,
});
}
}
break;
}
case 'customOptOutDirectives': {
const result = CustomOptOutDirectiveSchema.safeParse(value);
if (result.success) {
parsedOptions[key] = result.data;
} else {
CompilerError.throwInvalidConfig({
reason:
'Could not parse custom opt out directives. Update React Compiler config to fix the error',
description: `${fromZodError(result.error)}`,
loc: null,
suggestions: null,
});
}
break;
}
default: {
parsedOptions[key] = value;
}

View File

@@ -103,6 +103,9 @@ import {transformFire} from '../Transform';
import {validateNoImpureFunctionsInRender} from '../Validation/ValidateNoImpureFunctionsInRender';
import {CompilerError} from '..';
import {validateStaticComponents} from '../Validation/ValidateStaticComponents';
import {validateNoFreezingKnownMutableFunctions} from '../Validation/ValidateNoFreezingKnownMutableFunctions';
import {inferMutationAliasingEffects} from '../Inference/InferMutationAliasingEffects';
import {inferMutationAliasingRanges} from '../Inference/InferMutationAliasingRanges';
export type CompilerPipelineValue =
| {kind: 'ast'; name: string; value: CodegenFunction}
@@ -129,6 +132,7 @@ function run(
mode,
config,
contextIdentifiers,
func,
logger,
filename,
code,
@@ -225,15 +229,27 @@ function runWithEnvironment(
analyseFunctions(hir);
log({kind: 'hir', name: 'AnalyseFunctions', value: hir});
const fnEffectErrors = inferReferenceEffects(hir);
if (env.isInferredMemoEnabled) {
if (fnEffectErrors.length > 0) {
CompilerError.throw(fnEffectErrors[0]);
if (!env.config.enableNewMutationAliasingModel) {
const fnEffectErrors = inferReferenceEffects(hir);
if (env.isInferredMemoEnabled) {
if (fnEffectErrors.length > 0) {
CompilerError.throw(fnEffectErrors[0]);
}
}
log({kind: 'hir', name: 'InferReferenceEffects', value: hir});
} else {
const mutabilityAliasingErrors = inferMutationAliasingEffects(hir);
log({kind: 'hir', name: 'InferMutationAliasingEffects', value: hir});
if (env.isInferredMemoEnabled) {
if (mutabilityAliasingErrors.isErr()) {
throw mutabilityAliasingErrors.unwrapErr();
}
}
}
log({kind: 'hir', name: 'InferReferenceEffects', value: hir});
validateLocalsNotReassignedAfterRender(hir);
if (!env.config.enableNewMutationAliasingModel) {
validateLocalsNotReassignedAfterRender(hir);
}
// Note: Has to come after infer reference effects because "dead" code may still affect inference
deadCodeElimination(hir);
@@ -247,8 +263,21 @@ function runWithEnvironment(
pruneMaybeThrows(hir);
log({kind: 'hir', name: 'PruneMaybeThrows', value: hir});
inferMutableRanges(hir);
log({kind: 'hir', name: 'InferMutableRanges', value: hir});
if (!env.config.enableNewMutationAliasingModel) {
inferMutableRanges(hir);
log({kind: 'hir', name: 'InferMutableRanges', value: hir});
} else {
const mutabilityAliasingErrors = inferMutationAliasingRanges(hir, {
isFunctionExpression: false,
});
log({kind: 'hir', name: 'InferMutationAliasingRanges', value: hir});
if (env.isInferredMemoEnabled) {
if (mutabilityAliasingErrors.isErr()) {
throw mutabilityAliasingErrors.unwrapErr();
}
validateLocalsNotReassignedAfterRender(hir);
}
}
if (env.isInferredMemoEnabled) {
if (env.config.assertValidMutableRanges) {
@@ -274,6 +303,13 @@ function runWithEnvironment(
if (env.config.validateNoImpureFunctionsInRender) {
validateNoImpureFunctionsInRender(hir).unwrap();
}
if (
env.config.validateNoFreezingKnownMutableFunctions ||
env.config.enableNewMutationAliasingModel
) {
validateNoFreezingKnownMutableFunctions(hir).unwrap();
}
}
inferReactivePlaces(hir);
@@ -392,6 +428,11 @@ function runWithEnvironment(
if (env.config.inferEffectDependencies) {
inferEffectDependencies(hir);
log({
kind: 'hir',
name: 'InferEffectDependencies',
value: hir,
});
}
if (env.config.inlineJsxTransform) {

View File

@@ -12,7 +12,7 @@ import {
CompilerErrorDetail,
ErrorSeverity,
} from '../CompilerError';
import {EnvironmentConfig, ReactFunctionType} from '../HIR/Environment';
import {ExternalFunction, ReactFunctionType} from '../HIR/Environment';
import {CodegenFunction} from '../ReactiveScopes';
import {isComponentDeclaration} from '../Utils/ComponentDeclaration';
import {isHookDeclaration} from '../Utils/HookDeclaration';
@@ -31,6 +31,7 @@ import {
suppressionsToCompilerError,
} from './Suppression';
import {GeneratedSource} from '../HIR';
import {Err, Ok, Result} from '../Utils/Result';
export type CompilerPass = {
opts: PluginOptions;
@@ -40,22 +41,102 @@ export type CompilerPass = {
};
export const OPT_IN_DIRECTIVES = new Set(['use forget', 'use memo']);
export const OPT_OUT_DIRECTIVES = new Set(['use no forget', 'use no memo']);
const DYNAMIC_GATING_DIRECTIVE = new RegExp('^use memo if\\(([^\\)]*)\\)$');
export function findDirectiveEnablingMemoization(
export function tryFindDirectiveEnablingMemoization(
directives: Array<t.Directive>,
): Array<t.Directive> {
return directives.filter(directive =>
opts: PluginOptions,
): Result<t.Directive | null, CompilerError> {
const optIn = directives.find(directive =>
OPT_IN_DIRECTIVES.has(directive.value.value),
);
if (optIn != null) {
return Ok(optIn);
}
const dynamicGating = findDirectivesDynamicGating(directives, opts);
if (dynamicGating.isOk()) {
return Ok(dynamicGating.unwrap()?.directive ?? null);
} else {
return Err(dynamicGating.unwrapErr());
}
}
export function findDirectiveDisablingMemoization(
directives: Array<t.Directive>,
): Array<t.Directive> {
return directives.filter(directive =>
OPT_OUT_DIRECTIVES.has(directive.value.value),
{customOptOutDirectives}: PluginOptions,
): t.Directive | null {
if (customOptOutDirectives != null) {
return (
directives.find(
directive =>
customOptOutDirectives.indexOf(directive.value.value) !== -1,
) ?? null
);
}
return (
directives.find(directive =>
OPT_OUT_DIRECTIVES.has(directive.value.value),
) ?? null
);
}
function findDirectivesDynamicGating(
directives: Array<t.Directive>,
opts: PluginOptions,
): Result<
{
gating: ExternalFunction;
directive: t.Directive;
} | null,
CompilerError
> {
if (opts.dynamicGating === null) {
return Ok(null);
}
const errors = new CompilerError();
const result: Array<{directive: t.Directive; match: string}> = [];
for (const directive of directives) {
const maybeMatch = DYNAMIC_GATING_DIRECTIVE.exec(directive.value.value);
if (maybeMatch != null && maybeMatch[1] != null) {
if (t.isValidIdentifier(maybeMatch[1])) {
result.push({directive, match: maybeMatch[1]});
} else {
errors.push({
reason: `Dynamic gating directive is not a valid JavaScript identifier`,
description: `Found '${directive.value.value}'`,
severity: ErrorSeverity.InvalidReact,
loc: directive.loc ?? null,
suggestions: null,
});
}
}
}
if (errors.hasErrors()) {
return Err(errors);
} else if (result.length > 1) {
const error = new CompilerError();
error.push({
reason: `Multiple dynamic gating directives found`,
description: `Expected a single directive but found [${result
.map(r => r.directive.value.value)
.join(', ')}]`,
severity: ErrorSeverity.InvalidReact,
loc: result[0].directive.loc ?? null,
suggestions: null,
});
return Err(error);
} else if (result.length === 1) {
return Ok({
gating: {
source: opts.dynamicGating.source,
importSpecifierName: result[0].match,
},
directive: result[0].directive,
});
} else {
return Ok(null);
}
}
function isCriticalError(err: unknown): boolean {
return !(err instanceof CompilerError) || err.isCritical();
@@ -88,13 +169,16 @@ export type CompileResult = {
function logError(
err: unknown,
pass: CompilerPass,
context: {
opts: PluginOptions;
filename: string | null;
},
fnLoc: t.SourceLocation | null,
): void {
if (pass.opts.logger) {
if (context.opts.logger) {
if (err instanceof CompilerError) {
for (const detail of err.details) {
pass.opts.logger.logEvent(pass.filename, {
context.opts.logger.logEvent(context.filename, {
kind: 'CompileError',
fnLoc,
detail: detail.options,
@@ -108,7 +192,7 @@ function logError(
stringifiedError = err?.toString() ?? '[ null ]';
}
pass.opts.logger.logEvent(pass.filename, {
context.opts.logger.logEvent(context.filename, {
kind: 'PipelineError',
fnLoc,
data: stringifiedError,
@@ -118,13 +202,17 @@ function logError(
}
function handleError(
err: unknown,
pass: CompilerPass,
context: {
opts: PluginOptions;
filename: string | null;
},
fnLoc: t.SourceLocation | null,
): void {
logError(err, pass, fnLoc);
logError(err, context, fnLoc);
if (
pass.opts.panicThreshold === 'all_errors' ||
(pass.opts.panicThreshold === 'critical_errors' && isCriticalError(err)) ||
context.opts.panicThreshold === 'all_errors' ||
(context.opts.panicThreshold === 'critical_errors' &&
isCriticalError(err)) ||
isConfigError(err) // Always throws regardless of panic threshold
) {
throw err;
@@ -187,7 +275,6 @@ export function createNewFunctionNode(
}
}
// Avoid visiting the new transformed version
ALREADY_COMPILED.add(transformedFn);
return transformedFn;
}
@@ -239,13 +326,6 @@ function insertNewOutlinedFunctionNode(
}
}
/*
* This is a hack to work around what seems to be a Babel bug. Babel doesn't
* consistently respect the `skip()` function to avoid revisiting a node within
* a pass, so we use this set to track nodes that we have compiled.
*/
const ALREADY_COMPILED: WeakSet<object> | Set<object> = new (WeakSet ?? Set)();
const DEFAULT_ESLINT_SUPPRESSIONS = [
'react-hooks/exhaustive-deps',
'react-hooks/rules-of-hooks',
@@ -268,41 +348,43 @@ function isFilePartOfSources(
return false;
}
export type CompileProgramResult = {
export type CompileProgramMetadata = {
retryErrors: Array<{fn: BabelFn; error: CompilerError}>;
inferredEffectLocations: Set<t.SourceLocation>;
};
/**
* `compileProgram` is directly invoked by the react-compiler babel plugin, so
* exceptions thrown by this function will fail the babel build.
* - call `handleError` if your error is recoverable.
* Unless the error is a warning / info diagnostic, compilation of a function
* / entire file should also be skipped.
* - throw an exception if the error is fatal / not recoverable.
* Examples of this are invalid compiler configs or failure to codegen outlined
* functions *after* already emitting optimized components / hooks that invoke
* the outlined functions.
* Main entrypoint for React Compiler.
*
* @param program The Babel program node to compile
* @param pass Compiler configuration and context
* @returns Compilation results or null if compilation was skipped
*/
export function compileProgram(
program: NodePath<t.Program>,
pass: CompilerPass,
): CompileProgramResult | null {
): CompileProgramMetadata | null {
/**
* This is directly invoked by the react-compiler babel plugin, so exceptions
* thrown by this function will fail the babel build.
* - call `handleError` if your error is recoverable.
* Unless the error is a warning / info diagnostic, compilation of a function
* / entire file should also be skipped.
* - throw an exception if the error is fatal / not recoverable.
* Examples of this are invalid compiler configs or failure to codegen outlined
* functions *after* already emitting optimized components / hooks that invoke
* the outlined functions.
*/
if (shouldSkipCompilation(program, pass)) {
return null;
}
const environment = pass.opts.environment;
const restrictedImportsErr = validateRestrictedImports(program, environment);
const restrictedImportsErr = validateRestrictedImports(
program,
pass.opts.environment,
);
if (restrictedImportsErr) {
handleError(restrictedImportsErr, pass, null);
return null;
}
const programContext = new ProgramContext(
program,
pass.opts.target,
environment.hookPattern,
);
/*
* Record lint errors and critical errors as depending on Forget's config,
* we may still need to run Forget's analysis on every function (even if we
@@ -313,16 +395,103 @@ export function compileProgram(
pass.opts.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS,
pass.opts.flowSuppressions,
);
const queue: Array<{
kind: 'original' | 'outlined';
fn: BabelFn;
fnType: ReactFunctionType;
}> = [];
const programContext = new ProgramContext({
program: program,
opts: pass.opts,
filename: pass.filename,
code: pass.code,
suppressions,
hasModuleScopeOptOut:
findDirectiveDisablingMemoization(program.node.directives, pass.opts) !=
null,
});
const queue: Array<CompileSource> = findFunctionsToCompile(
program,
pass,
programContext,
);
const compiledFns: Array<CompileResult> = [];
while (queue.length !== 0) {
const current = queue.shift()!;
const compiled = processFn(current.fn, current.fnType, programContext);
if (compiled != null) {
for (const outlined of compiled.outlined) {
CompilerError.invariant(outlined.fn.outlined.length === 0, {
reason: 'Unexpected nested outlined functions',
loc: outlined.fn.loc,
});
const fn = insertNewOutlinedFunctionNode(
program,
current.fn,
outlined.fn,
);
fn.skip();
programContext.alreadyCompiled.add(fn.node);
if (outlined.type !== null) {
queue.push({
kind: 'outlined',
fn,
fnType: outlined.type,
});
}
}
compiledFns.push({
kind: current.kind,
originalFn: current.fn,
compiledFn: compiled,
});
}
}
// Avoid modifying the program if we find a program level opt-out
if (programContext.hasModuleScopeOptOut) {
if (compiledFns.length > 0) {
const error = new CompilerError();
error.pushErrorDetail(
new CompilerErrorDetail({
reason:
'Unexpected compiled functions when module scope opt-out is present',
severity: ErrorSeverity.Invariant,
loc: null,
}),
);
handleError(error, programContext, null);
}
return null;
}
// Insert React Compiler generated functions into the Babel AST
applyCompiledFunctions(program, compiledFns, pass, programContext);
return {
retryErrors: programContext.retryErrors,
inferredEffectLocations: programContext.inferredEffectLocations,
};
}
type CompileSource = {
kind: 'original' | 'outlined';
fn: BabelFn;
fnType: ReactFunctionType;
};
/**
* Find all React components and hooks that need to be compiled
*
* @returns An array of React functions from @param program to transform
*/
function findFunctionsToCompile(
program: NodePath<t.Program>,
pass: CompilerPass,
programContext: ProgramContext,
): Array<CompileSource> {
const queue: Array<CompileSource> = [];
const traverseFunction = (fn: BabelFn, pass: CompilerPass): void => {
const fnType = getReactFunctionType(fn, pass, environment);
if (fnType === null || ALREADY_COMPILED.has(fn.node)) {
const fnType = getReactFunctionType(fn, pass);
if (fnType === null || programContext.alreadyCompiled.has(fn.node)) {
return;
}
@@ -331,7 +500,7 @@ export function compileProgram(
* traversal will loop infinitely.
* Ensure we avoid visiting the original function again.
*/
ALREADY_COMPILED.add(fn.node);
programContext.alreadyCompiled.add(fn.node);
fn.skip();
queue.push({kind: 'original', fn, fnType});
@@ -346,7 +515,6 @@ export function compileProgram(
* can reference `this` which is unsafe for compilation
*/
node.skip();
return;
},
ClassExpression(node: NodePath<t.ClassExpression>) {
@@ -355,7 +523,6 @@ export function compileProgram(
* can reference `this` which is unsafe for compilation
*/
node.skip();
return;
},
FunctionDeclaration: traverseFunction,
@@ -370,223 +537,254 @@ export function compileProgram(
filename: pass.filename ?? null,
},
);
const retryErrors: Array<{fn: BabelFn; error: CompilerError}> = [];
const inferredEffectLocations = new Set<t.SourceLocation>();
const processFn = (
fn: BabelFn,
fnType: ReactFunctionType,
): null | CodegenFunction => {
let optInDirectives: Array<t.Directive> = [];
let optOutDirectives: Array<t.Directive> = [];
if (fn.node.body.type === 'BlockStatement') {
optInDirectives = findDirectiveEnablingMemoization(
fn.node.body.directives,
);
optOutDirectives = findDirectiveDisablingMemoization(
fn.node.body.directives,
);
}
return queue;
}
/**
* Note that Babel does not attach comment nodes to nodes; they are dangling off of the
* Program node itself. We need to figure out whether an eslint suppression range
* applies to this function first.
*/
const suppressionsInFunction = filterSuppressionsThatAffectFunction(
suppressions,
fn,
/**
* Try to compile a source function, taking into account all local suppressions,
* opt-ins, and opt-outs.
*
* Errors encountered during compilation are either logged (if recoverable) or
* thrown (if non-recoverable).
*
* @returns the compiled function or null if the function was skipped (due to
* config settings and/or outputs)
*/
function processFn(
fn: BabelFn,
fnType: ReactFunctionType,
programContext: ProgramContext,
): null | CodegenFunction {
let directives: {
optIn: t.Directive | null;
optOut: t.Directive | null;
};
if (fn.node.body.type !== 'BlockStatement') {
directives = {
optIn: null,
optOut: null,
};
} else {
const optIn = tryFindDirectiveEnablingMemoization(
fn.node.body.directives,
programContext.opts,
);
let compileResult:
| {kind: 'compile'; compiledFn: CodegenFunction}
| {kind: 'error'; error: unknown};
if (suppressionsInFunction.length > 0) {
compileResult = {
kind: 'error',
error: suppressionsToCompilerError(suppressionsInFunction),
};
if (optIn.isErr()) {
/**
* If parsing opt-in directive fails, it's most likely that React Compiler
* was not tested or rolled out on this function. In that case, we handle
* the error and fall back to the safest option which is to not optimize
* the function.
*/
handleError(optIn.unwrapErr(), programContext, fn.node.loc ?? null);
return null;
}
directives = {
optIn: optIn.unwrapOr(null),
optOut: findDirectiveDisablingMemoization(
fn.node.body.directives,
programContext.opts,
),
};
}
let compiledFn: CodegenFunction;
const compileResult = tryCompileFunction(fn, fnType, programContext);
if (compileResult.kind === 'error') {
if (directives.optOut != null) {
logError(compileResult.error, programContext, fn.node.loc ?? null);
} else {
try {
compileResult = {
kind: 'compile',
compiledFn: compileFn(
fn,
environment,
fnType,
'all_features',
programContext,
pass.opts.logger,
pass.filename,
pass.code,
),
};
} catch (err) {
compileResult = {kind: 'error', error: err};
}
handleError(compileResult.error, programContext, fn.node.loc ?? null);
}
if (compileResult.kind === 'error') {
/**
* If an opt out directive is present, log only instead of throwing and don't mark as
* containing a critical error.
*/
if (optOutDirectives.length > 0) {
logError(compileResult.error, pass, fn.node.loc ?? null);
} else {
handleError(compileResult.error, pass, fn.node.loc ?? null);
}
// If non-memoization features are enabled, retry regardless of error kind
if (
!(environment.enableFire || environment.inferEffectDependencies != null)
) {
return null;
}
try {
compileResult = {
kind: 'compile',
compiledFn: compileFn(
fn,
environment,
fnType,
'no_inferred_memo',
programContext,
pass.opts.logger,
pass.filename,
pass.code,
),
};
if (
!compileResult.compiledFn.hasFireRewrite &&
!compileResult.compiledFn.hasInferredEffect
) {
return null;
}
} catch (err) {
// TODO: we might want to log error here, but this will also result in duplicate logging
if (err instanceof CompilerError) {
retryErrors.push({fn, error: err});
}
return null;
}
const retryResult = retryCompileFunction(fn, fnType, programContext);
if (retryResult == null) {
return null;
}
compiledFn = retryResult;
} else {
compiledFn = compileResult.compiledFn;
}
pass.opts.logger?.logEvent(pass.filename, {
kind: 'CompileSuccess',
fnLoc: fn.node.loc ?? null,
fnName: compileResult.compiledFn.id?.name ?? null,
memoSlots: compileResult.compiledFn.memoSlotsUsed,
memoBlocks: compileResult.compiledFn.memoBlocks,
memoValues: compileResult.compiledFn.memoValues,
prunedMemoBlocks: compileResult.compiledFn.prunedMemoBlocks,
prunedMemoValues: compileResult.compiledFn.prunedMemoValues,
/**
* If 'use no forget/memo' is present and we still ran the code through the
* compiler for validation, log a skip event and don't mutate the babel AST.
* This allows us to flag if there is an unused 'use no forget/memo'
* directive.
*/
if (
programContext.opts.ignoreUseNoForget === false &&
directives.optOut != null
) {
programContext.logEvent({
kind: 'CompileSkip',
fnLoc: fn.node.body.loc ?? null,
reason: `Skipped due to '${directives.optOut.value}' directive.`,
loc: directives.optOut.loc ?? null,
});
return null;
}
programContext.logEvent({
kind: 'CompileSuccess',
fnLoc: fn.node.loc ?? null,
fnName: compiledFn.id?.name ?? null,
memoSlots: compiledFn.memoSlotsUsed,
memoBlocks: compiledFn.memoBlocks,
memoValues: compiledFn.memoValues,
prunedMemoBlocks: compiledFn.prunedMemoBlocks,
prunedMemoValues: compiledFn.prunedMemoValues,
});
/**
* Always compile functions with opt in directives.
*/
if (optInDirectives.length > 0) {
return compileResult.compiledFn;
} else if (pass.opts.compilationMode === 'annotation') {
/**
* No opt-in directive in annotation mode, so don't insert the compiled function.
*/
return null;
}
/**
* Otherwise if 'use no forget/memo' is present, we still run the code through the compiler
* for validation but we don't mutate the babel AST. This allows us to flag if there is an
* unused 'use no forget/memo' directive.
*/
if (pass.opts.ignoreUseNoForget === false && optOutDirectives.length > 0) {
for (const directive of optOutDirectives) {
pass.opts.logger?.logEvent(pass.filename, {
kind: 'CompileSkip',
fnLoc: fn.node.body.loc ?? null,
reason: `Skipped due to '${directive.value.value}' directive.`,
loc: directive.loc ?? null,
});
}
return null;
}
if (!pass.opts.noEmit) {
return compileResult.compiledFn;
}
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 compileResult.compiledFn.inferredEffectLocations) {
if (loc !== GeneratedSource) inferredEffectLocations.add(loc);
}
return null;
};
while (queue.length !== 0) {
const current = queue.shift()!;
const compiled = processFn(current.fn, current.fnType);
if (compiled === null) {
continue;
}
for (const outlined of compiled.outlined) {
CompilerError.invariant(outlined.fn.outlined.length === 0, {
reason: 'Unexpected nested outlined functions',
loc: outlined.fn.loc,
});
const fn = insertNewOutlinedFunctionNode(
program,
current.fn,
outlined.fn,
);
fn.skip();
ALREADY_COMPILED.add(fn.node);
if (outlined.type !== null) {
queue.push({
kind: 'outlined',
fn,
fnType: outlined.type,
});
for (const loc of compiledFn.inferredEffectLocations) {
if (loc !== GeneratedSource) {
programContext.inferredEffectLocations.add(loc);
}
}
compiledFns.push({
kind: current.kind,
compiledFn: compiled,
originalFn: current.fn,
});
return null;
} else if (
programContext.opts.compilationMode === 'annotation' &&
directives.optIn == null
) {
/**
* If no opt-in directive is found and the compiler is configured in
* annotation mode, don't insert the compiled function.
*/
return null;
} else {
return compiledFn;
}
}
function tryCompileFunction(
fn: BabelFn,
fnType: ReactFunctionType,
programContext: ProgramContext,
):
| {kind: 'compile'; compiledFn: CodegenFunction}
| {kind: 'error'; error: unknown} {
/**
* Note that Babel does not attach comment nodes to nodes; they are dangling off of the
* Program node itself. We need to figure out whether an eslint suppression range
* applies to this function first.
*/
const suppressionsInFunction = filterSuppressionsThatAffectFunction(
programContext.suppressions,
fn,
);
if (suppressionsInFunction.length > 0) {
return {
kind: 'error',
error: suppressionsToCompilerError(suppressionsInFunction),
};
}
/**
* Do not modify source if there is a module scope level opt out directive.
*/
const moduleScopeOptOutDirectives = findDirectiveDisablingMemoization(
program.node.directives,
);
if (moduleScopeOptOutDirectives.length > 0) {
try {
return {
kind: 'compile',
compiledFn: compileFn(
fn,
programContext.opts.environment,
fnType,
'all_features',
programContext,
programContext.opts.logger,
programContext.filename,
programContext.code,
),
};
} catch (err) {
return {kind: 'error', error: err};
}
}
/**
* 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;
}
/*
* Only insert Forget-ified functions if we have not encountered a critical
* error elsewhere in the file, regardless of bailout mode.
/**
* Note that function suppressions are not checked in the retry pipeline, as
* they only affect auto-memoization features.
*/
const referencedBeforeDeclared =
pass.opts.gating != null
? getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns)
: null;
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.
*/
function applyCompiledFunctions(
program: NodePath<t.Program>,
compiledFns: Array<CompileResult>,
pass: CompilerPass,
programContext: ProgramContext,
): void {
let referencedBeforeDeclared = null;
for (const result of compiledFns) {
const {kind, originalFn, compiledFn} = result;
const transformedFn = createNewFunctionNode(originalFn, compiledFn);
programContext.alreadyCompiled.add(transformedFn);
if (referencedBeforeDeclared != null && kind === 'original') {
CompilerError.invariant(pass.opts.gating != null, {
reason: "Expected 'gating' import to be present",
loc: null,
});
let dynamicGating: ExternalFunction | null = null;
if (originalFn.node.body.type === 'BlockStatement') {
const result = findDirectivesDynamicGating(
originalFn.node.body.directives,
pass.opts,
);
if (result.isOk()) {
dynamicGating = result.unwrap()?.gating ?? null;
}
}
const functionGating = dynamicGating ?? pass.opts.gating;
if (kind === 'original' && functionGating != null) {
referencedBeforeDeclared ??=
getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns);
insertGatedFunctionDeclaration(
originalFn,
transformedFn,
programContext,
pass.opts.gating,
functionGating,
referencedBeforeDeclared.has(result),
);
} else {
@@ -598,7 +796,6 @@ export function compileProgram(
if (compiledFns.length > 0) {
addImportsToProgram(program, programContext);
}
return {retryErrors, inferredEffectLocations};
}
function shouldSkipCompilation(
@@ -640,15 +837,16 @@ function shouldSkipCompilation(
function getReactFunctionType(
fn: BabelFn,
pass: CompilerPass,
/**
* TODO(mofeiZ): remove once we validate PluginOptions with Zod
*/
environment: EnvironmentConfig,
): ReactFunctionType | null {
const hookPattern = environment.hookPattern;
const hookPattern = pass.opts.environment.hookPattern;
if (fn.node.body.type === 'BlockStatement') {
if (findDirectiveEnablingMemoization(fn.node.body.directives).length > 0)
const optInDirectives = tryFindDirectiveEnablingMemoization(
fn.node.body.directives,
pass.opts,
);
if (optInDirectives.unwrapOr(null) != null) {
return getComponentOrHookLike(fn, hookPattern) ?? 'Other';
}
}
// Component and hook declarations are known components/hooks

View File

@@ -1,3 +1,10 @@
/**
* 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 type * as BabelCore from '@babel/core';
import {hasOwnProperty} from '../Utils/utils';
import {PluginOptions} from './Options';

View File

@@ -1,3 +1,10 @@
/**
* 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';
@@ -11,7 +18,7 @@ import {
import {getOrInsertWith} from '../Utils/utils';
import {Environment} from '../HIR';
import {DEFAULT_EXPORT} from '../HIR/Environment';
import {CompileProgramResult} from './Program';
import {CompileProgramMetadata} from './Program';
function throwInvalidReact(
options: Omit<CompilerErrorDetailOptions, 'severity'>,
@@ -102,7 +109,7 @@ export default function validateNoUntransformedReferences(
filename: string | null,
logger: Logger | null,
env: EnvironmentConfig,
compileResult: CompileProgramResult | null,
compileResult: CompileProgramMetadata | null,
): void {
const moduleLoadChecks = new Map<
string,
@@ -229,7 +236,7 @@ function transformProgram(
moduleLoadChecks: Map<string, Map<string, CheckInvalidReferenceFn>>,
filename: string | null,
logger: Logger | null,
compileResult: CompileProgramResult | null,
compileResult: CompileProgramMetadata | null,
): void {
const traversalState: TraversalState = {
shouldInvalidateScopes: true,

View File

@@ -1,3 +1,10 @@
/**
* 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 {CompilerError} from '..';
import {
BlockId,

View File

@@ -5,13 +5,14 @@
* LICENSE file in the root directory of this source tree.
*/
import invariant from 'invariant';
import {HIRFunction, Identifier, MutableRange} from './HIR';
import {HIRFunction, MutableRange, Place} from './HIR';
import {
eachInstructionLValue,
eachInstructionOperand,
eachTerminalOperand,
} from './visitors';
import {CompilerError} from '..';
import {printPlace} from './PrintHIR';
/*
* Checks that all mutable ranges in the function are well-formed, with
@@ -20,38 +21,43 @@ import {
export function assertValidMutableRanges(fn: HIRFunction): void {
for (const [, block] of fn.body.blocks) {
for (const phi of block.phis) {
visitIdentifier(phi.place.identifier);
for (const [, operand] of phi.operands) {
visitIdentifier(operand.identifier);
visit(phi.place, `phi for block bb${block.id}`);
for (const [pred, operand] of phi.operands) {
visit(operand, `phi predecessor bb${pred} for block bb${block.id}`);
}
}
for (const instr of block.instructions) {
for (const operand of eachInstructionLValue(instr)) {
visitIdentifier(operand.identifier);
visit(operand, `instruction [${instr.id}]`);
}
for (const operand of eachInstructionOperand(instr)) {
visitIdentifier(operand.identifier);
visit(operand, `instruction [${instr.id}]`);
}
}
for (const operand of eachTerminalOperand(block.terminal)) {
visitIdentifier(operand.identifier);
visit(operand, `terminal [${block.terminal.id}]`);
}
}
}
function visitIdentifier(identifier: Identifier): void {
validateMutableRange(identifier.mutableRange);
if (identifier.scope !== null) {
validateMutableRange(identifier.scope.range);
function visit(place: Place, description: string): void {
validateMutableRange(place, place.identifier.mutableRange, description);
if (place.identifier.scope !== null) {
validateMutableRange(place, place.identifier.scope.range, description);
}
}
function validateMutableRange(mutableRange: MutableRange): void {
invariant(
(mutableRange.start === 0 && mutableRange.end === 0) ||
mutableRange.end > mutableRange.start,
'Identifier scope mutableRange was invalid: [%s:%s]',
mutableRange.start,
mutableRange.end,
function validateMutableRange(
place: Place,
range: MutableRange,
description: string,
): void {
CompilerError.invariant(
(range.start === 0 && range.end === 0) || range.end > range.start,
{
reason: `Invalid mutable range: [${range.start}:${range.end}]`,
description: `${printPlace(place)} in ${description}`,
loc: place.loc,
},
);
}

View File

@@ -47,7 +47,7 @@ import {
makeType,
promoteTemporary,
} from './HIR';
import HIRBuilder, {Bindings} from './HIRBuilder';
import HIRBuilder, {Bindings, createTemporaryPlace} from './HIRBuilder';
import {BuiltInArrayId} from './ObjectShape';
/*
@@ -70,12 +70,14 @@ import {BuiltInArrayId} from './ObjectShape';
export function lower(
func: NodePath<t.Function>,
env: Environment,
// Bindings captured from the outer function, in case lower() is called recursively (for lambdas)
bindings: Bindings | null = null,
capturedRefs: Array<t.Identifier> = [],
// the outermost function being compiled, in case lower() is called recursively (for lambdas)
parent: NodePath<t.Function> | null = null,
): Result<HIRFunction, CompilerError> {
const builder = new HIRBuilder(env, parent ?? func, bindings, capturedRefs);
const builder = new HIRBuilder(env, {
bindings,
context: capturedRefs,
});
const context: HIRFunction['context'] = [];
for (const ref of capturedRefs ?? []) {
@@ -179,6 +181,7 @@ export function lower(
loc: GeneratedSource,
value: lowerExpressionToTemporary(builder, body),
id: makeInstructionId(0),
effects: null,
};
builder.terminateWithContinuation(terminal, fallthrough);
} else if (body.isBlockStatement()) {
@@ -208,6 +211,7 @@ export function lower(
loc: GeneratedSource,
}),
id: makeInstructionId(0),
effects: null,
},
null,
);
@@ -215,9 +219,10 @@ export function lower(
return Ok({
id,
params,
fnType: parent == null ? env.fnType : 'Other',
fnType: bindings == null ? env.fnType : 'Other',
returnTypeAnnotation: null, // TODO: extract the actual return type node if present
returnType: makeType(),
returns: createTemporaryPlace(env, func.node.loc ?? GeneratedSource),
body: builder.build(),
context,
generator: func.node.generator === true,
@@ -225,6 +230,7 @@ export function lower(
loc: func.node.loc ?? GeneratedSource,
env,
effects: null,
aliasingEffects: null,
directives,
});
}
@@ -285,6 +291,7 @@ function lowerStatement(
loc: stmt.node.loc ?? GeneratedSource,
value,
id: makeInstructionId(0),
effects: null,
};
builder.terminate(terminal, 'block');
return;
@@ -1235,6 +1242,7 @@ function lowerStatement(
kind: 'Debugger',
loc,
},
effects: null,
loc,
});
return;
@@ -1892,6 +1900,7 @@ function lowerExpression(
place: leftValue,
loc: exprLoc,
},
effects: null,
loc: exprLoc,
});
builder.terminateWithContinuation(
@@ -2406,6 +2415,19 @@ function lowerExpression(
kind: 'TypeCastExpression',
value: lowerExpressionToTemporary(builder, expr.get('expression')),
typeAnnotation: typeAnnotation.node,
typeAnnotationKind: 'cast',
type: lowerType(typeAnnotation.node),
loc: exprLoc,
};
}
case 'TSSatisfiesExpression': {
let expr = exprPath as NodePath<t.TSSatisfiesExpression>;
const typeAnnotation = expr.get('typeAnnotation');
return {
kind: 'TypeCastExpression',
value: lowerExpressionToTemporary(builder, expr.get('expression')),
typeAnnotation: typeAnnotation.node,
typeAnnotationKind: 'satisfies',
type: lowerType(typeAnnotation.node),
loc: exprLoc,
};
@@ -2417,6 +2439,7 @@ function lowerExpression(
kind: 'TypeCastExpression',
value: lowerExpressionToTemporary(builder, expr.get('expression')),
typeAnnotation: typeAnnotation.node,
typeAnnotationKind: 'as',
type: lowerType(typeAnnotation.node),
loc: exprLoc,
};
@@ -2813,6 +2836,7 @@ function lowerOptionalCallExpression(
args,
loc,
},
effects: null,
loc,
});
} else {
@@ -2826,6 +2850,7 @@ function lowerOptionalCallExpression(
args,
loc,
},
effects: null,
loc,
});
}
@@ -3403,7 +3428,7 @@ function lowerFunction(
| t.ObjectMethod
>,
): LoweredFunction | null {
const componentScope: Scope = builder.parentFunction.scope;
const componentScope: Scope = builder.environment.parentFunction.scope;
const capturedContext = gatherCapturedContext(expr, componentScope);
/*
@@ -3414,13 +3439,10 @@ function lowerFunction(
* This isn't a problem in practice because use Babel's scope analysis to
* identify the correct references.
*/
const lowering = lower(
expr,
builder.environment,
builder.bindings,
[...builder.context, ...capturedContext],
builder.parentFunction,
);
const lowering = lower(expr, builder.environment, builder.bindings, [
...builder.context,
...capturedContext,
]);
let loweredFunc: HIRFunction;
if (lowering.isErr()) {
lowering
@@ -3442,7 +3464,7 @@ function lowerExpressionToTemporary(
return lowerValueToTemporary(builder, value);
}
function lowerValueToTemporary(
export function lowerValueToTemporary(
builder: HIRBuilder,
value: InstructionValue,
): Place {
@@ -3452,9 +3474,10 @@ function lowerValueToTemporary(
const place: Place = buildTemporaryPlace(builder, value.loc);
builder.push({
id: makeInstructionId(0),
value: value,
loc: value.loc,
lvalue: {...place},
value: value,
effects: null,
loc: value.loc,
});
return place;
}
@@ -3595,31 +3618,40 @@ function lowerAssignment(
let temporary;
if (builder.isContextIdentifier(lvalue)) {
if (kind !== InstructionKind.Reassign && !isHoistedIdentifier) {
if (kind === InstructionKind.Const) {
builder.errors.push({
reason: `Expected \`const\` declaration not to be reassigned`,
severity: ErrorSeverity.InvalidJS,
loc: lvalue.node.loc ?? null,
suggestions: null,
});
}
lowerValueToTemporary(builder, {
kind: 'DeclareContext',
lvalue: {
kind: InstructionKind.Let,
place: {...place},
},
loc: place.loc,
if (kind === InstructionKind.Const && !isHoistedIdentifier) {
builder.errors.push({
reason: `Expected \`const\` declaration not to be reassigned`,
severity: ErrorSeverity.InvalidJS,
loc: lvalue.node.loc ?? null,
suggestions: null,
});
}
temporary = lowerValueToTemporary(builder, {
kind: 'StoreContext',
lvalue: {place: {...place}, kind: InstructionKind.Reassign},
value,
loc,
});
if (
kind !== InstructionKind.Const &&
kind !== InstructionKind.Reassign &&
kind !== InstructionKind.Let &&
kind !== InstructionKind.Function
) {
builder.errors.push({
reason: `Unexpected context variable kind`,
severity: ErrorSeverity.InvalidJS,
loc: lvalue.node.loc ?? null,
suggestions: null,
});
temporary = lowerValueToTemporary(builder, {
kind: 'UnsupportedNode',
node: lvalueNode,
loc: lvalueNode.loc ?? GeneratedSource,
});
} else {
temporary = lowerValueToTemporary(builder, {
kind: 'StoreContext',
lvalue: {place: {...place}, kind},
value,
loc,
});
}
} else {
const typeAnnotation = lvalue.get('typeAnnotation');
let type: t.FlowType | t.TSType | null;

View File

@@ -1,3 +1,10 @@
/**
* 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 {CompilerError} from '../CompilerError';
import {getScopes, recursivelyTraverseItems} from './AssertValidBlockNesting';
import {Environment} from './Environment';

View File

@@ -1,3 +1,10 @@
/**
* 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 {CompilerError} from '../CompilerError';
import {inRange} from '../ReactiveScopes/InferReactiveScopeVariables';
import {printDependency} from '../ReactiveScopes/PrintReactiveFunction';
@@ -12,6 +19,7 @@ import {
BasicBlock,
BlockId,
DependencyPathEntry,
FunctionExpression,
GeneratedSource,
getHookKind,
HIRFunction,
@@ -23,6 +31,7 @@ import {
PropertyLiteral,
ReactiveScopeDependency,
ScopeId,
TInstruction,
} from './HIR';
const DEBUG_PRINT = false;
@@ -120,6 +129,33 @@ export function collectHoistablePropertyLoads(
});
}
export function collectHoistablePropertyLoadsInInnerFn(
fnInstr: TInstruction<FunctionExpression>,
temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
hoistableFromOptionals: ReadonlyMap<BlockId, ReactiveScopeDependency>,
): ReadonlyMap<BlockId, BlockInfo> {
const fn = fnInstr.value.loweredFunc.func;
const initialContext: CollectHoistablePropertyLoadsContext = {
temporaries,
knownImmutableIdentifiers: new Set(),
hoistableFromOptionals,
registry: new PropertyPathRegistry(),
nestedFnImmutableContext: null,
assumedInvokedFns: fn.env.config.enableTreatFunctionDepsAsConditional
? new Set()
: getAssumedInvokedFunctions(fn),
};
const nestedFnImmutableContext = new Set(
fn.context
.filter(place =>
isImmutableAtInstr(place.identifier, fnInstr.id, initialContext),
)
.map(place => place.identifier.id),
);
initialContext.nestedFnImmutableContext = nestedFnImmutableContext;
return collectHoistablePropertyLoadsImpl(fn, initialContext);
}
type CollectHoistablePropertyLoadsContext = {
temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>;
knownImmutableIdentifiers: ReadonlySet<IdentifierId>;
@@ -205,7 +241,10 @@ type PropertyPathNode =
class PropertyPathRegistry {
roots: Map<IdentifierId, RootNode> = new Map();
getOrCreateIdentifier(identifier: Identifier): PropertyPathNode {
getOrCreateIdentifier(
identifier: Identifier,
reactive: boolean,
): PropertyPathNode {
/**
* Reads from a statically scoped variable are always safe in JS,
* with the exception of TDZ (not addressed by this pass).
@@ -219,12 +258,19 @@ class PropertyPathRegistry {
optionalProperties: new Map(),
fullPath: {
identifier,
reactive,
path: [],
},
hasOptional: false,
parent: null,
};
this.roots.set(identifier.id, rootNode);
} else {
CompilerError.invariant(reactive === rootNode.fullPath.reactive, {
reason:
'[HoistablePropertyLoads] Found inconsistencies in `reactive` flag when deduping identifier reads within the same scope',
loc: identifier.loc,
});
}
return rootNode;
}
@@ -242,6 +288,7 @@ class PropertyPathRegistry {
parent: parent,
fullPath: {
identifier: parent.fullPath.identifier,
reactive: parent.fullPath.reactive,
path: parent.fullPath.path.concat(entry),
},
hasOptional: parent.hasOptional || entry.optional,
@@ -257,7 +304,7 @@ class PropertyPathRegistry {
* so all subpaths of a PropertyLoad should already exist
* (e.g. a.b is added before a.b.c),
*/
let currNode = this.getOrCreateIdentifier(n.identifier);
let currNode = this.getOrCreateIdentifier(n.identifier, n.reactive);
if (n.path.length === 0) {
return currNode;
}
@@ -279,10 +326,11 @@ function getMaybeNonNullInInstruction(
instr: InstructionValue,
context: CollectHoistablePropertyLoadsContext,
): PropertyPathNode | null {
let path = null;
let path: ReactiveScopeDependency | null = null;
if (instr.kind === 'PropertyLoad') {
path = context.temporaries.get(instr.object.identifier.id) ?? {
identifier: instr.object.identifier,
reactive: instr.object.reactive,
path: [],
};
} else if (instr.kind === 'Destructure') {
@@ -345,7 +393,7 @@ function collectNonNullsInBlocks(
) {
const identifier = fn.params[0].identifier;
knownNonNullIdentifiers.add(
context.registry.getOrCreateIdentifier(identifier),
context.registry.getOrCreateIdentifier(identifier, true),
);
}
const nodes = new Map<
@@ -580,9 +628,11 @@ function reduceMaybeOptionalChains(
changed = false;
for (const original of optionalChainNodes) {
let {identifier, path: origPath} = original.fullPath;
let currNode: PropertyPathNode =
registry.getOrCreateIdentifier(identifier);
let {identifier, path: origPath, reactive} = original.fullPath;
let currNode: PropertyPathNode = registry.getOrCreateIdentifier(
identifier,
reactive,
);
for (let i = 0; i < origPath.length; i++) {
const entry = origPath[i];
// If the base is known to be non-null, replace with a non-optional load

View File

@@ -1,3 +1,10 @@
/**
* 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 {CompilerError} from '..';
import {assertNonNull} from './CollectHoistablePropertyLoads';
import {
@@ -283,6 +290,7 @@ function traverseOptionalBlock(
);
baseObject = {
identifier: maybeTest.instructions[0].value.place.identifier,
reactive: maybeTest.instructions[0].value.place.reactive,
path,
};
test = maybeTest.terminal;
@@ -384,6 +392,7 @@ function traverseOptionalBlock(
);
const load = {
identifier: baseObject.identifier,
reactive: baseObject.reactive,
path: [
...baseObject.path,
{

View File

@@ -25,8 +25,9 @@ export class ReactiveScopeDependencyTreeHIR {
* `identifier.path`, or `identifier?.path` is in this map, it is safe to
* evaluate (non-optional) PropertyLoads from.
*/
#hoistableObjects: Map<Identifier, HoistableNode> = new Map();
#deps: Map<Identifier, DependencyNode> = new Map();
#hoistableObjects: Map<Identifier, HoistableNode & {reactive: boolean}> =
new Map();
#deps: Map<Identifier, DependencyNode & {reactive: boolean}> = new Map();
/**
* @param hoistableObjects a set of paths from which we can safely evaluate
@@ -35,9 +36,10 @@ export class ReactiveScopeDependencyTreeHIR {
* duplicates when traversing the CFG.
*/
constructor(hoistableObjects: Iterable<ReactiveScopeDependency>) {
for (const {path, identifier} of hoistableObjects) {
for (const {path, identifier, reactive} of hoistableObjects) {
let currNode = ReactiveScopeDependencyTreeHIR.#getOrCreateRoot(
identifier,
reactive,
this.#hoistableObjects,
path.length > 0 && path[0].optional ? 'Optional' : 'NonNull',
);
@@ -70,7 +72,8 @@ export class ReactiveScopeDependencyTreeHIR {
static #getOrCreateRoot<T extends string>(
identifier: Identifier,
roots: Map<Identifier, TreeNode<T>>,
reactive: boolean,
roots: Map<Identifier, TreeNode<T> & {reactive: boolean}>,
defaultAccessType: T,
): TreeNode<T> {
// roots can always be accessed unconditionally in JS
@@ -79,9 +82,16 @@ export class ReactiveScopeDependencyTreeHIR {
if (rootNode === undefined) {
rootNode = {
properties: new Map(),
reactive,
accessType: defaultAccessType,
};
roots.set(identifier, rootNode);
} else {
CompilerError.invariant(reactive === rootNode.reactive, {
reason: '[DeriveMinimalDependenciesHIR] Conflicting reactive root flag',
description: `Identifier ${printIdentifier(identifier)}`,
loc: GeneratedSource,
});
}
return rootNode;
}
@@ -92,9 +102,10 @@ export class ReactiveScopeDependencyTreeHIR {
* safe-to-evaluate subpath
*/
addDependency(dep: ReactiveScopeDependency): void {
const {identifier, path} = dep;
const {identifier, reactive, path} = dep;
let depCursor = ReactiveScopeDependencyTreeHIR.#getOrCreateRoot(
identifier,
reactive,
this.#deps,
PropertyAccessType.UnconditionalAccess,
);
@@ -172,7 +183,13 @@ export class ReactiveScopeDependencyTreeHIR {
deriveMinimalDependencies(): Set<ReactiveScopeDependency> {
const results = new Set<ReactiveScopeDependency>();
for (const [rootId, rootNode] of this.#deps.entries()) {
collectMinimalDependenciesInSubtree(rootNode, rootId, [], results);
collectMinimalDependenciesInSubtree(
rootNode,
rootNode.reactive,
rootId,
[],
results,
);
}
return results;
@@ -294,25 +311,24 @@ type HoistableNode = TreeNode<'Optional' | 'NonNull'>;
type DependencyNode = TreeNode<PropertyAccessType>;
/**
* TODO: this is directly pasted from DeriveMinimalDependencies. Since we no
* longer have conditionally accessed nodes, we can simplify
*
* Recursively calculates minimal dependencies in a subtree.
* @param node DependencyNode representing a dependency subtree.
* @returns a minimal list of dependencies in this subtree.
*/
function collectMinimalDependenciesInSubtree(
node: DependencyNode,
reactive: boolean,
rootIdentifier: Identifier,
path: Array<DependencyPathEntry>,
results: Set<ReactiveScopeDependency>,
): void {
if (isDependency(node.accessType)) {
results.add({identifier: rootIdentifier, path});
results.add({identifier: rootIdentifier, reactive, path});
} else {
for (const [childName, childNode] of node.properties) {
collectMinimalDependenciesInSubtree(
childNode,
reactive,
rootIdentifier,
[
...path,

View File

@@ -9,15 +9,7 @@ import * as t from '@babel/types';
import {ZodError, z} from 'zod';
import {fromZodError} from 'zod-validation-error';
import {CompilerError} from '../CompilerError';
import {
CompilationMode,
defaultOptions,
Logger,
PanicThresholdOptions,
parsePluginOptions,
PluginOptions,
ProgramContext,
} from '../Entrypoint';
import {Logger, ProgramContext} from '../Entrypoint';
import {Err, Ok, Result} from '../Utils/Result';
import {
DEFAULT_GLOBALS,
@@ -55,7 +47,7 @@ import {
ShapeRegistry,
addHook,
} from './ObjectShape';
import {Scope as BabelScope} from '@babel/traverse';
import {Scope as BabelScope, NodePath} from '@babel/traverse';
import {TypeSchema} from './TypeSchema';
export const ReactElementSymbolSchema = z.object({
@@ -158,7 +150,7 @@ export type Hook = z.infer<typeof HookSchema>;
* missing some recursive Object / Function shapeIds
*/
const EnvironmentConfigSchema = z.object({
export const EnvironmentConfigSchema = z.object({
customHooks: z.map(z.string(), HookSchema).default(new Map()),
/**
@@ -251,6 +243,11 @@ const EnvironmentConfigSchema = z.object({
*/
enableUseTypeAnnotations: z.boolean().default(false),
/**
* Enable a new model for mutability and aliasing inference
*/
enableNewMutationAliasingModel: z.boolean().default(false),
/**
* Enables inference of optional dependency chains. Without this flag
* a property chain such as `props?.items?.foo` will infer as a dep on
@@ -367,6 +364,11 @@ const EnvironmentConfigSchema = z.object({
*/
validateNoImpureFunctionsInRender: z.boolean().default(false),
/**
* Validate against passing mutable functions to hooks
*/
validateNoFreezingKnownMutableFunctions: z.boolean().default(false),
/*
* When enabled, the compiler assumes that hooks follow the Rules of React:
* - Hooks may memoize computation based on any of their parameters, thus
@@ -635,191 +637,6 @@ const EnvironmentConfigSchema = z.object({
export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
/**
* For test fixtures and playground only.
*
* Pragmas are straightforward to parse for boolean options (`:true` and
* `:false`). These are 'enabled' config values for non-boolean configs (i.e.
* what is used when parsing `:true`).
*/
const testComplexConfigDefaults: PartialEnvironmentConfig = {
validateNoCapitalizedCalls: [],
enableChangeDetectionForDebugging: {
source: 'react-compiler-runtime',
importSpecifierName: '$structuralCheck',
},
enableEmitFreeze: {
source: 'react-compiler-runtime',
importSpecifierName: 'makeReadOnly',
},
enableEmitInstrumentForget: {
fn: {
source: 'react-compiler-runtime',
importSpecifierName: 'useRenderCounter',
},
gating: {
source: 'react-compiler-runtime',
importSpecifierName: 'shouldInstrument',
},
globalGating: 'DEV',
},
enableEmitHookGuards: {
source: 'react-compiler-runtime',
importSpecifierName: '$dispatcherGuard',
},
inlineJsxTransform: {
elementSymbol: 'react.transitional.element',
globalDevVar: 'DEV',
},
lowerContextAccess: {
source: 'react-compiler-runtime',
importSpecifierName: 'useContext_withSelector',
},
inferEffectDependencies: [
{
function: {
source: 'react',
importSpecifierName: 'useEffect',
},
numRequiredArgs: 1,
},
{
function: {
source: 'shared-runtime',
importSpecifierName: 'useSpecialEffect',
},
numRequiredArgs: 2,
},
{
function: {
source: 'useEffectWrapper',
importSpecifierName: 'default',
},
numRequiredArgs: 1,
},
],
};
/**
* For snap test fixtures and playground only.
*/
function parseConfigPragmaEnvironmentForTest(
pragma: string,
): EnvironmentConfig {
const maybeConfig: any = {};
// Get the defaults to programmatically check for boolean properties
const defaultConfig = EnvironmentConfigSchema.parse({});
for (const token of pragma.split(' ')) {
if (!token.startsWith('@')) {
continue;
}
const keyVal = token.slice(1);
let [key, val = undefined] = keyVal.split(':');
const isSet = val === undefined || val === 'true';
if (isSet && key in testComplexConfigDefaults) {
maybeConfig[key] =
testComplexConfigDefaults[key as keyof PartialEnvironmentConfig];
continue;
}
if (key === 'customMacros' && val) {
const valSplit = val.split('.');
if (valSplit.length > 0) {
const props = [];
for (const elt of valSplit.slice(1)) {
if (elt === '*') {
props.push({type: 'wildcard'});
} else if (elt.length > 0) {
props.push({type: 'name', name: elt});
}
}
maybeConfig[key] = [[valSplit[0], props]];
}
continue;
}
if (
key !== 'enableResetCacheOnSourceFileChanges' &&
typeof defaultConfig[key as keyof EnvironmentConfig] !== 'boolean'
) {
// skip parsing non-boolean properties
continue;
}
if (val === undefined || val === 'true') {
maybeConfig[key] = true;
} else {
maybeConfig[key] = false;
}
}
const config = EnvironmentConfigSchema.safeParse(maybeConfig);
if (config.success) {
/**
* Unless explicitly enabled, do not insert HMR handling code
* in test fixtures or playground to reduce visual noise.
*/
if (config.data.enableResetCacheOnSourceFileChanges == null) {
config.data.enableResetCacheOnSourceFileChanges = false;
}
return config.data;
}
CompilerError.invariant(false, {
reason: 'Internal error, could not parse config from pragma string',
description: `${fromZodError(config.error)}`,
loc: null,
suggestions: null,
});
}
export function parseConfigPragmaForTests(
pragma: string,
defaults: {
compilationMode: CompilationMode;
},
): PluginOptions {
const environment = parseConfigPragmaEnvironmentForTest(pragma);
let compilationMode: CompilationMode = defaults.compilationMode;
let panicThreshold: PanicThresholdOptions = 'all_errors';
let noEmit: boolean = defaultOptions.noEmit;
for (const token of pragma.split(' ')) {
if (!token.startsWith('@')) {
continue;
}
switch (token) {
case '@compilationMode(annotation)': {
compilationMode = 'annotation';
break;
}
case '@compilationMode(infer)': {
compilationMode = 'infer';
break;
}
case '@compilationMode(all)': {
compilationMode = 'all';
break;
}
case '@compilationMode(syntax)': {
compilationMode = 'syntax';
break;
}
case '@panicThreshold(none)': {
panicThreshold = 'none';
break;
}
case '@noEmit': {
noEmit = true;
break;
}
}
}
return parsePluginOptions({
environment,
compilationMode,
panicThreshold,
noEmit,
});
}
export type PartialEnvironmentConfig = Partial<EnvironmentConfig>;
export type ReactFunctionType = 'Component' | 'Hook' | 'Other';
@@ -863,6 +680,7 @@ export class Environment {
#contextIdentifiers: Set<t.Identifier>;
#hoistedIdentifiers: Set<t.Identifier>;
parentFunction: NodePath<t.Function>;
constructor(
scope: BabelScope,
@@ -870,6 +688,7 @@ export class Environment {
compilerMode: CompilerMode,
config: EnvironmentConfig,
contextIdentifiers: Set<t.Identifier>,
parentFunction: NodePath<t.Function>, // the outermost function being compiled
logger: Logger | null,
filename: string | null,
code: string | null,
@@ -928,6 +747,7 @@ export class Environment {
this.#moduleTypes.set(REANIMATED_MODULE_NAME, reanimatedModuleType);
}
this.parentFunction = parentFunction;
this.#contextIdentifiers = contextIdentifiers;
this.#hoistedIdentifiers = new Set();
}

View File

@@ -5,10 +5,11 @@
* LICENSE file in the root directory of this source tree.
*/
import {Effect, ValueKind, ValueReason} from './HIR';
import {Effect, makeIdentifierId, ValueKind, ValueReason} from './HIR';
import {
BUILTIN_SHAPES,
BuiltInArrayId,
BuiltInFireFunctionId,
BuiltInFireId,
BuiltInMapId,
BuiltInMixedReadonlyId,
@@ -16,6 +17,7 @@ import {
BuiltInSetId,
BuiltInUseActionStateId,
BuiltInUseContextHookId,
BuiltInUseEffectEventId,
BuiltInUseEffectHookId,
BuiltInUseInsertionEffectHookId,
BuiltInUseLayoutEffectHookId,
@@ -24,10 +26,15 @@ import {
BuiltInUseRefId,
BuiltInUseStateId,
BuiltInUseTransitionId,
BuiltInWeakMapId,
BuiltInWeakSetId,
BuiltinEffectEventId,
ReanimatedSharedValueId,
ShapeRegistry,
addFunction,
addHook,
addObject,
signatureArgument,
} from './ObjectShape';
import {BuiltInType, ObjectType, PolyType} from './Types';
import {TypeConfig} from './TypeSchema';
@@ -490,6 +497,38 @@ const TYPED_GLOBALS: Array<[string, BuiltInType]> = [
true,
),
],
[
'WeakMap',
addFunction(
DEFAULT_SHAPES,
[],
{
positionalParams: [Effect.ConditionallyMutateIterator],
restParam: null,
returnType: {kind: 'Object', shapeId: BuiltInWeakMapId},
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Mutable,
},
null,
true,
),
],
[
'WeakSet',
addFunction(
DEFAULT_SHAPES,
[],
{
positionalParams: [Effect.ConditionallyMutateIterator],
restParam: null,
returnType: {kind: 'Object', shapeId: BuiltInWeakSetId},
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Mutable,
},
null,
true,
),
],
// TODO: rest of Global objects
];
@@ -606,6 +645,41 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useEffect',
returnValueKind: ValueKind.Frozen,
aliasing: {
receiver: makeIdentifierId(0),
params: [],
rest: makeIdentifierId(1),
returns: makeIdentifierId(2),
temporaries: [signatureArgument(3)],
effects: [
// Freezes the function and deps
{
kind: 'Freeze',
value: signatureArgument(1),
reason: ValueReason.Effect,
},
// Internally creates an effect object that captures the function and deps
{
kind: 'Create',
into: signatureArgument(3),
value: ValueKind.Frozen,
reason: ValueReason.KnownReturnSignature,
},
// The effect stores the function and dependencies
{
kind: 'Capture',
from: signatureArgument(1),
into: signatureArgument(3),
},
// Returns undefined
{
kind: 'Create',
into: signatureArgument(2),
value: ValueKind.Primitive,
reason: ValueReason.KnownReturnSignature,
},
],
},
},
BuiltInUseEffectHookId,
),
@@ -674,13 +748,39 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
{
positionalParams: [],
restParam: null,
returnType: {kind: 'Primitive'},
returnType: {
kind: 'Function',
return: {kind: 'Poly'},
shapeId: BuiltInFireFunctionId,
isConstructor: false,
},
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Frozen,
},
BuiltInFireId,
),
],
[
'useEffectEvent',
addHook(
DEFAULT_SHAPES,
{
positionalParams: [],
restParam: Effect.Freeze,
returnType: {
kind: 'Function',
return: {kind: 'Poly'},
shapeId: BuiltinEffectEventId,
isConstructor: false,
},
calleeEffect: Effect.Read,
hookKind: 'useEffectEvent',
// Frozen because it should not mutate any locally-bound values
returnValueKind: ValueKind.Frozen,
},
BuiltInUseEffectEventId,
),
],
];
TYPED_GLOBALS.push(
@@ -902,7 +1002,7 @@ export function getReanimatedModuleType(registry: ShapeRegistry): ObjectType {
addHook(registry, {
positionalParams: [],
restParam: Effect.Freeze,
returnType: {kind: 'Poly'},
returnType: {kind: 'Object', shapeId: ReanimatedSharedValueId},
returnValueKind: ValueKind.Mutable,
noAlias: true,
calleeEffect: Effect.Read,

View File

@@ -13,6 +13,7 @@ import {Environment, ReactFunctionType} from './Environment';
import type {HookKind} from './ObjectShape';
import {Type, makeType} from './Types';
import {z} from 'zod';
import type {AliasingEffect} from '../Inference/AliasingEffects';
/*
* *******************************************************************************************
@@ -100,6 +101,7 @@ export type ReactiveInstruction = {
id: InstructionId;
lvalue: Place | null;
value: ReactiveValue;
effects?: Array<AliasingEffect> | null; // TODO make non-optional
loc: SourceLocation;
};
@@ -278,12 +280,14 @@ export type HIRFunction = {
params: Array<Place | SpreadPattern>;
returnTypeAnnotation: t.FlowType | t.TSType | null;
returnType: Type;
returns: Place;
context: Array<Place>;
effects: Array<FunctionEffect> | null;
body: HIR;
generator: boolean;
async: boolean;
directives: Array<string>;
aliasingEffects?: Array<AliasingEffect> | null;
};
export type FunctionEffect =
@@ -449,6 +453,7 @@ export type ReturnTerminal = {
value: Place;
id: InstructionId;
fallthrough?: never;
effects: Array<AliasingEffect> | null;
};
export type GotoTerminal = {
@@ -609,6 +614,7 @@ export type MaybeThrowTerminal = {
id: InstructionId;
loc: SourceLocation;
fallthrough?: never;
effects: Array<AliasingEffect> | null;
};
export type ReactiveScopeTerminal = {
@@ -645,12 +651,14 @@ export type Instruction = {
lvalue: Place;
value: InstructionValue;
loc: SourceLocation;
effects: Array<AliasingEffect> | null;
};
export type TInstruction<T extends InstructionValue> = {
id: InstructionId;
lvalue: Place;
value: T;
effects: Array<AliasingEffect> | null;
loc: SourceLocation;
};
@@ -746,6 +754,27 @@ export enum InstructionKind {
Function = 'Function',
}
export function convertHoistedLValueKind(
kind: InstructionKind,
): InstructionKind | null {
switch (kind) {
case InstructionKind.HoistedLet:
return InstructionKind.Let;
case InstructionKind.HoistedConst:
return InstructionKind.Const;
case InstructionKind.HoistedFunction:
return InstructionKind.Function;
case InstructionKind.Let:
case InstructionKind.Const:
case InstructionKind.Function:
case InstructionKind.Reassign:
case InstructionKind.Catch:
return null;
default:
assertExhaustive(kind, 'Unexpected lvalue kind');
}
}
function _staticInvariantInstructionValueHasLocation(
value: InstructionValue,
): SourceLocation {
@@ -880,8 +909,20 @@ export type InstructionValue =
| StoreLocal
| {
kind: 'StoreContext';
/**
* StoreContext kinds:
* Reassign: context variable reassignment in source
* Const: const declaration + assignment in source
* ('const' context vars are ones whose declarations are hoisted)
* Let: let declaration + assignment in source
* Function: function declaration in source (similar to `const`)
*/
lvalue: {
kind: InstructionKind.Reassign;
kind:
| InstructionKind.Reassign
| InstructionKind.Const
| InstructionKind.Let
| InstructionKind.Function;
place: Place;
};
value: Place;
@@ -910,13 +951,21 @@ export type InstructionValue =
value: Place;
loc: SourceLocation;
}
| {
| ({
kind: 'TypeCastExpression';
value: Place;
typeAnnotation: t.FlowType | t.TSType;
type: Type;
loc: SourceLocation;
}
} & (
| {
typeAnnotation: t.FlowType;
typeAnnotationKind: 'cast';
}
| {
typeAnnotation: t.TSType;
typeAnnotationKind: 'as' | 'satisfies';
}
))
| JsxExpression
| {
kind: 'ObjectExpression';
@@ -1339,6 +1388,11 @@ export enum ValueReason {
*/
JsxCaptured = 'jsx-captured',
/**
* Passed to an effect
*/
Effect = 'effect',
/**
* Return value of a function with known frozen return value, e.g. `useState`.
*/
@@ -1527,6 +1581,18 @@ export type DependencyPathEntry = {
export type DependencyPath = Array<DependencyPathEntry>;
export type ReactiveScopeDependency = {
identifier: Identifier;
/**
* Reflects whether the base identifier is reactive. Note that some reactive
* objects may have non-reactive properties, but we do not currently track
* this.
*
* ```js
* // Technically, result[0] is reactive and result[1] is not.
* // Currently, both dependencies would be marked as reactive.
* const result = useState();
* ```
*/
reactive: boolean;
path: DependencyPath;
};
@@ -1684,6 +1750,18 @@ export function isRefOrRefValue(id: Identifier): boolean {
return isUseRefType(id) || isRefValueType(id);
}
/*
* Returns true if the type is a Ref or a custom user type that acts like a ref when it
* shouldn't. For now the only other case of this is Reanimated's shared values.
*/
export function isRefOrRefLikeMutableType(type: Type): boolean {
return (
type.kind === 'Object' &&
(type.shapeId === 'BuiltInUseRefId' ||
type.shapeId == 'ReanimatedSharedValueId')
);
}
export function isSetStateType(id: Identifier): boolean {
return id.type.kind === 'Function' && id.type.shapeId === 'BuiltInSetState';
}
@@ -1714,6 +1792,19 @@ export function isDispatcherType(id: Identifier): boolean {
return id.type.kind === 'Function' && id.type.shapeId === 'BuiltInDispatch';
}
export function isFireFunctionType(id: Identifier): boolean {
return (
id.type.kind === 'Function' && id.type.shapeId === 'BuiltInFireFunction'
);
}
export function isEffectEventFunctionType(id: Identifier): boolean {
return (
id.type.kind === 'Function' &&
id.type.shapeId === 'BuiltInEffectEventFunction'
);
}
export function isStableType(id: Identifier): boolean {
return (
isSetStateType(id) ||
@@ -1724,6 +1815,40 @@ export function isStableType(id: Identifier): boolean {
);
}
export function isStableTypeContainer(id: Identifier): boolean {
const type_ = id.type;
if (type_.kind !== 'Object') {
return false;
}
return (
isUseStateType(id) || // setState
type_.shapeId === 'BuiltInUseActionState' || // setActionState
isUseReducerType(id) || // dispatcher
type_.shapeId === 'BuiltInUseTransition' // startTransition
);
}
export function evaluatesToStableTypeOrContainer(
env: Environment,
{value}: Instruction,
): boolean {
if (value.kind === 'CallExpression' || value.kind === 'MethodCall') {
const callee =
value.kind === 'CallExpression' ? value.callee : value.property;
const calleeHookKind = getHookKind(env, callee.identifier);
switch (calleeHookKind) {
case 'useState':
case 'useReducer':
case 'useActionState':
case 'useRef':
case 'useTransition':
return true;
}
}
return false;
}
export function isUseEffectHookType(id: Identifier): boolean {
return (
id.type.kind === 'Function' && id.type.shapeId === 'BuiltInUseEffectHook'

View File

@@ -110,7 +110,6 @@ export default class HIRBuilder {
#bindings: Bindings;
#env: Environment;
#exceptionHandlerStack: Array<BlockId> = [];
parentFunction: NodePath<t.Function>;
errors: CompilerError = new CompilerError();
/**
* Traversal context: counts the number of `fbt` tag parents
@@ -136,16 +135,17 @@ export default class HIRBuilder {
constructor(
env: Environment,
parentFunction: NodePath<t.Function>, // the outermost function being compiled
bindings: Bindings | null = null,
context: Array<t.Identifier> | null = null,
options?: {
bindings?: Bindings | null;
context?: Array<t.Identifier>;
entryBlockKind?: BlockKind;
},
) {
this.#env = env;
this.#bindings = bindings ?? new Map();
this.parentFunction = parentFunction;
this.#context = context ?? [];
this.#bindings = options?.bindings ?? new Map();
this.#context = options?.context ?? [];
this.#entry = makeBlockId(env.nextBlockId);
this.#current = newBlock(this.#entry, 'block');
this.#current = newBlock(this.#entry, options?.entryBlockKind ?? 'block');
}
currentBlockKind(): BlockKind {
@@ -165,6 +165,7 @@ export default class HIRBuilder {
handler: exceptionHandler,
id: makeInstructionId(0),
loc: instruction.loc,
effects: null,
},
continuationBlock,
);
@@ -239,7 +240,7 @@ export default class HIRBuilder {
// Check if the binding is from module scope
const outerBinding =
this.parentFunction.scope.parent.getBinding(originalName);
this.#env.parentFunction.scope.parent.getBinding(originalName);
if (babelBinding === outerBinding) {
const path = babelBinding.path;
if (path.isImportDefaultSpecifier()) {
@@ -293,7 +294,7 @@ export default class HIRBuilder {
const binding = this.#resolveBabelBinding(path);
if (binding) {
// Check if the binding is from module scope, if so return null
const outerBinding = this.parentFunction.scope.parent.getBinding(
const outerBinding = this.#env.parentFunction.scope.parent.getBinding(
path.node.name,
);
if (binding === outerBinding) {
@@ -376,7 +377,7 @@ export default class HIRBuilder {
}
// Terminate the current block w the given terminal, and start a new block
terminate(terminal: Terminal, nextBlockKind: BlockKind | null): void {
terminate(terminal: Terminal, nextBlockKind: BlockKind | null): BlockId {
const {id: blockId, kind, instructions} = this.#current;
this.#completed.set(blockId, {
kind,
@@ -390,6 +391,7 @@ export default class HIRBuilder {
const nextId = this.#env.nextBlockId;
this.#current = newBlock(nextId, nextBlockKind);
}
return blockId;
}
/*
@@ -746,6 +748,11 @@ function getReversePostorderedBlocks(func: HIR): HIR['blocks'] {
* (eg bb2 then bb1), we ensure that they get reversed back to the correct order.
*/
const block = func.blocks.get(blockId)!;
CompilerError.invariant(block != null, {
reason: '[HIRBuilder] Unexpected null block',
description: `expected block ${blockId} to exist`,
loc: GeneratedSource,
});
const successors = [...eachTerminalSuccessor(block.terminal)].reverse();
const fallthrough = terminalFallthrough(block.terminal);

View File

@@ -12,6 +12,7 @@ import {
GeneratedSource,
HIRFunction,
Instruction,
Place,
} from './HIR';
import {markPredecessors} from './HIRBuilder';
import {terminalFallthrough, terminalHasFallthrough} from './visitors';
@@ -80,20 +81,22 @@ export function mergeConsecutiveBlocks(fn: HIRFunction): void {
suggestions: null,
});
const operand = Array.from(phi.operands.values())[0]!;
const lvalue: Place = {
kind: 'Identifier',
identifier: phi.place.identifier,
effect: Effect.ConditionallyMutate,
reactive: false,
loc: GeneratedSource,
};
const instr: Instruction = {
id: predecessor.terminal.id,
lvalue: {
kind: 'Identifier',
identifier: phi.place.identifier,
effect: Effect.ConditionallyMutate,
reactive: false,
loc: GeneratedSource,
},
lvalue: {...lvalue},
value: {
kind: 'LoadLocal',
place: {...operand},
loc: GeneratedSource,
},
effects: [{kind: 'Alias', from: {...operand}, into: {...lvalue}}],
loc: GeneratedSource,
};
predecessor.instructions.push(instr);

View File

@@ -1,3 +1,10 @@
/**
* 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 {
HIRFunction,
InstructionId,

View File

@@ -6,10 +6,21 @@
*/
import {CompilerError} from '../CompilerError';
import {Effect, ValueKind, ValueReason} from './HIR';
import {AliasingSignature} from '../Inference/AliasingEffects';
import {
Effect,
GeneratedSource,
makeDeclarationId,
makeIdentifierId,
makeInstructionId,
Place,
ValueKind,
ValueReason,
} from './HIR';
import {
BuiltInType,
FunctionType,
makeType,
ObjectType,
PolyType,
PrimitiveType,
@@ -131,6 +142,7 @@ export type HookKind =
| 'useCallback'
| 'useTransition'
| 'useImperativeHandle'
| 'useEffectEvent'
| 'Custom';
/*
@@ -179,6 +191,9 @@ export type FunctionSignature = {
impure?: boolean;
canonicalName?: string;
aliasing?: AliasingSignature | null;
todo_aliasing?: AliasingSignature | null;
};
/*
@@ -203,6 +218,8 @@ export const BuiltInPropsId = 'BuiltInProps';
export const BuiltInArrayId = 'BuiltInArray';
export const BuiltInSetId = 'BuiltInSet';
export const BuiltInMapId = 'BuiltInMap';
export const BuiltInWeakSetId = 'BuiltInWeakSet';
export const BuiltInWeakMapId = 'BuiltInWeakMap';
export const BuiltInFunctionId = 'BuiltInFunction';
export const BuiltInJsxId = 'BuiltInJsx';
export const BuiltInObjectId = 'BuiltInObject';
@@ -223,6 +240,12 @@ export const BuiltInUseContextHookId = 'BuiltInUseContextHook';
export const BuiltInUseTransitionId = 'BuiltInUseTransition';
export const BuiltInStartTransitionId = 'BuiltInStartTransition';
export const BuiltInFireId = 'BuiltInFire';
export const BuiltInFireFunctionId = 'BuiltInFireFunction';
export const BuiltInUseEffectEventId = 'BuiltInUseEffectEvent';
export const BuiltinEffectEventId = 'BuiltInEffectEventFunction';
// See getReanimatedModuleType() in Globals.ts — this is part of supporting Reanimated's ref-like types
export const ReanimatedSharedValueId = 'ReanimatedSharedValueId';
// ShapeRegistry with default definitions for built-ins.
export const BUILTIN_SHAPES: ShapeRegistry = new Map();
@@ -296,6 +319,30 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [
returnType: PRIMITIVE_TYPE,
calleeEffect: Effect.Store,
returnValueKind: ValueKind.Primitive,
aliasing: {
receiver: makeIdentifierId(0),
params: [],
rest: makeIdentifierId(1),
returns: makeIdentifierId(2),
temporaries: [],
effects: [
// Push directly mutates the array itself
{kind: 'Mutate', value: signatureArgument(0)},
// The arguments are captured into the array
{
kind: 'Capture',
from: signatureArgument(1),
into: signatureArgument(0),
},
// Returns the new length, a primitive
{
kind: 'Create',
into: signatureArgument(2),
value: ValueKind.Primitive,
reason: ValueReason.KnownReturnSignature,
},
],
},
}),
],
[
@@ -326,6 +373,62 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [
returnValueKind: ValueKind.Mutable,
noAlias: true,
mutableOnlyIfOperandsAreMutable: true,
aliasing: {
receiver: makeIdentifierId(0),
params: [makeIdentifierId(1)],
rest: null,
returns: makeIdentifierId(2),
temporaries: [
// Temporary representing captured items of the receiver
signatureArgument(3),
// Temporary representing the result of the callback
signatureArgument(4),
/*
* Undefined `this` arg to the callback. Note the signature does not
* support passing an explicit thisArg second param
*/
signatureArgument(5),
],
effects: [
// Map creates a new mutable array
{
kind: 'Create',
into: signatureArgument(2),
value: ValueKind.Mutable,
reason: ValueReason.KnownReturnSignature,
},
// The first arg to the callback is an item extracted from the receiver array
{
kind: 'CreateFrom',
from: signatureArgument(0),
into: signatureArgument(3),
},
// The undefined this for the callback
{
kind: 'Create',
into: signatureArgument(5),
value: ValueKind.Primitive,
reason: ValueReason.KnownReturnSignature,
},
// calls the callback, returning the result into a temporary
{
kind: 'Apply',
receiver: signatureArgument(5),
args: [signatureArgument(3), {kind: 'Hole'}, signatureArgument(0)],
function: signatureArgument(1),
into: signatureArgument(4),
signature: null,
mutatesFunction: false,
loc: GeneratedSource,
},
// captures the result of the callback into the return array
{
kind: 'Capture',
from: signatureArgument(4),
into: signatureArgument(2),
},
],
},
}),
],
[
@@ -473,6 +576,32 @@ addObject(BUILTIN_SHAPES, BuiltInSetId, [
calleeEffect: Effect.Store,
// returnValueKind is technically dependent on the ValueKind of the set itself
returnValueKind: ValueKind.Mutable,
aliasing: {
receiver: makeIdentifierId(0),
params: [],
rest: makeIdentifierId(1),
returns: makeIdentifierId(2),
temporaries: [],
effects: [
// Set.add returns the receiver Set
{
kind: 'Assign',
from: signatureArgument(0),
into: signatureArgument(2),
},
// Set.add mutates the set itself
{
kind: 'Mutate',
value: signatureArgument(0),
},
// Captures the rest params into the set
{
kind: 'Capture',
from: signatureArgument(1),
into: signatureArgument(0),
},
],
},
}),
],
[
@@ -763,6 +892,101 @@ addObject(BUILTIN_SHAPES, BuiltInMapId, [
],
]);
addObject(BUILTIN_SHAPES, BuiltInWeakSetId, [
[
/**
* add(value)
* Parameters
* value: the value of the element to add to the Set object.
* Returns the Set object with added value.
*/
'add',
addFunction(BUILTIN_SHAPES, [], {
positionalParams: [Effect.Capture],
restParam: null,
returnType: {kind: 'Object', shapeId: BuiltInWeakSetId},
calleeEffect: Effect.Store,
// returnValueKind is technically dependent on the ValueKind of the set itself
returnValueKind: ValueKind.Mutable,
}),
],
[
/**
* setInstance.delete(value)
* Returns true if value was already in Set; otherwise false.
*/
'delete',
addFunction(BUILTIN_SHAPES, [], {
positionalParams: [Effect.Read],
restParam: null,
returnType: PRIMITIVE_TYPE,
calleeEffect: Effect.Store,
returnValueKind: ValueKind.Primitive,
}),
],
[
'has',
addFunction(BUILTIN_SHAPES, [], {
positionalParams: [Effect.Read],
restParam: null,
returnType: PRIMITIVE_TYPE,
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Primitive,
}),
],
]);
addObject(BUILTIN_SHAPES, BuiltInWeakMapId, [
[
'delete',
addFunction(BUILTIN_SHAPES, [], {
positionalParams: [Effect.Read],
restParam: null,
returnType: PRIMITIVE_TYPE,
calleeEffect: Effect.Store,
returnValueKind: ValueKind.Primitive,
}),
],
[
'get',
addFunction(BUILTIN_SHAPES, [], {
positionalParams: [Effect.Read],
restParam: null,
returnType: {kind: 'Poly'},
calleeEffect: Effect.Capture,
returnValueKind: ValueKind.Mutable,
}),
],
[
'has',
addFunction(BUILTIN_SHAPES, [], {
positionalParams: [Effect.Read],
restParam: null,
returnType: PRIMITIVE_TYPE,
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Primitive,
}),
],
[
/**
* Params
* key: the key of the element to add to the Map object. The key may be
* any JavaScript type (any primitive value or any type of JavaScript
* object).
* value: the value of the element to add to the Map object.
* Returns the Map object.
*/
'set',
addFunction(BUILTIN_SHAPES, [], {
positionalParams: [Effect.Capture, Effect.Capture],
restParam: null,
returnType: {kind: 'Object', shapeId: BuiltInWeakMapId},
calleeEffect: Effect.Store,
returnValueKind: ValueKind.Mutable,
}),
],
]);
addObject(BUILTIN_SHAPES, BuiltInUseStateId, [
['0', {kind: 'Poly'}],
[
@@ -847,6 +1071,19 @@ addObject(BUILTIN_SHAPES, BuiltInRefValueId, [
['*', {kind: 'Object', shapeId: BuiltInRefValueId}],
]);
addFunction(
BUILTIN_SHAPES,
[],
{
positionalParams: [],
restParam: Effect.ConditionallyMutate,
returnType: {kind: 'Poly'},
calleeEffect: Effect.ConditionallyMutate,
returnValueKind: ValueKind.Mutable,
},
BuiltinEffectEventId,
);
/**
* MixedReadOnly =
* | primitive
@@ -1068,3 +1305,22 @@ export const DefaultNonmutatingHook = addHook(
},
'DefaultNonmutatingHook',
);
export function signatureArgument(id: number): Place {
const place: Place = {
kind: 'Identifier',
effect: Effect.Unknown,
loc: GeneratedSource,
reactive: false,
identifier: {
declarationId: makeDeclarationId(id),
id: makeIdentifierId(id),
loc: GeneratedSource,
mutableRange: {start: makeInstructionId(0), end: makeInstructionId(0)},
name: null,
scope: null,
type: makeType(),
},
};
return place;
}

View File

@@ -35,6 +35,7 @@ import type {
Type,
} from './HIR';
import {GotoVariant, InstructionKind} from './HIR';
import {AliasingEffect, AliasingSignature} from '../Inference/AliasingEffects';
export type Options = {
indent: number;
@@ -67,13 +68,15 @@ export function printFunction(fn: HIRFunction): string {
})
.join(', ') +
')';
} else {
definition += '()';
}
if (definition.length !== 0) {
output.push(definition);
}
output.push(printType(fn.returnType));
output.push(printHIR(fn.body));
output.push(`: ${printType(fn.returnType)} @ ${printPlace(fn.returns)}`);
output.push(...fn.directives);
output.push(printHIR(fn.body));
return output.join('\n');
}
@@ -151,7 +154,10 @@ export function printMixedHIR(
export function printInstruction(instr: ReactiveInstruction): string {
const id = `[${instr.id}]`;
const value = printInstructionValue(instr.value);
let value = printInstructionValue(instr.value);
if (instr.effects != null) {
value += `\n ${instr.effects.map(printAliasingEffect).join('\n ')}`;
}
if (instr.lvalue !== null) {
return `${id} ${printPlace(instr.lvalue)} = ${value}`;
@@ -213,6 +219,9 @@ export function printTerminal(terminal: Terminal): Array<string> | string {
value = `[${terminal.id}] Return${
terminal.value != null ? ' ' + printPlace(terminal.value) : ''
}`;
if (terminal.effects != null) {
value += `\n ${terminal.effects.map(printAliasingEffect).join('\n ')}`;
}
break;
}
case 'goto': {
@@ -281,6 +290,9 @@ export function printTerminal(terminal: Terminal): Array<string> | string {
}
case 'maybe-throw': {
value = `[${terminal.id}] MaybeThrow continuation=bb${terminal.continuation} handler=bb${terminal.handler}`;
if (terminal.effects != null) {
value += `\n ${terminal.effects.map(printAliasingEffect).join('\n ')}`;
}
break;
}
case 'scope': {
@@ -555,8 +567,11 @@ export function printInstructionValue(instrValue: ReactiveValue): string {
}
})
.join(', ') ?? '';
const type = printType(instrValue.loweredFunc.func.returnType).trim();
value = `${kind} ${name} @context[${context}] @effects[${effects}]${type !== '' ? ` return${type}` : ''}:\n${fn}`;
const aliasingEffects =
instrValue.loweredFunc.func.aliasingEffects
?.map(printAliasingEffect)
?.join(', ') ?? '';
value = `${kind} ${name} @context[${context}] @effects[${effects}] @aliasingEffects=[${aliasingEffects}]\n${fn}`;
break;
}
case 'TaggedTemplateExpression': {
@@ -922,3 +937,107 @@ function getFunctionName(
return defaultValue;
}
}
export function printAliasingEffect(effect: AliasingEffect): string {
switch (effect.kind) {
case 'Assign': {
return `Assign ${printPlaceForAliasEffect(effect.into)} = ${printPlaceForAliasEffect(effect.from)}`;
}
case 'Alias': {
return `Alias ${printPlaceForAliasEffect(effect.into)} = ${printPlaceForAliasEffect(effect.from)}`;
}
case 'Capture': {
return `Capture ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`;
}
case 'ImmutableCapture': {
return `ImmutableCapture ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`;
}
case 'Create': {
return `Create ${printPlaceForAliasEffect(effect.into)} = ${effect.value}`;
}
case 'CreateFrom': {
return `Create ${printPlaceForAliasEffect(effect.into)} = kindOf(${printPlaceForAliasEffect(effect.from)})`;
}
case 'CreateFunction': {
return `Function ${printPlaceForAliasEffect(effect.into)} = Function captures=[${effect.captures.map(printPlaceForAliasEffect).join(', ')}]`;
}
case 'Apply': {
const receiverCallee =
effect.receiver.identifier.id === effect.function.identifier.id
? printPlaceForAliasEffect(effect.receiver)
: `${printPlaceForAliasEffect(effect.receiver)}.${printPlaceForAliasEffect(effect.function)}`;
const args = effect.args
.map(arg => {
if (arg.kind === 'Identifier') {
return printPlaceForAliasEffect(arg);
} else if (arg.kind === 'Hole') {
return ' ';
}
return `...${printPlaceForAliasEffect(arg.place)}`;
})
.join(', ');
let signature = '';
if (effect.signature != null) {
if (effect.signature.aliasing != null) {
signature = printAliasingSignature(effect.signature.aliasing);
} else {
signature = JSON.stringify(effect.signature, null, 2);
}
}
return `Apply ${printPlaceForAliasEffect(effect.into)} = ${receiverCallee}(${args})${signature != '' ? '\n ' : ''}${signature}`;
}
case 'Freeze': {
return `Freeze ${printPlaceForAliasEffect(effect.value)} ${effect.reason}`;
}
case 'Mutate':
case 'MutateConditionally':
case 'MutateTransitive':
case 'MutateTransitiveConditionally': {
return `${effect.kind} ${printPlaceForAliasEffect(effect.value)}`;
}
case 'MutateFrozen': {
return `MutateFrozen ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`;
}
case 'MutateGlobal': {
return `MutateGlobal ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`;
}
case 'Impure': {
return `Impure ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`;
}
case 'Render': {
return `Render ${printPlaceForAliasEffect(effect.place)}`;
}
default: {
assertExhaustive(effect, `Unexpected kind '${(effect as any).kind}'`);
}
}
}
function printPlaceForAliasEffect(place: Place): string {
return printIdentifier(place.identifier);
}
export function printAliasingSignature(signature: AliasingSignature): string {
const tokens: Array<string> = ['function '];
if (signature.temporaries.length !== 0) {
tokens.push('<');
tokens.push(
signature.temporaries.map(temp => `$${temp.identifier.id}`).join(', '),
);
tokens.push('>');
}
tokens.push('(');
tokens.push('this=$' + String(signature.receiver));
for (const param of signature.params) {
tokens.push(', $' + String(param));
}
if (signature.rest != null) {
tokens.push(`, ...$${String(signature.rest)}`);
}
tokens.push('): ');
tokens.push('$' + String(signature.returns) + ':');
for (const effect of signature.effects) {
tokens.push('\n ' + printAliasingEffect(effect));
}
return tokens.join('');
}

View File

@@ -1,3 +1,10 @@
/**
* 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 {
ScopeId,
HIRFunction,
@@ -23,6 +30,7 @@ import {
FunctionExpression,
ObjectMethod,
PropertyLiteral,
convertHoistedLValueKind,
} from './HIR';
import {
collectHoistablePropertyLoads,
@@ -109,7 +117,7 @@ export function propagateScopeDependenciesHIR(fn: HIRFunction): void {
}
}
function findTemporariesUsedOutsideDeclaringScope(
export function findTemporariesUsedOutsideDeclaringScope(
fn: HIRFunction,
): ReadonlySet<DeclarationId> {
/*
@@ -239,12 +247,18 @@ function isLoadContextMutable(
id: InstructionId,
): instrValue is LoadContext {
if (instrValue.kind === 'LoadContext') {
CompilerError.invariant(instrValue.place.identifier.scope != null, {
reason:
'[PropagateScopeDependencies] Expected all context variables to be assigned a scope',
loc: instrValue.loc,
});
return id >= instrValue.place.identifier.scope.range.end;
/**
* Not all context variables currently have scopes due to limitations of
* mutability analysis for function expressions.
*
* Currently, many function expressions references are inferred to be
* 'Read' | 'Freeze' effects which don't replay mutable effects of captured
* context.
*/
return (
instrValue.place.identifier.scope != null &&
id >= instrValue.place.identifier.scope.range.end
);
}
return false;
}
@@ -302,6 +316,7 @@ function collectTemporariesSidemapImpl(
) {
temporaries.set(lvalue.identifier.id, {
identifier: value.place.identifier,
reactive: value.place.reactive,
path: [],
});
}
@@ -355,11 +370,13 @@ function getProperty(
if (resolvedDependency == null) {
property = {
identifier: object.identifier,
reactive: object.reactive,
path: [{property: propertyName, optional}],
};
} else {
property = {
identifier: resolvedDependency.identifier,
reactive: resolvedDependency.reactive,
path: [...resolvedDependency.path, {property: propertyName, optional}],
};
}
@@ -371,7 +388,7 @@ type Decl = {
scope: Stack<ReactiveScope>;
};
class Context {
export class DependencyCollectionContext {
#declarations: Map<DeclarationId, Decl> = new Map();
#reassignments: Map<Identifier, Decl> = new Map();
@@ -464,6 +481,9 @@ class Context {
}
this.#reassignments.set(identifier, decl);
}
hasDeclared(identifier: Identifier): boolean {
return this.#declarations.has(identifier.declarationId);
}
// Checks if identifier is a valid dependency in the current scope
#checkValidDependency(maybeDependency: ReactiveScopeDependency): boolean {
@@ -515,6 +535,7 @@ class Context {
this.visitDependency(
this.#temporaries.get(place.identifier.id) ?? {
identifier: place.identifier,
reactive: place.reactive,
path: [],
},
);
@@ -579,6 +600,7 @@ class Context {
) {
maybeDependency = {
identifier: maybeDependency.identifier,
reactive: maybeDependency.reactive,
path: [],
};
}
@@ -600,7 +622,11 @@ class Context {
identifier =>
identifier.declarationId === place.identifier.declarationId,
) &&
this.#checkValidDependency({identifier: place.identifier, path: []})
this.#checkValidDependency({
identifier: place.identifier,
reactive: place.reactive,
path: [],
})
) {
currentScope.reassignments.add(place.identifier);
}
@@ -638,7 +664,10 @@ enum HIRValue {
Terminal,
}
function handleInstruction(instr: Instruction, context: Context): void {
export function handleInstruction(
instr: Instruction,
context: DependencyCollectionContext,
): void {
const {id, value, lvalue} = instr;
context.declare(lvalue.identifier, {
id,
@@ -662,21 +691,21 @@ function handleInstruction(instr: Instruction, context: Context): void {
});
} else if (value.kind === 'DeclareLocal' || value.kind === 'DeclareContext') {
/*
* Some variables may be declared and never initialized. We need
* to retain (and hoist) these declarations if they are included
* in a reactive scope. One approach is to simply add all `DeclareLocal`s
* as scope declarations.
* Some variables may be declared and never initialized. We need to retain
* (and hoist) these declarations if they are included in a reactive scope.
* One approach is to simply add all `DeclareLocal`s as scope declarations.
*
* Context variables with hoisted declarations only become live after their
* first assignment. We only declare real DeclareLocal / DeclareContext
* instructions (not hoisted ones) to avoid generating dependencies on
* hoisted declarations.
*/
/*
* We add context variable declarations here, not at `StoreContext`, since
* context Store / Loads are modeled as reads and mutates to the underlying
* variable reference (instead of through intermediate / inlined temporaries)
*/
context.declare(value.lvalue.place.identifier, {
id,
scope: context.currentScope,
});
if (convertHoistedLValueKind(value.lvalue.kind) === null) {
context.declare(value.lvalue.place.identifier, {
id,
scope: context.currentScope,
});
}
} else if (value.kind === 'Destructure') {
context.visitOperand(value.value);
for (const place of eachPatternOperand(value.lvalue.pattern)) {
@@ -688,6 +717,26 @@ function handleInstruction(instr: Instruction, context: Context): void {
scope: context.currentScope,
});
}
} else if (value.kind === 'StoreContext') {
/**
* Some StoreContext variables have hoisted declarations. If we're storing
* to a context variable that hasn't yet been declared, the StoreContext is
* the declaration.
* (see corresponding logic in PruneHoistedContext)
*/
if (
!context.hasDeclared(value.lvalue.place.identifier) ||
value.lvalue.kind !== InstructionKind.Reassign
) {
context.declare(value.lvalue.place.identifier, {
id,
scope: context.currentScope,
});
}
for (const operand of eachInstructionValueOperand(value)) {
context.visitOperand(operand);
}
} else {
for (const operand of eachInstructionValueOperand(value)) {
context.visitOperand(operand);
@@ -701,7 +750,7 @@ function collectDependencies(
temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
processedInstrsInOptional: ReadonlySet<Instruction | Terminal>,
): Map<ReactiveScope, Array<ReactiveScopeDependency>> {
const context = new Context(
const context = new DependencyCollectionContext(
usedOutsideDeclaringScope,
temporaries,
processedInstrsInOptional,

View File

@@ -1,3 +1,10 @@
/**
* 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 {CompilerError} from '..';
import {BlockId, GotoVariant, HIRFunction} from './HIR';

View File

@@ -0,0 +1,285 @@
import {
Place,
ReactiveScopeDependency,
Identifier,
makeInstructionId,
InstructionKind,
GeneratedSource,
BlockId,
makeTemporaryIdentifier,
Effect,
GotoVariant,
HIR,
} from './HIR';
import {CompilerError} from '../CompilerError';
import {Environment} from './Environment';
import HIRBuilder from './HIRBuilder';
import {lowerValueToTemporary} from './BuildHIR';
type DependencyInstructions = {
place: Place;
value: HIR;
exitBlockId: BlockId;
};
export function buildDependencyInstructions(
dep: ReactiveScopeDependency,
env: Environment,
): DependencyInstructions {
const builder = new HIRBuilder(env, {
entryBlockKind: 'value',
});
let dependencyValue: Identifier;
if (dep.path.every(path => !path.optional)) {
dependencyValue = writeNonOptionalDependency(dep, env, builder);
} else {
dependencyValue = writeOptionalDependency(dep, builder, null);
}
const exitBlockId = builder.terminate(
{
kind: 'unsupported',
loc: GeneratedSource,
id: makeInstructionId(0),
},
null,
);
return {
place: {
kind: 'Identifier',
identifier: dependencyValue,
effect: Effect.Freeze,
reactive: dep.reactive,
loc: GeneratedSource,
},
value: builder.build(),
exitBlockId,
};
}
/**
* Write instructions for a simple dependency (without optional chains)
*/
function writeNonOptionalDependency(
dep: ReactiveScopeDependency,
env: Environment,
builder: HIRBuilder,
): Identifier {
const loc = dep.identifier.loc;
let curr: Identifier = makeTemporaryIdentifier(env.nextIdentifierId, loc);
builder.push({
lvalue: {
identifier: curr,
kind: 'Identifier',
effect: Effect.Mutate,
reactive: dep.reactive,
loc,
},
value: {
kind: 'LoadLocal',
place: {
identifier: dep.identifier,
kind: 'Identifier',
effect: Effect.Freeze,
reactive: dep.reactive,
loc,
},
loc,
},
id: makeInstructionId(1),
loc: loc,
effects: null,
});
/**
* Iteratively build up dependency instructions by reading from the last written
* instruction.
*/
for (const path of dep.path) {
const next = makeTemporaryIdentifier(env.nextIdentifierId, loc);
builder.push({
lvalue: {
identifier: next,
kind: 'Identifier',
effect: Effect.Mutate,
reactive: dep.reactive,
loc,
},
value: {
kind: 'PropertyLoad',
object: {
identifier: curr,
kind: 'Identifier',
effect: Effect.Freeze,
reactive: dep.reactive,
loc,
},
property: path.property,
loc,
},
id: makeInstructionId(1),
loc: loc,
effects: null,
});
curr = next;
}
return curr;
}
/**
* Write a dependency into optional blocks.
*
* e.g. `a.b?.c.d` is written to an optional block that tests `a.b` and
* conditionally evaluates `c.d`.
*/
function writeOptionalDependency(
dep: ReactiveScopeDependency,
builder: HIRBuilder,
parentAlternate: BlockId | null,
): Identifier {
const env = builder.environment;
/**
* Reserve an identifier which will be used to store the result of this
* dependency.
*/
const dependencyValue: Place = {
kind: 'Identifier',
identifier: makeTemporaryIdentifier(env.nextIdentifierId, GeneratedSource),
effect: Effect.Mutate,
reactive: dep.reactive,
loc: GeneratedSource,
};
/**
* Reserve a block which is the fallthrough (and transitive successor) of this
* optional chain.
*/
const continuationBlock = builder.reserve(builder.currentBlockKind());
let alternate;
if (parentAlternate != null) {
alternate = parentAlternate;
} else {
/**
* If an outermost alternate block has not been reserved, write one
*
* $N = Primitive undefined
* $M = StoreLocal $OptionalResult = $N
* goto fallthrough
*/
alternate = builder.enter('value', () => {
const temp = lowerValueToTemporary(builder, {
kind: 'Primitive',
value: undefined,
loc: GeneratedSource,
});
lowerValueToTemporary(builder, {
kind: 'StoreLocal',
lvalue: {kind: InstructionKind.Const, place: {...dependencyValue}},
value: {...temp},
type: null,
loc: GeneratedSource,
});
return {
kind: 'goto',
variant: GotoVariant.Break,
block: continuationBlock.id,
id: makeInstructionId(0),
loc: GeneratedSource,
};
});
}
// Reserve the consequent block, which is the successor of the test block
const consequent = builder.reserve('value');
let testIdentifier: Identifier | null = null;
const testBlock = builder.enter('value', () => {
const testDependency = {
...dep,
path: dep.path.slice(0, dep.path.length - 1),
};
const firstOptional = dep.path.findIndex(path => path.optional);
CompilerError.invariant(firstOptional !== -1, {
reason:
'[ScopeDependencyUtils] Internal invariant broken: expected optional path',
loc: dep.identifier.loc,
description: null,
suggestions: null,
});
if (firstOptional === dep.path.length - 1) {
// Base case: the test block is simple
testIdentifier = writeNonOptionalDependency(testDependency, env, builder);
} else {
// Otherwise, the test block is a nested optional chain
testIdentifier = writeOptionalDependency(
testDependency,
builder,
alternate,
);
}
return {
kind: 'branch',
test: {
identifier: testIdentifier,
effect: Effect.Freeze,
kind: 'Identifier',
loc: GeneratedSource,
reactive: dep.reactive,
},
consequent: consequent.id,
alternate,
id: makeInstructionId(0),
loc: GeneratedSource,
fallthrough: continuationBlock.id,
};
});
builder.enterReserved(consequent, () => {
CompilerError.invariant(testIdentifier !== null, {
reason: 'Satisfy type checker',
description: null,
loc: null,
suggestions: null,
});
lowerValueToTemporary(builder, {
kind: 'StoreLocal',
lvalue: {kind: InstructionKind.Const, place: {...dependencyValue}},
value: lowerValueToTemporary(builder, {
kind: 'PropertyLoad',
object: {
identifier: testIdentifier,
kind: 'Identifier',
effect: Effect.Freeze,
reactive: dep.reactive,
loc: GeneratedSource,
},
property: dep.path.at(-1)!.property,
loc: GeneratedSource,
}),
type: null,
loc: GeneratedSource,
});
return {
kind: 'goto',
variant: GotoVariant.Break,
block: continuationBlock.id,
id: makeInstructionId(0),
loc: GeneratedSource,
};
});
builder.terminateWithContinuation(
{
kind: 'optional',
optional: dep.path.at(-1)!.optional,
test: testBlock,
fallthrough: continuationBlock.id,
id: makeInstructionId(0),
loc: GeneratedSource,
},
continuationBlock,
);
return dependencyValue.identifier;
}

View File

@@ -17,7 +17,6 @@ export {buildReactiveScopeTerminalsHIR} from './BuildReactiveScopeTerminalsHIR';
export {computeDominatorTree, computePostDominatorTree} from './Dominator';
export {
Environment,
parseConfigPragmaForTests,
validateEnvironmentConfig,
type EnvironmentConfig,
type ExternalFunction,

View File

@@ -735,6 +735,7 @@ export function mapTerminalSuccessors(
loc: terminal.loc,
value: terminal.value,
id: makeInstructionId(0),
effects: terminal.effects,
};
}
case 'throw': {
@@ -842,6 +843,7 @@ export function mapTerminalSuccessors(
handler,
id: makeInstructionId(0),
loc: terminal.loc,
effects: terminal.effects,
};
}
case 'try': {

View File

@@ -0,0 +1,233 @@
/**
* 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 {CompilerErrorDetailOptions} from '../CompilerError';
import {
FunctionExpression,
Hole,
IdentifierId,
ObjectMethod,
Place,
SourceLocation,
SpreadPattern,
ValueKind,
ValueReason,
} from '../HIR';
import {FunctionSignature} from '../HIR/ObjectShape';
/**
* `AliasingEffect` describes a set of "effects" that an instruction/terminal has on one or
* more values in a program. These effects include mutation of values, freezing values,
* tracking data flow between values, and other specialized cases.
*/
export type AliasingEffect =
/**
* Marks the given value and its direct aliases as frozen.
*
* Captured values are *not* considered frozen, because we cannot be sure that a previously
* captured value will still be captured at the point of the freeze.
*
* For example:
* const x = {};
* const y = [x];
* y.pop(); // y dosn't contain x anymore!
* freeze(y);
* mutate(x); // safe to mutate!
*
* The exception to this is FunctionExpressions - since it is impossible to change which
* value a function closes over[1] we can transitively freeze functions and their captures.
*
* [1] Except for `let` values that are reassigned and closed over by a function, but we
* handle this explicitly with StoreContext/LoadContext.
*/
| {kind: 'Freeze'; value: Place; reason: ValueReason}
/**
* Mutate the value and any direct aliases (not captures). Errors if the value is not mutable.
*/
| {kind: 'Mutate'; value: Place}
/**
* Mutate the value and any direct aliases (not captures), but only if the value is known mutable.
* This should be rare.
*
* TODO: this is only used for IteratorNext, but even then MutateTransitiveConditionally is more
* correct for iterators of unknown types.
*/
| {kind: 'MutateConditionally'; value: Place}
/**
* Mutate the value, any direct aliases, and any transitive captures. Errors if the value is not mutable.
*/
| {kind: 'MutateTransitive'; value: Place}
/**
* Mutates any of the value, its direct aliases, and its transitive captures that are mutable.
*/
| {kind: 'MutateTransitiveConditionally'; value: Place}
/**
* Records information flow from `from` to `into` in cases where local mutation of the destination
* will *not* mutate the source:
*
* - Capture a -> b and Mutate(b) X=> (does not imply) Mutate(a)
* - Capture a -> b and MutateTransitive(b) => (does imply) Mutate(a)
*
* Example: `array.push(item)`. Information from item is captured into array, but there is not a
* direct aliasing, and local mutations of array will not modify item.
*/
| {kind: 'Capture'; from: Place; into: Place}
/**
* Records information flow from `from` to `into` in cases where local mutation of the destination
* *will* mutate the source:
*
* - Alias a -> b and Mutate(b) => (does imply) Mutate(a)
* - Alias a -> b and MutateTransitive(b) => (does imply) Mutate(a)
*
* Example: `c = identity(a)`. We don't know what `identity()` returns so we can't use Assign.
* But we have to assume that it _could_ be returning its input, such that a local mutation of
* c could be mutating a.
*/
| {kind: 'Alias'; from: Place; into: Place}
/**
* Records direct assignment: `into = from`.
*/
| {kind: 'Assign'; from: Place; into: Place}
/**
* Creates a value of the given type at the given place
*/
| {kind: 'Create'; into: Place; value: ValueKind; reason: ValueReason}
/**
* Creates a new value with the same kind as the starting value.
*/
| {kind: 'CreateFrom'; from: Place; into: Place}
/**
* Immutable data flow, used for escape analysis. Does not influence mutable range analysis:
*/
| {kind: 'ImmutableCapture'; from: Place; into: Place}
/**
* Calls the function at the given place with the given arguments either captured or aliased,
* and captures/aliases the result into the given place.
*/
| {
kind: 'Apply';
receiver: Place;
function: Place;
mutatesFunction: boolean;
args: Array<Place | SpreadPattern | Hole>;
into: Place;
signature: FunctionSignature | null;
loc: SourceLocation;
}
/**
* Constructs a function value with the given captures. The mutability of the function
* will be determined by the mutability of the capture values when evaluated.
*/
| {
kind: 'CreateFunction';
captures: Array<Place>;
function: FunctionExpression | ObjectMethod;
into: Place;
}
/**
* Mutation of a value known to be immutable
*/
| {kind: 'MutateFrozen'; place: Place; error: CompilerErrorDetailOptions}
/**
* Mutation of a global
*/
| {
kind: 'MutateGlobal';
place: Place;
error: CompilerErrorDetailOptions;
}
/**
* Indicates a side-effect that is not safe during render
*/
| {kind: 'Impure'; place: Place; error: CompilerErrorDetailOptions}
/**
* Indicates that a given place is accessed during render. Used to distingush
* hook arguments that are known to be called immediately vs those used for
* event handlers/effects, and for JSX values known to be called during render
* (tags, children) vs those that may be events/effect (other props).
*/
| {
kind: 'Render';
place: Place;
};
export function hashEffect(effect: AliasingEffect): string {
switch (effect.kind) {
case 'Apply': {
return [
effect.kind,
effect.receiver.identifier.id,
effect.function.identifier.id,
effect.mutatesFunction,
effect.args
.map(a => {
if (a.kind === 'Hole') {
return '';
} else if (a.kind === 'Identifier') {
return a.identifier.id;
} else {
return `...${a.place.identifier.id}`;
}
})
.join(','),
effect.into.identifier.id,
].join(':');
}
case 'CreateFrom':
case 'ImmutableCapture':
case 'Assign':
case 'Alias':
case 'Capture': {
return [
effect.kind,
effect.from.identifier.id,
effect.into.identifier.id,
].join(':');
}
case 'Create': {
return [
effect.kind,
effect.into.identifier.id,
effect.value,
effect.reason,
].join(':');
}
case 'Freeze': {
return [effect.kind, effect.value.identifier.id, effect.reason].join(':');
}
case 'Impure':
case 'Render':
case 'MutateFrozen':
case 'MutateGlobal': {
return [effect.kind, effect.place.identifier.id].join(':');
}
case 'Mutate':
case 'MutateConditionally':
case 'MutateTransitive':
case 'MutateTransitiveConditionally': {
return [effect.kind, effect.value.identifier.id].join(':');
}
case 'CreateFunction': {
return [
effect.kind,
effect.into.identifier.id,
// return places are a unique way to identify functions themselves
effect.function.loweredFunc.func.returns.identifier.id,
effect.captures.map(p => p.identifier.id).join(','),
].join(':');
}
}
}
export type AliasingSignature = {
receiver: IdentifierId;
params: Array<IdentifierId>;
rest: IdentifierId | null;
returns: IdentifierId;
effects: Array<AliasingEffect>;
temporaries: Array<Place>;
};

View File

@@ -10,6 +10,7 @@ import {
Effect,
HIRFunction,
Identifier,
IdentifierId,
LoweredFunction,
isRefOrRefValue,
makeInstructionId,
@@ -19,6 +20,10 @@ import {inferReactiveScopeVariables} from '../ReactiveScopes';
import {rewriteInstructionKindsBasedOnReassignment} from '../SSA';
import {inferMutableRanges} from './InferMutableRanges';
import inferReferenceEffects from './InferReferenceEffects';
import {assertExhaustive} from '../Utils/utils';
import {inferMutationAliasingEffects} from './InferMutationAliasingEffects';
import {inferMutationAliasingFunctionEffects} from './InferMutationAliasingFunctionEffects';
import {inferMutationAliasingRanges} from './InferMutationAliasingRanges';
export default function analyseFunctions(func: HIRFunction): void {
for (const [_, block] of func.body.blocks) {
@@ -26,8 +31,12 @@ export default function analyseFunctions(func: HIRFunction): void {
switch (instr.value.kind) {
case 'ObjectMethod':
case 'FunctionExpression': {
lower(instr.value.loweredFunc.func);
infer(instr.value.loweredFunc);
if (!func.env.config.enableNewMutationAliasingModel) {
lower(instr.value.loweredFunc.func);
infer(instr.value.loweredFunc);
} else {
lowerWithMutationAliasing(instr.value.loweredFunc.func);
}
/**
* Reset mutable range for outer inferReferenceEffects
@@ -44,6 +53,87 @@ export default function analyseFunctions(func: HIRFunction): void {
}
}
function lowerWithMutationAliasing(fn: HIRFunction): void {
/**
* Phase 1: similar to lower(), but using the new mutation/aliasing inference
*/
analyseFunctions(fn);
inferMutationAliasingEffects(fn, {isFunctionExpression: true});
deadCodeElimination(fn);
inferMutationAliasingRanges(fn, {isFunctionExpression: true});
rewriteInstructionKindsBasedOnReassignment(fn);
inferReactiveScopeVariables(fn);
const effects = inferMutationAliasingFunctionEffects(fn);
fn.env.logger?.debugLogIRs?.({
kind: 'hir',
name: 'AnalyseFunction (inner)',
value: fn,
});
if (effects != null) {
fn.aliasingEffects ??= [];
fn.aliasingEffects?.push(...effects);
}
/**
* Phase 2: populate the Effect of each context variable to use in inferring
* the outer function. For example, InferMutationAliasingEffects uses context variable
* effects to decide if the function may be mutable or not.
*/
const capturedOrMutated = new Set<IdentifierId>();
for (const effect of effects ?? []) {
switch (effect.kind) {
case 'Assign':
case 'Alias':
case 'Capture':
case 'CreateFrom': {
capturedOrMutated.add(effect.from.identifier.id);
break;
}
case 'Apply': {
CompilerError.invariant(false, {
reason: `[AnalyzeFunctions] Expected Apply effects to be replaced with more precise effects`,
loc: effect.function.loc,
});
}
case 'Mutate':
case 'MutateConditionally':
case 'MutateTransitive':
case 'MutateTransitiveConditionally': {
capturedOrMutated.add(effect.value.identifier.id);
break;
}
case 'Impure':
case 'Render':
case 'MutateFrozen':
case 'MutateGlobal':
case 'CreateFunction':
case 'Create':
case 'Freeze':
case 'ImmutableCapture': {
// no-op
break;
}
default: {
assertExhaustive(
effect,
`Unexpected effect kind ${(effect as any).kind}`,
);
}
}
}
for (const operand of fn.context) {
if (
capturedOrMutated.has(operand.identifier.id) ||
operand.effect === Effect.Capture
) {
operand.effect = Effect.Capture;
} else {
operand.effect = Effect.Read;
}
}
}
function lower(func: HIRFunction): void {
analyseFunctions(func);
inferReferenceEffects(func, {isFunctionExpression: true});

View File

@@ -197,6 +197,7 @@ function makeManualMemoizationMarkers(
deps: depsList,
loc: fnExpr.loc,
},
effects: null,
loc: fnExpr.loc,
},
{
@@ -208,6 +209,7 @@ function makeManualMemoizationMarkers(
decl: {...memoDecl},
loc: fnExpr.loc,
},
effects: null,
loc: fnExpr.loc,
},
];

View File

@@ -0,0 +1,134 @@
/**
* 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 {
Effect,
HIRFunction,
Identifier,
isMutableEffect,
isRefOrRefLikeMutableType,
makeInstructionId,
} from '../HIR/HIR';
import {eachInstructionValueOperand} from '../HIR/visitors';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
import DisjointSet from '../Utils/DisjointSet';
/**
* If a function captures a mutable value but never gets called, we don't infer a
* mutable range for that function. This means that we also don't alias the function
* with its mutable captures.
*
* This case is tricky, because we don't generally know for sure what is a mutation
* and what may just be a normal function call. For example:
*
* ```
* hook useFoo() {
* const x = makeObject();
* return () => {
* return readObject(x); // could be a mutation!
* }
* }
* ```
*
* If we pessimistically assume that all such cases are mutations, we'd have to group
* lots of memo scopes together unnecessarily. However, if there is definitely a mutation:
*
* ```
* hook useFoo(createEntryForKey) {
* const cache = new WeakMap();
* return (key) => {
* let entry = cache.get(key);
* if (entry == null) {
* entry = createEntryForKey(key);
* cache.set(key, entry); // known mutation!
* }
* return entry;
* }
* }
* ```
*
* Then we have to ensure that the function and its mutable captures alias together and
* end up in the same scope. However, aliasing together isn't enough if the function
* and operands all have empty mutable ranges (end = start + 1).
*
* This pass finds function expressions and object methods that have an empty mutable range
* and known-mutable operands which also don't have a mutable range, and ensures that the
* function and those operands are aliased together *and* that their ranges are updated to
* end after the function expression. This is sufficient to ensure that a reactive scope is
* created for the alias set.
*/
export function inferAliasForUncalledFunctions(
fn: HIRFunction,
aliases: DisjointSet<Identifier>,
): void {
for (const block of fn.body.blocks.values()) {
instrs: for (const instr of block.instructions) {
const {lvalue, value} = instr;
if (
value.kind !== 'ObjectMethod' &&
value.kind !== 'FunctionExpression'
) {
continue;
}
/*
* If the function is known to be mutated, we will have
* already aliased any mutable operands with it
*/
const range = lvalue.identifier.mutableRange;
if (range.end > range.start + 1) {
continue;
}
/*
* If the function already has operands with an active mutable range,
* then we don't need to do anything — the function will have already
* been visited and included in some mutable alias set. This case can
* also occur due to visiting the same function in an earlier iteration
* of the outer fixpoint loop.
*/
for (const operand of eachInstructionValueOperand(value)) {
if (isMutable(instr, operand)) {
continue instrs;
}
}
const operands: Set<Identifier> = new Set();
for (const effect of value.loweredFunc.func.effects ?? []) {
if (effect.kind !== 'ContextMutation') {
continue;
}
/*
* We're looking for known-mutations only, so we look at the effects
* rather than function context
*/
if (effect.effect === Effect.Store || effect.effect === Effect.Mutate) {
for (const operand of effect.places) {
/*
* It's possible that function effect analysis thinks there was a context mutation,
* but then InferReferenceEffects figures out some operands are globals and therefore
* creates a non-mutable effect for those operands.
* We should change InferReferenceEffects to swap the ContextMutation for a global
* mutation in that case, but for now we just filter them out here
*/
if (
isMutableEffect(operand.effect, operand.loc) &&
!isRefOrRefLikeMutableType(operand.identifier.type)
) {
operands.add(operand.identifier);
}
}
}
}
if (operands.size !== 0) {
operands.add(lvalue.identifier);
aliases.union([...operands]);
// Update mutable ranges, if the ranges are empty then a reactive scope isn't created
for (const operand of operands) {
operand.mutableRange.end = makeInstructionId(instr.id + 1);
}
}
}
}
}

View File

@@ -1,8 +1,15 @@
/**
* 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 * as t from '@babel/types';
import {CompilerError, SourceLocation} from '..';
import {
ArrayExpression,
Effect,
Environment,
FunctionExpression,
GeneratedSource,
HIRFunction,
@@ -14,17 +21,41 @@ import {
ScopeId,
ReactiveScopeDependency,
Place,
ReactiveScope,
ReactiveScopeDependencies,
Terminal,
isUseRefType,
isSetStateType,
isFireFunctionType,
makeScopeId,
HIR,
BasicBlock,
BlockId,
isEffectEventFunctionType,
} from '../HIR';
import {collectHoistablePropertyLoadsInInnerFn} from '../HIR/CollectHoistablePropertyLoads';
import {collectOptionalChainSidemap} from '../HIR/CollectOptionalChainDependencies';
import {ReactiveScopeDependencyTreeHIR} from '../HIR/DeriveMinimalDependenciesHIR';
import {DEFAULT_EXPORT} from '../HIR/Environment';
import {
createTemporaryPlace,
fixScopeAndIdentifierRanges,
markInstructionIds,
markPredecessors,
reversePostorderBlocks,
} from '../HIR/HIRBuilder';
import {eachInstructionOperand, eachTerminalOperand} from '../HIR/visitors';
import {
collectTemporariesSidemap,
DependencyCollectionContext,
handleInstruction,
} from '../HIR/PropagateScopeDependenciesHIR';
import {buildDependencyInstructions} from '../HIR/ScopeDependencyUtils';
import {
eachInstructionOperand,
eachTerminalOperand,
terminalFallthrough,
} from '../HIR/visitors';
import {empty} from '../Utils/Stack';
import {getOrInsertWith} from '../Utils/utils';
/**
@@ -32,7 +63,6 @@ import {getOrInsertWith} from '../Utils/utils';
* a second argument to the useEffect call if no dependency array is provided.
*/
export function inferEffectDependencies(fn: HIRFunction): void {
let hasRewrite = false;
const fnExpressions = new Map<
IdentifierId,
TInstruction<FunctionExpression>
@@ -53,10 +83,7 @@ export function inferEffectDependencies(fn: HIRFunction): void {
const autodepFnLoads = new Map<IdentifierId, number>();
const autodepModuleLoads = new Map<IdentifierId, Map<string, number>>();
const scopeInfos = new Map<
ScopeId,
{pruned: boolean; deps: ReactiveScopeDependencies; hasSingleInstr: boolean}
>();
const scopeInfos = new Map<ScopeId, ReactiveScopeDependencies>();
const loadGlobals = new Set<IdentifierId>();
@@ -68,23 +95,23 @@ export function inferEffectDependencies(fn: HIRFunction): void {
* reactive(Identifier i) = Union_{reference of i}(reactive(reference))
*/
const reactiveIds = inferReactiveIdentifiers(fn);
const rewriteBlocks: Array<BasicBlock> = [];
for (const [, block] of fn.body.blocks) {
if (
block.terminal.kind === 'scope' ||
block.terminal.kind === 'pruned-scope'
) {
if (block.terminal.kind === 'scope') {
const scopeBlock = fn.body.blocks.get(block.terminal.block)!;
scopeInfos.set(block.terminal.scope.id, {
pruned: block.terminal.kind === 'pruned-scope',
deps: block.terminal.scope.dependencies,
hasSingleInstr:
scopeBlock.instructions.length === 1 &&
scopeBlock.terminal.kind === 'goto' &&
scopeBlock.terminal.block === block.terminal.fallthrough,
});
if (
scopeBlock.instructions.length === 1 &&
scopeBlock.terminal.kind === 'goto' &&
scopeBlock.terminal.block === block.terminal.fallthrough
) {
scopeInfos.set(
block.terminal.scope.id,
block.terminal.scope.dependencies,
);
}
}
const rewriteInstrs = new Map<InstructionId, Array<Instruction>>();
const rewriteInstrs: Array<SpliceInfo> = [];
for (const instr of block.instructions) {
const {value, lvalue} = instr;
if (value.kind === 'FunctionExpression') {
@@ -148,7 +175,6 @@ export function inferEffectDependencies(fn: HIRFunction): void {
) {
// We have a useEffect call with no deps array, so we need to infer the deps
const effectDeps: Array<Place> = [];
const newInstructions: Array<Instruction> = [];
const deps: ArrayExpression = {
kind: 'ArrayExpression',
elements: effectDeps,
@@ -164,22 +190,12 @@ export function inferEffectDependencies(fn: HIRFunction): void {
fnExpr.lvalue.identifier.scope != null
? scopeInfos.get(fnExpr.lvalue.identifier.scope.id)
: null;
CompilerError.invariant(scopeInfo != null, {
reason: 'Expected function expression scope to exist',
loc: value.loc,
});
if (scopeInfo.pruned || !scopeInfo.hasSingleInstr) {
/**
* TODO: retry pipeline that ensures effect function expressions
* are placed into their own scope
*/
CompilerError.throwTodo({
reason:
'[InferEffectDependencies] Expected effect function to have non-pruned scope and its scope to have exactly one instruction',
loc: fnExpr.loc,
});
let minimalDeps: Set<ReactiveScopeDependency>;
if (scopeInfo != null) {
minimalDeps = new Set(scopeInfo);
} else {
minimalDeps = inferMinimalDependencies(fnExpr);
}
/**
* Step 1: push dependencies to the effect deps array
*
@@ -187,131 +203,267 @@ export function inferEffectDependencies(fn: HIRFunction): void {
* the `infer-effect-deps/pruned-nonreactive-obj` fixture for an
* explanation.
*/
for (const dep of scopeInfo.deps) {
const usedDeps = [];
for (const maybeDep of minimalDeps) {
if (
(isUseRefType(dep.identifier) ||
isSetStateType(dep.identifier)) &&
!reactiveIds.has(dep.identifier.id)
((isUseRefType(maybeDep.identifier) ||
isSetStateType(maybeDep.identifier)) &&
!reactiveIds.has(maybeDep.identifier.id)) ||
isFireFunctionType(maybeDep.identifier) ||
isEffectEventFunctionType(maybeDep.identifier)
) {
// exclude non-reactive hook results, which will never be in a memo block
continue;
}
const {place, instructions} = writeDependencyToInstructions(
const dep = truncateDepAtCurrent(maybeDep);
const {place, value, exitBlockId} = buildDependencyInstructions(
dep,
reactiveIds.has(dep.identifier.id),
fn.env,
fnExpr.loc,
);
newInstructions.push(...instructions);
rewriteInstrs.push({
kind: 'block',
location: instr.id,
value,
exitBlockId: exitBlockId,
});
effectDeps.push(place);
usedDeps.push(dep);
}
newInstructions.push({
id: makeInstructionId(0),
loc: GeneratedSource,
lvalue: {...depsPlace, effect: Effect.Mutate},
value: deps,
});
// For LSP autodeps feature.
const decorations: Array<t.SourceLocation> = [];
for (const loc of collectDepUsages(usedDeps, fnExpr.value)) {
if (typeof loc === 'symbol') {
continue;
}
decorations.push(loc);
}
if (typeof value.loc !== 'symbol') {
fn.env.logger?.logEvent(fn.env.filename, {
kind: 'AutoDepsDecorations',
fnLoc: value.loc,
decorations,
});
}
// Step 2: push the inferred deps array as an argument of the useEffect
rewriteInstrs.push({
kind: 'instr',
location: instr.id,
value: {
id: makeInstructionId(0),
loc: GeneratedSource,
lvalue: {...depsPlace, effect: Effect.Mutate},
value: deps,
effects: null,
},
});
value.args.push({...depsPlace, effect: Effect.Freeze});
rewriteInstrs.set(instr.id, newInstructions);
fn.env.inferredEffectLocations.add(callee.loc);
} else if (loadGlobals.has(value.args[0].identifier.id)) {
// Global functions have no reactive dependencies, so we can insert an empty array
newInstructions.push({
id: makeInstructionId(0),
loc: GeneratedSource,
lvalue: {...depsPlace, effect: Effect.Mutate},
value: deps,
rewriteInstrs.push({
kind: 'instr',
location: instr.id,
value: {
id: makeInstructionId(0),
loc: GeneratedSource,
lvalue: {...depsPlace, effect: Effect.Mutate},
value: deps,
effects: null,
},
});
value.args.push({...depsPlace, effect: Effect.Freeze});
rewriteInstrs.set(instr.id, newInstructions);
fn.env.inferredEffectLocations.add(callee.loc);
}
} else if (
value.args.length >= 2 &&
value.args.length - 1 === autodepFnLoads.get(callee.identifier.id) &&
value.args[0] != null &&
value.args[0].kind === 'Identifier'
) {
const penultimateArg = value.args[value.args.length - 2];
const depArrayArg = value.args[value.args.length - 1];
if (
depArrayArg.kind !== 'Spread' &&
penultimateArg.kind !== 'Spread' &&
typeof depArrayArg.loc !== 'symbol' &&
typeof penultimateArg.loc !== 'symbol' &&
typeof value.loc !== 'symbol'
) {
fn.env.logger?.logEvent(fn.env.filename, {
kind: 'AutoDepsEligible',
fnLoc: value.loc,
depArrayLoc: {
...depArrayArg.loc,
start: penultimateArg.loc.end,
end: depArrayArg.loc.end,
},
});
}
}
}
}
if (rewriteInstrs.size > 0) {
hasRewrite = true;
const newInstrs = [];
for (const instr of block.instructions) {
const newInstr = rewriteInstrs.get(instr.id);
if (newInstr != null) {
newInstrs.push(...newInstr, instr);
} else {
newInstrs.push(instr);
}
}
block.instructions = newInstrs;
}
rewriteSplices(block, rewriteInstrs, rewriteBlocks);
}
if (hasRewrite) {
if (rewriteBlocks.length > 0) {
for (const block of rewriteBlocks) {
fn.body.blocks.set(block.id, block);
}
/**
* Fixup the HIR to restore RPO, ensure correct predecessors, and renumber
* instructions.
*/
reversePostorderBlocks(fn.body);
markPredecessors(fn.body);
// Renumber instructions and fix scope ranges
markInstructionIds(fn.body);
fixScopeAndIdentifierRanges(fn.body);
fn.env.hasInferredEffect = true;
}
}
function writeDependencyToInstructions(
function truncateDepAtCurrent(
dep: ReactiveScopeDependency,
reactive: boolean,
env: Environment,
loc: SourceLocation,
): {place: Place; instructions: Array<Instruction>} {
const instructions: Array<Instruction> = [];
let currValue = createTemporaryPlace(env, GeneratedSource);
currValue.reactive = reactive;
instructions.push({
id: makeInstructionId(0),
loc: GeneratedSource,
lvalue: {...currValue, effect: Effect.Mutate},
value: {
kind: 'LoadLocal',
place: {
kind: 'Identifier',
identifier: dep.identifier,
effect: Effect.Capture,
reactive,
loc: loc,
},
loc: loc,
},
});
for (const path of dep.path) {
if (path.optional) {
/**
* TODO: instead of truncating optional paths, reuse
* instructions from hoisted dependencies block(s)
*/
break;
}
if (path.property === 'current') {
/*
* Prune ref.current accesses. This may over-capture for non-ref values with
* a current property, but that's fine.
*/
break;
}
const nextValue = createTemporaryPlace(env, GeneratedSource);
nextValue.reactive = reactive;
instructions.push({
id: makeInstructionId(0),
loc: GeneratedSource,
lvalue: {...nextValue, effect: Effect.Mutate},
value: {
kind: 'PropertyLoad',
object: {...currValue, effect: Effect.Capture},
property: path.property,
loc: loc,
},
});
currValue = nextValue;
): ReactiveScopeDependency {
const idx = dep.path.findIndex(path => path.property === 'current');
if (idx === -1) {
return dep;
} else {
return {...dep, path: dep.path.slice(0, idx)};
}
currValue.effect = Effect.Freeze;
return {place: currValue, instructions};
}
type SpliceInfo =
| {kind: 'instr'; location: InstructionId; value: Instruction}
| {
kind: 'block';
location: InstructionId;
value: HIR;
exitBlockId: BlockId;
};
function rewriteSplices(
originalBlock: BasicBlock,
splices: Array<SpliceInfo>,
rewriteBlocks: Array<BasicBlock>,
): void {
if (splices.length === 0) {
return;
}
/**
* Splice instructions or value blocks into the original block.
* --- original block ---
* bb_original
* instr1
* ...
* instr2 <-- splice location
* instr3
* ...
* <original terminal>
*
* If there is more than one block in the splice, this means that we're
* splicing in a set of value-blocks of the following structure:
* --- blocks we're splicing in ---
* bb_entry:
* instrEntry
* ...
* <splice terminal> fallthrough=bb_exit
*
* bb1(value):
* ...
*
* bb_exit:
* instrExit
* ...
* <synthetic terminal>
*
*
* --- rewritten blocks ---
* bb_original
* instr1
* ... (original instructions)
* instr2
* instrEntry
* ... (spliced instructions)
* <splice terminal> fallthrough=bb_exit
*
* bb1(value):
* ...
*
* bb_exit:
* instrExit
* ... (spliced instructions)
* instr3
* ... (original instructions)
* <original terminal>
*/
const originalInstrs = originalBlock.instructions;
let currBlock: BasicBlock = {...originalBlock, instructions: []};
rewriteBlocks.push(currBlock);
let cursor = 0;
for (const rewrite of splices) {
while (originalInstrs[cursor].id < rewrite.location) {
CompilerError.invariant(
originalInstrs[cursor].id < originalInstrs[cursor + 1].id,
{
reason:
'[InferEffectDependencies] Internal invariant broken: expected block instructions to be sorted',
loc: originalInstrs[cursor].loc,
},
);
currBlock.instructions.push(originalInstrs[cursor]);
cursor++;
}
CompilerError.invariant(originalInstrs[cursor].id === rewrite.location, {
reason:
'[InferEffectDependencies] Internal invariant broken: splice location not found',
loc: originalInstrs[cursor].loc,
});
if (rewrite.kind === 'instr') {
currBlock.instructions.push(rewrite.value);
} else {
const {entry, blocks} = rewrite.value;
const entryBlock = blocks.get(entry)!;
// splice in all instructions from the entry block
currBlock.instructions.push(...entryBlock.instructions);
if (blocks.size > 1) {
/**
* We're splicing in a set of value-blocks, which means we need
* to push new blocks and update terminals.
*/
CompilerError.invariant(
terminalFallthrough(entryBlock.terminal) === rewrite.exitBlockId,
{
reason:
'[InferEffectDependencies] Internal invariant broken: expected entry block to have a fallthrough',
loc: entryBlock.terminal.loc,
},
);
const originalTerminal = currBlock.terminal;
currBlock.terminal = entryBlock.terminal;
for (const [id, block] of blocks) {
if (id === entry) {
continue;
}
if (id === rewrite.exitBlockId) {
block.terminal = originalTerminal;
currBlock = block;
}
rewriteBlocks.push(block);
}
}
}
}
currBlock.instructions.push(...originalInstrs.slice(cursor));
}
function inferReactiveIdentifiers(fn: HIRFunction): Set<IdentifierId> {
@@ -338,3 +490,163 @@ function inferReactiveIdentifiers(fn: HIRFunction): Set<IdentifierId> {
}
return reactiveIds;
}
function collectDepUsages(
deps: Array<ReactiveScopeDependency>,
fnExpr: FunctionExpression,
): Array<SourceLocation> {
const identifiers: Map<IdentifierId, ReactiveScopeDependency> = new Map();
const loadedDeps: Set<IdentifierId> = new Set();
const sourceLocations = [];
for (const dep of deps) {
identifiers.set(dep.identifier.id, dep);
}
for (const [, block] of fnExpr.loweredFunc.func.body.blocks) {
for (const instr of block.instructions) {
if (
instr.value.kind === 'LoadLocal' &&
identifiers.has(instr.value.place.identifier.id)
) {
loadedDeps.add(instr.lvalue.identifier.id);
}
for (const place of eachInstructionOperand(instr)) {
if (loadedDeps.has(place.identifier.id)) {
// TODO(@jbrown215): handle member exprs!!
sourceLocations.push(place.identifier.loc);
}
}
}
}
return sourceLocations;
}
function inferMinimalDependencies(
fnInstr: TInstruction<FunctionExpression>,
): Set<ReactiveScopeDependency> {
const fn = fnInstr.value.loweredFunc.func;
const temporaries = collectTemporariesSidemap(fn, new Set());
const {
hoistableObjects,
processedInstrsInOptional,
temporariesReadInOptional,
} = collectOptionalChainSidemap(fn);
const hoistablePropertyLoads = collectHoistablePropertyLoadsInInnerFn(
fnInstr,
temporaries,
hoistableObjects,
);
const hoistableToFnEntry = hoistablePropertyLoads.get(fn.body.entry);
CompilerError.invariant(hoistableToFnEntry != null, {
reason:
'[InferEffectDependencies] Internal invariant broken: missing entry block',
loc: fnInstr.loc,
});
const dependencies = inferDependencies(
fnInstr,
new Map([...temporaries, ...temporariesReadInOptional]),
processedInstrsInOptional,
);
const tree = new ReactiveScopeDependencyTreeHIR(
[...hoistableToFnEntry.assumedNonNullObjects].map(o => o.fullPath),
);
for (const dep of dependencies) {
tree.addDependency({...dep});
}
return tree.deriveMinimalDependencies();
}
function inferDependencies(
fnInstr: TInstruction<FunctionExpression>,
temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
processedInstrsInOptional: ReadonlySet<Instruction | Terminal>,
): Set<ReactiveScopeDependency> {
const fn = fnInstr.value.loweredFunc.func;
const context = new DependencyCollectionContext(
new Set(),
temporaries,
processedInstrsInOptional,
);
for (const dep of fn.context) {
context.declare(dep.identifier, {
id: makeInstructionId(0),
scope: empty(),
});
}
const placeholderScope: ReactiveScope = {
id: makeScopeId(0),
range: {
start: fnInstr.id,
end: makeInstructionId(fnInstr.id + 1),
},
dependencies: new Set(),
reassignments: new Set(),
declarations: new Map(),
earlyReturnValue: null,
merged: new Set(),
loc: GeneratedSource,
};
context.enterScope(placeholderScope);
inferDependenciesInFn(fn, context, temporaries);
context.exitScope(placeholderScope, false);
const resultUnfiltered = context.deps.get(placeholderScope);
CompilerError.invariant(resultUnfiltered != null, {
reason:
'[InferEffectDependencies] Internal invariant broken: missing scope dependencies',
loc: fn.loc,
});
const fnContext = new Set(fn.context.map(dep => dep.identifier.id));
const result = new Set<ReactiveScopeDependency>();
for (const dep of resultUnfiltered) {
if (fnContext.has(dep.identifier.id)) {
result.add(dep);
}
}
return result;
}
function inferDependenciesInFn(
fn: HIRFunction,
context: DependencyCollectionContext,
temporaries: ReadonlyMap<IdentifierId, ReactiveScopeDependency>,
): void {
for (const [, block] of fn.body.blocks) {
// Record referenced optional chains in phis
for (const phi of block.phis) {
for (const operand of phi.operands) {
const maybeOptionalChain = temporaries.get(operand[1].identifier.id);
if (maybeOptionalChain) {
context.visitDependency(maybeOptionalChain);
}
}
}
for (const instr of block.instructions) {
if (
instr.value.kind === 'FunctionExpression' ||
instr.value.kind === 'ObjectMethod'
) {
context.declare(instr.lvalue.identifier, {
id: instr.id,
scope: context.currentScope,
});
/**
* Recursively visit the inner function to extract dependencies
*/
const innerFn = instr.value.loweredFunc.func;
context.enterInnerFn(instr as TInstruction<FunctionExpression>, () => {
inferDependenciesInFn(innerFn, context, temporaries);
});
} else {
handleInstruction(instr, context);
}
}
}
}

View File

@@ -324,7 +324,7 @@ function isEffectSafeOutsideRender(effect: FunctionEffect): boolean {
return effect.kind === 'GlobalMutation';
}
function getWriteErrorReason(abstractValue: AbstractValue): string {
export function getWriteErrorReason(abstractValue: AbstractValue): string {
if (abstractValue.reason.has(ValueReason.Global)) {
return 'Writing to a variable defined outside a component or hook is not allowed. Consider using an effect';
} else if (abstractValue.reason.has(ValueReason.JsxCaptured)) {
@@ -339,6 +339,8 @@ function getWriteErrorReason(abstractValue: AbstractValue): string {
return "Mutating a value returned from 'useState()', which should not be mutated. Use the setter function to update instead";
} else if (abstractValue.reason.has(ValueReason.ReducerState)) {
return "Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead";
} else if (abstractValue.reason.has(ValueReason.Effect)) {
return 'Updating a value used previously in an effect function or as an effect dependency is not allowed. Consider moving the mutation before calling useEffect()';
} else {
return 'This mutates a variable that React considers immutable';
}

View File

@@ -176,9 +176,15 @@ export function inferMutableLifetimes(
if (
instr.value.kind === 'DeclareContext' ||
(instr.value.kind === 'StoreContext' &&
instr.value.lvalue.kind !== InstructionKind.Reassign)
instr.value.lvalue.kind !== InstructionKind.Reassign &&
!contextVariableDeclarationInstructions.has(
instr.value.lvalue.place.identifier,
))
) {
// Save declarations of context variables
/**
* Save declarations of context variables if they hasn't already been
* declared (due to hoisted declarations).
*/
contextVariableDeclarationInstructions.set(
instr.value.lvalue.place.identifier,
instr.id,

View File

@@ -6,6 +6,7 @@
*/
import {HIRFunction, Identifier} from '../HIR/HIR';
import {inferAliasForUncalledFunctions} from './InerAliasForUncalledFunctions';
import {inferAliases} from './InferAlias';
import {inferAliasForPhis} from './InferAliasForPhis';
import {inferAliasForStores} from './InferAliasForStores';
@@ -76,6 +77,7 @@ export function inferMutableRanges(ir: HIRFunction): void {
while (true) {
inferMutableRangesForAlias(ir, aliases);
inferAliasForPhis(ir, aliases);
inferAliasForUncalledFunctions(ir, aliases);
const nextAliases = aliases.canonicalize();
if (areEqualMaps(prevAliases, nextAliases)) {
break;
@@ -84,7 +86,7 @@ export function inferMutableRanges(ir: HIRFunction): void {
}
}
function areEqualMaps<T>(a: Map<T, T>, b: Map<T, T>): boolean {
function areEqualMaps<T, U>(a: Map<T, U>, b: Map<T, U>): boolean {
if (a.size !== b.size) {
return false;
}

View File

@@ -0,0 +1,206 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {HIRFunction, IdentifierId, Place, ValueKind, ValueReason} from '../HIR';
import {getOrInsertDefault} from '../Utils/utils';
import {AliasingEffect} from './AliasingEffects';
/**
* This function tracks data flow within an inner function expression in order to
* compute a set of data-flow aliasing effects describing data flow between the function's
* params, context variables, and return value.
*
* For example, consider the following function expression:
*
* ```
* (x) => { return [x, y] }
* ```
*
* This function captures both param `x` and context variable `y` into the return value.
* Unlike our previous inference which counted this as a mutation of x and y, we want to
* build a signature for the function that describes the data flow. We would infer
* `Capture x -> return, Capture y -> return` effects for this function.
*
* This function *also* propagates more ambient-style effects (MutateFrozen, MutateGlobal, Impure, Render)
* from instructions within the function up to the function itself.
*/
export function inferMutationAliasingFunctionEffects(
fn: HIRFunction,
): Array<AliasingEffect> | null {
const effects: Array<AliasingEffect> = [];
/**
* Map used to identify tracked variables: params, context vars, return value
* This is used to detect mutation/capturing/aliasing of params/context vars
*/
const tracked = new Map<IdentifierId, Place>();
tracked.set(fn.returns.identifier.id, fn.returns);
for (const operand of [...fn.context, ...fn.params]) {
const place = operand.kind === 'Identifier' ? operand : operand.place;
tracked.set(place.identifier.id, place);
}
/**
* Track capturing/aliasing of context vars and params into each other and into the return.
* We don't need to track locals and intermediate values, since we're only concerned with effects
* as they relate to arguments visible outside the function.
*
* For each aliased identifier we track capture/alias/createfrom and then merge this with how
* the value is used. Eg capturing an alias => capture. See joinEffects() helper.
*/
type AliasedIdentifier = {
kind: AliasingKind;
place: Place;
};
const dataFlow = new Map<IdentifierId, Array<AliasedIdentifier>>();
/*
* Check for aliasing of tracked values. Also joins the effects of how the value is
* used (@param kind) with the aliasing type of each value
*/
function lookup(
place: Place,
kind: AliasedIdentifier['kind'],
): Array<AliasedIdentifier> | null {
if (tracked.has(place.identifier.id)) {
return [{kind, place}];
}
return (
dataFlow.get(place.identifier.id)?.map(aliased => ({
kind: joinEffects(aliased.kind, kind),
place: aliased.place,
})) ?? null
);
}
// todo: fixpoint
for (const block of fn.body.blocks.values()) {
for (const phi of block.phis) {
const operands: Array<AliasedIdentifier> = [];
for (const operand of phi.operands.values()) {
const inputs = lookup(operand, 'Alias');
if (inputs != null) {
operands.push(...inputs);
}
}
if (operands.length !== 0) {
dataFlow.set(phi.place.identifier.id, operands);
}
}
for (const instr of block.instructions) {
if (instr.effects == null) continue;
for (const effect of instr.effects) {
if (
effect.kind === 'Assign' ||
effect.kind === 'Capture' ||
effect.kind === 'Alias' ||
effect.kind === 'CreateFrom'
) {
const from = lookup(effect.from, effect.kind);
if (from == null) {
continue;
}
const into = lookup(effect.into, 'Alias');
if (into == null) {
getOrInsertDefault(dataFlow, effect.into.identifier.id, []).push(
...from,
);
} else {
for (const aliased of into) {
getOrInsertDefault(
dataFlow,
aliased.place.identifier.id,
[],
).push(...from);
}
}
} else if (
effect.kind === 'Create' ||
effect.kind === 'CreateFunction'
) {
getOrInsertDefault(dataFlow, effect.into.identifier.id, [
{kind: 'Alias', place: effect.into},
]);
} else if (
effect.kind === 'MutateFrozen' ||
effect.kind === 'MutateGlobal' ||
effect.kind === 'Impure' ||
effect.kind === 'Render'
) {
effects.push(effect);
}
}
}
if (block.terminal.kind === 'return') {
const from = lookup(block.terminal.value, 'Alias');
if (from != null) {
getOrInsertDefault(dataFlow, fn.returns.identifier.id, []).push(
...from,
);
}
}
}
// Create aliasing effects based on observed data flow
let hasReturn = false;
for (const [into, from] of dataFlow) {
const input = tracked.get(into);
if (input == null) {
continue;
}
for (const aliased of from) {
if (
aliased.place.identifier.id === input.identifier.id ||
!tracked.has(aliased.place.identifier.id)
) {
continue;
}
const effect = {kind: aliased.kind, from: aliased.place, into: input};
effects.push(effect);
if (
into === fn.returns.identifier.id &&
(aliased.kind === 'Assign' || aliased.kind === 'CreateFrom')
) {
hasReturn = true;
}
}
}
// TODO: more precise return effect inference
if (!hasReturn) {
effects.unshift({
kind: 'Create',
into: fn.returns,
value:
fn.returnType.kind === 'Primitive'
? ValueKind.Primitive
: ValueKind.Mutable,
reason: ValueReason.KnownReturnSignature,
});
}
return effects;
}
export enum MutationKind {
None = 0,
Conditional = 1,
Definite = 2,
}
type AliasingKind = 'Alias' | 'Capture' | 'CreateFrom' | 'Assign';
function joinEffects(
effect1: AliasingKind,
effect2: AliasingKind,
): AliasingKind {
if (effect1 === 'Capture' || effect2 === 'Capture') {
return 'Capture';
} else if (effect1 === 'Assign' || effect2 === 'Assign') {
return 'Assign';
} else {
return 'Alias';
}
}

View File

@@ -0,0 +1,737 @@
/**
* 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 prettyFormat from 'pretty-format';
import {CompilerError, SourceLocation} from '..';
import {
BlockId,
Effect,
HIRFunction,
Identifier,
IdentifierId,
InstructionId,
makeInstructionId,
Place,
} from '../HIR/HIR';
import {
eachInstructionLValue,
eachInstructionValueOperand,
eachTerminalOperand,
} from '../HIR/visitors';
import {assertExhaustive, getOrInsertWith} from '../Utils/utils';
import {printFunction} from '../HIR';
import {printIdentifier, printPlace} from '../HIR/PrintHIR';
import {MutationKind} from './InferMutationAliasingFunctionEffects';
import {Result} from '../Utils/Result';
const DEBUG = false;
const VERBOSE = false;
/**
* Infers mutable ranges for all values in the program, using previously inferred
* mutation/aliasing effects. This pass builds a data flow graph using the effects,
* tracking an abstract notion of "when" each effect occurs relative to the others.
* It then walks each mutation effect against the graph, updating the range of each
* node that would be reachable at the "time" that the effect occurred.
*
* This pass also validates against invalid effects: any function that is reachable
* by being called, or via a Render effect, is validated against mutating globals
* or calling impure code.
*
* Note that this function also populates the outer function's aliasing effects with
* any mutations that apply to its params or context variables. For example, a
* function expression such as the following:
*
* ```
* (x) => { x.y = true }
* ```
*
* Would populate a `Mutate x` aliasing effect on the outer function.
*/
export function inferMutationAliasingRanges(
fn: HIRFunction,
{isFunctionExpression}: {isFunctionExpression: boolean},
): Result<void, CompilerError> {
if (VERBOSE) {
console.log();
console.log(printFunction(fn));
}
/**
* Part 1: Infer mutable ranges for values. We build an abstract model of
* values, the alias/capture edges between them, and the set of mutations.
* Edges and mutations are ordered, with mutations processed against the
* abstract model only after it is fully constructed by visiting all blocks
* _and_ connecting phis. Phis are considered ordered at the time of the
* phi node.
*
* This should (may?) mean that mutations are able to see the full state
* of the graph and mark all the appropriate identifiers as mutated at
* the correct point, accounting for both backward and forward edges.
* Ie a mutation of x accounts for both values that flowed into x,
* and values that x flowed into.
*/
const state = new AliasingState();
type PendingPhiOperand = {from: Place; into: Place; index: number};
const pendingPhis = new Map<BlockId, Array<PendingPhiOperand>>();
const mutations: Array<{
index: number;
id: InstructionId;
transitive: boolean;
kind: MutationKind;
place: Place;
}> = [];
const renders: Array<{index: number; place: Place}> = [];
let index = 0;
const errors = new CompilerError();
for (const param of [...fn.params, ...fn.context, fn.returns]) {
const place = param.kind === 'Identifier' ? param : param.place;
state.create(place, {kind: 'Object'});
}
const seenBlocks = new Set<BlockId>();
for (const block of fn.body.blocks.values()) {
for (const phi of block.phis) {
state.create(phi.place, {kind: 'Phi'});
for (const [pred, operand] of phi.operands) {
if (!seenBlocks.has(pred)) {
// NOTE: annotation required to actually typecheck and not silently infer `any`
const blockPhis = getOrInsertWith<BlockId, Array<PendingPhiOperand>>(
pendingPhis,
pred,
() => [],
);
blockPhis.push({from: operand, into: phi.place, index: index++});
} else {
state.assign(index++, operand, phi.place);
}
}
}
seenBlocks.add(block.id);
for (const instr of block.instructions) {
if (
instr.value.kind === 'FunctionExpression' ||
instr.value.kind === 'ObjectMethod'
) {
state.create(instr.lvalue, {
kind: 'Function',
function: instr.value.loweredFunc.func,
});
} else {
for (const lvalue of eachInstructionLValue(instr)) {
state.create(lvalue, {kind: 'Object'});
}
}
if (instr.effects == null) continue;
for (const effect of instr.effects) {
if (effect.kind === 'Create') {
state.create(effect.into, {kind: 'Object'});
} else if (effect.kind === 'CreateFunction') {
state.create(effect.into, {
kind: 'Function',
function: effect.function.loweredFunc.func,
});
} else if (effect.kind === 'CreateFrom') {
state.createFrom(index++, effect.from, effect.into);
} else if (effect.kind === 'Assign') {
if (!state.nodes.has(effect.into.identifier)) {
state.create(effect.into, {kind: 'Object'});
}
state.assign(index++, effect.from, effect.into);
} else if (effect.kind === 'Alias') {
state.assign(index++, effect.from, effect.into);
} else if (effect.kind === 'Capture') {
state.capture(index++, effect.from, effect.into);
} else if (
effect.kind === 'MutateTransitive' ||
effect.kind === 'MutateTransitiveConditionally'
) {
mutations.push({
index: index++,
id: instr.id,
transitive: true,
kind:
effect.kind === 'MutateTransitive'
? MutationKind.Definite
: MutationKind.Conditional,
place: effect.value,
});
} else if (
effect.kind === 'Mutate' ||
effect.kind === 'MutateConditionally'
) {
mutations.push({
index: index++,
id: instr.id,
transitive: false,
kind:
effect.kind === 'Mutate'
? MutationKind.Definite
: MutationKind.Conditional,
place: effect.value,
});
} else if (
effect.kind === 'MutateFrozen' ||
effect.kind === 'MutateGlobal' ||
effect.kind === 'Impure'
) {
errors.push(effect.error);
} else if (effect.kind === 'Render') {
renders.push({index: index++, place: effect.place});
}
}
}
const blockPhis = pendingPhis.get(block.id);
if (blockPhis != null) {
for (const {from, into, index} of blockPhis) {
state.assign(index, from, into);
}
}
if (block.terminal.kind === 'return') {
state.assign(index++, block.terminal.value, fn.returns);
}
if (
(block.terminal.kind === 'maybe-throw' ||
block.terminal.kind === 'return') &&
block.terminal.effects != null
) {
for (const effect of block.terminal.effects) {
if (effect.kind === 'Alias') {
state.assign(index++, effect.from, effect.into);
} else {
CompilerError.invariant(effect.kind === 'Freeze', {
reason: `Unexpected '${effect.kind}' effect for MaybeThrow terminal`,
loc: block.terminal.loc,
});
}
}
}
}
if (VERBOSE) {
console.log(state.debug());
console.log(pretty(mutations));
}
for (const mutation of mutations) {
state.mutate(
mutation.index,
mutation.place.identifier,
makeInstructionId(mutation.id + 1),
mutation.transitive,
mutation.kind,
mutation.place.loc,
errors,
);
}
for (const render of renders) {
state.render(render.index, render.place.identifier, errors);
}
if (DEBUG) {
console.log(pretty([...state.nodes.keys()]));
}
fn.aliasingEffects ??= [];
for (const param of [...fn.context, ...fn.params]) {
const place = param.kind === 'Identifier' ? param : param.place;
const node = state.nodes.get(place.identifier);
if (node == null) {
continue;
}
let mutated = false;
if (node.local != null) {
if (node.local.kind === MutationKind.Conditional) {
mutated = true;
fn.aliasingEffects.push({
kind: 'MutateConditionally',
value: {...place, loc: node.local.loc},
});
} else if (node.local.kind === MutationKind.Definite) {
mutated = true;
fn.aliasingEffects.push({
kind: 'Mutate',
value: {...place, loc: node.local.loc},
});
}
}
if (node.transitive != null) {
if (node.transitive.kind === MutationKind.Conditional) {
mutated = true;
fn.aliasingEffects.push({
kind: 'MutateTransitiveConditionally',
value: {...place, loc: node.transitive.loc},
});
} else if (node.transitive.kind === MutationKind.Definite) {
mutated = true;
fn.aliasingEffects.push({
kind: 'MutateTransitive',
value: {...place, loc: node.transitive.loc},
});
}
}
if (mutated) {
place.effect = Effect.Capture;
}
}
/**
* Part 2
* Add legacy operand-specific effects based on instruction effects and mutable ranges.
* Also fixes up operand mutable ranges, making sure that start is non-zero if the value
* is mutated (depended on by later passes like InferReactiveScopeVariables which uses this
* to filter spurious mutations of globals, which we now guard against more precisely)
*/
for (const block of fn.body.blocks.values()) {
for (const phi of block.phis) {
// TODO: we don't actually set these effects today!
phi.place.effect = Effect.Store;
const isPhiMutatedAfterCreation: boolean =
phi.place.identifier.mutableRange.end >
(block.instructions.at(0)?.id ?? block.terminal.id);
for (const operand of phi.operands.values()) {
operand.effect = isPhiMutatedAfterCreation
? Effect.Capture
: Effect.Read;
}
if (
isPhiMutatedAfterCreation &&
phi.place.identifier.mutableRange.start === 0
) {
/*
* TODO: ideally we'd construct a precise start range, but what really
* matters is that the phi's range appears mutable (end > start + 1)
* so we just set the start to the previous instruction before this block
*/
const firstInstructionIdOfBlock =
block.instructions.at(0)?.id ?? block.terminal.id;
phi.place.identifier.mutableRange.start = makeInstructionId(
firstInstructionIdOfBlock - 1,
);
}
}
for (const instr of block.instructions) {
for (const lvalue of eachInstructionLValue(instr)) {
lvalue.effect = Effect.ConditionallyMutate;
if (lvalue.identifier.mutableRange.start === 0) {
lvalue.identifier.mutableRange.start = instr.id;
}
if (lvalue.identifier.mutableRange.end === 0) {
lvalue.identifier.mutableRange.end = makeInstructionId(
Math.max(instr.id + 1, lvalue.identifier.mutableRange.end),
);
}
}
for (const operand of eachInstructionValueOperand(instr.value)) {
operand.effect = Effect.Read;
}
if (instr.effects == null) {
continue;
}
const operandEffects = new Map<IdentifierId, Effect>();
for (const effect of instr.effects) {
switch (effect.kind) {
case 'Assign':
case 'Alias':
case 'Capture':
case 'CreateFrom': {
const isMutatedOrReassigned =
effect.into.identifier.mutableRange.end > instr.id;
if (isMutatedOrReassigned) {
operandEffects.set(effect.from.identifier.id, Effect.Capture);
operandEffects.set(effect.into.identifier.id, Effect.Store);
} else {
operandEffects.set(effect.from.identifier.id, Effect.Read);
operandEffects.set(effect.into.identifier.id, Effect.Store);
}
break;
}
case 'CreateFunction':
case 'Create': {
break;
}
case 'Mutate': {
operandEffects.set(effect.value.identifier.id, Effect.Store);
break;
}
case 'Apply': {
CompilerError.invariant(false, {
reason: `[AnalyzeFunctions] Expected Apply effects to be replaced with more precise effects`,
loc: effect.function.loc,
});
}
case 'MutateTransitive':
case 'MutateConditionally':
case 'MutateTransitiveConditionally': {
operandEffects.set(
effect.value.identifier.id,
Effect.ConditionallyMutate,
);
break;
}
case 'Freeze': {
operandEffects.set(effect.value.identifier.id, Effect.Freeze);
break;
}
case 'ImmutableCapture': {
// no-op, Read is the default
break;
}
case 'Impure':
case 'Render':
case 'MutateFrozen':
case 'MutateGlobal': {
// no-op
break;
}
default: {
assertExhaustive(
effect,
`Unexpected effect kind ${(effect as any).kind}`,
);
}
}
}
for (const lvalue of eachInstructionLValue(instr)) {
const effect =
operandEffects.get(lvalue.identifier.id) ??
Effect.ConditionallyMutate;
lvalue.effect = effect;
}
for (const operand of eachInstructionValueOperand(instr.value)) {
if (
operand.identifier.mutableRange.end > instr.id &&
operand.identifier.mutableRange.start === 0
) {
operand.identifier.mutableRange.start = instr.id;
}
const effect = operandEffects.get(operand.identifier.id) ?? Effect.Read;
operand.effect = effect;
}
/**
* This case is targeted at hoisted functions like:
*
* ```
* x();
* function x() { ... }
* ```
*
* Which turns into:
*
* t0 = DeclareContext HoistedFunction x
* t1 = LoadContext x
* t2 = CallExpression t1 ( )
* t3 = FunctionExpression ...
* t4 = StoreContext Function x = t3
*
* If the function had captured mutable values, it would already have its
* range extended to include the StoreContext. But if the function doesn't
* capture any mutable values its range won't have been extended yet. We
* want to ensure that the value is memoized along with the context variable,
* not independently of it (bc of the way we do codegen for hoisted functions).
* So here we check for StoreContext rvalues and if they haven't already had
* their range extended to at least this instruction, we extend it.
*/
if (
instr.value.kind === 'StoreContext' &&
instr.value.value.identifier.mutableRange.end <= instr.id
) {
instr.value.value.identifier.mutableRange.end = makeInstructionId(
instr.id + 1,
);
}
}
if (block.terminal.kind === 'return') {
block.terminal.value.effect = isFunctionExpression
? Effect.Read
: Effect.Freeze;
} else {
for (const operand of eachTerminalOperand(block.terminal)) {
operand.effect = Effect.Read;
}
}
}
if (VERBOSE) {
console.log(printFunction(fn));
}
return errors.asResult();
}
function appendFunctionErrors(errors: CompilerError, fn: HIRFunction): void {
for (const effect of fn.aliasingEffects ?? []) {
switch (effect.kind) {
case 'Impure':
case 'MutateFrozen':
case 'MutateGlobal': {
errors.push(effect.error);
break;
}
}
}
}
type Node = {
id: Identifier;
createdFrom: Map<Identifier, number>;
captures: Map<Identifier, number>;
aliases: Map<Identifier, number>;
edges: Array<{index: number; node: Identifier; kind: 'capture' | 'alias'}>;
transitive: {kind: MutationKind; loc: SourceLocation} | null;
local: {kind: MutationKind; loc: SourceLocation} | null;
value:
| {kind: 'Object'}
| {kind: 'Phi'}
| {kind: 'Function'; function: HIRFunction};
};
class AliasingState {
nodes: Map<Identifier, Node> = new Map();
create(place: Place, value: Node['value']): void {
this.nodes.set(place.identifier, {
id: place.identifier,
createdFrom: new Map(),
captures: new Map(),
aliases: new Map(),
edges: [],
transitive: null,
local: null,
value,
});
}
createFrom(index: number, from: Place, into: Place): void {
this.create(into, {kind: 'Object'});
const fromNode = this.nodes.get(from.identifier);
const toNode = this.nodes.get(into.identifier);
if (fromNode == null || toNode == null) {
if (VERBOSE) {
console.log(
`skip: createFrom ${printPlace(from)}${!!fromNode} -> ${printPlace(into)}${!!toNode}`,
);
}
return;
}
fromNode.edges.push({index, node: into.identifier, kind: 'alias'});
if (!toNode.createdFrom.has(from.identifier)) {
toNode.createdFrom.set(from.identifier, index);
}
}
capture(index: number, from: Place, into: Place): void {
const fromNode = this.nodes.get(from.identifier);
const toNode = this.nodes.get(into.identifier);
if (fromNode == null || toNode == null) {
if (VERBOSE) {
console.log(
`skip: capture ${printPlace(from)}${!!fromNode} -> ${printPlace(into)}${!!toNode}`,
);
}
return;
}
fromNode.edges.push({index, node: into.identifier, kind: 'capture'});
if (!toNode.captures.has(from.identifier)) {
toNode.captures.set(from.identifier, index);
}
}
assign(index: number, from: Place, into: Place): void {
const fromNode = this.nodes.get(from.identifier);
const toNode = this.nodes.get(into.identifier);
if (fromNode == null || toNode == null) {
if (VERBOSE) {
console.log(
`skip: assign ${printPlace(from)}${!!fromNode} -> ${printPlace(into)}${!!toNode}`,
);
}
return;
}
fromNode.edges.push({index, node: into.identifier, kind: 'alias'});
if (!toNode.aliases.has(from.identifier)) {
toNode.aliases.set(from.identifier, index);
}
}
render(index: number, start: Identifier, errors: CompilerError): void {
const seen = new Set<Identifier>();
const queue: Array<Identifier> = [start];
while (queue.length !== 0) {
const current = queue.pop()!;
if (seen.has(current)) {
continue;
}
seen.add(current);
const node = this.nodes.get(current);
if (node == null || node.transitive != null || node.local != null) {
continue;
}
if (node.value.kind === 'Function') {
appendFunctionErrors(errors, node.value.function);
}
for (const [alias, when] of node.createdFrom) {
if (when >= index) {
continue;
}
queue.push(alias);
}
for (const [alias, when] of node.aliases) {
if (when >= index) {
continue;
}
queue.push(alias);
}
for (const [capture, when] of node.captures) {
if (when >= index) {
continue;
}
queue.push(capture);
}
}
}
mutate(
index: number,
start: Identifier,
end: InstructionId,
transitive: boolean,
kind: MutationKind,
loc: SourceLocation,
errors: CompilerError,
): void {
if (DEBUG) {
console.log(
`mutate ix=${index} start=$${start.id} end=[${end}]${transitive ? ' transitive' : ''} kind=${kind}`,
);
}
const seen = new Set<Identifier>();
const queue: Array<{
place: Identifier;
transitive: boolean;
direction: 'backwards' | 'forwards';
}> = [{place: start, transitive, direction: 'backwards'}];
while (queue.length !== 0) {
const {place: current, transitive, direction} = queue.pop()!;
if (seen.has(current)) {
continue;
}
seen.add(current);
const node = this.nodes.get(current);
if (node == null) {
if (DEBUG) {
console.log(
`no node! ${printIdentifier(start)} for identifier ${printIdentifier(current)}`,
);
}
continue;
}
if (DEBUG) {
console.log(
` mutate $${node.id.id} transitive=${transitive} direction=${direction}`,
);
}
node.id.mutableRange.end = makeInstructionId(
Math.max(node.id.mutableRange.end, end),
);
if (
node.value.kind === 'Function' &&
node.transitive == null &&
node.local == null
) {
appendFunctionErrors(errors, node.value.function);
}
if (transitive) {
if (node.transitive == null || node.transitive.kind < kind) {
node.transitive = {kind, loc};
}
} else {
if (node.local == null || node.local.kind < kind) {
node.local = {kind, loc};
}
}
/**
* all mutations affect "forward" edges by the rules:
* - Capture a -> b, mutate(a) => mutate(b)
* - Alias a -> b, mutate(a) => mutate(b)
*/
for (const edge of node.edges) {
if (edge.index >= index) {
break;
}
queue.push({place: edge.node, transitive, direction: 'forwards'});
}
for (const [alias, when] of node.createdFrom) {
if (when >= index) {
continue;
}
queue.push({place: alias, transitive: true, direction: 'backwards'});
}
if (direction === 'backwards' || node.value.kind !== 'Phi') {
/**
* all mutations affect backward alias edges by the rules:
* - Alias a -> b, mutate(b) => mutate(a)
* - Alias a -> b, mutateTransitive(b) => mutate(a)
*
* However, if we reached a phi because one of its inputs was mutated
* (and we're advancing "forwards" through that node's edges), then
* we know we've already processed the mutation at its source. The
* phi's other inputs can't be affected.
*/
for (const [alias, when] of node.aliases) {
if (when >= index) {
continue;
}
queue.push({place: alias, transitive, direction: 'backwards'});
}
}
/**
* but only transitive mutations affect captures
*/
if (transitive) {
for (const [capture, when] of node.captures) {
if (when >= index) {
continue;
}
queue.push({place: capture, transitive, direction: 'backwards'});
}
}
}
if (DEBUG) {
const nodes = new Map();
for (const id of seen) {
const node = this.nodes.get(id);
nodes.set(id.id, node);
}
console.log(pretty(nodes));
}
}
debug(): string {
return pretty(this.nodes);
}
}
export function pretty(v: any): string {
return prettyFormat(v, {
plugins: [
{
test: v =>
v !== null && typeof v === 'object' && v.kind === 'Identifier',
serialize: v => printPlace(v),
},
{
test: v =>
v !== null &&
typeof v === 'object' &&
typeof v.declarationId === 'number',
serialize: v =>
`${printIdentifier(v)}:${v.mutableRange.start}:${v.mutableRange.end}`,
},
],
});
}

View File

@@ -9,18 +9,24 @@ import {CompilerError} from '..';
import {
BlockId,
Effect,
Environment,
HIRFunction,
Identifier,
IdentifierId,
Instruction,
Place,
computePostDominatorTree,
evaluatesToStableTypeOrContainer,
getHookKind,
isStableType,
isStableTypeContainer,
isUseOperator,
isUseRefType,
} from '../HIR';
import {PostDominator} from '../HIR/Dominator';
import {
eachInstructionLValue,
eachInstructionOperand,
eachInstructionValueOperand,
eachTerminalOperand,
} from '../HIR/visitors';
@@ -31,6 +37,103 @@ import {
import DisjointSet from '../Utils/DisjointSet';
import {assertExhaustive} from '../Utils/utils';
/**
* Side map to track and propagate sources of stability (i.e. hook calls such as
* `useRef()` and property reads such as `useState()[1]). Note that this
* requires forward data flow analysis since stability is not part of React
* Compiler's type system.
*/
class StableSidemap {
map: Map<IdentifierId, {isStable: boolean}> = new Map();
env: Environment;
constructor(env: Environment) {
this.env = env;
}
handleInstruction(instr: Instruction): void {
const {value, lvalue} = instr;
switch (value.kind) {
case 'CallExpression':
case 'MethodCall': {
/**
* Sources of stability are known hook calls
*/
if (evaluatesToStableTypeOrContainer(this.env, instr)) {
if (isStableType(lvalue.identifier)) {
this.map.set(lvalue.identifier.id, {
isStable: true,
});
} else {
this.map.set(lvalue.identifier.id, {
isStable: false,
});
}
} else if (
this.env.config.enableTreatRefLikeIdentifiersAsRefs &&
isUseRefType(lvalue.identifier)
) {
this.map.set(lvalue.identifier.id, {
isStable: true,
});
}
break;
}
case 'Destructure':
case 'PropertyLoad': {
/**
* PropertyLoads may from stable containers may also produce stable
* values. ComputedLoads are technically safe for now (as all stable
* containers have differently-typed elements), but are not handled as
* they should be rare anyways.
*/
const source =
value.kind === 'Destructure'
? value.value.identifier.id
: value.object.identifier.id;
const entry = this.map.get(source);
if (entry) {
for (const lvalue of eachInstructionLValue(instr)) {
if (isStableTypeContainer(lvalue.identifier)) {
this.map.set(lvalue.identifier.id, {
isStable: false,
});
} else if (isStableType(lvalue.identifier)) {
this.map.set(lvalue.identifier.id, {
isStable: true,
});
}
}
}
break;
}
case 'StoreLocal': {
const entry = this.map.get(value.value.identifier.id);
if (entry) {
this.map.set(lvalue.identifier.id, entry);
this.map.set(value.lvalue.place.identifier.id, entry);
}
break;
}
case 'LoadLocal': {
const entry = this.map.get(value.place.identifier.id);
if (entry) {
this.map.set(lvalue.identifier.id, entry);
}
break;
}
}
}
isStable(id: IdentifierId): boolean {
const entry = this.map.get(id);
return entry != null ? entry.isStable : false;
}
}
/*
* Infers which `Place`s are reactive, ie may *semantically* change
* over the course of the component/hook's lifetime. Places are reactive
@@ -111,6 +214,7 @@ import {assertExhaustive} from '../Utils/utils';
*/
export function inferReactivePlaces(fn: HIRFunction): void {
const reactiveIdentifiers = new ReactivityMap(findDisjointMutableValues(fn));
const stableIdentifierSources = new StableSidemap(fn.env);
for (const param of fn.params) {
const place = param.kind === 'Identifier' ? param : param.place;
reactiveIdentifiers.markReactive(place);
@@ -184,11 +288,12 @@ export function inferReactivePlaces(fn: HIRFunction): void {
}
}
for (const instruction of block.instructions) {
stableIdentifierSources.handleInstruction(instruction);
const {value} = instruction;
let hasReactiveInput = false;
/*
* NOTE: we want to mark all operands as reactive or not, so we
* avoid short-circuting here
* avoid short-circuiting here
*/
for (const operand of eachInstructionValueOperand(value)) {
const reactive = reactiveIdentifiers.isReactive(operand);
@@ -218,7 +323,13 @@ export function inferReactivePlaces(fn: HIRFunction): void {
if (hasReactiveInput) {
for (const lvalue of eachInstructionLValue(instruction)) {
if (isStableType(lvalue.identifier)) {
/**
* Note that it's not correct to mark all stable-typed identifiers
* as non-reactive, since ternaries and other value blocks can
* produce reactive identifiers typed as these.
* (e.g. `props.cond ? setState1 : setState2`)
*/
if (stableIdentifierSources.isStable(lvalue.identifier.id)) {
continue;
}
reactiveIdentifiers.markReactive(lvalue);
@@ -265,6 +376,41 @@ export function inferReactivePlaces(fn: HIRFunction): void {
}
}
} while (reactiveIdentifiers.snapshot());
function propagateReactivityToInnerFunctions(
fn: HIRFunction,
isOutermost: boolean,
): void {
for (const [, block] of fn.body.blocks) {
for (const instr of block.instructions) {
if (!isOutermost) {
for (const operand of eachInstructionOperand(instr)) {
reactiveIdentifiers.isReactive(operand);
}
}
if (
instr.value.kind === 'ObjectMethod' ||
instr.value.kind === 'FunctionExpression'
) {
propagateReactivityToInnerFunctions(
instr.value.loweredFunc.func,
false,
);
}
}
if (!isOutermost) {
for (const operand of eachTerminalOperand(block.terminal)) {
reactiveIdentifiers.isReactive(operand);
}
}
}
}
/**
* Propagate reactivity for inner functions, as we eventually hoist and dedupe
* dependency instructions for scopes.
*/
propagateReactivityToInnerFunctions(fn, true);
}
/*

View File

@@ -48,7 +48,7 @@ import {
eachTerminalOperand,
eachTerminalSuccessor,
} from '../HIR/visitors';
import {assertExhaustive} from '../Utils/utils';
import {assertExhaustive, Set_isSuperset} from '../Utils/utils';
import {
inferTerminalFunctionEffects,
inferInstructionFunctionEffects,
@@ -111,7 +111,10 @@ export default function inferReferenceEffects(
* Initial state contains function params
* TODO: include module declarations here as well
*/
const initialState = InferenceState.empty(fn.env);
const initialState = InferenceState.empty(
fn.env,
options.isFunctionExpression,
);
const value: InstructionValue = {
kind: 'Primitive',
loc: fn.loc,
@@ -255,6 +258,7 @@ type FreezeAction = {values: Set<InstructionValue>; reason: Set<ValueReason>};
// Maintains a mapping of top-level variables to the kind of value they hold
class InferenceState {
env: Environment;
#isFunctionExpression: boolean;
// The kind of each value, based on its allocation site
#values: Map<InstructionValue, AbstractValue>;
@@ -267,16 +271,25 @@ class InferenceState {
constructor(
env: Environment,
isFunctionExpression: boolean,
values: Map<InstructionValue, AbstractValue>,
variables: Map<IdentifierId, Set<InstructionValue>>,
) {
this.env = env;
this.#isFunctionExpression = isFunctionExpression;
this.#values = values;
this.#variables = variables;
}
static empty(env: Environment): InferenceState {
return new InferenceState(env, new Map(), new Map());
static empty(
env: Environment,
isFunctionExpression: boolean,
): InferenceState {
return new InferenceState(env, isFunctionExpression, new Map(), new Map());
}
get isFunctionExpression(): boolean {
return this.#isFunctionExpression;
}
// (Re)initializes a @param value with its default @param kind.
@@ -394,9 +407,14 @@ class InferenceState {
freezeValues(values: Set<InstructionValue>, reason: Set<ValueReason>): void {
for (const value of values) {
if (value.kind === 'DeclareContext') {
if (
value.kind === 'DeclareContext' ||
(value.kind === 'StoreContext' &&
(value.lvalue.kind === InstructionKind.Let ||
value.lvalue.kind === InstructionKind.Const))
) {
/**
* Avoid freezing hoisted context declarations
* Avoid freezing context variable declarations, hoisted or otherwise
* function Component() {
* const cb = useBar(() => foo(2)); // produces a hoisted context declaration
* const foo = useFoo(); // reassigns to the context variable
@@ -613,6 +631,7 @@ class InferenceState {
} else {
return new InferenceState(
this.env,
this.#isFunctionExpression,
nextValues ?? new Map(this.#values),
nextVariables ?? new Map(this.#variables),
);
@@ -627,6 +646,7 @@ class InferenceState {
clone(): InferenceState {
return new InferenceState(
this.env,
this.#isFunctionExpression,
new Map(this.#values),
new Map(this.#variables),
);
@@ -759,7 +779,7 @@ function inferParam(
* │ Mutable │───┘
* └──────────────────────────┘
*/
function mergeValues(a: ValueKind, b: ValueKind): ValueKind {
export function mergeValueKinds(a: ValueKind, b: ValueKind): ValueKind {
if (a === b) {
return a;
} else if (a === ValueKind.MaybeFrozen || b === ValueKind.MaybeFrozen) {
@@ -801,28 +821,16 @@ function mergeValues(a: ValueKind, b: ValueKind): ValueKind {
}
}
/**
* @returns `true` if `a` is a superset of `b`.
*/
function isSuperset<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
for (const v of b) {
if (!a.has(v)) {
return false;
}
}
return true;
}
function mergeAbstractValues(
a: AbstractValue,
b: AbstractValue,
): AbstractValue {
const kind = mergeValues(a.kind, b.kind);
const kind = mergeValueKinds(a.kind, b.kind);
if (
kind === a.kind &&
kind === b.kind &&
isSuperset(a.reason, b.reason) &&
isSuperset(a.context, b.context)
Set_isSuperset(a.reason, b.reason) &&
Set_isSuperset(a.context, b.context)
) {
return a;
}
@@ -1591,6 +1599,14 @@ function inferBlock(
);
const lvalue = instr.lvalue;
if (instrValue.lvalue.kind !== InstructionKind.Reassign) {
state.initialize(instrValue, {
kind: ValueKind.Mutable,
reason: new Set([ValueReason.Other]),
context: new Set(),
});
state.define(instrValue.lvalue.place, instrValue);
}
state.alias(lvalue, instrValue.value);
lvalue.effect = Effect.Store;
continuation = {kind: 'funeffects'};
@@ -1781,8 +1797,15 @@ function inferBlock(
if (block.terminal.kind === 'return' || block.terminal.kind === 'throw') {
if (
state.isDefined(operand) &&
state.kind(operand).kind === ValueKind.Context
((operand.identifier.type.kind === 'Function' &&
state.isFunctionExpression) ||
state.kind(operand).kind === ValueKind.Context)
) {
/**
* Returned values should only be typed as 'frozen' if they are both (1)
* local and (2) not a function expression which may capture and mutate
* this function's outer context.
*/
effect = Effect.ConditionallyMutate;
} else {
effect = Effect.Freeze;
@@ -1954,7 +1977,7 @@ function areArgumentsImmutableAndNonMutating(
return true;
}
function getArgumentEffect(
export function getArgumentEffect(
signatureEffect: Effect | null,
arg: Place | SpreadPattern,
): Effect {

View File

@@ -17,6 +17,7 @@ import {
InstructionKind,
LabelTerminal,
Place,
isStatementBlockKind,
makeInstructionId,
promoteTemporary,
reversePostorderBlocks,
@@ -90,100 +91,106 @@ export function inlineImmediatelyInvokedFunctionExpressions(
*/
const queue = Array.from(fn.body.blocks.values());
queue: for (const block of queue) {
for (let ii = 0; ii < block.instructions.length; ii++) {
const instr = block.instructions[ii]!;
switch (instr.value.kind) {
case 'FunctionExpression': {
if (instr.lvalue.identifier.name === null) {
functions.set(instr.lvalue.identifier.id, instr.value);
/*
* We can't handle labels inside expressions yet, so we don't inline IIFEs if they are in an
* expression block.
*/
if (isStatementBlockKind(block.kind)) {
for (let ii = 0; ii < block.instructions.length; ii++) {
const instr = block.instructions[ii]!;
switch (instr.value.kind) {
case 'FunctionExpression': {
if (instr.lvalue.identifier.name === null) {
functions.set(instr.lvalue.identifier.id, instr.value);
}
break;
}
break;
}
case 'CallExpression': {
if (instr.value.args.length !== 0) {
// We don't support inlining when there are arguments
continue;
case 'CallExpression': {
if (instr.value.args.length !== 0) {
// We don't support inlining when there are arguments
continue;
}
const body = functions.get(instr.value.callee.identifier.id);
if (body === undefined) {
// Not invoking a local function expression, can't inline
continue;
}
if (
body.loweredFunc.func.params.length > 0 ||
body.loweredFunc.func.async ||
body.loweredFunc.func.generator
) {
// Can't inline functions with params, or async/generator functions
continue;
}
// We know this function is used for an IIFE and can prune it later
inlinedFunctions.add(instr.value.callee.identifier.id);
// Create a new block which will contain code following the IIFE call
const continuationBlockId = fn.env.nextBlockId;
const continuationBlock: BasicBlock = {
id: continuationBlockId,
instructions: block.instructions.slice(ii + 1),
kind: block.kind,
phis: new Set(),
preds: new Set(),
terminal: block.terminal,
};
fn.body.blocks.set(continuationBlockId, continuationBlock);
/*
* Trim the original block to contain instructions up to (but not including)
* the IIFE
*/
block.instructions.length = ii;
/*
* To account for complex control flow within the lambda, we treat the lambda
* as if it were a single labeled statement, and replace all returns with gotos
* to the label fallthrough.
*/
const newTerminal: LabelTerminal = {
block: body.loweredFunc.func.body.entry,
id: makeInstructionId(0),
kind: 'label',
fallthrough: continuationBlockId,
loc: block.terminal.loc,
};
block.terminal = newTerminal;
// We store the result in the IIFE temporary
const result = instr.lvalue;
// Declare the IIFE temporary
declareTemporary(fn.env, block, result);
// Promote the temporary with a name as we require this to persist
promoteTemporary(result.identifier);
/*
* Rewrite blocks from the lambda to replace any `return` with a
* store to the result and `goto` the continuation block
*/
for (const [id, block] of body.loweredFunc.func.body.blocks) {
block.preds.clear();
rewriteBlock(fn.env, block, continuationBlockId, result);
fn.body.blocks.set(id, block);
}
/*
* Ensure we visit the continuation block, since there may have been
* sequential IIFEs that need to be visited.
*/
queue.push(continuationBlock);
continue queue;
}
const body = functions.get(instr.value.callee.identifier.id);
if (body === undefined) {
// Not invoking a local function expression, can't inline
continue;
}
if (
body.loweredFunc.func.params.length > 0 ||
body.loweredFunc.func.async ||
body.loweredFunc.func.generator
) {
// Can't inline functions with params, or async/generator functions
continue;
}
// We know this function is used for an IIFE and can prune it later
inlinedFunctions.add(instr.value.callee.identifier.id);
// Create a new block which will contain code following the IIFE call
const continuationBlockId = fn.env.nextBlockId;
const continuationBlock: BasicBlock = {
id: continuationBlockId,
instructions: block.instructions.slice(ii + 1),
kind: block.kind,
phis: new Set(),
preds: new Set(),
terminal: block.terminal,
};
fn.body.blocks.set(continuationBlockId, continuationBlock);
/*
* Trim the original block to contain instructions up to (but not including)
* the IIFE
*/
block.instructions.length = ii;
/*
* To account for complex control flow within the lambda, we treat the lambda
* as if it were a single labeled statement, and replace all returns with gotos
* to the label fallthrough.
*/
const newTerminal: LabelTerminal = {
block: body.loweredFunc.func.body.entry,
id: makeInstructionId(0),
kind: 'label',
fallthrough: continuationBlockId,
loc: block.terminal.loc,
};
block.terminal = newTerminal;
// We store the result in the IIFE temporary
const result = instr.lvalue;
// Declare the IIFE temporary
declareTemporary(fn.env, block, result);
// Promote the temporary with a name as we require this to persist
promoteTemporary(result.identifier);
/*
* Rewrite blocks from the lambda to replace any `return` with a
* store to the result and `goto` the continuation block
*/
for (const [id, block] of body.loweredFunc.func.body.blocks) {
block.preds.clear();
rewriteBlock(fn.env, block, continuationBlockId, result);
fn.body.blocks.set(id, block);
}
/*
* Ensure we visit the continuation block, since there may have been
* sequential IIFEs that need to be visited.
*/
queue.push(continuationBlock);
continue queue;
}
default: {
for (const place of eachInstructionValueOperand(instr.value)) {
// Any other use of a function expression means it isn't an IIFE
functions.delete(place.identifier.id);
default: {
for (const place of eachInstructionValueOperand(instr.value)) {
// Any other use of a function expression means it isn't an IIFE
functions.delete(place.identifier.id);
}
}
}
}
@@ -235,6 +242,7 @@ function rewriteBlock(
type: null,
loc: terminal.loc,
},
effects: null,
});
block.terminal = {
kind: 'goto',
@@ -263,5 +271,6 @@ function declareTemporary(
type: null,
loc: result.loc,
},
effects: null,
});
}

View File

@@ -327,6 +327,23 @@ function evaluateInstruction(
}
return null;
}
case '-': {
const operand = read(constants, value.value);
if (
operand !== null &&
operand.kind === 'Primitive' &&
typeof operand.value === 'number'
) {
const result: Primitive = {
kind: 'Primitive',
value: operand.value * -1,
loc: value.loc,
};
instr.value = result;
return result;
}
return null;
}
default:
return null;
}
@@ -492,6 +509,73 @@ function evaluateInstruction(
}
return null;
}
case 'TemplateLiteral': {
if (value.subexprs.length === 0) {
const result: InstructionValue = {
kind: 'Primitive',
value: value.quasis.map(q => q.cooked).join(''),
loc: value.loc,
};
instr.value = result;
return result;
}
if (value.subexprs.length !== value.quasis.length - 1) {
return null;
}
if (value.quasis.some(q => q.cooked === undefined)) {
return null;
}
let quasiIndex = 0;
let resultString = value.quasis[quasiIndex].cooked as string;
++quasiIndex;
for (const subExpr of value.subexprs) {
const subExprValue = read(constants, subExpr);
if (!subExprValue || subExprValue.kind !== 'Primitive') {
return null;
}
const expressionValue = subExprValue.value;
if (
typeof expressionValue !== 'number' &&
typeof expressionValue !== 'string' &&
typeof expressionValue !== 'boolean' &&
!(typeof expressionValue === 'object' && expressionValue === null)
) {
// value is not supported (function, object) or invalid (symbol), or something else
return null;
}
const suffix = value.quasis[quasiIndex].cooked;
++quasiIndex;
if (suffix === undefined) {
return null;
}
/*
* Spec states that concat calls ToString(argument) internally on its parameters
* -> we don't have to implement ToString(argument) ourselves and just use the engine implementation
* Refs:
* - https://tc39.es/ecma262/2024/#sec-tostring
* - https://tc39.es/ecma262/2024/#sec-string.prototype.concat
* - https://tc39.es/ecma262/2024/#sec-template-literals-runtime-semantics-evaluation
*/
resultString = resultString.concat(expressionValue as string, suffix);
}
const result: InstructionValue = {
kind: 'Primitive',
value: resultString,
loc: value.loc,
};
instr.value = result;
return result;
}
case 'LoadLocal': {
const placeValue = read(constants, value.place);
if (placeValue !== null) {

View File

@@ -151,6 +151,7 @@ export function inlineJsxTransform(
type: null,
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
currentBlockInstructions.push(varInstruction);
@@ -167,6 +168,7 @@ export function inlineJsxTransform(
},
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
currentBlockInstructions.push(devGlobalInstruction);
@@ -220,6 +222,7 @@ export function inlineJsxTransform(
type: null,
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
thenBlockInstructions.push(reassignElseInstruction);
@@ -292,6 +295,7 @@ export function inlineJsxTransform(
],
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
elseBlockInstructions.push(reactElementInstruction);
@@ -309,6 +313,7 @@ export function inlineJsxTransform(
type: null,
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
elseBlockInstructions.push(reassignConditionalInstruction);
@@ -436,6 +441,7 @@ function createSymbolProperty(
binding: {kind: 'Global', name: 'Symbol'},
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
nextInstructions.push(symbolInstruction);
@@ -450,6 +456,7 @@ function createSymbolProperty(
property: makePropertyLiteral('for'),
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
nextInstructions.push(symbolForInstruction);
@@ -463,6 +470,7 @@ function createSymbolProperty(
value: symbolName,
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
nextInstructions.push(symbolValueInstruction);
@@ -478,6 +486,7 @@ function createSymbolProperty(
args: [symbolValueInstruction.lvalue],
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
const $$typeofProperty: ObjectProperty = {
@@ -508,6 +517,7 @@ function createTagProperty(
value: componentTag.name,
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
tagProperty = {
@@ -634,6 +644,7 @@ function createPropsProperties(
elements: [...children],
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
nextInstructions.push(childrenPropInstruction);
@@ -657,6 +668,7 @@ function createPropsProperties(
value: null,
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
refProperty = {
@@ -678,6 +690,7 @@ function createPropsProperties(
value: null,
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
keyProperty = {
@@ -711,6 +724,7 @@ function createPropsProperties(
properties: props,
loc: instr.value.loc,
},
effects: null,
loc: instr.loc,
};
propsProperty = {

View File

@@ -146,6 +146,7 @@ function emitLoadLoweredContextCallee(
id: makeInstructionId(0),
loc: GeneratedSource,
lvalue: createTemporaryPlace(env, GeneratedSource),
effects: null,
value: loadGlobal,
};
}
@@ -192,6 +193,7 @@ function emitPropertyLoad(
lvalue: object,
value: loadObj,
id: makeInstructionId(0),
effects: null,
loc: GeneratedSource,
};
@@ -206,6 +208,7 @@ function emitPropertyLoad(
lvalue: element,
value: loadProp,
id: makeInstructionId(0),
effects: null,
loc: GeneratedSource,
};
return {
@@ -237,6 +240,7 @@ function emitSelectorFn(env: Environment, keys: Array<string>): Instruction {
kind: 'return',
loc: GeneratedSource,
value: arrayInstr.lvalue,
effects: null,
},
preds: new Set(),
phis: new Set(),
@@ -250,6 +254,7 @@ function emitSelectorFn(env: Environment, keys: Array<string>): Instruction {
params: [obj],
returnTypeAnnotation: null,
returnType: makeType(),
returns: createTemporaryPlace(env, GeneratedSource),
context: [],
effects: null,
body: {
@@ -278,6 +283,7 @@ function emitSelectorFn(env: Environment, keys: Array<string>): Instruction {
loc: GeneratedSource,
},
lvalue: createTemporaryPlace(env, GeneratedSource),
effects: null,
loc: GeneratedSource,
};
return fnInstr;
@@ -294,6 +300,7 @@ function emitArrayInstr(elements: Array<Place>, env: Environment): Instruction {
id: makeInstructionId(0),
value: array,
lvalue: arrayLvalue,
effects: null,
loc: GeneratedSource,
};
return arrayInstr;

View File

@@ -297,6 +297,7 @@ function emitOutlinedJsx(
},
loc: GeneratedSource,
},
effects: null,
};
promoteTemporaryJsxTag(loadJsx.lvalue.identifier);
const jsxExpr: Instruction = {
@@ -312,6 +313,7 @@ function emitOutlinedJsx(
openingLoc: GeneratedSource,
closingLoc: GeneratedSource,
},
effects: null,
};
return [loadJsx, jsxExpr];
@@ -353,6 +355,7 @@ function emitOutlinedFn(
kind: 'return',
loc: GeneratedSource,
value: instructions.at(-1)!.lvalue,
effects: null,
},
preds: new Set(),
phis: new Set(),
@@ -366,6 +369,7 @@ function emitOutlinedFn(
params: [propsObj],
returnTypeAnnotation: null,
returnType: makeType(),
returns: createTemporaryPlace(env, GeneratedSource),
context: [],
effects: null,
body: {
@@ -517,6 +521,7 @@ function emitDestructureProps(
loc: GeneratedSource,
value: propsObj,
},
effects: null,
};
return destructurePropsInstr;
}

View File

@@ -44,7 +44,7 @@ import {
getHookKind,
makeIdentifierName,
} from '../HIR/HIR';
import {printIdentifier, printPlace} from '../HIR/PrintHIR';
import {printIdentifier, printInstruction, printPlace} from '../HIR/PrintHIR';
import {eachPatternOperand} from '../HIR/visitors';
import {Err, Ok, Result} from '../Utils/Result';
import {GuardKind} from '../Utils/RuntimeDiagnosticConstants';
@@ -1000,6 +1000,14 @@ function codegenTerminal(
lval = codegenLValue(cx, iterableItem.value.lvalue.pattern);
break;
}
case 'StoreContext': {
CompilerError.throwTodo({
reason: 'Support non-trivial for..in inits',
description: null,
loc: terminal.init.loc,
suggestions: null,
});
}
default:
CompilerError.invariant(false, {
reason: `Expected a StoreLocal or Destructure to be assigned to the collection`,
@@ -1092,6 +1100,14 @@ function codegenTerminal(
lval = codegenLValue(cx, iterableItem.value.lvalue.pattern);
break;
}
case 'StoreContext': {
CompilerError.throwTodo({
reason: 'Support non-trivial for..of inits',
description: null,
loc: terminal.init.loc,
suggestions: null,
});
}
default:
CompilerError.invariant(false, {
reason: `Expected a StoreLocal or Destructure to be assigned to the collection`,
@@ -1294,7 +1310,7 @@ function codegenInstructionNullable(
});
CompilerError.invariant(value?.type === 'FunctionExpression', {
reason: 'Expected a function as a function declaration value',
description: null,
description: `Got ${value == null ? String(value) : value.type} at ${printInstruction(instr)}`,
loc: instr.value.loc,
suggestions: null,
});
@@ -1710,7 +1726,7 @@ function codegenInstructionValue(
}
case 'UnaryExpression': {
value = t.unaryExpression(
instrValue.operator as 'throw', // todo
instrValue.operator,
codegenPlaceToExpression(cx, instrValue.value),
);
break;
@@ -2115,10 +2131,17 @@ function codegenInstructionValue(
}
case 'TypeCastExpression': {
if (t.isTSType(instrValue.typeAnnotation)) {
value = t.tsAsExpression(
codegenPlaceToExpression(cx, instrValue.value),
instrValue.typeAnnotation,
);
if (instrValue.typeAnnotationKind === 'satisfies') {
value = t.tsSatisfiesExpression(
codegenPlaceToExpression(cx, instrValue.value),
instrValue.typeAnnotation,
);
} else {
value = t.tsAsExpression(
codegenPlaceToExpression(cx, instrValue.value),
instrValue.typeAnnotation,
);
}
} else {
value = t.typeCastExpression(
codegenPlaceToExpression(cx, instrValue.value),
@@ -2304,9 +2327,12 @@ function codegenInstructionValue(
* u0080 to u009F: C1 control codes
* u00A0 to uFFFF: All non-basic Latin characters
* https://en.wikipedia.org/wiki/List_of_Unicode_characters#Control_codes
*
* u010000 to u10FFFF: Astral plane characters
* https://mathiasbynens.be/notes/javascript-unicode
*/
const STRING_REQUIRES_EXPR_CONTAINER_PATTERN =
/[\u{0000}-\u{001F}\u{007F}\u{0080}-\u{FFFF}]|"|\\/u;
/[\u{0000}-\u{001F}\u{007F}\u{0080}-\u{FFFF}\u{010000}-\u{10FFFF}]|"|\\/u;
function codegenJsxAttribute(
cx: Context,
attribute: JsxAttribute,
@@ -2556,7 +2582,16 @@ function codegenValue(
value: boolean | number | string | null | undefined,
): t.Expression {
if (typeof value === 'number') {
return t.numericLiteral(value);
if (value < 0) {
/**
* Babel's code generator produces invalid JS for negative numbers when
* run with { compact: true }.
* See repro https://codesandbox.io/p/devbox/5d47fr
*/
return t.unaryExpression('-', t.numericLiteral(-value), false);
} else {
return t.numericLiteral(value);
}
} else if (typeof value === 'boolean') {
return t.booleanLiteral(value);
} else if (typeof value === 'string') {

View File

@@ -456,6 +456,7 @@ function canMergeScopes(
new Set(
[...current.scope.declarations.values()].map(declaration => ({
identifier: declaration.identifier,
reactive: true,
path: [],
})),
),

View File

@@ -255,6 +255,12 @@ function writeReactiveValue(writer: Writer, value: ReactiveValue): void {
}
}
export function printReactiveTerminal(terminal: ReactiveTerminal): string {
const writer = new Writer();
writeTerminal(writer, terminal);
return writer.complete();
}
function writeTerminal(writer: Writer, terminal: ReactiveTerminal): void {
switch (terminal.kind) {
case 'break': {

View File

@@ -7,12 +7,17 @@
import {CompilerError} from '..';
import {
DeclarationId,
convertHoistedLValueKind,
IdentifierId,
InstructionId,
InstructionKind,
Place,
ReactiveFunction,
ReactiveInstruction,
ReactiveScopeBlock,
ReactiveStatement,
} from '../HIR';
import {empty, Stack} from '../Utils/Stack';
import {
ReactiveFunctionTransform,
Transformed,
@@ -22,138 +27,144 @@ import {
/*
* Prunes DeclareContexts lowered for HoistedConsts, and transforms any references back to its
* original instruction kind.
*
* Also detects and bails out on context variables which are:
* - function declarations, which are hoisted by JS engines to the nearest block scope
* - referenced before they are defined (i.e. having a `DeclareContext HoistedConst`)
* - declared
*
* This is because React Compiler converts a `function foo()` function declaration to
* 1. a `let foo;` declaration before reactive memo blocks
* 2. a `foo = function foo() {}` assignment within the block
*
* This means references before the assignment are invalid (see fixture
* error.todo-functiondecl-hoisting)
*/
export function pruneHoistedContexts(fn: ReactiveFunction): void {
const hoistedIdentifiers: HoistedIdentifiers = new Map();
visitReactiveFunction(fn, new Visitor(), hoistedIdentifiers);
visitReactiveFunction(fn, new Visitor(), {
activeScopes: empty(),
uninitialized: new Map(),
});
}
const REWRITTEN_HOISTED_CONST: unique symbol = Symbol(
'REWRITTEN_HOISTED_CONST',
);
const REWRITTEN_HOISTED_LET: unique symbol = Symbol('REWRITTEN_HOISTED_LET');
type VisitorState = {
activeScopes: Stack<Set<IdentifierId>>;
uninitialized: Map<
IdentifierId,
| {
kind: 'unknown-kind';
}
| {
kind: 'func';
definition: Place | null;
}
>;
};
type HoistedIdentifiers = Map<
DeclarationId,
| InstructionKind
| typeof REWRITTEN_HOISTED_CONST
| typeof REWRITTEN_HOISTED_LET
>;
class Visitor extends ReactiveFunctionTransform<HoistedIdentifiers> {
class Visitor extends ReactiveFunctionTransform<VisitorState> {
override visitScope(scope: ReactiveScopeBlock, state: VisitorState): void {
state.activeScopes = state.activeScopes.push(
new Set(scope.scope.declarations.keys()),
);
/**
* Add declared but not initialized / assigned variables. This may include
* function declarations that escape the memo block.
*/
for (const decl of scope.scope.declarations.values()) {
state.uninitialized.set(decl.identifier.id, {kind: 'unknown-kind'});
}
this.traverseScope(scope, state);
state.activeScopes.pop();
for (const decl of scope.scope.declarations.values()) {
state.uninitialized.delete(decl.identifier.id);
}
}
override visitPlace(
_id: InstructionId,
place: Place,
state: VisitorState,
): void {
const maybeHoistedFn = state.uninitialized.get(place.identifier.id);
if (
maybeHoistedFn?.kind === 'func' &&
maybeHoistedFn.definition !== place
) {
CompilerError.throwTodo({
reason: '[PruneHoistedContexts] Rewrite hoisted function references',
loc: place.loc,
});
}
}
override transformInstruction(
instruction: ReactiveInstruction,
state: HoistedIdentifiers,
state: VisitorState,
): Transformed<ReactiveStatement> {
this.visitInstruction(instruction, state);
/**
* Remove hoisted declarations to preserve TDZ
*/
if (
instruction.value.kind === 'DeclareContext' &&
instruction.value.lvalue.kind === 'HoistedConst'
) {
state.set(
instruction.value.lvalue.place.identifier.declarationId,
InstructionKind.Const,
if (instruction.value.kind === 'DeclareContext') {
const maybeNonHoisted = convertHoistedLValueKind(
instruction.value.lvalue.kind,
);
return {kind: 'remove'};
}
if (
instruction.value.kind === 'DeclareContext' &&
instruction.value.lvalue.kind === 'HoistedLet'
) {
state.set(
instruction.value.lvalue.place.identifier.declarationId,
InstructionKind.Let,
);
return {kind: 'remove'};
}
if (
instruction.value.kind === 'DeclareContext' &&
instruction.value.lvalue.kind === 'HoistedFunction'
) {
state.set(
instruction.value.lvalue.place.identifier.declarationId,
InstructionKind.Function,
);
return {kind: 'remove'};
}
if (instruction.value.kind === 'StoreContext') {
const kind = state.get(
instruction.value.lvalue.place.identifier.declarationId,
);
if (kind != null) {
CompilerError.invariant(kind !== REWRITTEN_HOISTED_CONST, {
reason: 'Expected exactly one store to a hoisted const variable',
loc: instruction.loc,
});
if (maybeNonHoisted != null) {
if (
kind === InstructionKind.Const ||
kind === InstructionKind.Function
maybeNonHoisted === InstructionKind.Function &&
state.uninitialized.has(instruction.value.lvalue.place.identifier.id)
) {
state.set(
instruction.value.lvalue.place.identifier.declarationId,
REWRITTEN_HOISTED_CONST,
);
return {
kind: 'replace',
value: {
kind: 'instruction',
instruction: {
...instruction,
value: {
...instruction.value,
lvalue: {
...instruction.value.lvalue,
kind,
},
type: null,
kind: 'StoreLocal',
},
},
state.uninitialized.set(
instruction.value.lvalue.place.identifier.id,
{
kind: 'func',
definition: null,
},
};
} else if (kind !== REWRITTEN_HOISTED_LET) {
/**
* Context variables declared with let may have reassignments. Only
* insert a `DeclareContext` for the first encountered `StoreContext`
* instruction.
*/
state.set(
instruction.value.lvalue.place.identifier.declarationId,
REWRITTEN_HOISTED_LET,
);
return {
kind: 'replace-many',
value: [
{
kind: 'instruction',
instruction: {
id: instruction.id,
lvalue: null,
value: {
kind: 'DeclareContext',
lvalue: {
kind: InstructionKind.Let,
place: {...instruction.value.lvalue.place},
},
loc: instruction.value.loc,
},
loc: instruction.loc,
},
},
{kind: 'instruction', instruction},
],
};
}
return {kind: 'remove'};
}
}
if (
instruction.value.kind === 'StoreContext' &&
instruction.value.lvalue.kind !== InstructionKind.Reassign
) {
/**
* Rewrite StoreContexts let/const that will be pre-declared in
* codegen to reassignments.
*/
const lvalueId = instruction.value.lvalue.place.identifier.id;
const isDeclaredByScope = state.activeScopes.find(scope =>
scope.has(lvalueId),
);
if (isDeclaredByScope) {
if (
instruction.value.lvalue.kind === InstructionKind.Let ||
instruction.value.lvalue.kind === InstructionKind.Const
) {
instruction.value.lvalue.kind = InstructionKind.Reassign;
} else if (instruction.value.lvalue.kind === InstructionKind.Function) {
const maybeHoistedFn = state.uninitialized.get(lvalueId);
if (maybeHoistedFn != null) {
CompilerError.invariant(maybeHoistedFn.kind === 'func', {
reason: '[PruneHoistedContexts] Unexpected hoisted function',
loc: instruction.loc,
});
maybeHoistedFn.definition = instruction.value.lvalue.place;
/**
* References to hoisted functions are now "safe" as variable assignments
* have finished.
*/
state.uninitialized.delete(lvalueId);
}
} else {
CompilerError.throwTodo({
reason: '[PruneHoistedContexts] Unexpected kind',
description: `(${instruction.value.lvalue.kind})`,
loc: instruction.loc,
});
}
}
}
this.visitInstruction(instruction, state);
return {kind: 'keep'};
}
}

View File

@@ -1,3 +1,10 @@
/**
* 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 {
BlockId,
ReactiveFunction,

View File

@@ -34,7 +34,11 @@ import {
} from '../HIR';
import {createTemporaryPlace, markInstructionIds} from '../HIR/HIRBuilder';
import {getOrInsertWith} from '../Utils/utils';
import {BuiltInFireId, DefaultNonmutatingHook} from '../HIR/ObjectShape';
import {
BuiltInFireFunctionId,
BuiltInFireId,
DefaultNonmutatingHook,
} from '../HIR/ObjectShape';
import {eachInstructionOperand} from '../HIR/visitors';
import {printSourceLocationLine} from '../HIR/PrintHIR';
import {USE_FIRE_FUNCTION_NAME} from '../HIR/Environment';
@@ -432,6 +436,7 @@ function makeLoadUseFireInstruction(
value: instrValue,
lvalue: {...useFirePlace},
loc: GeneratedSource,
effects: null,
};
}
@@ -456,6 +461,7 @@ function makeLoadFireCalleeInstruction(
},
lvalue: {...loadedFireCallee},
loc: GeneratedSource,
effects: null,
};
}
@@ -479,6 +485,7 @@ function makeCallUseFireInstruction(
value: useFireCall,
lvalue: {...useFireCallResultPlace},
loc: GeneratedSource,
effects: null,
};
}
@@ -507,6 +514,7 @@ function makeStoreUseFireInstruction(
},
lvalue: fireFunctionBindingLValuePlace,
loc: GeneratedSource,
effects: null,
};
}
@@ -633,6 +641,13 @@ class Context {
() => createTemporaryPlace(this.#env, GeneratedSource),
);
fireFunctionBinding.identifier.type = {
kind: 'Function',
shapeId: BuiltInFireFunctionId,
return: {kind: 'Poly'},
isConstructor: false,
};
this.#capturedCalleeIdentifierIds.set(callee.identifier.id, {
fireFunctionBinding,
capturedCalleeIdentifier: callee.identifier,

View File

@@ -4,4 +4,5 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
export {transformFire} from './TransformFire';

View File

@@ -0,0 +1,210 @@
/**
* 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 {fromZodError} from 'zod-validation-error';
import {CompilerError} from '../CompilerError';
import {
CompilationMode,
defaultOptions,
parsePluginOptions,
PluginOptions,
} from '../Entrypoint';
import {EnvironmentConfig} from '..';
import {
EnvironmentConfigSchema,
PartialEnvironmentConfig,
} from '../HIR/Environment';
import {Err, Ok, Result} from './Result';
import {hasOwnProperty} from './utils';
function tryParseTestPragmaValue(val: string): Result<unknown, unknown> {
try {
let parsedVal: unknown;
const stringMatch = /^"([^"]*)"$/.exec(val);
if (stringMatch && stringMatch.length > 1) {
parsedVal = stringMatch[1];
} else {
parsedVal = JSON.parse(val);
}
return Ok(parsedVal);
} catch (e) {
return Err(e);
}
}
const testComplexConfigDefaults: PartialEnvironmentConfig = {
validateNoCapitalizedCalls: [],
enableChangeDetectionForDebugging: {
source: 'react-compiler-runtime',
importSpecifierName: '$structuralCheck',
},
enableEmitFreeze: {
source: 'react-compiler-runtime',
importSpecifierName: 'makeReadOnly',
},
enableEmitInstrumentForget: {
fn: {
source: 'react-compiler-runtime',
importSpecifierName: 'useRenderCounter',
},
gating: {
source: 'react-compiler-runtime',
importSpecifierName: 'shouldInstrument',
},
globalGating: 'DEV',
},
enableEmitHookGuards: {
source: 'react-compiler-runtime',
importSpecifierName: '$dispatcherGuard',
},
inlineJsxTransform: {
elementSymbol: 'react.transitional.element',
globalDevVar: 'DEV',
},
lowerContextAccess: {
source: 'react-compiler-runtime',
importSpecifierName: 'useContext_withSelector',
},
inferEffectDependencies: [
{
function: {
source: 'react',
importSpecifierName: 'useEffect',
},
numRequiredArgs: 1,
},
{
function: {
source: 'shared-runtime',
importSpecifierName: 'useSpecialEffect',
},
numRequiredArgs: 2,
},
{
function: {
source: 'useEffectWrapper',
importSpecifierName: 'default',
},
numRequiredArgs: 1,
},
],
};
function* splitPragma(
pragma: string,
): Generator<{key: string; value: string | null}> {
for (const entry of pragma.split('@')) {
const keyVal = entry.trim();
const valIdx = keyVal.indexOf(':');
if (valIdx === -1) {
yield {key: keyVal.split(' ', 1)[0], value: null};
} else {
yield {key: keyVal.slice(0, valIdx), value: keyVal.slice(valIdx + 1)};
}
}
}
/**
* For snap test fixtures and playground only.
*/
function parseConfigPragmaEnvironmentForTest(
pragma: string,
): EnvironmentConfig {
const maybeConfig: Partial<Record<keyof EnvironmentConfig, unknown>> = {};
for (const {key, value: val} of splitPragma(pragma)) {
if (!hasOwnProperty(EnvironmentConfigSchema.shape, key)) {
continue;
}
const isSet = val == null || val === 'true';
if (isSet && key in testComplexConfigDefaults) {
maybeConfig[key] = testComplexConfigDefaults[key];
} else if (isSet) {
maybeConfig[key] = true;
} else if (val === 'false') {
maybeConfig[key] = false;
} else if (val) {
const parsedVal = tryParseTestPragmaValue(val).unwrap();
if (key === 'customMacros' && typeof parsedVal === 'string') {
const valSplit = parsedVal.split('.');
const props = [];
for (const elt of valSplit.slice(1)) {
if (elt === '*') {
props.push({type: 'wildcard'});
} else if (elt.length > 0) {
props.push({type: 'name', name: elt});
}
}
maybeConfig[key] = [[valSplit[0], props]];
continue;
}
maybeConfig[key] = parsedVal;
}
}
const config = EnvironmentConfigSchema.safeParse(maybeConfig);
if (config.success) {
/**
* Unless explicitly enabled, do not insert HMR handling code
* in test fixtures or playground to reduce visual noise.
*/
if (config.data.enableResetCacheOnSourceFileChanges == null) {
config.data.enableResetCacheOnSourceFileChanges = false;
}
return config.data;
}
CompilerError.invariant(false, {
reason: 'Internal error, could not parse config from pragma string',
description: `${fromZodError(config.error)}`,
loc: null,
suggestions: null,
});
}
const testComplexPluginOptionDefaults: Partial<PluginOptions> = {
gating: {
source: 'ReactForgetFeatureFlag',
importSpecifierName: 'isForgetEnabled_Fixtures',
},
};
export function parseConfigPragmaForTests(
pragma: string,
defaults: {
compilationMode: CompilationMode;
},
): PluginOptions {
const environment = parseConfigPragmaEnvironmentForTest(pragma);
const options: Record<keyof PluginOptions, unknown> = {
...defaultOptions,
panicThreshold: 'all_errors',
compilationMode: defaults.compilationMode,
environment,
};
for (const {key, value: val} of splitPragma(pragma)) {
if (!hasOwnProperty(defaultOptions, key)) {
continue;
}
const isSet = val == null || val === 'true';
if (isSet && key in testComplexPluginOptionDefaults) {
options[key] = testComplexPluginOptionDefaults[key];
} else if (isSet) {
options[key] = true;
} else if (val === 'false') {
options[key] = false;
} else if (val != null) {
const parsedVal = tryParseTestPragmaValue(val).unwrap();
if (key === 'target' && parsedVal === 'donotuse_meta_internal') {
options[key] = {
kind: parsedVal,
runtimeModule: 'react',
};
} else {
options[key] = parsedVal;
}
}
}
return parsePluginOptions(options);
}

View File

@@ -121,6 +121,21 @@ export function Set_intersect<T>(sets: Array<ReadonlySet<T>>): Set<T> {
return result;
}
/**
* @returns `true` if `a` is a superset of `b`.
*/
export function Set_isSuperset<T>(
a: ReadonlySet<T>,
b: ReadonlySet<T>,
): boolean {
for (const v of b) {
if (!a.has(v)) {
return false;
}
}
return true;
}
export function Iterable_some<T>(
iter: Iterable<T>,
pred: (item: T) => boolean,

View File

@@ -452,7 +452,7 @@ function visitFunctionExpression(errors: CompilerError, fn: HIRFunction): void {
reason:
'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)',
loc: callee.loc,
description: `Cannot call ${hookKind} within a function component`,
description: `Cannot call ${hookKind === 'Custom' ? 'hook' : hookKind} within a function expression`,
suggestions: null,
}),
);

View File

@@ -4,6 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {CompilerError, EnvironmentConfig, ErrorSeverity} from '..';
import {HIRFunction, IdentifierId} from '../HIR';
import {DEFAULT_GLOBALS} from '../HIR/Globals';

View File

@@ -0,0 +1,178 @@
/**
* 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 {CompilerError, Effect, ErrorSeverity} from '..';
import {
FunctionEffect,
HIRFunction,
IdentifierId,
isMutableEffect,
isRefOrRefLikeMutableType,
Place,
} from '../HIR';
import {
eachInstructionValueOperand,
eachTerminalOperand,
} from '../HIR/visitors';
import {Result} from '../Utils/Result';
import {Iterable_some} from '../Utils/utils';
/**
* Validates that functions with known mutations (ie due to types) cannot be passed
* where a frozen value is expected. Example:
*
* ```
* function Component() {
* const cache = new Map();
* const onClick = () => {
* cache.set(...);
* }
* useHook(onClick); // ERROR: cannot pass a mutable value
* return <Foo onClick={onClick} /> // ERROR: cannot pass a mutable value
* }
* ```
*
* Because `onClick` function mutates `cache` when called, `onClick` is equivalent to a mutable
* variables. But unlike other mutables values like an array, the receiver of the function has
* no way to avoid mutation — for example, a function can receive an array and choose not to mutate
* it, but there's no way to know that a function is mutable and avoid calling it.
*
* This pass detects functions with *known* mutations (Store or Mutate, not ConditionallyMutate)
* that are passed where a frozen value is expected and rejects them.
*/
export function validateNoFreezingKnownMutableFunctions(
fn: HIRFunction,
): Result<void, CompilerError> {
const errors = new CompilerError();
const contextMutationEffects: Map<
IdentifierId,
Extract<FunctionEffect, {kind: 'ContextMutation'}>
> = new Map();
function visitOperand(operand: Place): void {
if (operand.effect === Effect.Freeze) {
const effect = contextMutationEffects.get(operand.identifier.id);
if (effect != null) {
errors.push({
reason: `This argument is a function which may reassign or mutate local variables after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead`,
loc: operand.loc,
severity: ErrorSeverity.InvalidReact,
});
errors.push({
reason: `The function modifies a local variable here`,
loc: effect.loc,
severity: ErrorSeverity.InvalidReact,
});
}
}
}
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
const {lvalue, value} = instr;
switch (value.kind) {
case 'LoadLocal': {
const effect = contextMutationEffects.get(value.place.identifier.id);
if (effect != null) {
contextMutationEffects.set(lvalue.identifier.id, effect);
}
break;
}
case 'StoreLocal': {
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': {
const knownMutation = (value.loweredFunc.func.effects ?? []).find(
effect => {
return (
effect.kind === 'ContextMutation' &&
(effect.effect === Effect.Store ||
effect.effect === Effect.Mutate) &&
Iterable_some(effect.places, place => {
return (
isMutableEffect(place.effect, place.loc) &&
!isRefOrRefLikeMutableType(place.identifier.type)
);
})
);
},
);
if (knownMutation && knownMutation.kind === 'ContextMutation') {
contextMutationEffects.set(lvalue.identifier.id, knownMutation);
} else if (
fn.env.config.enableNewMutationAliasingModel &&
value.loweredFunc.func.aliasingEffects != null
) {
const context = new Set(
value.loweredFunc.func.context.map(p => p.identifier.id),
);
effects: for (const effect of value.loweredFunc.func
.aliasingEffects) {
switch (effect.kind) {
case 'Mutate':
case 'MutateTransitive': {
const knownMutation = contextMutationEffects.get(
effect.value.identifier.id,
);
if (knownMutation != null) {
contextMutationEffects.set(
lvalue.identifier.id,
knownMutation,
);
} else if (
context.has(effect.value.identifier.id) &&
!isRefOrRefLikeMutableType(effect.value.identifier.type)
) {
contextMutationEffects.set(lvalue.identifier.id, {
kind: 'ContextMutation',
effect: Effect.Mutate,
loc: effect.value.loc,
places: new Set([effect.value]),
});
break effects;
}
break;
}
case 'MutateConditionally':
case 'MutateTransitiveConditionally': {
const knownMutation = contextMutationEffects.get(
effect.value.identifier.id,
);
if (knownMutation != null) {
contextMutationEffects.set(
lvalue.identifier.id,
knownMutation,
);
}
break;
}
}
}
}
break;
}
default: {
for (const operand of eachInstructionValueOperand(value)) {
visitOperand(operand);
}
}
}
}
for (const operand of eachTerminalOperand(block.terminal)) {
visitOperand(operand);
}
}
return errors.asResult();
}

View File

@@ -141,14 +141,14 @@ function getCompareDependencyResultDescription(
): string {
switch (result) {
case CompareDependencyResult.Ok:
return 'dependencies equal';
return 'Dependencies equal';
case CompareDependencyResult.RootDifference:
case CompareDependencyResult.PathDifference:
return 'inferred different dependency than source';
return 'Inferred different dependency than source';
case CompareDependencyResult.RefAccessDifference:
return 'differences in ref.current access';
return 'Differences in ref.current access';
case CompareDependencyResult.Subpath:
return 'inferred less specific property than source';
return 'Inferred less specific property than source';
}
}
@@ -279,17 +279,20 @@ function validateInferredDep(
severity: ErrorSeverity.CannotPreserveMemoization,
reason:
'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',
description: DEBUG
? `The inferred dependency was \`${prettyPrintScopeDependency(
dep,
)}\`, but the source dependencies were [${validDepsInMemoBlock
.map(dep => printManualMemoDependency(dep, true))
.join(', ')}]. Detail: ${
errorDiagnostic
? getCompareDependencyResultDescription(errorDiagnostic)
: 'none'
}`
: null,
description:
DEBUG ||
// If the dependency is a named variable then we can report it. Otherwise only print in debug mode
(dep.identifier.name != null && dep.identifier.name.kind === 'named')
? `The inferred dependency was \`${prettyPrintScopeDependency(
dep,
)}\`, but the source dependencies were [${validDepsInMemoBlock
.map(dep => printManualMemoDependency(dep, true))
.join(', ')}]. ${
errorDiagnostic
? getCompareDependencyResultDescription(errorDiagnostic)
: 'Inferred dependency not present in source'
}`
: null,
loc: memoLocation,
suggestions: null,
});

View File

@@ -9,7 +9,7 @@ export {validateContextVariableLValues} from './ValidateContextVariableLValues';
export {validateHooksUsage} from './ValidateHooksUsage';
export {validateMemoizedEffectDependencies} from './ValidateMemoizedEffectDependencies';
export {validateNoCapitalizedCalls} from './ValidateNoCapitalizedCalls';
export {validateNoRefAccessInRender} from './ValidateNoRefAccesInRender';
export {validateNoRefAccessInRender} from './ValidateNoRefAccessInRender';
export {validateNoSetStateInRender} from './ValidateNoSetStateInRender';
export {validatePreservedManualMemoization} from './ValidatePreservedManualMemoization';
export {validateUseMemo} from './ValidateUseMemo';

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @compilationMode(infer)
// @compilationMode:"infer"
const Test = () => <div />;
export const FIXTURE_ENTRYPOINT = {
@@ -15,7 +15,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @compilationMode(infer)
import { c as _c } from "react/compiler-runtime"; // @compilationMode:"infer"
const Test = () => {
const $ = _c(1);
let t0;

View File

@@ -1,4 +1,4 @@
// @compilationMode(infer)
// @compilationMode:"infer"
const Test = () => <div />;
export const FIXTURE_ENTRYPOINT = {

View File

@@ -0,0 +1,56 @@
## Input
```javascript
import {Stringify} from 'shared-runtime';
function Repro(props) {
const MY_CONST = -2;
return <Stringify>{props.arg - MY_CONST}</Stringify>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Repro,
params: [
{
arg: 3,
},
],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { Stringify } from "shared-runtime";
function Repro(props) {
const $ = _c(2);
const t0 = props.arg - -2;
let t1;
if ($[0] !== t0) {
t1 = <Stringify>{t0}</Stringify>;
$[0] = t0;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Repro,
params: [
{
arg: 3,
},
],
};
```
### Eval output
(kind: ok) <div>{"children":5}</div>

View File

@@ -0,0 +1,15 @@
import {Stringify} from 'shared-runtime';
function Repro(props) {
const MY_CONST = -2;
return <Stringify>{props.arg - MY_CONST}</Stringify>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Repro,
params: [
{
arg: 3,
},
],
};

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @flow @enableTransitivelyFreezeFunctionExpressions:false
// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false
import {arrayPush, setPropertyByKey, Stringify} from 'shared-runtime';
/**

View File

@@ -1,4 +1,4 @@
// @flow @enableTransitivelyFreezeFunctionExpressions:false
// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false
import {arrayPush, setPropertyByKey, Stringify} from 'shared-runtime';
/**

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @flow @enableTransitivelyFreezeFunctionExpressions:false
// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false
import {setPropertyByKey, Stringify} from 'shared-runtime';
/**

View File

@@ -1,4 +1,4 @@
// @flow @enableTransitivelyFreezeFunctionExpressions:false
// @flow @enableTransitivelyFreezeFunctionExpressions:false @enableNewMutationAliasingModel:false
import {setPropertyByKey, Stringify} from 'shared-runtime';
/**

View File

@@ -2,6 +2,7 @@
## Input
```javascript
// @enableNewMutationAliasingModel:false
import {makeArray, mutate} from 'shared-runtime';
/**
@@ -56,7 +57,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { c as _c } from "react/compiler-runtime"; // @enableNewMutationAliasingModel:false
import { makeArray, mutate } from "shared-runtime";
/**

View File

@@ -1,3 +1,4 @@
// @enableNewMutationAliasingModel:false
import {makeArray, mutate} from 'shared-runtime';
/**

View File

@@ -1,79 +0,0 @@
## Input
```javascript
import {Stringify} from 'shared-runtime';
/**
* Fixture currently fails with
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok) <div>{"result":{"value":2},"fn":{"kind":"Function","result":{"value":2}},"shouldInvokeFns":true}</div>
* Forget:
* (kind: exception) bar is not a function
*/
function Foo({value}) {
const result = bar();
function bar() {
return {value};
}
return <Stringify result={result} fn={bar} shouldInvokeFns={true} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [{value: 2}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { Stringify } from "shared-runtime";
/**
* Fixture currently fails with
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok) <div>{"result":{"value":2},"fn":{"kind":"Function","result":{"value":2}},"shouldInvokeFns":true}</div>
* Forget:
* (kind: exception) bar is not a function
*/
function Foo(t0) {
const $ = _c(6);
const { value } = t0;
let bar;
let result;
if ($[0] !== value) {
result = bar();
bar = function bar() {
return { value };
};
$[0] = value;
$[1] = bar;
$[2] = result;
} else {
bar = $[1];
result = $[2];
}
let t1;
if ($[3] !== bar || $[4] !== result) {
t1 = <Stringify result={result} fn={bar} shouldInvokeFns={true} />;
$[3] = bar;
$[4] = result;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [{ value: 2 }],
};
```

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