Compare commits

..

11 Commits

Author SHA1 Message Date
Joe Savona
4573abc7e3 [compiler] Remove local CompilerError accumulators, emit directly to env.recordError()
Removes unnecessary indirection in 17 compiler passes that previously
accumulated errors in a local `CompilerError` instance before flushing
them to `env.recordErrors()` at the end of each pass. Errors are now
emitted directly via `env.recordError()` as they're discovered.

For passes with recursive error-detection patterns (ValidateNoRefAccessInRender,
ValidateNoSetStateInRender), the internal accumulator is kept but flushed
via individual `recordError()` calls. For InferMutationAliasingRanges,
a `shouldRecordErrors` flag preserves the conditional suppression logic.
For TransformFire, the throw-based error propagation is replaced with
direct recording plus an early-exit check in Pipeline.ts.
2026-02-21 13:08:10 -08:00
Joe Savona
deca1ebd8b [compiler] Remove tryRecord, add catch-all error handling, fix remaining throws
Remove `tryRecord()` from the compilation pipeline now that all passes record
errors directly via `env.recordError()` / `env.recordErrors()`. A single
catch-all try/catch in Program.ts provides the safety net for any pass that
incorrectly throws instead of recording.

Key changes:
- Remove all ~64 `env.tryRecord()` wrappers in Pipeline.ts
- Delete `tryRecord()` method from Environment.ts
- Add `CompileUnexpectedThrow` logger event so thrown errors are detectable
- Log `CompileUnexpectedThrow` in Program.ts catch-all for non-invariant throws
- Fail snap tests on `CompileUnexpectedThrow` to surface pass bugs in dev
- Convert throwTodo/throwDiagnostic calls in HIRBuilder (fbt, this),
  CodegenReactiveFunction (for-in/for-of), and BuildReactiveFunction to
  record errors or use invariants as appropriate
- Remove try/catch from BuildHIR's lower() since inner throws are now recorded
- CollectOptionalChainDependencies: return null instead of throwing on
  unsupported optional chain patterns (graceful optimization skip)
2026-02-21 13:07:26 -08:00
Joe Savona
3dc96d6a49 [compiler] Cleanup: consistent tryRecord() wrapping and error recording 2026-02-21 13:04:36 -08:00
Joe Savona
d371f07622 [compiler] Add fault tolerance test fixtures 2026-02-21 13:04:12 -08:00
Joe Savona
d401744f03 [compiler] Phase 3: Make lower() always produce HIRFunction 2026-02-21 13:04:10 -08:00
Joe Savona
f92560c498 [compiler] Phase 8: Add multi-error test fixture and update plan
Add test fixture demonstrating fault tolerance: the compiler now reports
both a mutation error and a ref access error in the same function, where
previously only one would be reported before bailing out.

Update plan doc to mark all phases as complete.
2026-02-21 13:03:56 -08:00
Joe Savona
bb174a5230 [compiler] Phase 4 (batch 2), 5, 6: Update remaining passes for fault tolerance
Update remaining validation passes to record errors on env:
- validateMemoizedEffectDependencies
- validatePreservedManualMemoization
- validateSourceLocations (added env parameter)
- validateContextVariableLValues (changed throwTodo to recordError)
- validateLocalsNotReassignedAfterRender (changed throw to recordError)
- validateNoDerivedComputationsInEffects (changed throw to recordError)

Update inference passes:
- inferMutationAliasingEffects: return void, errors on env
- inferMutationAliasingRanges: return Array<AliasingEffect> directly, errors on env

Update codegen:
- codegenFunction: return CodegenFunction directly, errors on env
- codegenReactiveFunction: same pattern

Update Pipeline.ts to call all passes directly without tryRecord/unwrap.
Also update AnalyseFunctions.ts which called inferMutationAliasingRanges.
2026-02-21 13:03:54 -08:00
Joe Savona
70ae844c65 [compiler] Phase 4 (batch 1): Update validation passes to record errors on env
Update 9 validation passes to record errors directly on fn.env instead of
returning Result<void, CompilerError>:
- validateHooksUsage
- validateNoCapitalizedCalls (also changed throwInvalidReact to recordError)
- validateUseMemo
- dropManualMemoization
- validateNoRefAccessInRender
- validateNoSetStateInRender
- validateNoImpureFunctionsInRender
- validateNoFreezingKnownMutableFunctions
- validateExhaustiveDependencies

