Skip to content

Commit ab85702

Browse files
authored
fix: unstable_io() returns hanging promise during prerendering (#979)
* fix: unstable_io() returns hanging promise during prerendering (#972) During prerendering (static export, --prerender-all, TPR), unstable_io() must return a hanging promise to suspend React past the IO boundary. Previously it always returned a resolved promise, matching only the browser/client implementation. Changes: - Add makeHangingPromise utility (never-resolving promise, rejects on abort) - Define typed WorkUnitStore discriminated union in workUnitAsyncStorage - unstable_io() branches on work unit store type per Next.js's io.ts - Set prerender work unit store in RSC entry when VINEXT_PRERENDER=1 - Add tests for prerender, request, cache, and abort scenarios * fix: add knip entries for prerender-work-unit and work-unit types (#972) * fix(prerender): address PR review feedback for unstable_io work unit store - AbortController now properly aborted in .finally() to prevent listener leaks - Add optional route field to PrerenderStore for better error messages - Pass pathname as route in app-rsc-entry handler - Use actual route instead of hardcoded 'unknown' in unstable_io() - Add explicit return in default branch of cache.ts for clarity - Remove premature knip entry for make-hanging-promise.ts - Replace V8-internal .status check with Promise.race pattern in tests * fix(app-rsc-entry): place route option in correct location Move { route: __pathname } to __runWithPrerenderWorkUnit() call instead of incorrectly appending to _handleRequest closing brace. Fixes parse error introduced in 035a54f. Refs #979 * fix: address prerender hanging promise review feedback - Suppress unhandled rejection when signal already aborted in makeHangingPromise - Add lazy pathname extraction via getter function in runWithPrerenderWorkUnit - Add listener array cleanup after abort in makeHangingPromise - Add test for unhandled rejection suppression on early-return path - Update entry template snapshots for lazy route getter * fix: resolve merge conflicts with main for prerender work unit - Take main's refactored app-rsc-entry.ts as base (extracted runtime primitives) - Apply prerender-work-unit wrapping around handler function - Update tests to match refactored code structure (delegated helpers) - Update entry-templates snapshots for new generated code structure * fix: resolve merge conflicts with main for test files - Keep main's tests/app-router.test.ts (already has correct refactored tests) - Update entry-templates snapshots to include prerender-work-unit generated code
1 parent d45c590 commit ab85702

8 files changed

Lines changed: 359 additions & 23 deletions

File tree

knip.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ export default {
5757
"src/shims/internal/api-utils.ts",
5858
"src/shims/internal/app-router-context.ts",
5959
"src/shims/internal/utils.ts",
60+
// Typed WorkUnitStore exports consumed by cache.ts via AsyncLocalStorage
61+
// generic — knip cannot trace type-only dependencies through ALS.
62+
"src/shims/internal/work-unit-async-storage.ts",
63+
// Imported via template string in app-rsc-entry.ts (generated code),
64+
// so knip cannot trace the import statically.
65+
"src/server/prerender-work-unit-setup.ts",
6066
],
6167
project: ["src/**/*.{ts,tsx}"],
6268
},

packages/vinext/src/entries/app-rsc-entry.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ const appPrerenderEndpointsPath = resolveEntryPath(
7474
"../server/app-prerender-endpoints.js",
7575
import.meta.url,
7676
);
77+
const prerenderWorkUnitSetupPath = resolveEntryPath(
78+
"../server/prerender-work-unit-setup.js",
79+
import.meta.url,
80+
);
7781
const rscStreamHintsPath = resolveEntryPath("../server/rsc-stream-hints.js", import.meta.url);
7882
const isrCachePath = resolveEntryPath("../server/isr-cache.js", import.meta.url);
7983
const rootParamsShimPath = resolveEntryPath("../shims/root-params.js", import.meta.url);
@@ -285,6 +289,7 @@ import {
285289
} from ${JSON.stringify(isrCachePath)};
286290
// Import server-only state module to register ALS-backed accessors.
287291
import "vinext/navigation-state";
292+
import { runWithPrerenderWorkUnit as __runWithPrerenderWorkUnit } from ${JSON.stringify(prerenderWorkUnitSetupPath)};
288293
import { runWithRequestContext as _runWithUnifiedCtx, createRequestContext as _createUnifiedCtx } from "vinext/unified-request-context";
289294
import { reportRequestError as _reportRequestError } from "vinext/instrumentation";
290295
import { flattenErrorCauses as __flattenErrorCauses } from ${JSON.stringify(errorCausePath)};
@@ -734,7 +739,8 @@ export default async function handler(request, ctx) {
734739
executionContext: ctx ?? _getRequestExecutionContext() ?? null,
735740
unstableCacheRevalidation: "background",
736741
});
737-
return _runWithUnifiedCtx(__uCtx, async () => {
742+
return _runWithUnifiedCtx(__uCtx, () =>
743+
__runWithPrerenderWorkUnit(async () => {
738744
_ensureFetchPatch();
739745
const __reqCtx = requestContextFromRequest(request);
740746
// Per-request container for middleware state. Passed into
@@ -775,7 +781,8 @@ export default async function handler(request, ctx) {
775781
}
776782
}
777783
return response;
778-
});
784+
}, { route: () => new URL(request.url).pathname })
785+
);
779786
}
780787
781788
async function _handleRequest(request, __reqCtx, _mwCtx) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Sets up the work unit async storage for prerendering.
3+
*
4+
* When VINEXT_PRERENDER=1, wraps execution in a workUnitAsyncStorage.run()
5+
* with a PrerenderStore so that dynamic APIs (e.g., unstable_io()) can
6+
* detect the prerender context and return hanging promises.
7+
*
8+
* Used by: app-rsc-entry.ts handler template.
9+
*
10+
* TODO: If future dynamic APIs need request-scoped stores for normal (non-prerender)
11+
* requests, add a `{ type: "request" }` store during normal request handling.
12+
*/
13+
import { workUnitAsyncStorage } from "../shims/internal/work-unit-async-storage.js";
14+
15+
export function runWithPrerenderWorkUnit<T>(
16+
fn: () => Promise<T>,
17+
options?: { route?: string | (() => string) },
18+
): Promise<T> {
19+
if (process.env.VINEXT_PRERENDER === "1") {
20+
const controller = new AbortController();
21+
const route = typeof options?.route === "function" ? options.route() : options?.route;
22+
return workUnitAsyncStorage
23+
.run(
24+
{
25+
type: "prerender",
26+
renderSignal: controller.signal,
27+
route,
28+
},
29+
fn,
30+
)
31+
.finally(() => controller.abort());
32+
}
33+
return fn();
34+
}

