[Flight] Clear chunk reason after successful module initialization (#36024)

When `requireModule` triggers a reentrant `readChunk` on the same module
chunk, the reentrant call can fail and set `chunk.reason` to an error.
After the outer `requireModule` succeeds, the chunk transitions to
initialized but retains the stale error as `reason`.

When the Flight response stream later closes, it iterates all chunks and
expects `reason` on initialized chunks to be a `FlightStreamController`.
Since the stale `reason` is an `Error` object instead, calling
`chunk.reason.error()` crashes with `TypeError: chunk.reason.error is
not a function`.

The reentrancy can occur when module evaluation synchronously triggers
`readChunk` on the same chunk — for example, when code called during
evaluation tries to resolve the client reference for the module that is
currently being initialized. In Fizz SSR, `captureOwnerStack()` can
trigger this because it constructs component stacks that resolve lazy
client references via `readChunk`. The reentrant `requireModule` call
returns the module's namespace object, but since the module is still
being evaluated, accessing the export binding throws a TDZ (Temporal
Dead Zone) `ReferenceError`. This sets the chunk to the errored state,
and the `ReferenceError` becomes the stale `chunk.reason` after the
outer call succeeds.

This scenario is triggered in Next.js when a client module calls an
instrumented API like `Math.random()` in module scope, which
synchronously invokes `captureOwnerStack()`.
This commit is contained in:
Hendrik Liebau
2026-03-12 19:17:24 +01:00
committed by GitHub
parent aa7cf87d1f
commit 7f4191db3c
3 changed files with 94 additions and 0 deletions
+4
View File
@@ -1040,6 +1040,8 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
// Initialize any debug info and block the initializing chunk on any
// unresolved entries.
initializeDebugChunk(response, chunk);
// TODO: The chunk might have transitioned to ERRORED now.
// Should we return early if that happens?
}
try {
@@ -1075,6 +1077,7 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
const initializedChunk: InitializedChunk<T> = (chunk: any);
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
initializedChunk.reason = null;
if (__DEV__) {
processChunkDebugInfo(response, initializedChunk, value);
@@ -1097,6 +1100,7 @@ function initializeModuleChunk<T>(chunk: ResolvedModuleChunk<T>): void {
const initializedChunk: InitializedChunk<T> = (chunk: any);
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
initializedChunk.reason = null;
} catch (error) {
const erroredChunk: ErroredChunk<T> = (chunk: any);
erroredChunk.status = ERRORED;
@@ -1418,6 +1418,95 @@ describe('ReactFlightDOM', () => {
expect(reportedErrors).toEqual([]);
});
it('should not retain stale error reason after reentrant module chunk initialization', async () => {
function MyComponent() {
return <div>hello from client component</div>;
}
const ClientComponent = clientExports(MyComponent);
let resolveAsyncComponent;
async function AsyncComponent() {
await new Promise(r => {
resolveAsyncComponent = r;
});
return null;
}
function ServerComponent() {
return (
<>
<ClientComponent />
<Suspense>
<AsyncComponent />
</Suspense>
</>
);
}
const {writable: flightWritable, readable: flightReadable} =
getTestStream();
const {writable: fizzWritable, readable: fizzReadable} = getTestStream();
const {pipe} = await serverAct(() =>
ReactServerDOMServer.renderToPipeableStream(
<ServerComponent />,
webpackMap,
),
);
pipe(flightWritable);
let response = null;
function getResponse() {
if (response === null) {
response =
ReactServerDOMClient.createFromReadableStream(flightReadable);
}
return response;
}
// Simulate a module that calls captureOwnerStack() during evaluation.
// In Fizz SSR, this causes a reentrant readChunk on the same module chunk.
// The reentrant require throws a TDZ error.
let evaluatingModuleId = null;
const origRequire = global.__webpack_require__;
global.__webpack_require__ = function (id) {
if (id === evaluatingModuleId) {
throw new ReferenceError(
"Cannot access 'MyComponent' before initialization",
);
}
const result = origRequire(id);
if (result === MyComponent) {
evaluatingModuleId = id;
if (__DEV__) {
React.captureOwnerStack();
}
evaluatingModuleId = null;
}
return result;
};
function App() {
return use(getResponse());
}
await serverAct(async () => {
ReactDOMFizzServer.renderToPipeableStream(<App />).pipe(fizzWritable);
});
global.__webpack_require__ = origRequire;
// Resolve the async component so the Flight stream closes after the client
// module chunk was initialized.
await serverAct(async () => {
resolveAsyncComponent();
});
const container = document.createElement('div');
await readInto(container, fizzReadable);
expect(container.innerHTML).toContain('hello from client component');
});
it('should be able to recover from a direct reference erroring server-side', async () => {
const reportedErrors = [];
+1
View File
@@ -478,6 +478,7 @@ function loadServerReference<A: Iterable<any>, T>(
const initializedPromise: InitializedChunk<T> = (blockedPromise: any);
initializedPromise.status = INITIALIZED;
initializedPromise.value = resolvedValue;
initializedPromise.reason = null;
return resolvedValue;
}
} else if (bound instanceof ReactPromise) {