Compare commits

..

13 Commits

Author SHA1 Message Date
Joe Savona
f49ce79b26 [compiler] Improve snap usability
A whole bunch of changes to snap aimed at making it more usable for humans and agents. Here's the new CLI interface:

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

Key changes:
* Added abbreviations for common arguments
* No more testfilter.txt! Filtering/debugging works more like Jest, see below.
* The `--debug` flag (`-d`) controls whether to emit debug information. In watch mode, this flag sets the initial debug value, and it can be toggled by pressing the 'd' key while watching.
* The `--pattern` flag (`-p`) sets a filter pattern. In watch mode, this flag sets the initial filter. It can be changed by pressing 'p' and typing a new pattern, or pressing 'a' to switch to running all tests.
* As before, we only actually enable debugging if debug mode is enabled _and_ there is only one test selected.
2026-01-22 14:34:50 -08:00
Joe Savona
9eb8b04a87 [compiler] Claude file/settings
Initializes CLAUDE.md and a settings file for the compiler/ directory to help use claude with the compiler. Note that some of the commands here depend on changes to snap from the next PR.
2026-01-22 14:34:47 -08:00
Sebastian "Sebbie" Silbermann
5aec1b2a8d [DevTools] Attach async info in filtered fallback to parent of Suspense (#35456) 2026-01-10 11:33:48 +01:00
lauren
d6cae440e3 [ci] Add size-balanced test sequencer for better shard distribution (#35458)
Jest's default test sequencer sorts alphabetically, causing large test
files
(eg ReactDOMFloat-test.js at 9k lines,
ReactHooksWithNoopRenderer-test.js at 4k
lines) to cluster in shard 3/5. This made shard 3/5 average 117s vs 77s
for
other shards, a 52% slowdown. I'm using filesize as a rough proxy for
number of tests.

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

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

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

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

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

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

## How did you test this change?

- Ran the existing test suite for the server renderer:

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

---------

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

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

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

-->

## Summary

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

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


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

Browser extension react devtools


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

React native dev tools:


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

---------

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
2025-12-15 13:41:43 +00:00
Sebastian "Sebbie" Silbermann
ba5b843692 [test] Exclude repository root from assertions (#35361) 2025-12-15 11:45:17 +01:00
53 changed files with 1139 additions and 367 deletions

View File

@@ -635,6 +635,7 @@ module.exports = {
FocusOptions: 'readonly',
OptionalEffectTiming: 'readonly',
__REACT_ROOT_PATH_TEST__: 'readonly',
spyOnDev: 'readonly',
spyOnDevAndProd: 'readonly',
spyOnProd: 'readonly',

View File

@@ -382,9 +382,6 @@ jobs:
-r=experimental --env=development,
-r=experimental --env=production,
# Dev Tools
--project=devtools -r=experimental,
# TODO: Update test config to support www build tests
# - "-r=www-classic --env=development --variant=false"
# - "-r=www-classic --env=production --variant=false"
@@ -450,6 +447,50 @@ jobs:
run: ls -R build
- run: yarn test --build ${{ matrix.test_params }} --shard=${{ matrix.shard }} --ci
test_build_devtools:
name: yarn test-build (devtools)
needs: [build_and_lint, runtime_node_modules_cache]
strategy:
fail-fast: false
matrix:
shard:
- 1/5
- 2/5
- 3/5
- 4/5
- 5/5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
cache-dependency-path: yarn.lock
- name: Restore cached node_modules
uses: actions/cache/restore@v4
id: node_modules
with:
path: |
**/node_modules
key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
# Don't use restore-keys here. Otherwise the cache grows indefinitely.
- name: Ensure clean build directory
run: rm -rf build
- run: yarn install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
- name: Restore archived build
uses: actions/download-artifact@v4
with:
pattern: _build_*
path: build
merge-multiple: true
- name: Display structure of build
run: ls -R build
- run: yarn test --build --project=devtools -r=experimental --shard=${{ matrix.shard }} --ci
process_artifacts_combined:
name: Process artifacts combined
needs: [build_and_lint, runtime_node_modules_cache]

View File

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

2
compiler/.gitignore vendored
View File

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

221
compiler/CLAUDE.md Normal file
View File

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

View File

@@ -225,8 +225,15 @@ export const EnvironmentConfigSchema = z.object({
/**
* Validate that dependencies supplied to effect hooks are exhaustive.
* Can be:
* - 'off': No validation (default)
* - 'all': Validate and report both missing and extra dependencies
* - 'missing-only': Only report missing dependencies
* - 'extra-only': Only report extra/unnecessary dependencies
*/
validateExhaustiveEffectDependencies: z.boolean().default(false),
validateExhaustiveEffectDependencies: z
.enum(['off', 'all', 'missing-only', 'extra-only'])
.default('off'),
/**
* When this is true, rather than pruning existing manual memoization but ensuring or validating

View File

@@ -141,6 +141,7 @@ export function validateExhaustiveDependencies(
reactive,
startMemo.depsLoc,
ErrorCategory.MemoDependencies,
'all',
);
if (diagnostic != null) {
error.pushDiagnostic(diagnostic);
@@ -159,7 +160,7 @@ export function validateExhaustiveDependencies(
onStartMemoize,
onFinishMemoize,
onEffect: (inferred, manual, manualMemoLoc) => {
if (env.config.validateExhaustiveEffectDependencies === false) {
if (env.config.validateExhaustiveEffectDependencies === 'off') {
return;
}
if (DEBUG) {
@@ -195,12 +196,17 @@ export function validateExhaustiveDependencies(
});
}
}
const effectReportMode =
typeof env.config.validateExhaustiveEffectDependencies === 'string'
? env.config.validateExhaustiveEffectDependencies
: 'all';
const diagnostic = validateDependencies(
Array.from(inferred),
manualDeps,
reactive,
manualMemoLoc,
ErrorCategory.EffectExhaustiveDependencies,
effectReportMode,
);
if (diagnostic != null) {
error.pushDiagnostic(diagnostic);
@@ -220,6 +226,7 @@ function validateDependencies(
category:
| ErrorCategory.MemoDependencies
| ErrorCategory.EffectExhaustiveDependencies,
exhaustiveDepsReportMode: 'all' | 'missing-only' | 'extra-only',
): CompilerDiagnostic | null {
// Sort dependencies by name and path, with shorter/non-optional paths first
inferred.sort((a, b) => {
@@ -370,9 +377,20 @@ function validateDependencies(
extra.push(dep);
}
if (missing.length !== 0 || extra.length !== 0) {
// Filter based on report mode
const filteredMissing =
exhaustiveDepsReportMode === 'extra-only' ? [] : missing;
const filteredExtra =
exhaustiveDepsReportMode === 'missing-only' ? [] : extra;
if (filteredMissing.length !== 0 || filteredExtra.length !== 0) {
let suggestion: CompilerSuggestion | null = null;
if (manualMemoLoc != null && typeof manualMemoLoc !== 'symbol') {
if (
manualMemoLoc != null &&
typeof manualMemoLoc !== 'symbol' &&
manualMemoLoc.start.index != null &&
manualMemoLoc.end.index != null
) {
suggestion = {
description: 'Update dependencies',
range: [manualMemoLoc.start.index, manualMemoLoc.end.index],
@@ -388,8 +406,13 @@ function validateDependencies(
.join(', ')}]`,
};
}
const diagnostic = createDiagnostic(category, missing, extra, suggestion);
for (const dep of missing) {
const diagnostic = createDiagnostic(
category,
filteredMissing,
filteredExtra,
suggestion,
);
for (const dep of filteredMissing) {
let reactiveStableValueHint = '';
if (isStableType(dep.identifier)) {
reactiveStableValueHint =
@@ -402,7 +425,7 @@ function validateDependencies(
loc: dep.loc,
});
}
for (const dep of extra) {
for (const dep of filteredExtra) {
if (dep.root.kind === 'Global') {
diagnostic.withDetails({
kind: 'error',

View File

@@ -511,13 +511,6 @@ type TreeNode = {
children: Array<TreeNode>;
};
type DataFlowInfo = {
rootSources: string;
trees: Array<string>;
propsArr: Array<string>;
stateArr: Array<string>;
};
function buildTreeNode(
sourceId: IdentifierId,
context: ValidationContext,
@@ -635,51 +628,6 @@ function renderTree(
return result;
}
/**
* Builds the data flow information including trees and source categorization
*/
function buildDataFlowInfo(
sourceIds: Set<IdentifierId>,
context: ValidationContext,
): DataFlowInfo {
const propsSet = new Set<string>();
const stateSet = new Set<string>();
const rootNodesMap = new Map<string, TreeNode>();
for (const id of sourceIds) {
const nodes = buildTreeNode(id, context);
for (const node of nodes) {
if (!rootNodesMap.has(node.name)) {
rootNodesMap.set(node.name, node);
}
}
}
const rootNodes = Array.from(rootNodesMap.values());
const trees = rootNodes.map((node, index) =>
renderTree(node, '', index === rootNodes.length - 1, propsSet, stateSet),
);
const propsArr = Array.from(propsSet);
const stateArr = Array.from(stateSet);
let rootSources = '';
if (propsArr.length > 0) {
rootSources += `Props: [${propsArr.join(', ')}]`;
}
if (stateArr.length > 0) {
if (rootSources) rootSources += '\n';
rootSources += `State: [${stateArr.join(', ')}]`;
}
return {
rootSources,
trees,
propsArr,
stateArr,
};
}
function getFnLocalDeps(
fn: FunctionExpression | undefined,
): Set<IdentifierId> | undefined {
@@ -844,16 +792,47 @@ function validateEffect(
effectSetStateUsages.get(rootSetStateCall)!.size ===
context.setStateUsages.get(rootSetStateCall)!.size - 1
) {
const propsSet = new Set<string>();
const stateSet = new Set<string>();
const rootNodesMap = new Map<string, TreeNode>();
for (const id of derivedSetStateCall.sourceIds) {
const nodes = buildTreeNode(id, context);
for (const node of nodes) {
if (!rootNodesMap.has(node.name)) {
rootNodesMap.set(node.name, node);
}
}
}
const rootNodes = Array.from(rootNodesMap.values());
const trees = rootNodes.map((node, index) =>
renderTree(
node,
'',
index === rootNodes.length - 1,
propsSet,
stateSet,
),
);
for (const dep of derivedSetStateCall.sourceIds) {
if (cleanUpFunctionDeps !== undefined && cleanUpFunctionDeps.has(dep)) {
return;
}
}
const {rootSources, trees} = buildDataFlowInfo(
derivedSetStateCall.sourceIds,
context,
);
const propsArr = Array.from(propsSet);
const stateArr = Array.from(stateSet);
let rootSources = '';
if (propsArr.length > 0) {
rootSources += `Props: [${propsArr.join(', ')}]`;
}
if (stateArr.length > 0) {
if (rootSources) rootSources += '\n';
rootSources += `State: [${stateArr.join(', ')}]`;
}
const description = `Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
@@ -878,80 +857,6 @@ See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-o
message: 'This should be computed during render, not in an effect',
}),
);
} else if (
rootSetStateCall !== null &&
effectSetStateUsages.has(rootSetStateCall) &&
context.setStateUsages.has(rootSetStateCall) &&
effectSetStateUsages.get(rootSetStateCall)!.size <
context.setStateUsages.get(rootSetStateCall)!.size
) {
for (const dep of derivedSetStateCall.sourceIds) {
if (cleanUpFunctionDeps !== undefined && cleanUpFunctionDeps.has(dep)) {
return;
}
}
const {rootSources, trees} = buildDataFlowInfo(
derivedSetStateCall.sourceIds,
context,
);
// Find setState calls outside the effect
const allSetStateUsages = context.setStateUsages.get(rootSetStateCall)!;
const effectUsages = effectSetStateUsages.get(rootSetStateCall)!;
const outsideEffectUsages: Array<SourceLocation> = [];
for (const usage of allSetStateUsages) {
if (!effectUsages.has(usage)) {
outsideEffectUsages.push(usage);
}
}
const description = `Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
This setState call is setting a derived value that depends on the following reactive sources:
${rootSources}
Data Flow Tree:
${trees.join('\n')}
This state is also being set outside of the effect. Consider hoisting the state up to a parent component and making this a controlled component.
See: https://react.dev/learn/sharing-state-between-components`;
const diagnosticDetails: Array<{
kind: 'error';
loc: SourceLocation;
message: string;
}> = [
{
kind: 'error',
loc: derivedSetStateCall.value.callee.loc,
message: 'setState in effect',
},
];
for (const usage of outsideEffectUsages) {
diagnosticDetails.push({
kind: 'error',
loc: usage,
message: 'setState outside effect',
});
}
let diagnostic = CompilerDiagnostic.create({
description: description,
category: ErrorCategory.EffectDerivationsOfState,
reason:
'Consider hoisting state to parent and making this a controlled component',
});
for (const detail of diagnosticDetails) {
diagnostic = diagnostic.withDetails(detail);
}
context.errors.pushDiagnostic(diagnostic);
}
}
}

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect, useEffectEvent} from 'react';
function Component({x, y, z}) {

View File

@@ -1,4 +1,4 @@
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect, useEffectEvent} from 'react';
function Component({x, y, z}) {

View File

@@ -0,0 +1,83 @@
## Input
```javascript
// @validateExhaustiveEffectDependencies:"extra-only"
import {useEffect} from 'react';
function Component({x, y, z}) {
// no error: missing dep not reported in extra-only mode
useEffect(() => {
log(x);
}, []);
// error: extra dep - y
useEffect(() => {
log(x);
}, [x, y]);
// error: extra dep - y (missing dep - z not reported)
useEffect(() => {
log(x, z);
}, [x, y]);
// error: extra dep - x.y
useEffect(() => {
log(x);
}, [x.y]);
}
```
## Error
```
Found 3 errors:
Error: Found extra effect dependencies
Extra dependencies can cause an effect to fire more often than it should, resulting in performance problems such as excessive renders and side effects.
error.invalid-exhaustive-effect-deps-extra-only.ts:13:9
11 | useEffect(() => {
12 | log(x);
> 13 | }, [x, y]);
| ^ Unnecessary dependency `y`
14 |
15 | // error: extra dep - y (missing dep - z not reported)
16 | useEffect(() => {
Inferred dependencies: `[x]`
Error: Found extra effect dependencies
Extra dependencies can cause an effect to fire more often than it should, resulting in performance problems such as excessive renders and side effects.
error.invalid-exhaustive-effect-deps-extra-only.ts:18:9
16 | useEffect(() => {
17 | log(x, z);
> 18 | }, [x, y]);
| ^ Unnecessary dependency `y`
19 |
20 | // error: extra dep - x.y
21 | useEffect(() => {
Inferred dependencies: `[x, z]`
Error: Found extra effect dependencies
Extra dependencies can cause an effect to fire more often than it should, resulting in performance problems such as excessive renders and side effects.
error.invalid-exhaustive-effect-deps-extra-only.ts:23:6
21 | useEffect(() => {
22 | log(x);
> 23 | }, [x.y]);
| ^^^ Overly precise dependency `x.y`, use `x` instead
24 | }
25 |
Inferred dependencies: `[x]`
```

View File

@@ -0,0 +1,24 @@
// @validateExhaustiveEffectDependencies:"extra-only"
import {useEffect} from 'react';
function Component({x, y, z}) {
// no error: missing dep not reported in extra-only mode
useEffect(() => {
log(x);
}, []);
// error: extra dep - y
useEffect(() => {
log(x);
}, [x, y]);
// error: extra dep - y (missing dep - z not reported)
useEffect(() => {
log(x, z);
}, [x, y]);
// error: extra dep - x.y
useEffect(() => {
log(x);
}, [x.y]);
}

View File

@@ -0,0 +1,84 @@
## Input
```javascript
// @validateExhaustiveEffectDependencies:"missing-only"
import {useEffect} from 'react';
function Component({x, y, z}) {
// error: missing dep - x
useEffect(() => {
log(x);
}, []);
// no error: extra dep not reported in missing-only mode
useEffect(() => {
log(x);
}, [x, y]);
// error: missing dep - z (extra dep - y not reported)
useEffect(() => {
log(x, z);
}, [x, y]);
// error: missing dep x
useEffect(() => {
log(x);
}, [x.y]);
}
```
## Error
```
Found 3 errors:
Error: Found missing effect dependencies
Missing dependencies can cause an effect to fire less often than it should.
error.invalid-exhaustive-effect-deps-missing-only.ts:7:8
5 | // error: missing dep - x
6 | useEffect(() => {
> 7 | log(x);
| ^ Missing dependency `x`
8 | }, []);
9 |
10 | // no error: extra dep not reported in missing-only mode
Inferred dependencies: `[x]`
Error: Found missing effect dependencies
Missing dependencies can cause an effect to fire less often than it should.
error.invalid-exhaustive-effect-deps-missing-only.ts:17:11
15 | // error: missing dep - z (extra dep - y not reported)
16 | useEffect(() => {
> 17 | log(x, z);
| ^ Missing dependency `z`
18 | }, [x, y]);
19 |
20 | // error: missing dep x
Inferred dependencies: `[x, z]`
Error: Found missing effect dependencies
Missing dependencies can cause an effect to fire less often than it should.
error.invalid-exhaustive-effect-deps-missing-only.ts:22:8
20 | // error: missing dep x
21 | useEffect(() => {
> 22 | log(x);
| ^ Missing dependency `x`
23 | }, [x.y]);
24 | }
25 |
Inferred dependencies: `[x]`
```

View File

@@ -0,0 +1,24 @@
// @validateExhaustiveEffectDependencies:"missing-only"
import {useEffect} from 'react';
function Component({x, y, z}) {
// error: missing dep - x
useEffect(() => {
log(x);
}, []);
// no error: extra dep not reported in missing-only mode
useEffect(() => {
log(x);
}, [x, y]);
// error: missing dep - z (extra dep - y not reported)
useEffect(() => {
log(x, z);
}, [x, y]);
// error: missing dep x
useEffect(() => {
log(x);
}, [x.y]);
}

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect} from 'react';
function Component({x, y, z}) {

View File

@@ -1,4 +1,4 @@
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect} from 'react';
function Component({x, y, z}) {

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies:"all"
import {
useCallback,
useTransition,
@@ -69,7 +69,7 @@ export const FIXTURE_ENTRYPOINT = {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies:"all"
import {
useCallback,
useTransition,

View File

@@ -1,4 +1,4 @@
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies:"all"
import {
useCallback,
useTransition,

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect, useEffectEvent} from 'react';
function Component({x, y, z}) {
@@ -30,7 +30,7 @@ function Component({x, y, z}) {
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveEffectDependencies
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveEffectDependencies:"all"
import { useEffect, useEffectEvent } from "react";
function Component(t0) {

View File

@@ -1,4 +1,4 @@
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect, useEffectEvent} from 'react';
function Component({x, y, z}) {

View File

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

View File

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

View File

@@ -8,8 +8,8 @@
import watcher from '@parcel/watcher';
import path from 'path';
import ts from 'typescript';
import {FILTER_FILENAME, FIXTURES_PATH, PROJECT_ROOT} from './constants';
import {TestFilter, readTestFilter} from './fixture-utils';
import {FIXTURES_PATH, PROJECT_ROOT} from './constants';
import {TestFilter} from './fixture-utils';
import {execSync} from 'child_process';
export function watchSrc(
@@ -117,6 +117,10 @@ export type RunnerState = {
lastUpdate: number;
mode: RunnerMode;
filter: TestFilter | null;
debug: boolean;
// Input mode for interactive pattern entry
inputMode: 'none' | 'pattern';
inputBuffer: string;
};
function subscribeFixtures(
@@ -142,26 +146,6 @@ function subscribeFixtures(
});
}
function subscribeFilterFile(
state: RunnerState,
onChange: (state: RunnerState) => void,
) {
watcher.subscribe(PROJECT_ROOT, async (err, events) => {
if (err) {
console.error(err);
process.exit(1);
} else if (
events.findIndex(event => event.path.includes(FILTER_FILENAME)) !== -1
) {
if (state.mode.filter) {
state.filter = await readTestFilter();
state.mode.action = RunnerAction.Test;
onChange(state);
}
}
});
}
function subscribeTsc(
state: RunnerState,
onChange: (state: RunnerState) => void,
@@ -200,15 +184,67 @@ function subscribeKeyEvents(
onChange: (state: RunnerState) => void,
) {
process.stdin.on('keypress', async (str, key) => {
// Handle input mode (pattern entry)
if (state.inputMode !== 'none') {
if (key.name === 'return') {
// Enter pressed - process input
const pattern = state.inputBuffer.trim();
state.inputMode = 'none';
state.inputBuffer = '';
process.stdout.write('\n');
if (pattern !== '') {
// Set the pattern as filter
state.filter = {paths: [pattern]};
state.mode.filter = true;
state.mode.action = RunnerAction.Test;
onChange(state);
}
// If empty, just exit input mode without changes
return;
} else if (key.name === 'escape') {
// Cancel input mode
state.inputMode = 'none';
state.inputBuffer = '';
process.stdout.write(' (cancelled)\n');
return;
} else if (key.name === 'backspace') {
if (state.inputBuffer.length > 0) {
state.inputBuffer = state.inputBuffer.slice(0, -1);
// Erase character: backspace, space, backspace
process.stdout.write('\b \b');
}
return;
} else if (str && !key.ctrl && !key.meta) {
// Regular character - accumulate and echo
state.inputBuffer += str;
process.stdout.write(str);
return;
}
return; // Ignore other keys in input mode
}
// Normal mode keypress handling
if (key.name === 'u') {
// u => update fixtures
state.mode.action = RunnerAction.Update;
} else if (key.name === 'q') {
process.exit(0);
} else if (key.name === 'f') {
state.mode.filter = !state.mode.filter;
state.filter = state.mode.filter ? await readTestFilter() : null;
} else if (key.name === 'a') {
// a => exit filter mode and run all tests
state.mode.filter = false;
state.filter = null;
state.mode.action = RunnerAction.Test;
} else if (key.name === 'd') {
// d => toggle debug logging
state.debug = !state.debug;
state.mode.action = RunnerAction.Test;
} else if (key.name === 'p') {
// p => enter pattern input mode
state.inputMode = 'pattern';
state.inputBuffer = '';
process.stdout.write('Pattern: ');
return; // Don't trigger onChange yet
} else {
// any other key re-runs tests
state.mode.action = RunnerAction.Test;
@@ -219,21 +255,33 @@ function subscribeKeyEvents(
export async function makeWatchRunner(
onChange: (state: RunnerState) => void,
filterMode: boolean,
debugMode: boolean,
initialPattern?: string,
): Promise<void> {
const state = {
// Determine initial filter state
let filter: TestFilter | null = null;
let filterEnabled = false;
if (initialPattern) {
filter = {paths: [initialPattern]};
filterEnabled = true;
}
const state: RunnerState = {
compilerVersion: 0,
isCompilerBuildValid: false,
lastUpdate: -1,
mode: {
action: RunnerAction.Test,
filter: filterMode,
filter: filterEnabled,
},
filter: filterMode ? await readTestFilter() : null,
filter,
debug: debugMode,
inputMode: 'none',
inputBuffer: '',
};
subscribeTsc(state, onChange);
subscribeFixtures(state, onChange);
subscribeKeyEvents(state, onChange);
subscribeFilterFile(state, onChange);
}

View File

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

View File

@@ -167,3 +167,16 @@ function InvalidUseMemo({items}) {
const sorted = useMemo(() => [...items].sort(), []);
return <div>{sorted.length}</div>;
}
// Invalid: missing/extra deps in useEffect
function InvalidEffectDeps({a, b}) {
useEffect(() => {
console.log(a);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
console.log(a);
// TODO: eslint-disable-next-line react-hooks/exhaustive-effect-dependencies
}, [a, b]);
}

View File

@@ -603,6 +603,18 @@ has-flag@^4.0.0:
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
hermes-estree@0.25.1:
version "0.25.1"
resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480"
integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==
hermes-parser@^0.25.1:
version "0.25.1"
resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1"
integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==
dependencies:
hermes-estree "0.25.1"
ignore@^5.2.0:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
@@ -877,12 +889,12 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
"zod-validation-error@^3.0.3 || ^4.0.0":
"zod-validation-error@^3.5.0 || ^4.0.0":
version "4.0.2"
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918"
integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==
"zod@^3.22.4 || ^4.0.0":
version "4.1.11"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5"
integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==
"zod@^3.25.0 || ^4.0.0":
version "4.2.0"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.2.0.tgz#01e86f2c2b6d525a1b9fa6dbe78beccad082118f"
integrity sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==

View File

@@ -42,6 +42,7 @@ const COMPILER_OPTIONS: PluginOptions = {
// Temporarily enabled for internal testing
enableUseKeyedState: true,
enableVerboseNoSetStateInEffect: true,
validateExhaustiveEffectDependencies: 'extra-only',
},
};

View File

@@ -1,9 +1,5 @@
'use strict';
const path = require('path');
const repoRoot = path.resolve(__dirname, '../../');
type DebugInfoConfig = {
ignoreProps?: boolean,
ignoreRscStreamInfo?: boolean,
@@ -34,7 +30,7 @@ function normalizeStack(stack) {
const [name, file, line, col, enclosingLine, enclosingCol] = stack[i];
copy.push([
name,
file.replace(repoRoot, ''),
file.replace(__REACT_ROOT_PATH_TEST__, ''),
line,
col,
enclosingLine,

View File

@@ -10,8 +10,6 @@
'use strict';
const path = require('path');
if (typeof Blob === 'undefined') {
global.Blob = require('buffer').Blob;
}
@@ -33,9 +31,8 @@ function normalizeCodeLocInfo(str) {
);
}
const repoRoot = path.resolve(__dirname, '../../../../');
function normalizeReactCodeLocInfo(str) {
const repoRootForRegexp = repoRoot.replace(/\//g, '\\/');
const repoRootForRegexp = __REACT_ROOT_PATH_TEST__.replace(/\//g, '\\/');
const repoFileLocMatch = new RegExp(`${repoRootForRegexp}.+?:\\d+:\\d+`, 'g');
return str && str.replace(repoFileLocMatch, '**');
}
@@ -727,6 +724,25 @@ describe('ReactFlight', () => {
});
});
it('can transport cyclic arrays', async () => {
function ComponentClient({prop, obj}) {
expect(prop[1]).toBe(prop);
expect(prop[0]).toBe(obj);
}
const Component = clientReference(ComponentClient);
const obj = {};
const cyclic = [obj];
cyclic[1] = cyclic;
const model = <Component prop={cyclic} obj={obj} />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
});
it('can render a lazy component as a shared component on the server', async () => {
function SharedComponent({text}) {
return (

View File

@@ -32,7 +32,7 @@ if (process.env.NODE_ENV !== 'production') {
#### `Settings`
| Spec | Default value |
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <pre>{<br> appendComponentStack: boolean,<br> breakOnConsoleErrors: boolean,<br> showInlineWarningsAndErrors: boolean,<br> hideConsoleLogsInStrictMode: boolean<br>}</pre> | <pre>{<br> appendComponentStack: true,<br> breakOnConsoleErrors: false,<br> showInlineWarningsAndErrors: true,<br> hideConsoleLogsInStrictMode: false<br>}</pre> |
| <pre>{<br> appendComponentStack: boolean,<br> breakOnConsoleErrors: boolean,<br> showInlineWarningsAndErrors: boolean,<br> hideConsoleLogsInStrictMode: boolean,<br> disableSecondConsoleLogDimmingInStrictMode: boolean<br>}</pre> | <pre>{<br> appendComponentStack: true,<br> breakOnConsoleErrors: false,<br> showInlineWarningsAndErrors: true,<br> hideConsoleLogsInStrictMode: false,<br> disableSecondConsoleLogDimmingInStrictMode: false<br>}</pre> |
### `connectToDevTools` options
| Prop | Default | Description |
@@ -53,7 +53,7 @@ if (process.env.NODE_ENV !== 'production') {
| `onSubscribe` | Function, which receives listener (function, with a single argument) as an argument. Called when backend subscribes to messages from the other end (frontend). |
| `onUnsubscribe` | Function, which receives listener (function) as an argument. Called when backend unsubscribes to messages from the other end (frontend). |
| `onMessage` | Function, which receives 2 arguments: event (string) and payload (any). Called when backend emits a message, which should be sent to the frontend. |
| `onSettingsUpdated` | A callback that will be called when the user updates the settings in the UI. You can use it for persisting user settings. |
| `onSettingsUpdated` | A callback that will be called when the user updates the settings in the UI. You can use it for persisting user settings. |
Unlike `connectToDevTools`, `connectWithCustomMessagingProtocol` returns a callback, which can be used for unsubscribing the backend from the global DevTools hook.

View File

@@ -24,6 +24,11 @@ async function messageListener(event: MessageEvent) {
if (typeof settings.hideConsoleLogsInStrictMode !== 'boolean') {
settings.hideConsoleLogsInStrictMode = false;
}
if (
typeof settings.disableSecondConsoleLogDimmingInStrictMode !== 'boolean'
) {
settings.disableSecondConsoleLogDimmingInStrictMode = false;
}
window.postMessage({
source: 'react-devtools-hook-settings-injector',

View File

@@ -27,6 +27,7 @@ function startActivation(contentWindow: any, bridge: BackendBridge) {
componentFilters,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
disableSecondConsoleLogDimmingInStrictMode,
} = data;
contentWindow.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ =
@@ -38,6 +39,8 @@ function startActivation(contentWindow: any, bridge: BackendBridge) {
showInlineWarningsAndErrors;
contentWindow.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ =
hideConsoleLogsInStrictMode;
contentWindow.__REACT_DEVTOOLS_DISABLE_SECOND_CONSOLE_LOG_DIMMING_IN_STRICT_MODE__ =
disableSecondConsoleLogDimmingInStrictMode;
// TRICKY
// The backend entry point may be required in the context of an iframe or the parent window.
@@ -53,6 +56,8 @@ function startActivation(contentWindow: any, bridge: BackendBridge) {
showInlineWarningsAndErrors;
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ =
hideConsoleLogsInStrictMode;
window.__REACT_DEVTOOLS_DISABLE_SECOND_CONSOLE_LOG_DIMMING_IN_STRICT_MODE__ =
disableSecondConsoleLogDimmingInStrictMode;
}
finishActivation(contentWindow, bridge);

View File

@@ -733,4 +733,85 @@ describe('console', () => {
: 'in Child (at **)\n in Intermediate (at **)\n in Parent (at **)',
]);
});
it('should not dim console logs if disableSecondConsoleLogDimmingInStrictMode is enabled', () => {
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.appendComponentStack = false;
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.hideConsoleLogsInStrictMode =
false;
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.disableSecondConsoleLogDimmingInStrictMode =
true;
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
function App() {
console.log('log');
console.warn('warn');
console.error('error');
return <div />;
}
act(() =>
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
),
);
// Both logs should be called (double logging)
expect(global.consoleLogMock).toHaveBeenCalledTimes(2);
expect(global.consoleWarnMock).toHaveBeenCalledTimes(2);
expect(global.consoleErrorMock).toHaveBeenCalledTimes(2);
// The second log should NOT have dimming (no ANSI codes)
expect(global.consoleLogMock.mock.calls[1]).toEqual(['log']);
expect(global.consoleWarnMock.mock.calls[1]).toEqual(['warn']);
expect(global.consoleErrorMock.mock.calls[1]).toEqual(['error']);
});
it('should dim console logs if disableSecondConsoleLogDimmingInStrictMode is disabled', () => {
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.appendComponentStack = false;
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.hideConsoleLogsInStrictMode =
false;
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.disableSecondConsoleLogDimmingInStrictMode =
false;
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
function App() {
console.log('log');
console.warn('warn');
console.error('error');
return <div />;
}
act(() =>
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
),
);
// Both logs should be called (double logging)
expect(global.consoleLogMock).toHaveBeenCalledTimes(2);
expect(global.consoleWarnMock).toHaveBeenCalledTimes(2);
expect(global.consoleErrorMock).toHaveBeenCalledTimes(2);
// The second log should have dimming (ANSI codes present)
expect(global.consoleLogMock.mock.calls[1]).toEqual([
'\x1b[2;38;2;124;124;124m%s\x1b[0m',
'log',
]);
expect(global.consoleWarnMock.mock.calls[1]).toEqual([
'\x1b[2;38;2;124;124;124m%s\x1b[0m',
'warn',
]);
expect(global.consoleErrorMock.mock.calls[1]).toEqual([
'\x1b[2;38;2;124;124;124m%s\x1b[0m',
'error',
]);
});
});

View File

@@ -654,7 +654,7 @@ describe('ProfilerContext', () => {
expect(store.profilerStore.isProfilingBasedOnUserInput).toBe(false);
document.body.removeChild(profilerContainer);
});
}, 20000);
it('should navigate between commits when the keyboard shortcut is pressed', async () => {
const Parent = () => <Child />;

View File

@@ -248,6 +248,7 @@ beforeEach(() => {
breakOnConsoleErrors: false,
showInlineWarningsAndErrors: true,
hideConsoleLogsInStrictMode: false,
disableSecondConsoleLogDimmingInStrictMode: false,
});
const bridgeListeners = [];

View File

@@ -3143,6 +3143,59 @@ describe('Store', () => {
expect(store).toMatchInlineSnapshot(``);
});
// @reactVersion >= 17.0
it('should track suspended by in filtered fallback', async () => {
function IgnoreMe({promise}) {
return readValue(promise);
}
function Component({promise}) {
return readValue(promise);
}
await actAsync(
async () =>
(store.componentFilters = [createDisplayNameFilter('^IgnoreMe', true)]),
);
let resolveFallback;
const fallbackPromise = new Promise(resolve => {
resolveFallback = resolve;
});
let resolveContent;
const contentPromise = new Promise(resolve => {
resolveContent = resolve;
});
await actAsync(() =>
render(
<React.Suspense
name="main"
fallback={<IgnoreMe promise={fallbackPromise} />}>
<Component promise={contentPromise} />
</React.Suspense>,
),
);
expect(store).toMatchInlineSnapshot(``);
await actAsync(() => resolveFallback('loading'));
expect(store).toMatchInlineSnapshot(`
[root]
<Suspense name="main">
[suspense-root] rects={null}
<Suspense name="main" rects={null}>
`);
await actAsync(() => resolveContent('content'));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense name="main">
<Component>
[suspense-root] rects={null}
<Suspense name="main" rects={null}>
`);
});
// @reactVersion >= 19
it('should keep suspended boundaries in the Suspense tree but not hidden Activity', async () => {
const Activity = React.Activity || React.unstable_Activity;

View File

@@ -2992,6 +2992,30 @@ export function attach(
) {
parentInstance = parentInstance.parent;
}
if (parentInstance.kind === FIBER_INSTANCE) {
const fiber = parentInstance.data;
if (
fiber.tag === SuspenseComponent &&
parentInstance !== parentSuspenseNode.instance
) {
// We're about to attach async info to a Suspense boundary we're not
// actually considering the parent Suspense boundary for this async info.
// We must have not found a suitable Fiber inside the fallback (e.g. due to filtering).
// Use the parent of this instance instead since we treat async info
// attached to a Suspense boundary as that async info triggering the
// fallback of that boundary.
const parent = parentInstance.parent;
if (parent === null) {
// This shouldn't happen. Any <Suspense> would have at least have the
// host root as the parent which can't have a fallback.
throw new Error(
'Did not find a suitable instance for this async info. This is a bug in React.',
);
}
parentInstance = parent;
}
}
const suspenseNodeSuspendedBy = parentSuspenseNode.suspendedBy;
const ioInfo = asyncInfo.awaited;
@@ -5255,9 +5279,9 @@ export function attach(
// It might even result in a bad user experience for e.g. node selection in the Elements panel.
// The easiest fix is to strip out the intermediate Fragment fibers,
// so the Elements panel and Profiler don't need to special case them.
// Suspense components only have a non-null memoizedState if they're timed-out.
const isLegacySuspense =
nextFiber.tag === SuspenseComponent && OffscreenComponent === -1;
// Suspense components only have a non-null memoizedState if they're timed-out.
const prevDidTimeout =
isLegacySuspense && prevFiber.memoizedState !== null;
const nextDidTimeOut =

View File

@@ -597,4 +597,5 @@ export type DevToolsHookSettings = {
breakOnConsoleErrors: boolean,
showInlineWarningsAndErrors: boolean,
hideConsoleLogsInStrictMode: boolean,
disableSecondConsoleLogDimmingInStrictMode: boolean,
};

View File

@@ -36,6 +36,10 @@ export default function DebuggingSettings({
useState(usedHookSettings.hideConsoleLogsInStrictMode);
const [showInlineWarningsAndErrors, setShowInlineWarningsAndErrors] =
useState(usedHookSettings.showInlineWarningsAndErrors);
const [
disableSecondConsoleLogDimmingInStrictMode,
setDisableSecondConsoleLogDimmingInStrictMode,
] = useState(usedHookSettings.disableSecondConsoleLogDimmingInStrictMode);
useEffect(() => {
store.setShouldShowWarningsAndErrors(showInlineWarningsAndErrors);
@@ -47,6 +51,7 @@ export default function DebuggingSettings({
breakOnConsoleErrors,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
disableSecondConsoleLogDimmingInStrictMode,
});
}, [
store,
@@ -54,6 +59,7 @@ export default function DebuggingSettings({
breakOnConsoleErrors,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
disableSecondConsoleLogDimmingInStrictMode,
]);
return (
@@ -105,9 +111,12 @@ export default function DebuggingSettings({
<input
type="checkbox"
checked={hideConsoleLogsInStrictMode}
onChange={({currentTarget}) =>
setHideConsoleLogsInStrictMode(currentTarget.checked)
}
onChange={({currentTarget}) => {
setHideConsoleLogsInStrictMode(currentTarget.checked);
if (currentTarget.checked) {
setDisableSecondConsoleLogDimmingInStrictMode(false);
}
}}
className={styles.SettingRowCheckbox}
/>
Hide logs during additional invocations in&nbsp;
@@ -120,6 +129,40 @@ export default function DebuggingSettings({
</a>
</label>
</div>
<div
className={
hideConsoleLogsInStrictMode
? `${styles.SettingDisabled} ${styles.SettingWrapper}`
: styles.SettingWrapper
}>
<label
className={
hideConsoleLogsInStrictMode
? `${styles.SettingDisabled} ${styles.SettingRow}`
: styles.SettingRow
}>
<input
type="checkbox"
checked={disableSecondConsoleLogDimmingInStrictMode}
disabled={hideConsoleLogsInStrictMode}
onChange={({currentTarget}) =>
setDisableSecondConsoleLogDimmingInStrictMode(
currentTarget.checked,
)
}
className={styles.SettingRowCheckbox}
/>
Disable log dimming during additional invocations in&nbsp;
<a
className={styles.StrictModeLink}
target="_blank"
rel="noopener noreferrer"
href="https://react.dev/reference/react/StrictMode">
Strict Mode
</a>
</label>
</div>
</div>
);
}

View File

@@ -26,6 +26,11 @@
margin: 0.125rem 0.25rem 0.125rem 0;
}
.SettingDisabled {
opacity: 0.5;
cursor: not-allowed;
}
.OptionGroup {
display: inline-flex;
flex-direction: row;

View File

@@ -367,17 +367,22 @@ export function installHook(
return;
}
// Dim the text color of the double logs if we're not hiding them.
// Firefox doesn't support ANSI escape sequences
if (__IS_FIREFOX__) {
originalMethod(
...formatWithStyles(args, FIREFOX_CONSOLE_DIMMING_COLOR),
);
if (settings.disableSecondConsoleLogDimmingInStrictMode) {
// Don't dim the console logs
originalMethod(...args);
} else {
originalMethod(
ANSI_STYLE_DIMMING_TEMPLATE,
...formatConsoleArguments(...args),
);
// Dim the text color of the double logs if we're not hiding them.
// Firefox doesn't support ANSI escape sequences
if (__IS_FIREFOX__) {
originalMethod(
...formatWithStyles(args, FIREFOX_CONSOLE_DIMMING_COLOR),
);
} else {
originalMethod(
ANSI_STYLE_DIMMING_TEMPLATE,
...formatConsoleArguments(...args),
);
}
}
};
@@ -579,7 +584,10 @@ export function installHook(
debugger;
}
if (isRunningDuringStrictModeInvocation) {
if (
isRunningDuringStrictModeInvocation &&
!settings.disableSecondConsoleLogDimmingInStrictMode
) {
// Dim the text color of the double logs if we're not hiding them.
// Firefox doesn't support ANSI escape sequences
if (__IS_FIREFOX__) {
@@ -667,6 +675,7 @@ export function installHook(
breakOnConsoleErrors: false,
showInlineWarningsAndErrors: true,
hideConsoleLogsInStrictMode: false,
disableSecondConsoleLogDimmingInStrictMode: false,
};
patchConsoleForErrorsAndWarnings();
} else {

View File

@@ -235,6 +235,31 @@ function warnForPropDifference(
}
}
function hasViewTransition(htmlElement: HTMLElement): boolean {
return !!(
htmlElement.getAttribute('vt-share') ||
htmlElement.getAttribute('vt-exit') ||
htmlElement.getAttribute('vt-enter') ||
htmlElement.getAttribute('vt-update')
);
}
function isExpectedViewTransitionName(htmlElement: HTMLElement): boolean {
if (!hasViewTransition(htmlElement)) {
// We didn't expect to see a view transition name applied.
return false;
}
const expectedVtName = htmlElement.getAttribute('vt-name');
const actualVtName: string = (htmlElement.style: any)['view-transition-name'];
if (expectedVtName) {
return expectedVtName === actualVtName;
} else {
// Auto-generated name.
// TODO: If Fizz starts applying a prefix to this name, we need to consider that.
return actualVtName.startsWith('_T_');
}
}
function warnForExtraAttributes(
domElement: Element,
attributeNames: Set<string>,
@@ -242,10 +267,28 @@ function warnForExtraAttributes(
) {
if (__DEV__) {
attributeNames.forEach(function (attributeName) {
serverDifferences[getPropNameFromAttributeName(attributeName)] =
attributeName === 'style'
? getStylesObjectFromElement(domElement)
: domElement.getAttribute(attributeName);
if (attributeName === 'style') {
if (domElement.getAttribute(attributeName) === '') {
// Skip empty style. It's fine.
return;
}
const htmlElement = ((domElement: any): HTMLElement);
const style = htmlElement.style;
const isOnlyVTStyles =
(style.length === 1 && style[0] === 'view-transition-name') ||
(style.length === 2 &&
style[0] === 'view-transition-class' &&
style[1] === 'view-transition-name');
if (isOnlyVTStyles && isExpectedViewTransitionName(htmlElement)) {
// If the only extra style was the view-transition-name that we applied from the Fizz
// runtime, then we should ignore it.
} else {
serverDifferences.style = getStylesObjectFromElement(domElement);
}
} else {
serverDifferences[getPropNameFromAttributeName(attributeName)] =
domElement.getAttribute(attributeName);
}
});
}
}
@@ -1977,13 +2020,21 @@ function getStylesObjectFromElement(domElement: Element): {
[styleName: string]: string,
} {
const serverValueInObjectForm: {[prop: string]: string} = {};
const style = ((domElement: any): HTMLElement).style;
const htmlElement: HTMLElement = (domElement: any);
const style = htmlElement.style;
for (let i = 0; i < style.length; i++) {
const styleName: string = style[i];
// TODO: We should use the original prop value here if it is equivalent.
// TODO: We could use the original client capitalization if the equivalent
// other capitalization exists in the DOM.
serverValueInObjectForm[styleName] = style.getPropertyValue(styleName);
if (
styleName === 'view-transition-name' &&
isExpectedViewTransitionName(htmlElement)
) {
// This is a view transition name added by the Fizz runtime, not the user's props.
} else {
serverValueInObjectForm[styleName] = style.getPropertyValue(styleName);
}
}
return serverValueInObjectForm;
}
@@ -2018,6 +2069,20 @@ function diffHydratedStyles(
return;
}
if (
// Trailing semi-colon means this was regenerated.
normalizedServerValue[normalizedServerValue.length - 1] === ';' &&
// TODO: Should we just ignore any style if the style as been manipulated?
hasViewTransition((domElement: any))
) {
// If this had a view transition we might have applied a view transition
// name/class and removed it. If that happens, the style attribute gets
// regenerated from the style object. This means we've lost the format
// that we sent from the server and is unable to diff it. We just treat
// it as passing even if it should be a mismatch in this edge case.
return;
}
// Otherwise, we create the object from the DOM for the diff view.
serverDifferences.style = getStylesObjectFromElement(domElement);
}

View File

@@ -45,30 +45,6 @@ function coerceFormActionProp(
}
}
function createFormDataWithSubmitter(
form: HTMLFormElement,
submitter: HTMLInputElement | HTMLButtonElement,
) {
// The submitter's value should be included in the FormData.
// It should be in the document order in the form.
// Since the FormData constructor invokes the formdata event it also
// needs to be available before that happens so after construction it's too
// late. We use a temporary fake node for the duration of this event.
// TODO: FormData takes a second argument that it's the submitter but this
// is fairly new so not all browsers support it yet. Switch to that technique
// when available.
const temp = submitter.ownerDocument.createElement('input');
temp.name = submitter.name;
temp.value = submitter.value;
if (form.id) {
temp.setAttribute('form', form.id);
}
(submitter.parentNode: any).insertBefore(temp, submitter);
const formData = new FormData(form);
(temp.parentNode: any).removeChild(temp);
return formData;
}
/**
* This plugin invokes action functions on forms, inputs and buttons if
* the form doesn't prevent default.
@@ -129,9 +105,7 @@ function extractEvents(
if (didCurrentEventScheduleTransition()) {
// We're going to set the pending form status, but because the submission
// was prevented, we should not fire the action function.
const formData = submitter
? createFormDataWithSubmitter(form, submitter)
: new FormData(form);
const formData = new FormData(form, submitter);
const pendingState: FormStatus = {
pending: true,
data: formData,
@@ -160,9 +134,7 @@ function extractEvents(
event.preventDefault();
// Dispatch the action and set a pending form status.
const formData = submitter
? createFormDataWithSubmitter(form, submitter)
: new FormData(form);
const formData = new FormData(form, submitter);
const pendingState: FormStatus = {
pending: true,
data: formData,

View File

@@ -14,4 +14,4 @@ export const completeBoundaryWithStyles =
export const completeSegment =
'$RS=function(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)};';
export const formReplaying =
'addEventListener("submit",function(a){if(!a.defaultPrevented){var c=a.target,d=a.submitter,e=c.action,b=d;if(d){var f=d.getAttribute("formAction");null!=f&&(e=f,b=null)}"javascript:throw new Error(\'React form unexpectedly submitted.\')"===e&&(a.preventDefault(),b?(a=document.createElement("input"),a.name=b.name,a.value=b.value,b.parentNode.insertBefore(a,b),b=new FormData(c),a.parentNode.removeChild(a)):b=new FormData(c),a=c.ownerDocument||c,(a.$$reactFormReplay=a.$$reactFormReplay||[]).push(c,d,b))}});';
'addEventListener("submit",function(a){if(!a.defaultPrevented){var b=a.target,d=a.submitter,c=b.action,e=d;if(d){var f=d.getAttribute("formAction");null!=f&&(c=f,e=null)}"javascript:throw new Error(\'React form unexpectedly submitted.\')"===c&&(a.preventDefault(),a=new FormData(b,e),c=b.ownerDocument||b,(c.$$reactFormReplay=c.$$reactFormReplay||[]).push(b,d,a))}});';

View File

@@ -634,25 +634,7 @@ export function listenToFormSubmissionsForReplaying() {
event.preventDefault();
// Take a snapshot of the FormData at the time of the event.
let formData;
if (formDataSubmitter) {
// The submitter's value should be included in the FormData.
// It should be in the document order in the form.
// Since the FormData constructor invokes the formdata event it also
// needs to be available before that happens so after construction it's too
// late. We use a temporary fake node for the duration of this event.
// TODO: FormData takes a second argument that it's the submitter but this
// is fairly new so not all browsers support it yet. Switch to that technique
// when available.
const temp = document.createElement('input');
temp.name = formDataSubmitter.name;
temp.value = formDataSubmitter.value;
formDataSubmitter.parentNode.insertBefore(temp, formDataSubmitter);
formData = new FormData(form);
temp.parentNode.removeChild(temp);
} else {
formData = new FormData(form);
}
const formData = new FormData(form, formDataSubmitter);
// Queue for replaying later. This field could potentially be shared with multiple
// Reacts on the same page since each one will preventDefault for the next one.

View File

@@ -14,8 +14,8 @@ global.IS_REACT_ACT_ENVIRONMENT = true;
// Our current version of JSDOM doesn't implement the event dispatching
// so we polyfill it.
const NativeFormData = global.FormData;
const FormDataPolyfill = function FormData(form) {
const formData = new NativeFormData(form);
const FormDataPolyfill = function FormData(form, submitter) {
const formData = new NativeFormData(form, submitter);
const formDataEvent = new Event('formdata', {
bubbles: true,
cancelable: false,
@@ -489,11 +489,16 @@ describe('ReactDOMForm', () => {
const inputRef = React.createRef();
const buttonRef = React.createRef();
const outsideButtonRef = React.createRef();
const imageButtonRef = React.createRef();
let button;
let buttonX;
let buttonY;
let title;
function action(formData) {
button = formData.get('button');
buttonX = formData.get('button.x');
buttonY = formData.get('button.y');
title = formData.get('title');
}
@@ -508,6 +513,12 @@ describe('ReactDOMForm', () => {
<button name="button" value="edit" ref={buttonRef}>
Edit
</button>
<input
type="image"
name="button"
href="/some/image.png"
ref={imageButtonRef}
/>
</form>
<form id="form" action={action}>
<input type="text" name="title" defaultValue="hello" />
@@ -546,9 +557,12 @@ describe('ReactDOMForm', () => {
expect(button).toBe('outside');
expect(title).toBe('hello');
// Ensure that the type field got correctly restored
expect(inputRef.current.getAttribute('type')).toBe('submit');
expect(buttonRef.current.getAttribute('type')).toBe(null);
await submit(imageButtonRef.current);
expect(button).toBe(null);
expect(buttonX).toBe('0');
expect(buttonY).toBe('0');
expect(title).toBe('hello');
});
it('excludes the submitter name when the submitter is a function action', async () => {

View File

@@ -39,6 +39,10 @@ function normalizeCodeLocInfo(str) {
);
}
function normalizeSerializedContent(str) {
return str.replaceAll(__REACT_ROOT_PATH_TEST__, '**');
}
describe('ReactFlightDOMEdge', () => {
beforeEach(() => {
// Mock performance.now for timing tests
@@ -481,8 +485,10 @@ describe('ReactFlightDOMEdge', () => {
);
const [stream1, stream2] = passThrough(stream).tee();
const serializedContent = await readResult(stream1);
expect(serializedContent.length).toBeLessThan(1100);
const serializedContent = normalizeSerializedContent(
await readResult(stream1),
);
expect(serializedContent.length).toBeLessThan(1075);
const result = await ReactServerDOMClient.createFromReadableStream(
stream2,
@@ -551,9 +557,11 @@ describe('ReactFlightDOMEdge', () => {
);
const [stream1, stream2] = passThrough(stream).tee();
const serializedContent = await readResult(stream1);
const serializedContent = normalizeSerializedContent(
await readResult(stream1),
);
expect(serializedContent.length).toBeLessThan(490);
expect(serializedContent.length).toBeLessThan(465);
expect(timesRendered).toBeLessThan(5);
const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
@@ -623,8 +631,10 @@ describe('ReactFlightDOMEdge', () => {
);
const [stream1, stream2] = passThrough(stream).tee();
const serializedContent = await readResult(stream1);
expect(serializedContent.length).toBeLessThan(__DEV__ ? 680 : 400);
const serializedContent = normalizeSerializedContent(
await readResult(stream1),
);
expect(serializedContent.length).toBeLessThan(__DEV__ ? 630 : 400);
expect(timesRendered).toBeLessThan(5);
const model = await serverAct(() =>
@@ -657,8 +667,10 @@ describe('ReactFlightDOMEdge', () => {
<ServerComponent recurse={20} />,
),
);
const serializedContent = await readResult(stream);
const expectedDebugInfoSize = __DEV__ ? 320 * 20 : 0;
const serializedContent = normalizeSerializedContent(
await readResult(stream),
);
const expectedDebugInfoSize = __DEV__ ? 295 * 20 : 0;
expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize);
});

View File

@@ -121,17 +121,7 @@ describe('ReactFlightDOMForm', () => {
const method = (submitter && submitter.formMethod) || form.method;
const encType = (submitter && submitter.formEnctype) || form.enctype;
if (method === 'post' && encType === 'multipart/form-data') {
let formData;
if (submitter) {
const temp = document.createElement('input');
temp.name = submitter.name;
temp.value = submitter.value;
submitter.parentNode.insertBefore(temp, submitter);
formData = new FormData(form);
temp.parentNode.removeChild(temp);
} else {
formData = new FormData(form);
}
const formData = new FormData(form, submitter);
return POST(formData);
}
throw new Error('Navigate to: ' + action);

View File

@@ -650,6 +650,17 @@ describe('ReactFlightDOMReply', () => {
expect(root.prop.obj).toBe(root.prop);
});
it('can transport cyclic arrays', async () => {
const obj = {};
const cyclic = [obj];
cyclic[1] = cyclic;
const body = await ReactServerDOMClient.encodeReply({prop: cyclic, obj});
const root = await ReactServerDOMServer.decodeReply(body, webpackServerMap);
expect(root.prop[1]).toBe(root.prop);
expect(root.prop[0]).toBe(root.obj);
});
it('can abort an unresolved model and get the partial result', async () => {
const promise = new Promise(r => {});
const controller = new AbortController();

View File

@@ -133,14 +133,20 @@ ReactPromise.prototype.then = function <T>(
// Recursively check if the value is itself a ReactPromise and if so if it points
// back to itself. This helps catch recursive thenables early error.
let cycleProtection = 0;
const visited = new Set<typeof ReactPromise>();
while (inspectedValue instanceof ReactPromise) {
cycleProtection++;
if (inspectedValue === chunk || cycleProtection > 1000) {
if (
inspectedValue === chunk ||
visited.has(inspectedValue) ||
cycleProtection > 1000
) {
if (typeof reject === 'function') {
reject(new Error('Cannot have cyclic thenables.'));
}
return;
}
visited.add(inspectedValue);
if (inspectedValue.status === INITIALIZED) {
inspectedValue = inspectedValue.value;
} else {

View File

@@ -2,6 +2,7 @@
module.exports = {
globalSetup: require.resolve('./setupGlobal.js'),
testSequencer: require.resolve('./sizeBalancedSequencer.js'),
modulePathIgnorePatterns: [
'<rootDir>/scripts/rollup/shims/',
'<rootDir>/scripts/bench/',

View File

@@ -6,6 +6,7 @@ const {
resetAllUnexpectedConsoleCalls,
patchConsoleMethods,
} = require('internal-test-utils/consoleMock');
const path = require('path');
if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
// Inside the class equivalence tester, we have a custom environment, let's
@@ -18,6 +19,9 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
const spyOn = jest.spyOn;
const noop = jest.fn;
// Can be used to normalize paths in stackframes
global.__REACT_ROOT_PATH_TEST__ = path.resolve(__dirname, '../..');
// Spying on console methods in production builds can mask errors.
// This is why we added an explicit spyOnDev() helper.
// It's too easy to accidentally use the more familiar spyOn() helper though,

View File

@@ -0,0 +1,28 @@
'use strict';
const Sequencer = require('@jest/test-sequencer').default;
const fs = require('fs');
class SizeBalancedSequencer extends Sequencer {
shard(tests, {shardIndex, shardCount}) {
const shards = Array.from({length: shardCount}, () => ({
tests: [],
size: 0,
}));
const sorted = [...tests].sort(
(a, b) => fs.statSync(b.path).size - fs.statSync(a.path).size
);
for (let i = 0; i < sorted.length; i++) {
const test = sorted[i];
const size = fs.statSync(test.path).size;
const smallest = shards.reduce((min, s) => (s.size < min.size ? s : min));
smallest.tests.push(test);
smallest.size += size;
}
return shards[shardIndex - 1].tests;
}
}
module.exports = SizeBalancedSequencer;