-
Notifications
You must be signed in to change notification settings - Fork 320
fix: unstable_io() returns hanging promise during prerendering #979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ea50693
f415e57
035a54f
7963603
0617b21
01b5c18
feab64b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| /** | ||
| * Sets up the work unit async storage for prerendering. | ||
| * | ||
| * When VINEXT_PRERENDER=1, wraps execution in a workUnitAsyncStorage.run() | ||
| * with a PrerenderStore so that dynamic APIs (e.g., unstable_io()) can | ||
| * detect the prerender context and return hanging promises. | ||
| * | ||
| * Used by: app-rsc-entry.ts handler template. | ||
| * | ||
| * TODO: If future dynamic APIs need request-scoped stores for normal (non-prerender) | ||
| * requests, add a `{ type: "request" }` store during normal request handling. | ||
| */ | ||
| import { workUnitAsyncStorage } from "../shims/internal/work-unit-async-storage.js"; | ||
|
|
||
| export function runWithPrerenderWorkUnit<T>( | ||
| fn: () => Promise<T>, | ||
| options?: { route?: string | (() => string) }, | ||
| ): Promise<T> { | ||
| if (process.env.VINEXT_PRERENDER === "1") { | ||
| const controller = new AbortController(); | ||
| const route = typeof options?.route === "function" ? options.route() : options?.route; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth noting: when |
||
| return workUnitAsyncStorage | ||
| .run( | ||
| { | ||
| type: "prerender", | ||
| renderSignal: controller.signal, | ||
| route, | ||
| }, | ||
| fn, | ||
| ) | ||
| .finally(() => controller.abort()); | ||
| } | ||
| return fn(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -25,6 +25,8 @@ import { | |||||||||
| getRequestContext, | ||||||||||
| runWithUnifiedStateMutation, | ||||||||||
| } from "./unified-request-context.js"; | ||||||||||
| import { workUnitAsyncStorage } from "./internal/work-unit-async-storage.js"; | ||||||||||
| import { makeHangingPromise } from "./internal/make-hanging-promise.js"; | ||||||||||
|
|
||||||||||
| // --------------------------------------------------------------------------- | ||||||||||
| // Lazy accessor for cache context — avoids circular imports with cache-runtime. | ||||||||||
|
|
@@ -447,17 +449,58 @@ const _resolvedIOPromise: Promise<void> = Promise.resolve(undefined); | |||||||||
| (_resolvedIOPromise as unknown as Record<string, unknown>).value = undefined; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Marks an IO boundary in server components by returning a resolved promise. | ||||||||||
| * Marks an IO boundary in server components by returning a resolved promise | ||||||||||
| * during requests and a hanging promise during prerendering. | ||||||||||
| * | ||||||||||
| * See: https://github.com/vercel/next.js/pull/92521 | ||||||||||
| * Guard removed: https://github.com/vercel/next.js/pull/92923 | ||||||||||
| * | ||||||||||
| * In Next.js, `unstable_io()` during prerendering contexts returns a hanging | ||||||||||
| * promise to prevent execution past the IO boundary. vinext does support | ||||||||||
| * prerendering (static export, --prerender-all, TPR), but the hanging IO | ||||||||||
| * boundary behavior is not yet implemented, so this always resolves immediately. | ||||||||||
| * Ported from Next.js: packages/next/src/server/request/io.ts | ||||||||||
| * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/request/io.ts | ||||||||||
| * | ||||||||||
| * Behavior by work unit type: | ||||||||||
| * - request → resolve immediately (no delay needed for dynamic SSR) | ||||||||||
| * - prerender / prerender-client / prerender-runtime → hang (prevent | ||||||||||
| * execution past IO boundary during static generation) | ||||||||||
| * - cache / private-cache / unstable-cache → resolve immediately | ||||||||||
| * (caches capture IO results at fill time) | ||||||||||
| * - generate-static-params → resolve immediately (build time, no prerender to stall) | ||||||||||
| * - prerender-legacy → resolve immediately (no cache components) | ||||||||||
| * | ||||||||||
| * When no work unit store is present (e.g. client-side, standalone script), | ||||||||||
| * resolves immediately — matching the browser/client implementation. | ||||||||||
| */ | ||||||||||
| export function unstable_io(): Promise<void> { | ||||||||||
| const workUnitStore = workUnitAsyncStorage.getStore(); | ||||||||||
|
|
||||||||||
| if (workUnitStore) { | ||||||||||
| switch (workUnitStore.type) { | ||||||||||
| case "request": | ||||||||||
| return _resolvedIOPromise; | ||||||||||
| case "prerender": | ||||||||||
| case "prerender-client": | ||||||||||
| case "prerender-runtime": | ||||||||||
| // Prevent execution past the IO boundary during prerendering. | ||||||||||
| // The hanging promise suspends React's render indefinitely until | ||||||||||
| // the prerender is aborted or completed. | ||||||||||
| return makeHangingPromise( | ||||||||||
| workUnitStore.renderSignal, | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The route is hardcoded to Not a blocker, but consider adding a |
||||||||||
| /* route */ workUnitStore.route ?? "unknown", | ||||||||||
| "`unstable_io()`", | ||||||||||
| ); | ||||||||||
| case "cache": | ||||||||||
| case "private-cache": | ||||||||||
| case "unstable-cache": | ||||||||||
| case "generate-static-params": | ||||||||||
| case "prerender-legacy": | ||||||||||
| return _resolvedIOPromise; | ||||||||||
| default: | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the
Suggested change
|
||||||||||
| workUnitStore satisfies never; | ||||||||||
| return _resolvedIOPromise; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // No work store — outside rendering context (client, standalone script). | ||||||||||
| return _resolvedIOPromise; | ||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,64 @@ | ||||||||||||||||||
| /** | ||||||||||||||||||
| * makeHangingPromise — returns a promise that never resolves during prerendering. | ||||||||||||||||||
| * | ||||||||||||||||||
| * When prerendering, `unstable_io()` must return a hanging promise to prevent | ||||||||||||||||||
| * React from executing past the IO boundary. The promise never resolves—it only | ||||||||||||||||||
| * rejects if the render signal is aborted (e.g., due to a dynamic error or | ||||||||||||||||||
| * cache-fill completion). | ||||||||||||||||||
| * | ||||||||||||||||||
| * Ported from Next.js: packages/next/src/server/dynamic-rendering-utils.ts | ||||||||||||||||||
| * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/dynamic-rendering-utils.ts | ||||||||||||||||||
| */ | ||||||||||||||||||
|
|
||||||||||||||||||
| class HangingPromiseRejectionError extends Error { | ||||||||||||||||||
| constructor(route: string, expression: string) { | ||||||||||||||||||
| super( | ||||||||||||||||||
| `Route ${route} used ${expression} during prerendering but the render was aborted. ` + | ||||||||||||||||||
| `This is expected when prerendering is cut short (e.g. due to a dynamic access).`, | ||||||||||||||||||
| ); | ||||||||||||||||||
| this.name = "HangingPromiseRejectionError"; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const abortListenersBySignal = new WeakMap<AbortSignal, (() => void)[]>(); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The With the |
||||||||||||||||||
|
|
||||||||||||||||||
| function suppressUnhandledRejection(): void { | ||||||||||||||||||
| // intentionally empty — suppresses "unhandled rejection" warnings | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| export function makeHangingPromise<T>( | ||||||||||||||||||
| signal: AbortSignal, | ||||||||||||||||||
| route: string, | ||||||||||||||||||
| expression: string, | ||||||||||||||||||
| ): Promise<T> { | ||||||||||||||||||
| if (signal.aborted) { | ||||||||||||||||||
| const rejected = Promise.reject(new HangingPromiseRejectionError(route, expression)); | ||||||||||||||||||
| rejected.catch(suppressUnhandledRejection); | ||||||||||||||||||
| return rejected; | ||||||||||||||||||
| } | ||||||||||||||||||
| const hangingPromise = new Promise<T>((_, reject) => { | ||||||||||||||||||
| const boundRejection = reject.bind(null, new HangingPromiseRejectionError(route, expression)); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: each call to This matches Next.js's implementation, so it's not a divergence — just noting that a possible future optimization would be to defer error creation to the abort handler. Not worth doing now. |
||||||||||||||||||
| const currentListeners = abortListenersBySignal.get(signal); | ||||||||||||||||||
| if (currentListeners) { | ||||||||||||||||||
| currentListeners.push(boundRejection); | ||||||||||||||||||
| } else { | ||||||||||||||||||
| const listeners = [boundRejection]; | ||||||||||||||||||
| abortListenersBySignal.set(signal, listeners); | ||||||||||||||||||
| signal.addEventListener( | ||||||||||||||||||
| "abort", | ||||||||||||||||||
| () => { | ||||||||||||||||||
| for (let i = 0; i < listeners.length; i++) { | ||||||||||||||||||
|
Comment on lines
+48
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: after firing all rejection callbacks, the
Suggested change
Non-blocking since the signal lifetime is bounded by the prerender work unit, but it's good hygiene. |
||||||||||||||||||
| listeners[i](); | ||||||||||||||||||
| } | ||||||||||||||||||
| listeners.length = 0; | ||||||||||||||||||
| }, | ||||||||||||||||||
| { once: true }, | ||||||||||||||||||
| ); | ||||||||||||||||||
| } | ||||||||||||||||||
| }); | ||||||||||||||||||
| // Suppress unhandled rejection — the promise is expected to be used with | ||||||||||||||||||
| // React's use() which handles rejections. If never awaited, the abort | ||||||||||||||||||
| // rejection is a no-op cleanup. | ||||||||||||||||||
| hangingPromise.catch(suppressUnhandledRejection); | ||||||||||||||||||
| return hangingPromise; | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,13 +2,55 @@ | |
| * Shim for next/dist/server/app-render/work-unit-async-storage.external | ||
| * and next/dist/client/components/request-async-storage.external | ||
| * | ||
| * Used by: @sentry/nextjs (runtime resolve for request context injection). | ||
| * Provides a minimal AsyncLocalStorage-like export that Sentry can detect | ||
| * and use without crashing. | ||
| * Tracks the current rendering context type so that dynamic APIs | ||
| * (unstable_io, headers, cookies, etc.) can branch on whether they're | ||
| * inside a request, prerender, cache scope, or other context. | ||
| * | ||
| * Used by: @sentry/nextjs (runtime resolve for request context injection), | ||
| * unstable_io() for hanging-promise behavior during prerendering. | ||
| */ | ||
| import { AsyncLocalStorage } from "node:async_hooks"; | ||
|
|
||
| export const workUnitAsyncStorage = new AsyncLocalStorage<unknown>(); | ||
| // ── WorkUnitStore discriminated union ─────────────────────────────────── | ||
|
|
||
| export type RequestStore = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Observation for future work: Next.js's |
||
| readonly type: "request"; | ||
| }; | ||
|
|
||
| export type PrerenderStore = { | ||
| readonly type: "prerender" | "prerender-client" | "prerender-runtime"; | ||
| /** AbortSignal that fires when the prerender is cancelled or completed. */ | ||
| readonly renderSignal: AbortSignal; | ||
| /** Optional route identifier for debugging and error messages. */ | ||
| readonly route?: string; | ||
| }; | ||
|
|
||
| export type CacheStore = { | ||
| readonly type: "cache" | "private-cache" | "unstable-cache"; | ||
| }; | ||
|
|
||
| export type GenerateStaticParamsStore = { | ||
| readonly type: "generate-static-params"; | ||
| }; | ||
|
|
||
| export type PrerenderLegacyStore = { | ||
| readonly type: "prerender-legacy"; | ||
| }; | ||
|
|
||
| /** | ||
| * Discriminated union of all known work unit types. | ||
| * Matches Next.js's WorkUnitStore: packages/next/src/server/app-render/work-unit-async-storage.external.ts | ||
| */ | ||
| export type WorkUnitStore = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good forward-looking design. The discriminated union covers all Next.js work unit types even though only One thing to verify when that work happens: Next.js's |
||
| | RequestStore | ||
| | PrerenderStore | ||
| | CacheStore | ||
| | GenerateStaticParamsStore | ||
| | PrerenderLegacyStore; | ||
|
|
||
| export type WorkUnitAsyncStorage = AsyncLocalStorage<WorkUnitStore>; | ||
|
|
||
| export const workUnitAsyncStorage: WorkUnitAsyncStorage = new AsyncLocalStorage(); | ||
|
|
||
| // Legacy name (Next 13.x–14.x) | ||
| export const requestAsyncStorage = workUnitAsyncStorage; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says knip can't trace type-only dependencies through ALS. That's accurate —
cache.tsimportsworkUnitAsyncStorage(a value), but knip may not follow the type exports (WorkUnitStore,PrerenderStore, etc.) since they're consumed via the generic parameter. The entry is justified.