Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export default {
"src/shims/internal/api-utils.ts",
"src/shims/internal/app-router-context.ts",
"src/shims/internal/utils.ts",
// Typed WorkUnitStore exports consumed by cache.ts via AsyncLocalStorage
// generic — knip cannot trace type-only dependencies through ALS.
"src/shims/internal/work-unit-async-storage.ts",
Copy link
Copy Markdown
Contributor

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.ts imports workUnitAsyncStorage (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.

// Imported via template string in app-rsc-entry.ts (generated code),
// so knip cannot trace the import statically.
"src/server/prerender-work-unit-setup.ts",
],
project: ["src/**/*.{ts,tsx}"],
},
Expand Down
497 changes: 98 additions & 399 deletions packages/vinext/src/entries/app-rsc-entry.ts

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions packages/vinext/src/server/prerender-work-unit-setup.ts
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting: when VINEXT_PRERENDER=1 and options?.route is a function, it's evaluated eagerly here (before fn() runs). This is fine because request.url is available before the handler executes, but it does mean the route value is captured at setup time, not at the point where unstable_io() is called. That's correct for the current use case — just documenting the timing for future readers.

return workUnitAsyncStorage
.run(
{
type: "prerender",
renderSignal: controller.signal,
route,
},
fn,
)
.finally(() => controller.abort());
}
return fn();
}
53 changes: 48 additions & 5 deletions packages/vinext/src/shims/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route is hardcoded to "unknown" here. In Next.js, the actual route path is passed so the error message is actionable ("Route /api/foo used unstable_io() during prerendering...").

Not a blocker, but consider adding a route field to PrerenderStore so this can provide a useful message. Even a TODO comment would help track this.

/* route */ workUnitStore.route ?? "unknown",
"`unstable_io()`",
);
case "cache":
case "private-cache":
case "unstable-cache":
case "generate-static-params":
case "prerender-legacy":
return _resolvedIOPromise;
default:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the default branch executes workUnitStore satisfies never (which is a no-op at runtime), then falls through to the return _resolvedIOPromise at line 503. This is technically correct but somewhat subtle — a reader might expect either a return or throw in the default branch. TypeScript will catch unexpected types at compile time, but for runtime defensiveness:

Suggested change
default:
default:
workUnitStore satisfies never;
return _resolvedIOPromise;

workUnitStore satisfies never;
return _resolvedIOPromise;
}
}

// No work store — outside rendering context (client, standalone script).
return _resolvedIOPromise;
}

Expand Down
64 changes: 64 additions & 0 deletions packages/vinext/src/shims/internal/make-hanging-promise.ts
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)[]>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The abortListenersBySignal WeakMap is module-scoped, so it persists across requests. The listener array per signal can grow unboundedly if many unstable_io() calls happen within a single prerender pass. This isn't a critical issue since the WeakMap entries get GC'd when the AbortSignal is collected, but it's worth noting that the cleanup depends on the signal's abort being fired (which currently doesn't happen — see the comment on prerender-work-unit-setup.ts).

With the .finally(() => controller.abort()) fix, this cleans itself up nicely.


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));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: each call to makeHangingPromise for the same signal creates a new HangingPromiseRejectionError instance upfront (in the boundRejection). If a prerender has many unstable_io() calls (e.g., a page with dozens of IO boundaries), that's dozens of error objects allocated that may never be thrown (if the prerender completes successfully and the signal is aborted for cleanup rather than for an actual error).

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: after firing all rejection callbacks, the listeners array and its closures stay alive in the WeakMap until the signal is GC'd. Adding cleanup here would release the closure references eagerly:

Suggested change
"abort",
() => {
for (let i = 0; i < listeners.length; i++) {
() => {
for (let i = 0; i < listeners.length; i++) {
listeners[i]();
}
listeners.length = 0;

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;
}
50 changes: 46 additions & 4 deletions packages/vinext/src/shims/internal/work-unit-async-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observation for future work: Next.js's RequestStore carries a lot more state (headers, cookies, draft mode, mutableCookies, etc.). When headers() / cookies() branching is added to vinext, this type will need to grow. The current minimal { type: "request" } shape is the right starting point.

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 =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 prerender (with renderSignal) is actively used today. This will make it straightforward to add headers()/cookies() branching later without touching the type definitions.

One thing to verify when that work happens: Next.js's RequestStore type carries much more state (headers, cookies, draft mode, etc.). This minimal { type: "request" } shape is fine for now but will need to grow.

| 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;
Loading
Loading