Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d808047504 | ||
|
|
8b6b11f703 | ||
|
|
ab18f33d46 |
@@ -11,7 +11,6 @@ import {
|
||||
injectReanimatedFlag,
|
||||
pipelineUsesReanimatedPlugin,
|
||||
} from '../Entrypoint/Reanimated';
|
||||
import validateNoUntransformedReferences from '../Entrypoint/ValidateNoUntransformedReferences';
|
||||
import {CompilerError} from '..';
|
||||
|
||||
const ENABLE_REACT_COMPILER_TIMINGS =
|
||||
@@ -64,19 +63,12 @@ export default function BabelPluginReactCompiler(
|
||||
},
|
||||
};
|
||||
}
|
||||
const result = compileProgram(prog, {
|
||||
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',
|
||||
|
||||
@@ -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 {BabelFn, getReactCompilerRuntimeModule} from './Program';
|
||||
import {getReactCompilerRuntimeModule} from './Program';
|
||||
import {SuppressionRange} from './Suppression';
|
||||
|
||||
export function validateRestrictedImports(
|
||||
@@ -84,11 +84,6 @@ 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,
|
||||
|
||||
@@ -228,8 +228,6 @@ 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',
|
||||
]);
|
||||
|
||||
@@ -350,9 +350,6 @@ function isFilePartOfSources(
|
||||
return false;
|
||||
}
|
||||
|
||||
export type CompileProgramMetadata = {
|
||||
retryErrors: Array<{fn: BabelFn; error: CompilerError}>;
|
||||
};
|
||||
/**
|
||||
* Main entrypoint for React Compiler.
|
||||
*
|
||||
@@ -363,7 +360,7 @@ export type CompileProgramMetadata = {
|
||||
export function compileProgram(
|
||||
program: NodePath<t.Program>,
|
||||
pass: CompilerPass,
|
||||
): CompileProgramMetadata | null {
|
||||
): void {
|
||||
/**
|
||||
* This is directly invoked by the react-compiler babel plugin, so exceptions
|
||||
* thrown by this function will fail the babel build.
|
||||
@@ -376,7 +373,7 @@ export function compileProgram(
|
||||
* the outlined functions.
|
||||
*/
|
||||
if (shouldSkipCompilation(program, pass)) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
const restrictedImportsErr = validateRestrictedImports(
|
||||
program,
|
||||
@@ -384,7 +381,7 @@ export function compileProgram(
|
||||
);
|
||||
if (restrictedImportsErr) {
|
||||
handleError(restrictedImportsErr, pass, null);
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* Record lint errors and critical errors as depending on Forget's config,
|
||||
@@ -478,15 +475,11 @@ export function compileProgram(
|
||||
);
|
||||
handleError(error, programContext, null);
|
||||
}
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert React Compiler generated functions into the Babel AST
|
||||
applyCompiledFunctions(program, compiledFns, pass, programContext);
|
||||
|
||||
return {
|
||||
retryErrors: programContext.retryErrors,
|
||||
};
|
||||
}
|
||||
|
||||
type CompileSource = {
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -629,9 +629,6 @@ export class Environment {
|
||||
case 'ssr': {
|
||||
return true;
|
||||
}
|
||||
case 'client-no-memo': {
|
||||
return false;
|
||||
}
|
||||
default: {
|
||||
assertExhaustive(
|
||||
this.outputMode,
|
||||
@@ -648,8 +645,7 @@ export class Environment {
|
||||
// linting also enables memoization so that we can check if manual memoization is preserved
|
||||
return true;
|
||||
}
|
||||
case 'ssr':
|
||||
case 'client-no-memo': {
|
||||
case 'ssr': {
|
||||
return false;
|
||||
}
|
||||
default: {
|
||||
@@ -668,9 +664,6 @@ export class Environment {
|
||||
case 'ssr': {
|
||||
return true;
|
||||
}
|
||||
case 'client-no-memo': {
|
||||
return false;
|
||||
}
|
||||
default: {
|
||||
assertExhaustive(
|
||||
this.outputMode,
|
||||
|
||||
@@ -3991,9 +3991,23 @@ 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 (includesSomeLane(renderLanes, primaryChildLanes)) {
|
||||
if (
|
||||
contextChanged ||
|
||||
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);
|
||||
|
||||
@@ -20,7 +20,11 @@ import type {Hook} from './ReactFiberHooks';
|
||||
|
||||
import {isPrimaryRenderer, HostTransitionContext} from './ReactFiberConfig';
|
||||
import {createCursor, push, pop} from './ReactFiberStack';
|
||||
import {ContextProvider, DehydratedFragment} from './ReactWorkTags';
|
||||
import {
|
||||
ContextProvider,
|
||||
DehydratedFragment,
|
||||
SuspenseComponent,
|
||||
} from './ReactWorkTags';
|
||||
import {NoLanes, isSubsetOfLanes, mergeLanes} from './ReactFiberLane';
|
||||
import {
|
||||
NoFlags,
|
||||
@@ -295,6 +299,37 @@ 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;
|
||||
@@ -331,9 +366,9 @@ export function lazilyPropagateParentContextChanges(
|
||||
current: Fiber,
|
||||
workInProgress: Fiber,
|
||||
renderLanes: Lanes,
|
||||
) {
|
||||
): boolean {
|
||||
const forcePropagateEntireTree = false;
|
||||
propagateParentContextChanges(
|
||||
return propagateParentContextChanges(
|
||||
current,
|
||||
workInProgress,
|
||||
renderLanes,
|
||||
@@ -364,7 +399,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;
|
||||
@@ -460,6 +495,7 @@ 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(
|
||||
|
||||
@@ -2,6 +2,7 @@ let React;
|
||||
let ReactNoop;
|
||||
let Scheduler;
|
||||
let act;
|
||||
let use;
|
||||
let useState;
|
||||
let useContext;
|
||||
let Suspense;
|
||||
@@ -19,6 +20,7 @@ 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;
|
||||
@@ -937,4 +939,102 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user