packages/vinext/src/shims/cache.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
getRequestContext,
2626
runWithUnifiedStateMutation,
2727
} from "./unified-request-context.js";
28+
import { workUnitAsyncStorage } from "./internal/work-unit-async-storage.js";
29+
import { makeHangingPromise } from "./internal/make-hanging-promise.js";
2830

2931
// ---------------------------------------------------------------------------
3032
// Lazy accessor for cache context — avoids circular imports with cache-runtime.
@@ -447,17 +449,58 @@ const _resolvedIOPromise: Promise<void> = Promise.resolve(undefined);
447449
(_resolvedIOPromise as unknown as Record<string, unknown>).value = undefined;
448450

449451
/**
450-
* Marks an IO boundary in server components by returning a resolved promise.
452+
* Marks an IO boundary in server components by returning a resolved promise
453+
* during requests and a hanging promise during prerendering.
451454
*
452455
* See: https://github.com/vercel/next.js/pull/92521
453456
* Guard removed: https://github.com/vercel/next.js/pull/92923
454457
*
455-
* In Next.js, `unstable_io()` during prerendering contexts returns a hanging
456-
* promise to prevent execution past the IO boundary. vinext does support
457-
* prerendering (static export, --prerender-all, TPR), but the hanging IO
458-
* boundary behavior is not yet implemented, so this always resolves immediately.
458+
* Ported from Next.js: packages/next/src/server/request/io.ts
459+
* https://github.com/vercel/next.js/blob/canary/packages/next/src/server/request/io.ts
460+
*
461+
* Behavior by work unit type:
462+
* - request → resolve immediately (no delay needed for dynamic SSR)
463+
* - prerender / prerender-client / prerender-runtime → hang (prevent
464+
* execution past IO boundary during static generation)
465+
* - cache / private-cache / unstable-cache → resolve immediately
466+
* (caches capture IO results at fill time)
467+
* - generate-static-params → resolve immediately (build time, no prerender to stall)
468+
* - prerender-legacy → resolve immediately (no cache components)
469+
*
470+
* When no work unit store is present (e.g. client-side, standalone script),
471+
* resolves immediately — matching the browser/client implementation.
459472
*/
460473
export function unstable_io(): Promise<void> {
474+
const workUnitStore = workUnitAsyncStorage.getStore();
475+
476+
if (workUnitStore) {
477+
switch (workUnitStore.type) {
478+
case "request":
479+
return _resolvedIOPromise;
480+
case "prerender":
481+
case "prerender-client":
482+
case "prerender-runtime":
483+
// Prevent execution past the IO boundary during prerendering.
484+
// The hanging promise suspends React's render indefinitely until
485+
// the prerender is aborted or completed.
486+
return makeHangingPromise(
487+
workUnitStore.renderSignal,
488+
/* route */ workUnitStore.route ?? "unknown",
489+
"`unstable_io()`",
490+
);
491+
case "cache":
492+
case "private-cache":
493+
case "unstable-cache":
494+
case "generate-static-params":
495+
case "prerender-legacy":
496+
return _resolvedIOPromise;
497+
default:
498+
workUnitStore satisfies never;
499+
return _resolvedIOPromise;
500+
}
501+
}
502+
503+
// No work store — outside rendering context (client, standalone script).
461504
return _resolvedIOPromise;
462505
}
463506

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* makeHangingPromise — returns a promise that never resolves during prerendering.
3+
*
4+
* When prerendering, `unstable_io()` must return a hanging promise to prevent
5+
* React from executing past the IO boundary. The promise never resolves—it only
6+
* rejects if the render signal is aborted (e.g., due to a dynamic error or
7+
* cache-fill completion).
8+
*
9+
* Ported from Next.js: packages/next/src/server/dynamic-rendering-utils.ts
10+
* https://github.com/vercel/next.js/blob/canary/packages/next/src/server/dynamic-rendering-utils.ts
11+
*/
12+
13+
class HangingPromiseRejectionError extends Error {
14+
constructor(route: string, expression: string) {
15+
super(
16+
`Route ${route} used ${expression} during prerendering but the render was aborted. ` +
17+
`This is expected when prerendering is cut short (e.g. due to a dynamic access).`,
18+
);
19+
this.name = "HangingPromiseRejectionError";
20+
}
21+
}
22+
23+
const abortListenersBySignal = new WeakMap<AbortSignal, (() => void)[]>();
24+
25+
function suppressUnhandledRejection(): void {
26+
// intentionally empty — suppresses "unhandled rejection" warnings
27+
}
28+
29+
export function makeHangingPromise<T>(
30+
signal: AbortSignal,
31+
route: string,
32+
expression: string,
33+
): Promise<T> {
34+
if (signal.aborted) {
35+
const rejected = Promise.reject(new HangingPromiseRejectionError(route, expression));
36+
rejected.catch(suppressUnhandledRejection);
37+
return rejected;
38+
}
39+
const hangingPromise = new Promise<T>((_, reject) => {
40+
const boundRejection = reject.bind(null, new HangingPromiseRejectionError(route, expression));
41+
const currentListeners = abortListenersBySignal.get(signal);
42+
if (currentListeners) {
43+
currentListeners.push(boundRejection);
44+
} else {
45+
const listeners = [boundRejection];
46+
abortListenersBySignal.set(signal, listeners);
47+
signal.addEventListener(
48+
"abort",
49+
() => {
50+
for (let i = 0; i < listeners.length; i++) {
51+
listeners[i]();
52+
}
53+
listeners.length = 0;
54+
},
55+
{ once: true },
56+
);
57+
}
58+
});
59+
// Suppress unhandled rejection — the promise is expected to be used with
60+
// React's use() which handles rejections. If never awaited, the abort
61+
// rejection is a no-op cleanup.
62+
hangingPromise.catch(suppressUnhandledRejection);
63+
return hangingPromise;
64+
}