Each pass now calls fn.env.recordErrors() instead of returning errors.asResult().
Pipeline.ts call sites updated to remove tryRecord() wrappers and .unwrap().
2026-02-21 13:03:38 -08:00
Joe Savona
2bf8fa8e91 [compiler] Phase 2+7: Wrap pipeline passes in tryRecord for fault tolerance
- Change runWithEnvironment/run/compileFn to return Result<CodegenFunction, CompilerError>
- Wrap all pipeline passes in env.tryRecord() to catch and record CompilerErrors
- Record inference pass errors via env.recordErrors() instead of throwing
- Handle codegen Result explicitly, returning Err on failure
- Add final error check: return Err(env.aggregateErrors()) if any errors accumulated
- Update tryCompileFunction and retryCompileFunction in Program.ts to handle Result
- Keep lint-only passes using env.logErrors() (non-blocking)
- Update 52 test fixture expectations that now report additional errors

This is the core integration that enables fault tolerance: errors are caught,
recorded, and the pipeline continues to discover more errors.
2026-02-21 13:03:35 -08:00
Joe Savona
8593874f38 [compiler] Phase 1: Add error accumulation infrastructure to Environment
Add error accumulation methods to the Environment class:
- #errors field to accumulate CompilerErrors across passes
- recordError() to record a single diagnostic (throws if Invariant)
- recordErrors() to record all diagnostics from a CompilerError
- hasErrors() to check if any errors have been recorded
- aggregateErrors() to retrieve the accumulated CompilerError
- tryRecord() to wrap callbacks and catch CompilerErrors
2026-02-21 13:01:33 -08:00
Joe Savona
00ae726dd3 [compiler] Add fault tolerance plan document
Add detailed plan for making the React Compiler fault-tolerant by
accumulating errors across all passes instead of stopping at the first
error. This enables reporting multiple compilation errors at once.
2026-02-21 13:01:33 -08:00
17 changed files with 303 additions and 175 deletions

View File

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

View File

