Files
cloudflare__vinext/tests/app-segment-config.test.ts
T
James Anderson fbf68d588e fix(app-router): restore cache and parallel route parity (#2261)
* fix(app-router): preserve searchParams cache variants

* test(app-router): cover RSC metadata cache variants

* fix(app-router): isolate metadata searchParams observation

* fix(app-router): preserve static ISR cache proofs

* fix(app-router): limit queryless cache probes

* fix(app-router): skip redundant cache proof probes

* fix(app-router): harden incomplete cache proofs

* fix(app-router): observe loading page execution

* fix(app-router): preserve exotic page exports

* fix(app-router): preserve client page ISR

* fix(app-router): avoid duplicate queryless page execution

* fix(app-router): observe viewport search params

* fix(app-router): clear force-static search params

* fix(app-router): isolate intercept static context

* fix(app-router): preserve intercept request context

* fix(app-router): isolate action static context

* fix(app-router): match static request API semantics

* fix(app-router): preserve dynamic segment constraints

* fix(app-router): apply slot-only segment config

* fix(app-router): preserve draft mode reads in static errors

* fix(app-router): include nested slot constraints

* fix(app-router): refresh slot layout constraints

* fix(app-router): render nested slot layouts

* fix(app-router): preserve nested slot params

* fix(app-router): preserve intercepted slot head

* fix(app-router): scope parallel layout params

* fix(app-router): preserve intercept layout parity

* fix(app-router): preserve intercept ancestor params

* fix(app-router): scope intercept source params

* fix(app-router): preserve intercept source route params

* test(app-router): stabilize parity validation
2026-06-22 23:17:41 +01:00

418 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isEdgeRuntime,
resolveAppPageFetchCacheMode,
resolveAppPageSegmentConfig,
resolveAppRouteHandlerFetchCacheMode,
} 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",
revalidateSeconds: 0,
});
});
it("keeps ancestor force-dynamic sticky across child dynamic overrides", () => {
// Next.js create-component-tree.tsx sets workStore.forceDynamic and never
// clears it when a deeper segment selects auto/error/force-static.
for (const childDynamic of ["auto", "error", "force-static"] as const) {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "force-dynamic" }],
page: { dynamic: childDynamic },
}),
).toEqual({
dynamicConfig: "force-dynamic",
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("includes active parallel route segments in effective route config", () => {
// Ported from Next.js: packages/next/src/build/segment-config/app/app-segments.ts
// collectAppPageSegments() breadth-first traverses every parallel route before reduceAppConfig().
expect(
resolveAppPageSegmentConfig({
layouts: [{ revalidate: 300 }],
page: { dynamic: "auto", runtime: "nodejs" },
parallelSegments: [
{ fetchCache: "only-cache", revalidate: 60, runtime: "edge" },
{ dynamic: "force-dynamic", revalidate: 120 },
],
}),
).toEqual({
dynamicConfig: "force-dynamic",
fetchCache: "only-cache",
revalidateSeconds: 0,
runtime: "nodejs",
});
});
it("uses slot-only route config values", () => {
// Next.js collectAppPageSegments() includes parallel route layouts/pages
// before reduceAppConfig() selects the route-level config.
expect(
resolveAppPageSegmentConfig({
parallelSegments: [
{
dynamic: "error",
dynamicParams: false,
fetchCache: "default-cache",
runtime: "edge",
},
],
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: false,
fetchCache: "default-cache",
revalidateSeconds: null,
runtime: "edge",
});
});
it("does not invent last-wins ordering for ambiguous parallel branch configs", () => {
expect(
resolveAppPageSegmentConfig({
page: { dynamic: "error", runtime: "nodejs" },
parallelSegments: [{ dynamic: "force-static", runtime: "edge" }],
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: false,
fetchCache: "only-cache",
revalidateSeconds: null,
runtime: "nodejs",
});
expect(
resolveAppPageSegmentConfig({
page: { fetchCache: "default-cache" },
parallelSegments: [{ fetchCache: "default-no-store" }],
}).fetchCache,
).toBe("default-cache");
});
it("rejects fetchCache conflicts from active parallel route segments", () => {
expect(() =>
resolveAppPageSegmentConfig({
page: { fetchCache: "only-cache" },
parallelSegments: [{ fetchCache: "only-no-store" }],
}),
).toThrow(/incompatible fetchCache/);
});
it("lets force fetchCache modes override opposing only modes", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "only-cache" }],
page: { fetchCache: "force-no-store" },
}).fetchCache,
).toBe("force-no-store");
expect(
resolveAppPageSegmentConfig({
page: { fetchCache: "only-no-store" },
parallelSegments: [{ fetchCache: "force-cache" }],
}).fetchCache,
).toBe("force-cache");
});
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("resolveAppRouteHandlerFetchCacheMode", () => {
it("returns the handler module's fetchCache export when valid", () => {
expect(resolveAppRouteHandlerFetchCacheMode({ fetchCache: "force-cache" })).toBe("force-cache");
expect(resolveAppRouteHandlerFetchCacheMode({ fetchCache: "default-no-store" })).toBe(
"default-no-store",
);
});
it("returns null for missing or invalid fetchCache values", () => {
expect(resolveAppRouteHandlerFetchCacheMode({})).toBeNull();
expect(resolveAppRouteHandlerFetchCacheMode({ fetchCache: "bogus" })).toBeNull();
expect(resolveAppRouteHandlerFetchCacheMode({ fetchCache: 42 })).toBeNull();
});
});
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);
});
});