Compare commits

..

11 Commits

Author SHA1 Message Date
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
59 changed files with 923 additions and 329 deletions

View File

@@ -17,6 +17,17 @@ on:
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.'
@@ -61,10 +72,36 @@ 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

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

@@ -22,6 +22,7 @@ jobs:
release_channel: stable
dist_tag: canary,next
enableFailureNotification: true
dry: false
secrets:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -43,6 +44,7 @@ jobs:
release_channel: experimental
dist_tag: experimental
enableFailureNotification: true
dry: false
secrets:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

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);
}
}
}
}

View File

@@ -0,0 +1,132 @@
## Input
```javascript
import {useRef, useEffect} from 'react';
/**
* The postfix increment operator should return the value before incrementing.
* ```js
* const id = count.current; // 0
* count.current = count.current + 1; // 1
* return id;
* ```
* The bug is that we currently increment the value before the expression is evaluated.
* This bug does not trigger when the incremented value is a plain primitive.
*
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"}
* logs: ['id = 0','count = 1']
* Forget:
* (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"}
* logs: ['id = 1','count = 1']
*/
function useFoo() {
const count = useRef(0);
const updateCountPostfix = () => {
const id = count.current++;
return id;
};
const updateCountPrefix = () => {
const id = ++count.current;
return id;
};
useEffect(() => {
const id = updateCountPostfix();
console.log(`id = ${id}`);
console.log(`count = ${count.current}`);
}, []);
return {count, updateCountPostfix, updateCountPrefix};
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { useRef, useEffect } from "react";
/**
* The postfix increment operator should return the value before incrementing.
* ```js
* const id = count.current; // 0
* count.current = count.current + 1; // 1
* return id;
* ```
* The bug is that we currently increment the value before the expression is evaluated.
* This bug does not trigger when the incremented value is a plain primitive.
*
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"}
* logs: ['id = 0','count = 1']
* Forget:
* (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"}
* logs: ['id = 1','count = 1']
*/
function useFoo() {
const $ = _c(5);
const count = useRef(0);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
count.current = count.current + 1;
const id = count.current;
return id;
};
$[0] = t0;
} else {
t0 = $[0];
}
const updateCountPostfix = t0;
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = () => {
const id_0 = (count.current = count.current + 1);
return id_0;
};
$[1] = t1;
} else {
t1 = $[1];
}
const updateCountPrefix = t1;
let t2;
let t3;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = () => {
const id_1 = updateCountPostfix();
console.log(`id = ${id_1}`);
console.log(`count = ${count.current}`);
};
t3 = [];
$[2] = t2;
$[3] = t3;
} else {
t2 = $[2];
t3 = $[3];
}
useEffect(t2, t3);
let t4;
if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
t4 = { count, updateCountPostfix, updateCountPrefix };
$[4] = t4;
} else {
t4 = $[4];
}
return t4;
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```

View File

@@ -0,0 +1,42 @@
import {useRef, useEffect} from 'react';
/**
* The postfix increment operator should return the value before incrementing.
* ```js
* const id = count.current; // 0
* count.current = count.current + 1; // 1
* return id;
* ```
* The bug is that we currently increment the value before the expression is evaluated.
* This bug does not trigger when the incremented value is a plain primitive.
*
* Found differences in evaluator results
* Non-forget (expected):
* (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"}
* logs: ['id = 0','count = 1']
* Forget:
* (kind: ok) {"count":{"current":0},"updateCountPostfix":"[[ function params=0 ]]","updateCountPrefix":"[[ function params=0 ]]"}
* logs: ['id = 1','count = 1']
*/
function useFoo() {
const count = useRef(0);
const updateCountPostfix = () => {
const id = count.current++;
return id;
};
const updateCountPrefix = () => {
const id = ++count.current;
return id;
};
useEffect(() => {
const id = updateCountPostfix();
console.log(`id = ${id}`);
console.log(`count = ${count.current}`);
}, []);
return {count, updateCountPostfix, updateCountPrefix};
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};

View File

@@ -1,32 +0,0 @@
## Input
```javascript
function Component(props) {
return (
useMemo(() => {
return [props.value];
}) || []
);
}
```
## Error
```
1 | function Component(props) {
2 | return (
> 3 | useMemo(() => {
| ^^^^^^^^^^^^^^^
> 4 | return [props.value];
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 5 | }) || []
| ^^^^^^^^^^^^^ Todo: Support labeled statements combined with value blocks (conditional, logical, optional chaining, etc) (3:5)
6 | );
7 | }
8 |
```

View File

@@ -1,7 +0,0 @@
function Component(props) {
return (
useMemo(() => {
return [props.value];
}) || []
);
}

View File

@@ -0,0 +1,40 @@
## Input
```javascript
function Component(props) {
const x = props.foo
? 1
: (() => {
throw new Error('Did not receive 1');
})();
return items;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{foo: true}],
};
```
## Code
```javascript
function Component(props) {
props.foo ? 1 : _temp();
return items;
}
function _temp() {
throw new Error("Did not receive 1");
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ foo: true }],
};
```
### Eval output
(kind: exception) items is not defined