@@ -19,7 +19,7 @@ import {getOrInsertWith} from '../Utils/utils';
import {ExternalFunction, isHookName} from '../HIR/Environment';
import {Err, Ok, Result} from '../Utils/Result';
import {LoggerEvent, ParsedPluginOptions} from './Options';
import {getReactCompilerRuntimeModule} from './Program';
import {BabelFn, getReactCompilerRuntimeModule} from './Program';
import {SuppressionRange} from './Suppression';
export function validateRestrictedImports(
@@ -84,6 +84,11 @@ export class ProgramContext {
// generated imports
imports: Map<string, Map<string, NonLocalImportSpecifier>> = new Map();
/**
* Metadata from compilation
*/
retryErrors: Array<{fn: BabelFn; error: CompilerError}> = [];
constructor({
program,
suppressions,

View File

@@ -228,6 +228,8 @@ const CompilerOutputModeSchema = z.enum([
'ssr',
// Build optimized for the client, with auto memoization
'client',
// Build optimized for the client without auto memo
'client-no-memo',
// Lint mode, the output is unused but validations should run
'lint',
]);

View File

@@ -91,6 +91,7 @@ import {validateNoJSXInTryStatement} from '../Validation/ValidateNoJSXInTryState
import {propagateScopeDependenciesHIR} from '../HIR/PropagateScopeDependenciesHIR';
import {outlineJSX} from '../Optimization/OutlineJsx';
import {optimizePropsMethodCalls} from '../Optimization/OptimizePropsMethodCalls';
import {validateNoImpureFunctionsInRender} from '../Validation/ValidateNoImpureFunctionsInRender';
import {validateStaticComponents} from '../Validation/ValidateStaticComponents';
import {validateNoFreezingKnownMutableFunctions} from '../Validation/ValidateNoFreezingKnownMutableFunctions';
import {inferMutationAliasingEffects} from '../Inference/InferMutationAliasingEffects';
@@ -260,6 +261,10 @@ function runWithEnvironment(
env.logErrors(validateNoJSXInTryStatement(hir));
}
if (env.config.validateNoImpureFunctionsInRender) {
validateNoImpureFunctionsInRender(hir);
}
validateNoFreezingKnownMutableFunctions(hir);
}
@@ -495,6 +500,7 @@ function runWithEnvironment(
value: reactiveFunction,
});
if (
env.config.enablePreserveExistingMemoizationGuarantees ||
env.config.validatePreserveExistingMemoizationGuarantees

View File

@@ -350,6 +350,9 @@ function isFilePartOfSources(
return false;
}
export type CompileProgramMetadata = {
retryErrors: Array<{fn: BabelFn; error: CompilerError}>;
};
/**
* Main entrypoint for React Compiler.
*
@@ -360,7 +363,7 @@ function isFilePartOfSources(
export function compileProgram(
program: NodePath<t.Program>,
pass: CompilerPass,
): void {
): CompileProgramMetadata | null {
/**
* This is directly invoked by the react-compiler babel plugin, so exceptions
* thrown by this function will fail the babel build.
@@ -373,7 +376,7 @@ export function compileProgram(
* the outlined functions.
*/
if (shouldSkipCompilation(program, pass)) {
return;
return null;
}
const restrictedImportsErr = validateRestrictedImports(
program,
@@ -381,7 +384,7 @@ export function compileProgram(
);
if (restrictedImportsErr) {
handleError(restrictedImportsErr, pass, null);
return;
return null;
}
/*
* Record lint errors and critical errors as depending on Forget's config,
@@ -475,11 +478,15 @@ export function compileProgram(
);
handleError(error, programContext, null);
}
return;
return null;
}
// Insert React Compiler generated functions into the Babel AST
applyCompiledFunctions(program, compiledFns, pass, programContext);
return {
retryErrors: programContext.retryErrors,
};
}
type CompileSource = {
@@ -731,6 +738,7 @@ function tryCompileFunction(
}
}
/**
* Applies React Compiler generated functions to the babel AST by replacing
* existing functions in place or inserting new declarations.

View File

@@ -0,0 +1,162 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {NodePath} from '@babel/core';
import * as t from '@babel/types';
import {CompilerError, EnvironmentConfig, Logger} from '..';
import {getOrInsertWith} from '../Utils/utils';
import {GeneratedSource} from '../HIR';
import {DEFAULT_EXPORT} from '../HIR/Environment';
import {CompileProgramMetadata} from './Program';
export default function validateNoUntransformedReferences(
path: NodePath<t.Program>,
filename: string | null,
logger: Logger | null,
env: EnvironmentConfig,
compileResult: CompileProgramMetadata | null,
): void {
const moduleLoadChecks = new Map<
string,
Map<string, CheckInvalidReferenceFn>
>();
if (moduleLoadChecks.size > 0) {
transformProgram(path, moduleLoadChecks, filename, logger, compileResult);
}
}
type TraversalState = {
shouldInvalidateScopes: boolean;
program: NodePath<t.Program>;
logger: Logger | null;
filename: string | null;
transformErrors: Array<{fn: NodePath<t.Node>; error: CompilerError}>;
};
type CheckInvalidReferenceFn = (
paths: Array<NodePath<t.Node>>,
context: TraversalState,
) => void;
function validateImportSpecifier(
specifier: NodePath<t.ImportSpecifier>,
importSpecifierChecks: Map<string, CheckInvalidReferenceFn>,
state: TraversalState,
): void {
const imported = specifier.get('imported');
const specifierName: string =
imported.node.type === 'Identifier'
? imported.node.name
: imported.node.value;
const checkFn = importSpecifierChecks.get(specifierName);
if (checkFn == null) {
return;
}
if (state.shouldInvalidateScopes) {
state.shouldInvalidateScopes = false;
state.program.scope.crawl();
}
const local = specifier.get('local');
const binding = local.scope.getBinding(local.node.name);
CompilerError.invariant(binding != null, {
reason: 'Expected binding to be found for import specifier',
loc: local.node.loc ?? GeneratedSource,
});
checkFn(binding.referencePaths, state);
}
function validateNamespacedImport(
specifier: NodePath<t.ImportNamespaceSpecifier | t.ImportDefaultSpecifier>,
importSpecifierChecks: Map<string, CheckInvalidReferenceFn>,
state: TraversalState,
): void {
if (state.shouldInvalidateScopes) {
state.shouldInvalidateScopes = false;
state.program.scope.crawl();
}
const local = specifier.get('local');
const binding = local.scope.getBinding(local.node.name);
const defaultCheckFn = importSpecifierChecks.get(DEFAULT_EXPORT);
CompilerError.invariant(binding != null, {
reason: 'Expected binding to be found for import specifier',
loc: local.node.loc ?? GeneratedSource,
});
const filteredReferences = new Map<
CheckInvalidReferenceFn,
Array<NodePath<t.Node>>
>();
for (const reference of binding.referencePaths) {
if (defaultCheckFn != null) {
getOrInsertWith(filteredReferences, defaultCheckFn, () => []).push(
reference,
);
}
const parent = reference.parentPath;
if (
parent != null &&
parent.isMemberExpression() &&
parent.get('object') === reference
) {
if (parent.node.computed || parent.node.property.type !== 'Identifier') {
continue;
}
const checkFn = importSpecifierChecks.get(parent.node.property.name);
if (checkFn != null) {
getOrInsertWith(filteredReferences, checkFn, () => []).push(parent);
}
}
}
for (const [checkFn, references] of filteredReferences) {
checkFn(references, state);
}
}
function transformProgram(
path: NodePath<t.Program>,
moduleLoadChecks: Map<string, Map<string, CheckInvalidReferenceFn>>,
filename: string | null,
logger: Logger | null,
compileResult: CompileProgramMetadata | null,
): void {
const traversalState: TraversalState = {
shouldInvalidateScopes: true,
program: path,
filename,
logger,
transformErrors: compileResult?.retryErrors ?? [],
};
path.traverse({
ImportDeclaration(path: NodePath<t.ImportDeclaration>) {
const importSpecifierChecks = moduleLoadChecks.get(
path.node.source.value,
);
if (importSpecifierChecks == null) {
return;
}
const specifiers = path.get('specifiers');
for (const specifier of specifiers) {
if (specifier.isImportSpecifier()) {
validateImportSpecifier(
specifier,
importSpecifierChecks,
traversalState,
);
} else {
validateNamespacedImport(
specifier as NodePath<
t.ImportNamespaceSpecifier | t.ImportDefaultSpecifier
>,
importSpecifierChecks,
traversalState,
);
}
}
},
});
}

View File

@@ -561,10 +561,8 @@ function lowerStatement(
const initBlock = builder.enter('loop', _blockId => {
const init = stmt.get('init');
if (init.node == null) {
/*
* No init expression (e.g., `for (; ...)`), add a placeholder to avoid
* invariant about empty blocks
*/
// No init expression (e.g., `for (; ...)`), add a placeholder to avoid
// invariant about empty blocks
lowerValueToTemporary(builder, {
kind: 'Primitive',
value: undefined,

View File

@@ -640,6 +640,9 @@ export class Environment {
case 'ssr': {
return true;
}
case 'client-no-memo': {
return false;
}
default: {
assertExhaustive(
this.outputMode,
@@ -656,7 +659,8 @@ export class Environment {
// linting also enables memoization so that we can check if manual memoization is preserved
return true;
}
case 'ssr': {
case 'ssr':
case 'client-no-memo': {
return false;
}
default: {
@@ -675,6 +679,9 @@ export class Environment {
case 'ssr': {
return true;
}
case 'client-no-memo': {
return false;
}
default: {
assertExhaustive(
this.outputMode,
@@ -734,6 +741,13 @@ export class Environment {
} else {
this.#errors.pushErrorDetail(error);
}
if (this.logger != null) {
this.logger.logEvent(this.filename, {
kind: 'CompileError',
detail: error,
fnLoc: null,
});
}
}
/**

View File

@@ -230,7 +230,7 @@ function validateInferredDep(
temporaries: Map<IdentifierId, ManualMemoDependency>,
declsWithinMemoBlock: Set<DeclarationId>,
validDepsInMemoBlock: Array<ManualMemoDependency>,
env: Environment,
errorState: Environment,
memoLocation: SourceLocation,
): void {
let normalizedDep: ManualMemoDependency;
@@ -280,7 +280,7 @@ function validateInferredDep(
errorDiagnostic = merge(errorDiagnostic ?? compareResult, compareResult);
}
}
env.recordError(
errorState.recordError(
CompilerDiagnostic.create({
category: ErrorCategory.PreserveManualMemo,
reason: 'Existing memoization could not be preserved',

View File

@@ -17,7 +17,7 @@ function Component() {
## Error
```
Found 3 errors:
Found 6 errors:
Error: Cannot call impure function during render
@@ -57,6 +57,45 @@ error.invalid-impure-functions-in-render.ts:6:15
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
9 |
Error: Cannot call impure function during render
`Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:4:15
2 |
3 | function Component() {
> 4 | const date = Date.now();
| ^^^^^^^^ Cannot call impure function
5 | const now = performance.now();
6 | const rand = Math.random();
7 | return <Foo date={date} now={now} rand={rand} />;
Error: Cannot call impure function during render
`performance.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:5:14
3 | function Component() {
4 | const date = Date.now();
> 5 | const now = performance.now();
| ^^^^^^^^^^^^^^^ Cannot call impure function
6 | const rand = Math.random();
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
Error: Cannot call impure function during render
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:6:15
4 | const date = Date.now();
5 | const now = performance.now();
> 6 | const rand = Math.random();
| ^^^^^^^^^^^ Cannot call impure function
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
9 |
```

View File

@@ -58,6 +58,7 @@ export const FIXTURE_ENTRYPOINT = {
## Logs
```
{"kind":"CompileError","detail":{"options":{"category":"PreserveManualMemo","reason":"Existing memoization could not be preserved","description":"React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `value`, but the source dependencies were []. Inferred dependency not present in source","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":9,"column":31,"index":337},"end":{"line":9,"column":52,"index":358},"filename":"dynamic-gating-bailout-nopanic.ts"},"message":"Could not preserve existing manual memoization"}]}},"fnLoc":null}
{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":255},"end":{"line":16,"column":1,"index":482},"filename":"dynamic-gating-bailout-nopanic.ts"},"detail":{"options":{"category":"PreserveManualMemo","reason":"Existing memoization could not be preserved","description":"React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `value`, but the source dependencies were []. Inferred dependency not present in source","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":9,"column":31,"index":337},"end":{"line":9,"column":52,"index":358},"filename":"dynamic-gating-bailout-nopanic.ts"},"message":"Could not preserve existing manual memoization"}]}}}
```

View File

@@ -17,7 +17,7 @@ function Component() {
## Error
```
Found 3 errors:
Found 6 errors:
Error: Cannot call impure function during render
@@ -57,6 +57,45 @@ error.invalid-impure-functions-in-render.ts:6:15
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
9 |
Error: Cannot call impure function during render
`Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:4:15
2 |
3 | function Component() {
> 4 | const date = Date.now();
| ^^^^^^^^ Cannot call impure function
5 | const now = performance.now();
6 | const rand = Math.random();
7 | return <Foo date={date} now={now} rand={rand} />;
Error: Cannot call impure function during render
`performance.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:5:14
3 | function Component() {
4 | const date = Date.now();
> 5 | const now = performance.now();
| ^^^^^^^^^^^^^^^ Cannot call impure function
6 | const rand = Math.random();
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
Error: Cannot call impure function during render
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:6:15
4 | const date = Date.now();
5 | const now = performance.now();
> 6 | const rand = Math.random();
| ^^^^^^^^^^^ Cannot call impure function
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
9 |
```

View File

@@ -64,9 +64,6 @@ testRule(
makeTestCaseError(
'Capitalized functions are reserved for components',
),
makeTestCaseError(
'Capitalized functions are reserved for components',
),
],
},
],

View File

@@ -57,6 +57,7 @@ testRule('plugin-recommended', TestRecommendedRules, {
],
invalid: [
{
// TODO: actually return multiple diagnostics in this case
name: 'Multiple diagnostic kinds from the same function are surfaced',
code: normalizeIndent`
import Child from './Child';
@@ -69,7 +70,6 @@ testRule('plugin-recommended', TestRecommendedRules, {
`,
errors: [
makeTestCaseError('Hooks must always be called in a consistent order'),
makeTestCaseError('Capitalized functions are reserved for components'),
],
},
{
@@ -128,7 +128,6 @@ testRule('plugin-recommended', TestRecommendedRules, {
makeTestCaseError(
'Calling setState from useMemo may trigger an infinite loop',
),
makeTestCaseError('Found extra memoization dependencies'),
],
},
],

View File

@@ -3991,23 +3991,9 @@ function attemptEarlyBailoutIfNoScheduledUpdate(
// whether to retry the primary children, or to skip over it and
// go straight to the fallback. Check the priority of the primary
// child fragment.
//
// Propagate context changes first. If a parent context changed
// and the primary children's consumer fibers were discarded
// during initial mount suspension, normal propagation can't find
// them. In that case we conservatively retry the boundary — the
// re-mounted children will read the updated context value.
const contextChanged = lazilyPropagateParentContextChanges(
current,
workInProgress,
renderLanes,
);
const primaryChildFragment: Fiber = (workInProgress.child: any);
const primaryChildLanes = primaryChildFragment.childLanes;
if (
contextChanged ||
includesSomeLane(renderLanes, primaryChildLanes)
) {
if (includesSomeLane(renderLanes, primaryChildLanes)) {
// The primary children have pending work. Use the normal path
// to attempt to render the primary children again.
return updateSuspenseComponent(current, workInProgress, renderLanes);

View File

@@ -20,11 +20,7 @@ import type {Hook} from './ReactFiberHooks';
import {isPrimaryRenderer, HostTransitionContext} from './ReactFiberConfig';
import {createCursor, push, pop} from './ReactFiberStack';
import {
ContextProvider,
DehydratedFragment,
SuspenseComponent,
} from './ReactWorkTags';
import {ContextProvider, DehydratedFragment} from './ReactWorkTags';
import {NoLanes, isSubsetOfLanes, mergeLanes} from './ReactFiberLane';
import {
NoFlags,
@@ -299,37 +295,6 @@ function propagateContextChanges<T>(
workInProgress,
);
nextFiber = null;
} else if (
fiber.tag === SuspenseComponent &&
fiber.memoizedState !== null &&
fiber.memoizedState.dehydrated === null
) {
// This is a client-rendered Suspense boundary that is currently
// showing its fallback. The primary children may include context
// consumers, but their fibers may not exist in the tree — during
// initial mount, if the primary children suspended, their fibers
// were discarded since there was no current tree to preserve them.
// We can't walk into the primary tree to find consumers, so
// conservatively mark the Suspense boundary itself for retry.
// When it re-renders, it will re-mount the primary children,
// which will read the updated context value.
fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, renderLanes);
}
scheduleContextWorkOnParentPath(
fiber.return,
renderLanes,
workInProgress,
);
if (!forcePropagateEntireTree) {
// During lazy propagation, we can defer propagating changes to
// the children, same as the consumer match above.
nextFiber = null;
} else {
nextFiber = fiber.child;
}
} else {
// Traverse down.
nextFiber = fiber.child;
@@ -366,9 +331,9 @@ export function lazilyPropagateParentContextChanges(
current: Fiber,
workInProgress: Fiber,
renderLanes: Lanes,
): boolean {
) {
const forcePropagateEntireTree = false;
return propagateParentContextChanges(
propagateParentContextChanges(
current,
workInProgress,
renderLanes,
@@ -399,7 +364,7 @@ function propagateParentContextChanges(
workInProgress: Fiber,
renderLanes: Lanes,
forcePropagateEntireTree: boolean,
): boolean {
) {
// Collect all the parent providers that changed. Since this is usually small
// number, we use an Array instead of Set.
let contexts = null;
@@ -495,7 +460,6 @@ function propagateParentContextChanges(
// then we could remove both `DidPropagateContext` and `NeedsPropagation`.
// Consider this as part of the next refactor to the fiber tree structure.
workInProgress.flags |= DidPropagateContext;
return contexts !== null;
}
export function checkIfContextChanged(

View File

@@ -2,7 +2,6 @@ let React;
let ReactNoop;
let Scheduler;
let act;
let use;
let useState;
let useContext;
let Suspense;
@@ -20,7 +19,6 @@ describe('ReactLazyContextPropagation', () => {
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
use = React.use;
useState = React.useState;
useContext = React.useContext;
Suspense = React.Suspense;
@@ -939,102 +937,4 @@ describe('ReactLazyContextPropagation', () => {
assertLog(['B', 'B']);
expect(root).toMatchRenderedOutput('BB');
});
it('regression: context change triggers retry of suspended Suspense boundary on initial mount', async () => {
// Regression test for a bug where a context change above a suspended
// Suspense boundary would fail to trigger a retry. When a Suspense
// boundary suspends during initial mount, the primary children's fibers
// are discarded because there is no current tree to preserve them. If
// the suspended promise never resolves, the only way to retry is
// something external — like a context change. Context propagation must
// mark suspended Suspense boundaries for retry even though the consumer
// fibers no longer exist in the tree.
//
// The Provider component owns the state update. The children are
// passed in from above, so they are not re-created when the Provider
// re-renders — this means the Suspense boundary bails out, exercising
// the lazy context propagation path where the bug manifests.
const Context = React.createContext(null);
const neverResolvingPromise = new Promise(() => {});
const resolvedThenable = {status: 'fulfilled', value: 'Result', then() {}};
function Consumer() {
return <Text text={use(use(Context))} />;
}
let setPromise;
function Provider({children}) {
const [promise, _setPromise] = useState(neverResolvingPromise);
setPromise = _setPromise;
return <Context.Provider value={promise}>{children}</Context.Provider>;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<Provider>
<Suspense fallback={<Text text="Loading" />}>
<Consumer />
</Suspense>
</Provider>,
);
});
assertLog(['Loading']);
expect(root).toMatchRenderedOutput('Loading');
await act(() => {
setPromise(resolvedThenable);
});
assertLog(['Result']);
expect(root).toMatchRenderedOutput('Result');
});
it('regression: context change triggers retry of suspended Suspense boundary on initial mount (nested)', async () => {
// Same as above, but with an additional indirection component between
// the provider and the Suspense boundary. This exercises the
// propagateContextChanges walker path rather than the
// propagateParentContextChanges path.
const Context = React.createContext(null);
const neverResolvingPromise = new Promise(() => {});
const resolvedThenable = {status: 'fulfilled', value: 'Result', then() {}};
function Consumer() {
return <Text text={use(use(Context))} />;
}
function Indirection({children}) {
Scheduler.log('Indirection');
return children;
}
let setPromise;
function Provider({children}) {
const [promise, _setPromise] = useState(neverResolvingPromise);
setPromise = _setPromise;
return <Context.Provider value={promise}>{children}</Context.Provider>;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<Provider>
<Indirection>
<Suspense fallback={<Text text="Loading" />}>
<Consumer />
</Suspense>
</Indirection>
</Provider>,
);
});
assertLog(['Indirection', 'Loading']);
expect(root).toMatchRenderedOutput('Loading');
// Indirection should not re-render — only the Suspense boundary
// should be retried.
await act(() => {
setPromise(resolvedThenable);
});
assertLog(['Result']);
expect(root).toMatchRenderedOutput('Result');
});
});