Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49ce79b26 | ||
|
|
9eb8b04a87 | ||
|
|
5aec1b2a8d | ||
|
|
d6cae440e3 | ||
|
|
00908be9ff | ||
|
|
0e180141bf | ||
|
|
65eec428c4 | ||
|
|
454fc41fc7 | ||
|
|
f93b9fd44b | ||
|
|
b731fe28cc | ||
|
|
88ee1f5955 | ||
|
|
bcf97c7564 | ||
|
|
ba5b843692 |
@@ -635,6 +635,7 @@ module.exports = {
|
||||
FocusOptions: 'readonly',
|
||||
OptionalEffectTiming: 'readonly',
|
||||
|
||||
__REACT_ROOT_PATH_TEST__: 'readonly',
|
||||
spyOnDev: 'readonly',
|
||||
spyOnDevAndProd: 'readonly',
|
||||
spyOnProd: 'readonly',
|
||||
|
||||
47
.github/workflows/runtime_build_and_test.yml
vendored
47
.github/workflows/runtime_build_and_test.yml
vendored
@@ -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]
|
||||
|
||||
@@ -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
2
compiler/.gitignore
vendored
@@ -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
221
compiler/CLAUDE.md
Normal 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,
|
||||
});
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect, useEffectEvent} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect, useEffectEvent} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -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]`
|
||||
```
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]`
|
||||
```
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies:"all"
|
||||
import {
|
||||
useCallback,
|
||||
useTransition,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect, useEffectEvent} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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==
|
||||
|
||||
@@ -42,6 +42,7 @@ const COMPILER_OPTIONS: PluginOptions = {
|
||||
// Temporarily enabled for internal testing
|
||||
enableUseKeyedState: true,
|
||||
enableVerboseNoSetStateInEffect: true,
|
||||
validateExhaustiveEffectDependencies: 'extra-only',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -248,6 +248,7 @@ beforeEach(() => {
|
||||
breakOnConsoleErrors: false,
|
||||
showInlineWarningsAndErrors: true,
|
||||
hideConsoleLogsInStrictMode: false,
|
||||
disableSecondConsoleLogDimmingInStrictMode: false,
|
||||
});
|
||||
|
||||
const bridgeListeners = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -597,4 +597,5 @@ export type DevToolsHookSettings = {
|
||||
breakOnConsoleErrors: boolean,
|
||||
showInlineWarningsAndErrors: boolean,
|
||||
hideConsoleLogsInStrictMode: boolean,
|
||||
disableSecondConsoleLogDimmingInStrictMode: boolean,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
<a
|
||||
className={styles.StrictModeLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://react.dev/reference/react/StrictMode">
|
||||
Strict Mode
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
packages/react-devtools-shared/src/hook.js
vendored
31
packages/react-devtools-shared/src/hook.js
vendored
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))}});';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
module.exports = {
|
||||
globalSetup: require.resolve('./setupGlobal.js'),
|
||||
testSequencer: require.resolve('./sizeBalancedSequencer.js'),
|
||||
modulePathIgnorePatterns: [
|
||||
'<rootDir>/scripts/rollup/shims/',
|
||||
'<rootDir>/scripts/bench/',
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
scripts/jest/sizeBalancedSequencer.js
Normal file
28
scripts/jest/sizeBalancedSequencer.js
Normal 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;
|
||||
Reference in New Issue
Block a user