View File

@@ -0,0 +1,13 @@
function Component(props) {
const x = props.foo
? 1
: (() => {
throw new Error('Did not receive 1');
})();
return items;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{foo: true}],
};

View File

@@ -0,0 +1,47 @@
## Input
```javascript
import {useMemo} from 'react';
function Component(props) {
return (
useMemo(() => {
return [props.value];
}) || []
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 1}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { useMemo } from "react";
function Component(props) {
const $ = _c(2);
let t0;
if ($[0] !== props.value) {
t0 = (() => [props.value])() || [];
$[0] = props.value;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ value: 1 }],
};
```
### Eval output
(kind: ok) [1]

View File

@@ -0,0 +1,13 @@
import {useMemo} from 'react';
function Component(props) {
return (
useMemo(() => {
return [props.value];
}) || []
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 1}],
};

View File

@@ -460,6 +460,7 @@ const skipFilter = new Set([
'fbt/bug-fbt-plural-multiple-function-calls',
'fbt/bug-fbt-plural-multiple-mixed-call-tag',
'bug-invalid-phi-as-dependency',
'bug-ref-prefix-postfix-operator',
// 'react-compiler-runtime' not yet supported
'flag-enable-emit-hook-guards',

View File

@@ -4,6 +4,7 @@ import React, {
useEffect,
useState,
unstable_addTransitionType as addTransitionType,
use,
} from 'react';
import Chrome from './Chrome';

View File

@@ -0,0 +1,36 @@
import React, {Suspense, use} from 'react';
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function Use({useable}) {
use(useable);
return null;
}
let delay1;
let delay2;
export default function NestedReveal({}) {
if (!delay1) {
delay1 = sleep(100);
// Needs to happen before the throttled reveal of delay 1
delay2 = sleep(200);
}
return (
<div className="swipe-recognizer">
Shell
<Suspense fallback="Loading level 1">
<div>Level 1</div>
<Use useable={delay1} />
<Suspense fallback="Loading level 2">
<div>Level 2</div>
<Use useable={delay2} />
</Suspense>
</Suspense>
</div>
);
}

View File

@@ -18,6 +18,7 @@ import SwipeRecognizer from './SwipeRecognizer';
import './Page.css';
import transitions from './Transitions.module.css';
import NestedReveal from './NestedReveal';
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
@@ -241,6 +242,7 @@ export default function Page({url, navigate}) {
</div>
</ViewTransition>
</SwipeRecognizer>
<NestedReveal />
</div>
);
}

View File

@@ -21,7 +21,6 @@ const rules = {
const configRules = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/react-compiler': 'error',
} satisfies Linter.RulesRecord;
// Flat config

View File

@@ -41,7 +41,8 @@ import {useExtensionComponentsPanelVisibility} from 'react-devtools-shared/src/f
import {useChangeOwnerAction} from './OwnersListContext';
// Never indent more than this number of pixels (even if we have the room).
const DEFAULT_INDENTATION_SIZE = 12;
const MAX_INDENTATION_SIZE = 12;
const MIN_INDENTATION_SIZE = 4;
export type ItemData = {
isNavigatingWithKeyboard: boolean,
@@ -490,11 +491,11 @@ function updateIndentationSizeVar(
// Reset the max indentation size if the width of the tree has increased.
if (listWidth > prevListWidthRef.current) {
indentationSizeRef.current = DEFAULT_INDENTATION_SIZE;
indentationSizeRef.current = MAX_INDENTATION_SIZE;
}
prevListWidthRef.current = listWidth;
let maxIndentationSize: number = indentationSizeRef.current;
let indentationSize: number = indentationSizeRef.current;
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const child of innerDiv.children) {
@@ -517,12 +518,13 @@ function updateIndentationSizeVar(
const remainingWidth = Math.max(0, listWidth - childWidth);
maxIndentationSize = Math.min(maxIndentationSize, remainingWidth / depth);
indentationSize = Math.min(indentationSize, remainingWidth / depth);
}
indentationSizeRef.current = maxIndentationSize;
indentationSize = Math.max(indentationSize, MIN_INDENTATION_SIZE);
indentationSizeRef.current = indentationSize;
list.style.setProperty('--indentation-size', `${maxIndentationSize}px`);
list.style.setProperty('--indentation-size', `${indentationSize}px`);
}
// $FlowFixMe[missing-local-annot]
@@ -545,7 +547,7 @@ function InnerElementType({children, style}) {
// The user may have resized the window specifically to make more room for DevTools.
// In either case, this should reset our max indentation size logic.
// 2. The second is when the user enters or exits an owner tree.
const indentationSizeRef = useRef<number>(DEFAULT_INDENTATION_SIZE);
const indentationSizeRef = useRef<number>(MAX_INDENTATION_SIZE);
const prevListWidthRef = useRef<number>(0);
const prevOwnerIDRef = useRef<number | null>(ownerID);
const divRef = useRef<HTMLDivElement | null>(null);
@@ -554,7 +556,7 @@ function InnerElementType({children, style}) {
// so when the user opens the "owners tree" view, we should discard the previous width.
if (ownerID !== prevOwnerIDRef.current) {
prevOwnerIDRef.current = ownerID;
indentationSizeRef.current = DEFAULT_INDENTATION_SIZE;
indentationSizeRef.current = MAX_INDENTATION_SIZE;
}
// When we render new content, measure to see if we need to shrink indentation to fit it.

View File

@@ -1,74 +1,213 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<title>React DevTools</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
#target {
flex: 1;
border: none;
}
#devtools {
height: 400px;
max-height: 50%;
overflow: hidden;
z-index: 10000001;
}
body {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,
sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
font-size: 12px;
line-height: 1.5;
}
.optionsRow {
width: 100%;
display: flex;
padding: 0.25rem;
background: aliceblue;
border-bottom: 1px solid lightblue;
box-sizing: border-box;
}
.optionsRowSpacer {
flex: 1;
}
</style>
</head>
<body>
<div class="optionsRow">
<button id="mountButton">Unmount test app</button>
<div class="optionsRowSpacer">&nbsp;</div>
<span>
<a href="/multi.html">multi DevTools</a>
|
<a href="/e2e.html">e2e tests</a>
|
<a href="/e2e-regression.html">e2e regression tests</a>
|
<a href="/perf-regression.html">perf regression tests</a>
</span>
</div>
<head>
<meta charset="utf8">
<title>React DevTools</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
#panes {
display: grid;
height: 100%;
width: 100%;
position: relative;
}
#divider {
position: absolute;
z-index: 10000002;
background-color: #ccc;
transition: background-color 0.2s;
}
#divider:hover,
#divider.dragging {
background-color: #aaa;
}
#divider.horizontal-divider {
width: 100%;
height: 5px;
cursor: row-resize;
}
#divider.vertical-divider {
width: 5px;
height: 100%;
cursor: col-resize;
}
#target {
height: 100%;
width: 100%;
border: none;
}
#devtools {
height: 100%;
width: 100%;
overflow: hidden;
z-index: 10000001;
}
body {
display: flex;
height: 100vh;
width: 100vw;
contain: strict;
flex-direction: column;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,
sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
font-size: 12px;
line-height: 1.5;
}
.optionsRow {
width: 100%;
display: flex;
padding: 0.25rem;
background: aliceblue;
border-bottom: 1px solid lightblue;
box-sizing: border-box;
}
.optionsRowSpacer {
flex: 1;
}
</style>
</head>
<body>
<div class="optionsRow">
<button id="mountButton">Unmount test app</button>
<div class="optionsRowSpacer">&nbsp;</div>
<span>
<a href="/multi.html">multi DevTools</a>
|
<a href="/e2e.html">e2e tests</a>
|
<a href="/e2e-regression.html">e2e regression tests</a>
|
<a href="/perf-regression.html">perf regression tests</a>
</span>
<label style="margin-left: 4px">
Layout:
<select id="layout">
<option value="leftright">Left/Right Split</option>
<option value="topbottom">Top/Bottom Split</option>
</select></label>
</div>
<div id="panes">
<!-- React test app (shells/dev/app) is injected here -->
<!-- DevTools backend (shells/dev/src) is injected here -->
<!-- global "hook" is defined on the iframe's contentWindow -->
<iframe id="target"></iframe>
<!-- Draggable divider between panes -->
<div id="divider"></div>
<!-- DevTools frontend UI (shells/dev/src) renders here -->
<div id="devtools"></div>
</div>
<!-- This script installs the hook, injects the backend, and renders the DevTools UI -->
<!-- In DEV mode, this file is served by the Webpack dev server -->
<!-- For production builds, it's built by Webpack and uploaded from the local file system -->
<script src="dist/app-devtools.js"></script>
</body>
</html>
<!-- This script installs the hook, injects the backend, and renders the DevTools UI -->
<!-- In DEV mode, this file is served by the Webpack dev server -->
<!-- For production builds, it's built by Webpack and uploaded from the local file system -->
<script src="dist/app-devtools.js"></script>
<script type="module">
let layoutType = 'leftright';
let splitRatio = 0.5;
let isDragging = false;
// handle layout changes
const layout = document.getElementById('layout');
function setLayout(layoutType, splitRatio) {
const panes = document.getElementById('panes');
if (layoutType === 'topbottom') {
panes.style.gridTemplateColumns = '100%'; // Full width for each row
panes.style.gridTemplateRows = `${splitRatio * 100}% ${(1 - splitRatio) * 100}%`;
} else if (layoutType === 'leftright') {
panes.style.gridTemplateRows = '100%'; // Full height for each column
panes.style.gridTemplateColumns = `${splitRatio * 100}% ${(1 - splitRatio) * 100}%`;
}
}
layout.addEventListener('change', () => {
layoutType = layout.value;
setLayout(layoutType, splitRatio);
updateDividerPosition(); // Ensure divider updates when layout changes
});
// handle changing the split ratio
const divider = document.getElementById('divider');
function updateDividerPosition() {
if (layoutType === 'topbottom') {
// For top/bottom layout, divider should be horizontal (spanning across)
divider.className = 'horizontal-divider';
divider.style.top = `calc(${splitRatio * 100}% - 2.5px)`;
divider.style.left = '0';
} else {
// For left/right layout, divider should be vertical (spanning down)
divider.className = 'vertical-divider';
divider.style.left = `calc(${splitRatio * 100}% - 2.5px)`;
divider.style.top = '0';
}
}
// Add event listeners for dragging
divider.addEventListener('mousedown', (e) => {
isDragging = true;
divider.classList.add('dragging');
// Disable pointer events on the iframe to prevent it from capturing mouse events
const iframe = document.getElementById('target');
iframe.style.pointerEvents = 'none';
e.preventDefault(); // Prevent text selection during drag
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const panes = document.getElementById('panes');
const rect = panes.getBoundingClientRect();
if (layoutType === 'topbottom') {
// Calculate new split ratio based on vertical position
const newRatio = Math.max(0.1, Math.min(0.9, (e.clientY - rect.top) / rect.height));
splitRatio = newRatio;
} else {
// Calculate new split ratio based on horizontal position
const newRatio = Math.max(0.1, Math.min(0.9, (e.clientX - rect.left) / rect.width));
splitRatio = newRatio;
}
// Update layout and divider position
setLayout(layoutType, splitRatio);
updateDividerPosition();
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
divider.classList.remove('dragging');
// Re-enable pointer events on the iframe
const iframe = document.getElementById('target');
iframe.style.pointerEvents = 'auto';
}
});
// Initialize
setLayout(
layoutType,
splitRatio,
);
updateDividerPosition();
</script>
</body>
</html>