packages/vinext/src/shims/internal/work-unit-async-storage.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,55 @@
22
* Shim for next/dist/server/app-render/work-unit-async-storage.external
33
* and next/dist/client/components/request-async-storage.external
44
*
5-
* Used by: @sentry/nextjs (runtime resolve for request context injection).
6-
* Provides a minimal AsyncLocalStorage-like export that Sentry can detect
7-
* and use without crashing.
5+
* Tracks the current rendering context type so that dynamic APIs
6+
* (unstable_io, headers, cookies, etc.) can branch on whether they're
7+
* inside a request, prerender, cache scope, or other context.
8+
*
9+
* Used by: @sentry/nextjs (runtime resolve for request context injection),
10+
* unstable_io() for hanging-promise behavior during prerendering.
811
*/
912
import { AsyncLocalStorage } from "node:async_hooks";
1013

11-
export const workUnitAsyncStorage = new AsyncLocalStorage<unknown>();
14+
// ── WorkUnitStore discriminated union ───────────────────────────────────
15+
16+
export type RequestStore = {
17+
readonly type: "request";
18+
};
19+
20+
export type PrerenderStore = {
21+
readonly type: "prerender" | "prerender-client" | "prerender-runtime";
22+
/** AbortSignal that fires when the prerender is cancelled or completed. */
23+
readonly renderSignal: AbortSignal;
24+
/** Optional route identifier for debugging and error messages. */
25+
readonly route?: string;
26+
};
27+
28+
export type CacheStore = {
29+
readonly type: "cache" | "private-cache" | "unstable-cache";
30+
};
31+
32+
export type GenerateStaticParamsStore = {
33+
readonly type: "generate-static-params";
34+
};
35+
36+
export type PrerenderLegacyStore = {
37+
readonly type: "prerender-legacy";
38+
};
39+
40+
/**
41+
* Discriminated union of all known work unit types.
42+
* Matches Next.js's WorkUnitStore: packages/next/src/server/app-render/work-unit-async-storage.external.ts
43+
*/
44+
export type WorkUnitStore =
45+
| RequestStore
46+
| PrerenderStore
47+
| CacheStore
48+
| GenerateStaticParamsStore
49+
| PrerenderLegacyStore;
50+
51+
export type WorkUnitAsyncStorage = AsyncLocalStorage<WorkUnitStore>;
52+
53+
export const workUnitAsyncStorage: WorkUnitAsyncStorage = new AsyncLocalStorage();
1254

1355
// Legacy name (Next 13.x–14.x)
1456
export const requestAsyncStorage = workUnitAsyncStorage;

0 commit comments

Comments
 (0)