Skip to content

Commit 5e9eedb

Browse files
authored
[Flight] Clear chunk reason after successful module initialization (facebook#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()`.
1 parent 1e31523 commit 5e9eedb

File tree

3 files changed

+94
-0
lines changed

3 files changed

+94
-0
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,8 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
10401040
// Initialize any debug info and block the initializing chunk on any
10411041
// unresolved entries.
10421042
initializeDebugChunk(response, chunk);
1043+
// TODO: The chunk might have transitioned to ERRORED now.
1044+
// Should we return early if that happens?
10431045
}
10441046

10451047
try {
@@ -1075,6 +1077,7 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
10751077
const initializedChunk: InitializedChunk<T> = (chunk: any);
10761078
initializedChunk.status = INITIALIZED;
10771079
initializedChunk.value = value;
1080+
initializedChunk.reason = null;
10781081

10791082
if (__DEV__) {
10801083
processChunkDebugInfo(response, initializedChunk, value);
@@ -1097,6 +1100,7 @@ function initializeModuleChunk<T>(chunk: ResolvedModuleChunk<T>): void {
10971100
const initializedChunk: InitializedChunk<T> = (chunk: any);
10981101
initializedChunk.status = INITIALIZED;
10991102
initializedChunk.value = value;
1103+
initializedChunk.reason = null;
11001104
} catch (error) {
11011105
const erroredChunk: ErroredChunk<T> = (chunk: any);
11021106
erroredChunk.status = ERRORED;

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1418,6 +1418,95 @@ describe('ReactFlightDOM', () => {
14181418
expect(reportedErrors).toEqual([]);
14191419
});
14201420

1421+
it('should not retain stale error reason after reentrant module chunk initialization', async () => {
1422+
function MyComponent() {
1423+
return <div>hello from client component</div>;
1424+
}
1425+
const ClientComponent = clientExports(MyComponent);
1426+
1427+
let resolveAsyncComponent;
1428+
async function AsyncComponent() {
1429+
await new Promise(r => {
1430+
resolveAsyncComponent = r;
1431+
});
1432+
return null;
1433+
}
1434+
1435+
function ServerComponent() {
1436+
return (
1437+
<>
1438+
<ClientComponent />
1439+
<Suspense>
1440+
<AsyncComponent />
1441+
</Suspense>
1442+
</>
1443+
);
1444+
}
1445+
1446+
const {writable: flightWritable, readable: flightReadable} =
1447+
getTestStream();
1448+
const {writable: fizzWritable, readable: fizzReadable} = getTestStream();
1449+
1450+
const {pipe} = await serverAct(() =>
1451+
ReactServerDOMServer.renderToPipeableStream(
1452+
<ServerComponent />,
1453+
webpackMap,
1454+
),
1455+
);
1456+
pipe(flightWritable);
1457+
1458+
let response = null;
1459+
function getResponse() {
1460+
if (response === null) {
1461+
response =
1462+
ReactServerDOMClient.createFromReadableStream(flightReadable);
1463+
}
1464+
return response;
1465+
}
1466+
1467+
// Simulate a module that calls captureOwnerStack() during evaluation.
1468+
// In Fizz SSR, this causes a reentrant readChunk on the same module chunk.
1469+
// The reentrant require throws a TDZ error.
1470+
let evaluatingModuleId = null;
1471+
const origRequire = global.__webpack_require__;
1472+
global.__webpack_require__ = function (id) {
1473+
if (id === evaluatingModuleId) {
1474+
throw new ReferenceError(
1475+
"Cannot access 'MyComponent' before initialization",
1476+
);
1477+
}
1478+
const result = origRequire(id);
1479+
if (result === MyComponent) {
1480+
evaluatingModuleId = id;
1481+
if (__DEV__) {
1482+
React.captureOwnerStack();
1483+
}
1484+
evaluatingModuleId = null;
1485+
}
1486+
return result;
1487+
};
1488+
1489+
function App() {
1490+
return use(getResponse());
1491+
}
1492+
1493+
await serverAct(async () => {
1494+
ReactDOMFizzServer.renderToPipeableStream(<App />).pipe(fizzWritable);
1495+
});
1496+
1497+
global.__webpack_require__ = origRequire;
1498+
1499+
// Resolve the async component so the Flight stream closes after the client
1500+
// module chunk was initialized.
1501+
await serverAct(async () => {
1502+
resolveAsyncComponent();
1503+
});
1504+
1505+
const container = document.createElement('div');
1506+
await readInto(container, fizzReadable);
1507+
expect(container.innerHTML).toContain('hello from client component');
1508+
});
1509+
14211510
it('should be able to recover from a direct reference erroring server-side', async () => {
14221511
const reportedErrors = [];
14231512

packages/react-server/src/ReactFlightReplyServer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ function loadServerReference<A: Iterable<any>, T>(
478478
const initializedPromise: InitializedChunk<T> = (blockedPromise: any);
479479
initializedPromise.status = INITIALIZED;
480480
initializedPromise.value = resolvedValue;
481+
initializedPromise.reason = null;
481482
return resolvedValue;
482483
}
483484
} else if (bound instanceof ReactPromise) {

0 commit comments

Comments
 (0)