View File

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

View File

@@ -34,6 +34,10 @@ export function revealCompletedBoundaries(batch) {
for (let i = 0; i < batch.length; i += 2) {
const suspenseIdNode = batch[i];
const contentNode = batch[i + 1];
// We can detach the content now.
// Completions of boundaries within this contentNode will now find the boundary
// in its designated place.
contentNode.parentNode.removeChild(contentNode);
// Clear all the existing children. This is complicated because
// there can be embedded Suspense boundaries in the fallback.
@@ -385,13 +389,16 @@ export function completeBoundary(suspenseBoundaryID, contentID) {
// the segment. Regardless we can ignore this case.
return;
}
// We'll detach the content node so that regardless of what happens next we don't leave in the tree.
// This might also help by not causing recalcing each time we move a child from here to the target.
contentNodeOuter.parentNode.removeChild(contentNodeOuter);
// Find the fallback's first element.
const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID);
if (!suspenseIdNodeOuter) {
// We'll never reveal this boundary so we can remove its content immediately.
// Otherwise we'll leave it in until we reveal it.
// This is important in case this specific boundary contains other boundaries
// that may get completed before we reveal this one.
contentNodeOuter.parentNode.removeChild(contentNodeOuter);
// The user must have already navigated away from this tree.
// E.g. because the parent was hydrated. That's fine there's nothing to do
// but we have to make sure that we already deleted the container node.

View File

@@ -70,6 +70,7 @@ type Options = {
environmentName?: string | (() => string),
filterStackFrame?: (url: string, functionName: string) => boolean,
identifierPrefix?: string,
signal?: AbortSignal,
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
};
@@ -87,6 +88,18 @@ function render(model: ReactClientValue, options?: Options): Destination {
__DEV__ && options ? options.environmentName : undefined,
__DEV__ && options ? options.filterStackFrame : undefined,
);
const signal = options ? options.signal : undefined;
if (signal) {
if (signal.aborted) {
ReactNoopFlightServer.abort(request, (signal: any).reason);
} else {
const listener = () => {
ReactNoopFlightServer.abort(request, (signal: any).reason);
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
}
}
ReactNoopFlightServer.startWork(request);
ReactNoopFlightServer.startFlowing(request, destination);
return destination;

View File

@@ -40,7 +40,6 @@ import {
enableScopeAPI,
enableLegacyHidden,
enableTransitionTracing,
enableDO_NOT_USE_disableStrictPassiveEffect,
disableLegacyMode,
enableObjectFiber,
enableViewTransition,
@@ -92,7 +91,6 @@ import {
ProfileMode,
StrictLegacyMode,
StrictEffectsMode,
NoStrictPassiveEffectsMode,
SuspenseyImagesMode,
} from './ReactTypeOfMode';
import {
@@ -599,12 +597,6 @@ export function createFiberFromTypeAndProps(
if (disableLegacyMode || (mode & ConcurrentMode) !== NoMode) {
// Strict effects should never run on legacy roots
mode |= StrictEffectsMode;
if (
enableDO_NOT_USE_disableStrictPassiveEffect &&
pendingProps.DO_NOT_USE_disableStrictPassiveEffect
) {
mode |= NoStrictPassiveEffectsMode;
}
}
break;
case REACT_PROFILER_TYPE:

View File

@@ -25,8 +25,14 @@ function getCacheForType<T>(resourceType: () => T): T {
return cacheForType;
}
function cacheSignal(): null | AbortSignal {
const cache: Cache = readContext(CacheContext);
return cache.controller.signal;
}
export const DefaultAsyncDispatcher: AsyncDispatcher = ({
getCacheForType,
cacheSignal,
}: any);
if (__DEV__) {

View File

@@ -55,7 +55,6 @@ import {
ConcurrentMode,
StrictEffectsMode,
StrictLegacyMode,
NoStrictPassiveEffectsMode,
} from './ReactTypeOfMode';
import {
NoLane,
@@ -2672,8 +2671,7 @@ function mountEffect(
): void {
if (
__DEV__ &&
(currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode &&
(currentlyRenderingFiber.mode & NoStrictPassiveEffectsMode) === NoMode
(currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode
) {
mountEffectImpl(
MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect,

View File

@@ -123,7 +123,6 @@ import {
ConcurrentMode,
StrictLegacyMode,
StrictEffectsMode,
NoStrictPassiveEffectsMode,
} from './ReactTypeOfMode';
import {
HostRoot,
@@ -4607,21 +4606,13 @@ function recursivelyTraverseAndDoubleInvokeEffectsInDEV(
}
// Unconditionally disconnects and connects passive and layout effects.
function doubleInvokeEffectsOnFiber(
root: FiberRoot,
fiber: Fiber,
shouldDoubleInvokePassiveEffects: boolean = true,
) {
function doubleInvokeEffectsOnFiber(root: FiberRoot, fiber: Fiber) {
setIsStrictModeForDevtools(true);
try {
disappearLayoutEffects(fiber);
if (shouldDoubleInvokePassiveEffects) {
disconnectPassiveEffect(fiber);
}
disconnectPassiveEffect(fiber);
reappearLayoutEffects(root, fiber.alternate, fiber, false);
if (shouldDoubleInvokePassiveEffects) {
reconnectPassiveEffects(root, fiber, NoLanes, null, false, 0);
}
reconnectPassiveEffects(root, fiber, NoLanes, null, false, 0);
} finally {
setIsStrictModeForDevtools(false);
}
@@ -4640,13 +4631,7 @@ function doubleInvokeEffectsInDEVIfNecessary(
if (fiber.tag !== OffscreenComponent) {
if (fiber.flags & PlacementDEV) {
if (isInStrictMode) {
runWithFiberInDEV(
fiber,
doubleInvokeEffectsOnFiber,
root,
fiber,
(fiber.mode & NoStrictPassiveEffectsMode) === NoMode,
);
runWithFiberInDEV(fiber, doubleInvokeEffectsOnFiber, root, fiber);
}
} else {
recursivelyTraverseAndDoubleInvokeEffectsInDEV(

View File

@@ -459,6 +459,7 @@ export type Dispatcher = {
export type AsyncDispatcher = {
getCacheForType: <T>(resourceType: () => T) => T,
cacheSignal: () => null | AbortSignal,
// DEV-only
getOwner: () => null | Fiber | ReactComponentInfo | ComponentStackNode,
};

View File

@@ -16,7 +16,6 @@ export const ProfileMode = /* */ 0b0000010;
//export const DebugTracingMode = /* */ 0b0000100; // Removed
export const StrictLegacyMode = /* */ 0b0001000;
export const StrictEffectsMode = /* */ 0b0010000;
export const NoStrictPassiveEffectsMode = /* */ 0b1000000;
// Keep track of if we're in a SuspenseyImages eligible subtree.
// TODO: Remove this when enableSuspenseyImages ship where it's always on.
export const SuspenseyImagesMode = /* */ 0b0100000;

View File

@@ -55,28 +55,6 @@ describe('Activity StrictMode', () => {
]);
});
// @gate __DEV__ && enableActivity && enableDO_NOT_USE_disableStrictPassiveEffect
it('does not trigger strict effects when disableStrictPassiveEffect is presented on StrictMode', async () => {
await act(() => {
ReactNoop.render(
<React.StrictMode DO_NOT_USE_disableStrictPassiveEffect={true}>
<Activity>
<Component label="A" />
</Activity>
</React.StrictMode>,
);
});
expect(log).toEqual([
'A: render',
'A: render',
'A: useLayoutEffect mount',
'A: useEffect mount',
'A: useLayoutEffect unmount',
'A: useLayoutEffect mount',
]);
});
// @gate __DEV__ && enableActivity
it('should not trigger strict effects when offscreen is hidden', async () => {
await act(() => {

View File

@@ -14,6 +14,7 @@ let React;
let ReactNoopFlightServer;
let ReactNoopFlightClient;
let cache;
let cacheSignal;
describe('ReactCache', () => {
beforeEach(() => {
@@ -25,6 +26,7 @@ describe('ReactCache', () => {
ReactNoopFlightClient = require('react-noop-renderer/flight-client');
cache = React.cache;
cacheSignal = React.cacheSignal;
jest.resetModules();
__unmockReact();
@@ -220,4 +222,86 @@ describe('ReactCache', () => {
expect(cachedFoo.length).toBe(0);
expect(cachedFoo.displayName).toBe(undefined);
});
it('cacheSignal() returns null outside a render', async () => {
expect(cacheSignal()).toBe(null);
});
it('cacheSignal() aborts when the render finishes normally', async () => {
let renderedCacheSignal = null;
let resolve;
const promise = new Promise(r => (resolve = r));
async function Test() {
renderedCacheSignal = cacheSignal();
await promise;
return 'Hi';
}
const controller = new AbortController();
const errors = [];
const result = ReactNoopFlightServer.render(<Test />, {
signal: controller.signal,
onError(x) {
errors.push(x);
},
});
expect(errors).toEqual([]);
expect(renderedCacheSignal).not.toBe(controller.signal); // In the future we might make these the same
expect(renderedCacheSignal.aborted).toBe(false);
await resolve();
await 0;
await 0;
expect(await ReactNoopFlightClient.read(result)).toBe('Hi');
expect(errors).toEqual([]);
expect(renderedCacheSignal.aborted).toBe(true);
expect(renderedCacheSignal.reason.message).toContain(
'This render completed successfully.',
);
});
it('cacheSignal() aborts when the render is aborted', async () => {
let renderedCacheSignal = null;
const promise = new Promise(() => {});
async function Test() {
renderedCacheSignal = cacheSignal();
await promise;
return 'Hi';
}
const controller = new AbortController();
const errors = [];
const result = ReactNoopFlightServer.render(<Test />, {
signal: controller.signal,
onError(x) {
errors.push(x);
return 'hi';
},
});
expect(errors).toEqual([]);
expect(renderedCacheSignal).not.toBe(controller.signal); // In the future we might make these the same
expect(renderedCacheSignal.aborted).toBe(false);
const reason = new Error('Timed out');
controller.abort(reason);
expect(errors).toEqual([reason]);
expect(renderedCacheSignal.aborted).toBe(true);
expect(renderedCacheSignal.reason).toBe(reason);
let clientError = null;
try {
await ReactNoopFlightClient.read(result);
} catch (x) {
clientError = x;
}
expect(clientError).not.toBe(null);
if (__DEV__) {
expect(clientError.message).toBe('Timed out');
}
expect(clientError.digest).toBe('hi');
});
});

View File

@@ -16,8 +16,13 @@ function getCacheForType<T>(resourceType: () => T): T {
throw new Error('Not implemented.');
}
function cacheSignal(): null | AbortSignal {
throw new Error('Not implemented.');
}
export const DefaultAsyncDispatcher: AsyncDispatcher = ({
getCacheForType,
cacheSignal,
}: any);
if (__DEV__) {

View File

@@ -419,6 +419,7 @@ export type Request = {
destination: null | Destination,
bundlerConfig: ClientManifest,
cache: Map<Function, mixed>,
cacheController: AbortController,
nextChunkId: number,
pendingChunks: number,
hints: Hints,
@@ -529,6 +530,7 @@ function RequestInstance(
this.destination = null;
this.bundlerConfig = bundlerConfig;
this.cache = new Map();
this.cacheController = new AbortController();
this.nextChunkId = 0;
this.pendingChunks = 0;
this.hints = hints;
@@ -604,7 +606,7 @@ export function createRequest(
model: ReactClientValue,
bundlerConfig: ClientManifest,
onError: void | ((error: mixed) => ?string),
identifierPrefix?: string,
identifierPrefix: void | string,
onPostpone: void | ((reason: string) => void),
temporaryReferences: void | TemporaryReferenceSet,
environmentName: void | string | (() => string), // DEV-only
@@ -636,7 +638,7 @@ export function createPrerenderRequest(
onAllReady: () => void,
onFatalError: () => void,
onError: void | ((error: mixed) => ?string),
identifierPrefix?: string,
identifierPrefix: void | string,
onPostpone: void | ((reason: string) => void),
temporaryReferences: void | TemporaryReferenceSet,
environmentName: void | string | (() => string), // DEV-only
@@ -3369,6 +3371,13 @@ function fatalError(request: Request, error: mixed): void {
request.status = CLOSING;
request.fatalError = error;
}
const abortReason = new Error(
'The render was aborted due to a fatal error.',
{
cause: error,
},
);
request.cacheController.abort(abortReason);
}
function emitPostponeChunk(
@@ -4840,6 +4849,12 @@ function flushCompletedChunks(
if (enableTaint) {
cleanupTaintQueue(request);
}
if (request.status < ABORTING) {
const abortReason = new Error(
'This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.',
);
request.cacheController.abort(abortReason);
}
request.status = CLOSED;
close(destination);
request.destination = null;
@@ -4921,6 +4936,7 @@ export function abort(request: Request, reason: mixed): void {
// We define any status below OPEN as OPEN equivalent
if (request.status <= OPEN) {
request.status = ABORTING;
request.cacheController.abort(reason);
}
const abortableTasks = request.abortableTasks;
if (abortableTasks.size > 0) {

View File

@@ -31,6 +31,13 @@ export const DefaultAsyncDispatcher: AsyncDispatcher = ({
}
return entry;
},
cacheSignal(): null | AbortSignal {
const request = resolveRequest();
if (request) {
return request.cacheController.signal;
}
return null;
},
}: any);
if (__DEV__) {

View File

@@ -22,6 +22,9 @@ export function waitForSuspense<T>(fn: () => T): Promise<T> {
}
return entry;
},
cacheSignal(): null {
return null;
},
getOwner(): null {
return null;
},

View File

@@ -44,6 +44,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
unstable_LegacyHidden,
unstable_Activity,

View File

@@ -27,6 +27,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
unstable_Activity,
unstable_postpone,

View File

@@ -27,6 +27,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
unstable_Activity,
unstable_postpone,

View File

@@ -14,6 +14,7 @@ export {
__COMPILER_RUNTIME,
act,
cache,
cacheSignal,
Children,
cloneElement,
Component,

View File

@@ -44,6 +44,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
unstable_LegacyHidden,
unstable_Activity,

View File

@@ -27,6 +27,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
unstable_useCacheRefresh,
startTransition,
useId,

View File

@@ -27,6 +27,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
unstable_useCacheRefresh,
startTransition,
useId,

View File

@@ -8,9 +8,12 @@
*/
import {disableClientCache} from 'shared/ReactFeatureFlags';
import {cache as cacheImpl} from './ReactCacheImpl';
import {
cache as cacheImpl,
cacheSignal as cacheSignalImpl,
} from './ReactCacheImpl';
export function noopCache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
function noopCache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
// On the client (i.e. not a Server Components environment) `cache` has
// no caching behavior. We just return the function as-is.
//
@@ -32,3 +35,11 @@ export function noopCache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
export const cache: typeof noopCache = disableClientCache
? noopCache
: cacheImpl;
function noopCacheSignal(): null | AbortSignal {
return null;
}
export const cacheSignal: () => null | AbortSignal = disableClientCache
? noopCacheSignal
: cacheSignalImpl;

View File

@@ -126,3 +126,15 @@ export function cache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
}
};
}
export function cacheSignal(): null | AbortSignal {
const dispatcher = ReactSharedInternals.A;
if (!dispatcher) {
// If there is no dispatcher, then we treat this as not having an AbortSignal
// since in the same context, a cached function will be allowed to be called
// but it won't be cached. So it's neither an infinite AbortSignal nor an
// already resolved one.
return null;
}
return dispatcher.cacheSignal();
}

View File

@@ -7,4 +7,4 @@
* @flow
*/
export {cache} from './ReactCacheImpl';
export {cache, cacheSignal} from './ReactCacheImpl';

View File

@@ -33,7 +33,7 @@ import {createContext} from './ReactContext';
import {lazy} from './ReactLazy';
import {forwardRef} from './ReactForwardRef';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheClient';
import {cache, cacheSignal} from './ReactCacheClient';
import {postpone} from './ReactPostpone';
import {
getCacheForType,
@@ -83,6 +83,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
postpone as unstable_postpone,
useCallback,
useContext,

View File

@@ -35,7 +35,7 @@ import {
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheServer';
import {cache, cacheSignal} from './ReactCacheServer';
import {startTransition} from './ReactStartTransition';
import {postpone} from './ReactPostpone';
import {captureOwnerStack} from './ReactOwnerStack';
@@ -70,6 +70,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
getCacheForType as unstable_getCacheForType,
postpone as unstable_postpone,

View File

@@ -36,7 +36,7 @@ import {
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheServer';
import {cache, cacheSignal} from './ReactCacheServer';
import {startTransition} from './ReactStartTransition';
import {postpone} from './ReactPostpone';
import version from 'shared/ReactVersion';
@@ -70,6 +70,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
startTransition,
getCacheForType as unstable_getCacheForType,
postpone as unstable_postpone,

View File

@@ -27,7 +27,7 @@ import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks';
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheServer';
import {cache, cacheSignal} from './ReactCacheServer';
import version from 'shared/ReactVersion';
const Children = {
@@ -58,6 +58,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
useId,
useCallback,
useDebugValue,

View File

@@ -26,7 +26,7 @@ import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks';
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
import {cache} from './ReactCacheServer';
import {cache, cacheSignal} from './ReactCacheServer';
import version from 'shared/ReactVersion';
import {captureOwnerStack} from './ReactOwnerStack';
@@ -53,6 +53,7 @@ export {
lazy,
memo,
cache,
cacheSignal,
useId,
useCallback,
useDebugValue,

View File

@@ -104,52 +104,6 @@ describe('ReactStrictMode', () => {
]);
});
// @gate enableDO_NOT_USE_disableStrictPassiveEffect
it('should include legacy + strict effects mode, but not strict passive effect with disableStrictPassiveEffect', async () => {
await act(() => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
root.render(
<React.StrictMode DO_NOT_USE_disableStrictPassiveEffect={true}>
<Component label="A" />
</React.StrictMode>,
);
});
expect(log).toEqual([
'A: render',
'A: render',
'A: useLayoutEffect mount',
'A: useEffect mount',
'A: useLayoutEffect unmount',
'A: useLayoutEffect mount',
]);
});
// @gate enableDO_NOT_USE_disableStrictPassiveEffect
it('should include legacy + strict effects mode, but not strict passive effect with disableStrictPassiveEffect in Suspense', async () => {
await act(() => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
root.render(
<React.StrictMode DO_NOT_USE_disableStrictPassiveEffect={true}>
<React.Suspense>
<Component label="A" />
</React.Suspense>
</React.StrictMode>,
);
});
expect(log).toEqual([
'A: render',
'A: render',
'A: useLayoutEffect mount',
'A: useEffect mount',
'A: useLayoutEffect unmount',
'A: useLayoutEffect mount',
]);
});
it('should allow level to be increased with nesting', async () => {
await act(() => {
const container = document.createElement('div');

View File

@@ -260,7 +260,4 @@ export const enableAsyncDebugInfo = __EXPERIMENTAL__;
// Track which Fiber(s) schedule render work.
export const enableUpdaterTracking = __PROFILE__;
// Internal only.
export const enableDO_NOT_USE_disableStrictPassiveEffect = false;
export const ownerStackLimit = 1e4;

View File

@@ -44,7 +44,6 @@ export const enableAsyncDebugInfo = false;
export const enableAsyncIterableChildren = false;
export const enableCPUSuspense = true;
export const enableCreateEventHandleAPI = false;
export const enableDO_NOT_USE_disableStrictPassiveEffect = false;
export const enableMoveBefore = true;
export const enableFizzExternalRuntime = true;
export const enableHalt = false;

View File

@@ -29,7 +29,6 @@ export const enableAsyncDebugInfo = false;
export const enableAsyncIterableChildren = false;
export const enableCPUSuspense = false;
export const enableCreateEventHandleAPI = false;
export const enableDO_NOT_USE_disableStrictPassiveEffect = false;
export const enableMoveBefore = true;
export const enableFizzExternalRuntime = true;
export const enableHalt = false;

View File

@@ -49,7 +49,6 @@ export const enableLegacyHidden = false;
export const enableTransitionTracing = false;
export const enableDO_NOT_USE_disableStrictPassiveEffect = false;
export const enableFizzExternalRuntime = true;
export const alwaysThrottleRetries = true;

View File

@@ -24,7 +24,6 @@ export const enableAsyncDebugInfo = false;
export const enableAsyncIterableChildren = false;
export const enableCPUSuspense = true;
export const enableCreateEventHandleAPI = false;
export const enableDO_NOT_USE_disableStrictPassiveEffect = false;
export const enableMoveBefore = false;
export const enableFizzExternalRuntime = true;
export const enableHalt = false;

View File

@@ -50,7 +50,6 @@ export const enableLegacyHidden = false;
export const enableTransitionTracing = false;
export const enableDO_NOT_USE_disableStrictPassiveEffect = false;
export const enableFizzExternalRuntime = false;
export const alwaysThrottleRetries = true;

View File

@@ -17,7 +17,6 @@ export const alwaysThrottleRetries = __VARIANT__;
export const disableDefaultPropsExceptForClasses = __VARIANT__;
export const disableLegacyContextForFunctionComponents = __VARIANT__;
export const disableSchedulerTimeoutInWorkLoop = __VARIANT__;
export const enableDO_NOT_USE_disableStrictPassiveEffect = __VARIANT__;
export const enableHiddenSubtreeInsertionEffectCleanup = __VARIANT__;
export const enableNoCloningMemoCache = __VARIANT__;
export const enableObjectFiber = __VARIANT__;

View File

@@ -19,7 +19,6 @@ export const {
disableDefaultPropsExceptForClasses,
disableLegacyContextForFunctionComponents,
disableSchedulerTimeoutInWorkLoop,
enableDO_NOT_USE_disableStrictPassiveEffect,
enableHiddenSubtreeInsertionEffectCleanup,
enableInfiniteRenderLoopDetection,
enableNoCloningMemoCache,

View File

@@ -546,5 +546,7 @@
"558": "Client rendering an Activity suspended it again. This is a bug in React.",
"559": "Expected to find a host node. This is a bug in React.",
"560": "Cannot use a startGestureTransition() with a comment node root.",
"561": "This rendered a large document (>%s kB) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a <Suspense> or <SuspenseList> around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML."
"561": "This rendered a large document (>%s kB) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a <Suspense> or <SuspenseList> around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML.",
"562": "The render was aborted due to a fatal error.",
"563": "This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources."
}