Files
cloudflare__vinext/tests/app-segment-config.test.ts
T
Nathan Nguyen 8498e4a37e fix(app-router): emit per-page dynamic stale time metadata (#1711)
* fix(app-router): emit per-page dynamic stale time metadata

App Router pages that export unstable_dynamicStaleTime currently behave as if no page-level stale window exists. The server segment config resolver discards the export, so RSC responses cannot carry the per-page metadata that the client cache needs for independently configured pages.

The violated invariant is that route segment config resolution must preserve page-only stale-time metadata across active parallel pages before response construction. Parse the page export, reduce active page slots by shortest stale time, thread the value through generated App page dispatch, and emit it on RSC responses for the client cache to consume.

* fix(app-router): gate dynamic stale time header to dynamic-only renders

Only emit X-Vinext-Dynamic-Stale-Time when the render is truly dynamic
(not prerender or force-static), matching Next.js behavior where the
response `d` field is only set for non-static-generation renders.

The client treats the headers presence as authoritative; leaking it onto
static/prerendered RSC responses would cause the client cache to apply
dynamic stale time semantics to a statically-cached entry.

* test(app-router): add behavioral tests for dynamic stale time gating

* docs(app-page-render): add line-numbered link to Next.js source

* refactor(app-router): drop redundant Number.isFinite check

Number.isInteger already returns false for Infinity/NaN, so the
preceding Number.isFinite guard is redundant in both the dynamic
stale-time header check and resolveDynamicStaleTimeSeconds.

* fix(app-router): gate dynamic stale time on cache-capture predicate

The previous gating (isPrerender || isForceStatic) was too narrow for
production: isPrerender is only true during the build-time prerender
pass, so a production ISR render (revalidate > 0) still emitted
X-Vinext-Dynamic-Stale-Time even though that render is captured into the
ISR cache. Next.js gates on !workStore.isStaticGeneration; the runtime
analog here is shouldCaptureRscForCacheMetadata, so add it to the guard.

Adds production ISR coverage to lock down the gating.

* docs(app-router): document dynamic stale time over-gating boundary

Add a comment explaining that shouldCaptureRscForCacheMetadata is
computed from config before the stream runs, so a production
default-config route that turns dynamic via a late dynamic API loses the
advisory stale-time hint even though Next.js (!isStaticGeneration) would
emit it. The value is unknowable until after headers are built.

Adds a complementary test pinning down this boundary behavior.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 16:23:46 +01:00

298 lines
8.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isEdgeRuntime,
resolveAppPageFetchCacheMode,
resolveAppPageSegmentConfig,
} from "../packages/vinext/src/server/app-segment-config.js";
describe("resolveAppPageSegmentConfig", () => {
it("returns defaults when no segment config is present", () => {
expect(resolveAppPageSegmentConfig({})).toEqual({
revalidateSeconds: null,
});
});
it("merges route segment config from layouts and the page", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [
{ revalidate: 120, dynamicParams: false, fetchCache: "default-cache" },
{ dynamic: "error", revalidate: 60 },
],
page: { dynamic: "force-static", revalidate: 300 },
}),
).toEqual({
dynamicConfig: "force-static",
dynamicParamsConfig: false,
fetchCache: "default-cache",
revalidateSeconds: 60,
});
});
it("treats force-dynamic from any effective segment as revalidate zero", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ revalidate: 60 }],
page: { dynamic: "force-dynamic" },
}),
).toEqual({
dynamicConfig: "force-dynamic",
fetchCache: "force-no-store",
revalidateSeconds: 0,
});
});
it("derives fetchCache from static-only dynamic modes", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "error" }],
page: {},
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: false,
fetchCache: "only-cache",
revalidateSeconds: null,
});
});
it("lets explicit fetchCache override the dynamic mode default", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "error" }],
page: { fetchCache: "default-cache" },
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: false,
fetchCache: "default-cache",
revalidateSeconds: null,
});
});
it("resolves fetchCache force modes with route-level precedence", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "only-cache" }],
page: { fetchCache: "force-cache" },
}).fetchCache,
).toBe("force-cache");
expect(
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "force-no-store" }],
page: { fetchCache: "only-no-store" },
}).fetchCache,
).toBe("force-no-store");
});
it("rejects incompatible cross-segment fetchCache modes", () => {
expect(() =>
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "only-cache" }],
page: { fetchCache: "only-no-store" },
}),
).toThrow(/incompatible fetchCache/);
expect(() =>
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "force-cache" }],
page: { fetchCache: "force-no-store" },
}),
).toThrow(/incompatible fetchCache/);
expect(() =>
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "default-no-store" }],
page: { fetchCache: "auto" },
}),
).toThrow(/incompatible fetchCache/);
});
it("ignores unknown dynamic values", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "sometimes" }],
page: { dynamic: "force-static" },
}),
).toEqual({
dynamicConfig: "force-static",
dynamicParamsConfig: false,
revalidateSeconds: null,
});
});
it("defaults dynamicParams to false for static-only dynamic modes", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "error" }],
page: {},
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: false,
fetchCache: "only-cache",
revalidateSeconds: null,
});
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "force-static" }],
page: {},
}),
).toEqual({
dynamicConfig: "force-static",
dynamicParamsConfig: false,
revalidateSeconds: null,
});
});
it("lets explicit dynamicParams override static-only dynamic defaults", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "error" }],
page: { dynamicParams: true },
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: true,
fetchCache: "only-cache",
revalidateSeconds: null,
});
});
it("uses the child route runtime when segment runtimes differ", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ runtime: "edge" }],
page: { runtime: "nodejs" },
}).runtime,
).toBe("nodejs");
});
it("ignores unknown runtime values", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ runtime: "bun" }],
page: {},
}),
).toEqual({
revalidateSeconds: null,
});
});
it("keeps explicit dynamicParams false sticky across child segments", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamicParams: false }],
page: { dynamicParams: true },
}),
).toEqual({
dynamicParamsConfig: false,
revalidateSeconds: null,
});
});
it("resolves revalidate = false as Infinity (cache indefinitely)", () => {
expect(
resolveAppPageSegmentConfig({
page: { revalidate: false },
}),
).toEqual({
revalidateSeconds: Infinity,
});
});
it("resolves shortest-wins: finite revalidate beats false (Infinity)", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ revalidate: 60 }],
page: { revalidate: false },
}).revalidateSeconds,
).toBe(60);
});
it("resolves shortest-wins: false (Infinity) loses to any finite value", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ revalidate: false }],
page: { revalidate: 60 },
}).revalidateSeconds,
).toBe(60);
});
it("reads unstable_dynamicStaleTime only from page modules", () => {
// Ported from Next.js: test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts
// See also: packages/next/src/server/app-render/app-render.tsx#getDynamicStaleTime
expect(
resolveAppPageSegmentConfig({
layouts: [{ unstable_dynamicStaleTime: 5 }],
page: { unstable_dynamicStaleTime: 60 },
}),
).toEqual({
dynamicStaleTimeSeconds: 60,
revalidateSeconds: null,
});
});
it("uses the shortest unstable_dynamicStaleTime across active page slots", () => {
// Ported from Next.js: test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts
expect(
resolveAppPageSegmentConfig({
page: { unstable_dynamicStaleTime: 60 },
parallelPages: [
{ unstable_dynamicStaleTime: 15 },
{ unstable_dynamicStaleTime: 30 },
{ unstable_dynamicStaleTime: "not-a-number" },
],
}),
).toEqual({
dynamicStaleTimeSeconds: 15,
revalidateSeconds: null,
});
});
it("resolves just the fetchCache mode for route-specific render scopes", () => {
expect(
resolveAppPageFetchCacheMode({
layouts: [{ fetchCache: "only-cache" }],
page: {},
}),
).toBe("only-cache");
expect(
resolveAppPageFetchCacheMode({
layouts: [{ revalidate: 60 }],
page: {},
}),
).toBeNull();
});
it("captures the runtime export and lets child segments override parents", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ runtime: "nodejs" }],
page: { runtime: "edge" },
}).runtime,
).toBe("edge");
expect(
resolveAppPageSegmentConfig({
layouts: [{ runtime: "edge" }],
page: {},
}).runtime,
).toBe("edge");
expect(resolveAppPageSegmentConfig({ page: {} }).runtime).toBeUndefined();
});
});
describe("isEdgeRuntime", () => {
it("matches Next.js' edge-runtime values", () => {
expect(isEdgeRuntime("edge")).toBe(true);
expect(isEdgeRuntime("experimental-edge")).toBe(true);
expect(isEdgeRuntime("nodejs")).toBe(false);
expect(isEdgeRuntime(undefined)).toBe(false);
});
});