mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
73f56ef3f8
* fix(app-router): honor cacheLife stale on the client router
`cacheLife` profiles carry three independent numbers, and `stale` is the
client-router dimension: how long the browser may reuse cached route output
without asking the server. vinext aggregated it correctly (`resolveCacheLife`
min-reduces all three; the request scope accumulates min-wins) and then
projected it away at every consumer, so the only staleness a browser ever saw
was `dynamicStaleTimeSeconds` from `experimental.staleTimes` — a build-time
constant unrelated to the cached subtrees that produced the render. A subtree
declaring `cacheLife("seconds")` (stale: 30) was held for the full 5-minute
visited-response TTL.
Carry the resolved `stale` to the client on `x-nextjs-stale-time` (matching
Next.js's `NEXT_ROUTER_STALE_TIME_HEADER`) and combine it with the config
value by taking the minimum, so neither min-wins lattice overrides the other.
The 30s prefetch floor applies to the new value too.
Two normalization rules, both deliberate:
- An absent `stale` is never synthesized from `revalidate`/`expire`. The
`default` profile has no `stale` and an `expire` of ~136 years, so deriving
one would license session-long reuse without a refresh.
- The three numbers are not assumed to be ordered — `seconds` is
`{ stale: 30, revalidate: 1, expire: 60 }` — so `revalidate` never
constrains `stale`. Only the hard `expire` ceiling does.
* fix(app-router): source the client stale time from completed renders
The previous commit derived `x-nextjs-stale-time` from `peekRequestCacheLife()`
at response-construction time. That read happens after `probeAppPageBeforeRender`
but before the RSC stream is consumed, and the probe only awaits the page
component's own async result — it does not render the returned tree. Any
`use cache` scope in a child Server Component registers later, during stream
consumption, so the header described the probe rather than the output.
Because `cacheLife` aggregation is minimum-wins, missing one late scope is
enough to make the value wrong, and "it can only shorten" did not hold: with no
`dynamicStaleTimeSeconds` present (the usual case for `use cache` pages, which
are not dynamic renders), a peeked `stale: 300` widened the prefetch window from
`PREFETCH_CACHE_TTL` (30s) to 300s — 10x longer than today, in the direction the
change set out to fix.
Carry the value on the cache entry instead. The ISR write path reads the
request-scoped accumulation via the consuming `getRequestCacheLife()` inside the
cache-write closure, which runs after the captured stream has drained, so it
observes the completed render's minimum. `CacheControlMetadata` gains `stale`,
`isrSet` persists it, and `buildAppPageCachedResponse` re-advertises it on hits.
Prerender seeds carry it through `VINEXT_PRERENDER_CACHE_LIFE_HEADER` and the
prerender manifest so a seeded entry makes the same claim a runtime render would.
Two links that would otherwise silently drop the claim are closed with it: the
`use cache` entry write now persists `stale`, and `recordRequestScopedCacheControl`
re-registers it on a data-cache hit — without both, a page's advertised freshness
would depend on data-cache temperature rather than on what it declared.
Streaming responses now advertise nothing and leave the client on its configured
`experimental.staleTimes`, which is the honest answer for a value that is not yet
known when headers are committed. Covering fresh streaming renders needs a
render-completion signal the streaming RSC path does not have today; Next.js
solves it by streaming an `AsyncIterable<number>` in the RSC payload
(`app-render.tsx` `baseResponse.s`, closed after the render settles), which is
the natural follow-up.
The client-side combination logic is unchanged: both stale signals are still
min-reduced, absent `stale` is never synthesized from `revalidate`/`expire`,
`revalidate` never clamps `stale`, and `expire` still caps it.
* fix(app-router): deliver the resolved client stale time on fresh renders
The previous round persisted the completed render's cacheLife stale onto
the ISR entry and replayed it on cache hits, which left the value missing
or drifting wherever the entry's lifetime diverged from the render's:
- Fresh streaming renders advertised nothing, so the first visitor of any
route (and every dev render, since dev writes no ISR entry) stayed on
the configured staleTimes despite a declared cacheLife. The done-script
emitted by the RSC embed transform's finalize() runs only after the
full RSC stream has drained, so it can carry the completed render's
minimum where streaming headers cannot. Emit the peeked request-scoped
cacheLife there and seed the hydration visited-response entry from it.
This also makes the client's min-combination of the config and
cacheLife signals reachable: a dynamic render with use cache subtrees
now legitimately carries both.
- The expire clamp bounded the value but not the elapsed window: an
entry of { stale: 30, expire: 60 } hit at age 59s replayed a 30s reuse
window reaching 29s past expire. Age the serve-time clamp with the
entry's lastModified so only the remaining window is advertised.
- The two client caches applied different floors: prefetch entries
floored a cacheLife stale at 30s while cold navigations honored it
verbatim, so the same declaration produced two behaviors keyed on
whether a prefetch fired first. Floor the cacheLife signal once in the
shared resolver, mirroring Next.js getStaleTimeMs, before the min so
it can never raise the config-derived bound.
Mechanically, the app-page cache setter's six-position signature
(declared identically in four modules) collapses into one exported
AppPageCacheSetter taking an AppPageCacheWritePolicy object, with
isrSetAppPage adapting to the shared positional isrSet, which stays
unchanged for the Pages Router and route handlers.
* docs(app-router): pin the cold RSC stale contract and expire precedence
The cold RSC fetch gap is a design decision, not a pending follow-up:
Next.js's AsyncIterable stale transport only works under staged rendering
(cacheComponents), where cache scopes settle before the render task queue
drains — in vinext's lazy streaming model the iterable could never close.
Next.js's plain-mode mechanism is a blocking cold render, which #961
deliberately rejected to keep ISR page streams unblocked. Assert the
resulting no-header contract on the streaming-response test.
Also reword the write-policy expire comment: a cacheLife-declared expire
replaces the config expireTime fallback (Next.js precedence), it is not
min-merged with a route-level ceiling — no such ceiling exists outside
cacheLife.
* fix(app-router): carry stale through regen and bound cold responses
Three fixes from review round 3:
Background regeneration dropped the regenerating render's cacheLife stale:
renderAppPageCacheArtifacts returned only { revalidate, expire }, so
resolveRegeneratedAppPageCachePolicy could never receive the stale it was
built to preserve and the first regen silently widened client reuse back
to the configured fallback. The producer now carries it, with a
real-producer regression test the mocked-cacheControl tests could not
provide.
The age-aware expire clamp is removed. Composed with the client's 30s
floor it delivered neither contract (a clamped 1 re-floored to 30), and
cached HTML replayed the unclamped done-script value regardless. Next.js
stores the stale header at generation and replays it verbatim on every
hit; vinext now does the same, keeping expire a serve-side ceiling.
Cold cacheable RSC responses stream before their cacheLife resolves
(#961), which left them on the 300s client fallback — reproducing the
headline bug for the first request of every entry epoch. They now carry
X-Vinext-Stale-Time-Pending, and both client caches bound such responses
at the 30s floor: the unresolved claim, once floored, could never
license less.
* fix(app-router): bound pending-stale responses by the dynamic stale time
The pending marker meant 'capture was attempted', but the client read it
as 'a cacheLife claim exists'. Capture eligibility is decided before the
lazy stream runs, so a late request-API read can make the completed
render dynamic — the finalizer skips the ISR write and no claim ever
exists, yet the marker granted 30 seconds of reuse even under
staleTimes.dynamic: 0.
Pending responses now carry the configured dynamic stale time (including
0) and the client takes the minimum, so an unresolved response never
receives a wider window than the dynamic bound.
* fix(app-router): keep the pending cap independent of staleTimes.dynamic
Pairing the pending marker with the configured dynamic stale time broke
the segment-cache-client-params compat test: dynamic-param routes
prefetch with no minimum TTL, so the paired 0 default made every cold
prefetch entry expire instantly and navigations refetched routes that
Next.js serves entirely from a static prefetch.
A cold stream cannot distinguish a render that will resolve static from
one that turns dynamic mid-stream, and bounding both by staleTimes.dynamic
sacrifices the guaranteed-correct case for the ambiguous one. Pending
responses go back to the 30s floor cap; the late-dynamic exposure
(one epoch-cold response, at most 30s) is documented as the price of
non-blocking cold renders (#961).
* fix: carry the client stale claim through KV, the prerender index, and nested cache hits
- writePrerenderIndex now copies `stale` into vinext-prerender.json so
seedMemoryCacheFromPrerender actually receives it
- KVCacheHandler.set() and buildPrerenderKVPairs persist cacheControl.stale
so warm hits on the Cloudflare KV backend replay the producing render's
claim; validateCacheEntry accepts the field
- a nested use cache HIT pushes its stored lifetime into the enclosing
cache context's lifeConfigs, mirroring the MISS path, so the outer entry
keeps the child's stale claim once the outer goes warm
* perf: trim shipped comment bytes in the stale-time plumbing
The added modules ride in every consumer build environment; the review-grade
rationale lives in the PR body, the code keeps one-line constraints.
* refactor(app-router): model the cached client stale claim as one state
CachedRscResponse carried staleTimePending and staleTimeSeconds as two
independent optionals, so the cached form admitted a state the wire never
produces (pending and resolved at once) and every consumer had to encode
the precedence rule. Replace both with a discriminated serverStaleTime
(pending | resolved), collapsed once at the header-parse boundary.
* refactor(isr): give isrSet a cache-metadata write policy
The generic setter had grown to six positional arguments, the last an
App-Router-only `stale` value, with isrSetAppPage as a policy-object
wrapper that unpacked straight back into it. Take { cacheControl, tags }
instead: routers construct the metadata they actually claim, the wrapper
and its type disappear, and the shared isrCacheControl builder replaces
the cache-control literal duplicated across writers.
* fix(app-router): keep the dynamic bound on captured dynamic renders
The done-script metadata reused the RSC header's rule of dropping the
config-derived dynamic stale time while the speculative ISR capture was
armed. That rule only holds on the header path, which substitutes the
pending marker and its 30s floor; the done script emits the resolved
cacheLife instead, so a production render that turned dynamic shipped the
cacheLife claim as its only bound and let the hydration-seeded entry reuse
dynamic output for its full duration. Dev never took the capture path, so
the two diverged.
Also migrates the pages-basic seed fixture, the last positional isrSet
caller, which typecheck does not cover.
* ci: re-run after unrelated dev-overlay canary flake
* fix(app-router): preserve completed client stale metadata
* fix(app-router): strip completion metadata during HMR
* fix(app-router): stream completion metadata safely
* fix(app-router): validate completed stale metadata
* docs(cache): clarify zero stale client claim
---------
Co-authored-by: James <james@eli.cx>
242 lines
8.9 KiB
TypeScript
242 lines
8.9 KiB
TypeScript
/**
|
|
* CDN cache adapter unit + integration tests.
|
|
*
|
|
* Covers the page-level ISR serving-strategy split:
|
|
* - DefaultCdnCacheAdapter delegates storage to the data cache and reproduces
|
|
* the framework's existing header behavior (byte-for-byte).
|
|
* - A custom edge adapter can return null from get (origin renders fresh),
|
|
* no-op set, emit split Cache-Control + CDN-Cache-Control headers, skip
|
|
* in-process background regeneration, and purge via revalidateTag().
|
|
* - isrGet/isrSet route through the active CDN adapter.
|
|
* - revalidateTag/revalidatePath/updateTag invalidate the data cache AND ask
|
|
* the CDN adapter to purge.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
|
|
import {
|
|
DefaultCdnCacheAdapter,
|
|
getCdnCacheAdapter,
|
|
setCdnCacheAdapter,
|
|
type CdnCacheAdapter,
|
|
type CdnCacheableHeaderInput,
|
|
type CdnResponseHeaders,
|
|
} from "../packages/vinext/src/shims/cdn-cache.js";
|
|
import {
|
|
MemoryCacheHandler,
|
|
setDataCacheHandler,
|
|
setCacheHandler,
|
|
getDataCacheHandler,
|
|
getCacheHandler,
|
|
revalidateTag,
|
|
revalidatePath,
|
|
updateTag,
|
|
type CacheHandler,
|
|
} from "../packages/vinext/src/shims/cache.js";
|
|
import {
|
|
isrGet,
|
|
isrSet,
|
|
triggerBackgroundRegeneration,
|
|
buildPagesCacheValue,
|
|
} from "../packages/vinext/src/server/isr-cache.js";
|
|
import { setHeadersAccessPhase } from "../packages/vinext/src/shims/headers.js";
|
|
|
|
function resetAdapters(): void {
|
|
setDataCacheHandler(new MemoryCacheHandler());
|
|
setCdnCacheAdapter(new DefaultCdnCacheAdapter());
|
|
}
|
|
|
|
beforeEach(resetAdapters);
|
|
afterEach(resetAdapters);
|
|
|
|
// ─── Backwards-compatible data cache aliases ─────────────────────────────
|
|
|
|
describe("data cache handler aliases", () => {
|
|
it("setCacheHandler is an alias for setDataCacheHandler", () => {
|
|
const handler = new MemoryCacheHandler();
|
|
setCacheHandler(handler);
|
|
expect(getDataCacheHandler()).toBe(handler);
|
|
expect(getCacheHandler()).toBe(handler);
|
|
});
|
|
|
|
it("setDataCacheHandler is visible through the legacy getter", () => {
|
|
const handler = new MemoryCacheHandler();
|
|
setDataCacheHandler(handler);
|
|
expect(getCacheHandler()).toBe(handler);
|
|
});
|
|
});
|
|
|
|
// ─── DefaultCdnCacheAdapter ──────────────────────────────────────────────
|
|
|
|
describe("DefaultCdnCacheAdapter", () => {
|
|
it("owns background revalidation (origin-managed ISR)", () => {
|
|
expect(new DefaultCdnCacheAdapter().ownsBackgroundRevalidation).toBe(true);
|
|
});
|
|
|
|
it("delegates get/set to the active data cache handler", async () => {
|
|
const get = vi.fn(async () => null);
|
|
const set = vi.fn(async () => {});
|
|
const handler: CacheHandler = { get, set, async revalidateTag() {} };
|
|
setDataCacheHandler(handler);
|
|
|
|
const adapter = new DefaultCdnCacheAdapter();
|
|
await adapter.set("k", buildPagesCacheValue("<p>x</p>", {}), { tags: ["t"] });
|
|
await adapter.get("k", { kind: "PAGES" });
|
|
|
|
expect(set).toHaveBeenCalledWith("k", expect.objectContaining({ kind: "PAGES" }), {
|
|
tags: ["t"],
|
|
});
|
|
expect(get).toHaveBeenCalledWith("k", { kind: "PAGES" });
|
|
});
|
|
|
|
it("emits a single Cache-Control header for a cacheable policy", () => {
|
|
const headers = new DefaultCdnCacheAdapter().buildResponseHeaders({
|
|
cacheControl: "s-maxage=60, stale-while-revalidate",
|
|
});
|
|
expect(headers).toEqual({ "Cache-Control": "s-maxage=60, stale-while-revalidate" });
|
|
});
|
|
|
|
it("forces no-store while a streamed render's dynamic-ness is unproven", () => {
|
|
const headers = new DefaultCdnCacheAdapter().buildResponseHeaders({
|
|
cacheControl: "s-maxage=60, stale-while-revalidate",
|
|
pendingDynamicCheck: true,
|
|
});
|
|
// Matches the legacy NO_STORE_CACHE_CONTROL the finalize path used to stamp.
|
|
expect(headers).toEqual({ "Cache-Control": "no-store, must-revalidate" });
|
|
});
|
|
|
|
it("revalidateTag() is a no-op (data cache owns store invalidation)", async () => {
|
|
await expect(new DefaultCdnCacheAdapter().revalidateTag("tag")).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── Active adapter resolution ───────────────────────────────────────────
|
|
|
|
describe("getCdnCacheAdapter / setCdnCacheAdapter", () => {
|
|
it("defaults to a DefaultCdnCacheAdapter", () => {
|
|
expect(getCdnCacheAdapter()).toBeInstanceOf(DefaultCdnCacheAdapter);
|
|
});
|
|
|
|
it("returns the adapter set via setCdnCacheAdapter", () => {
|
|
const custom = new DefaultCdnCacheAdapter();
|
|
setCdnCacheAdapter(custom);
|
|
expect(getCdnCacheAdapter()).toBe(custom);
|
|
});
|
|
});
|
|
|
|
// ─── Edge-managed (Cloudflare-style) adapter ─────────────────────────────
|
|
|
|
/** Minimal edge adapter: never serves from origin, emits split headers, purges. */
|
|
class EdgeCdnAdapter implements CdnCacheAdapter {
|
|
readonly ownsBackgroundRevalidation = false;
|
|
readonly purges: string[] = [];
|
|
writes = 0;
|
|
|
|
async get(): Promise<null> {
|
|
return null; // origin renders fresh; the edge serves the cache
|
|
}
|
|
async set(): Promise<void> {
|
|
this.writes++; // intentionally does not persist anything
|
|
}
|
|
buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders {
|
|
if (!input.cacheControl) return { "Cache-Control": "no-store" };
|
|
return { "Cache-Control": "no-store", "CDN-Cache-Control": input.cacheControl };
|
|
}
|
|
async revalidateTag(tags: string | string[]): Promise<void> {
|
|
for (const tag of Array.isArray(tags) ? tags : [tags]) this.purges.push(tag);
|
|
}
|
|
}
|
|
|
|
describe("edge CDN adapter integration", () => {
|
|
it("isrGet returns null (origin renders) even after isrSet", async () => {
|
|
setCdnCacheAdapter(new EdgeCdnAdapter());
|
|
await isrSet("app:/p:html", buildPagesCacheValue("<p>cached</p>", {}), {
|
|
cacheControl: { revalidate: 60 },
|
|
});
|
|
expect(await isrGet("app:/p:html")).toBeNull();
|
|
});
|
|
|
|
it("isrSet does not write to the data cache when the edge adapter no-ops storage", async () => {
|
|
const set = vi.fn(async () => {});
|
|
setDataCacheHandler({
|
|
async get() {
|
|
return null;
|
|
},
|
|
set,
|
|
async revalidateTag() {},
|
|
});
|
|
const edge = new EdgeCdnAdapter();
|
|
setCdnCacheAdapter(edge);
|
|
|
|
await isrSet("app:/p:html", buildPagesCacheValue("<p>x</p>", {}), {
|
|
cacheControl: { revalidate: 60 },
|
|
});
|
|
|
|
expect(edge.writes).toBe(1);
|
|
expect(set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips in-process background regeneration when the adapter does not own it", async () => {
|
|
setCdnCacheAdapter(new EdgeCdnAdapter());
|
|
const renderFn = vi.fn(async () => {});
|
|
triggerBackgroundRegeneration("regen-edge", renderFn);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(renderFn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still runs background regeneration under the default adapter", async () => {
|
|
const renderFn = vi.fn(async () => {});
|
|
triggerBackgroundRegeneration("regen-default-cdn", renderFn);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(renderFn).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
// ─── Invalidation propagation ────────────────────────────────────────────
|
|
|
|
describe("revalidation propagates to both data cache and CDN adapter", () => {
|
|
function spyAdapters() {
|
|
const dataRevalidate = vi.fn(
|
|
async (_tags: string | string[], _durations?: { expire?: number }) => {},
|
|
);
|
|
setDataCacheHandler({
|
|
async get() {
|
|
return null;
|
|
},
|
|
async set() {},
|
|
revalidateTag: dataRevalidate,
|
|
});
|
|
const edge = new EdgeCdnAdapter();
|
|
setCdnCacheAdapter(edge);
|
|
return { dataRevalidate, edge };
|
|
}
|
|
|
|
it("revalidateTag invalidates the data cache and purges the CDN", async () => {
|
|
const { dataRevalidate, edge } = spyAdapters();
|
|
await Promise.resolve(revalidateTag("posts"));
|
|
expect(dataRevalidate).toHaveBeenCalledWith("posts", undefined);
|
|
expect(edge.purges).toEqual(["posts"]);
|
|
});
|
|
|
|
it("revalidatePath invalidates the data cache and purges the CDN", async () => {
|
|
const { dataRevalidate, edge } = spyAdapters();
|
|
await Promise.resolve(revalidatePath("/blog"));
|
|
// Same encoded tag is sent to both layers.
|
|
expect(dataRevalidate).toHaveBeenCalledTimes(1);
|
|
const tag = dataRevalidate.mock.calls[0][0];
|
|
expect(edge.purges).toEqual([tag]);
|
|
});
|
|
|
|
it("updateTag invalidates the data cache and purges the CDN", async () => {
|
|
const { dataRevalidate, edge } = spyAdapters();
|
|
// updateTag may only be called from within a Server Action.
|
|
const previousPhase = setHeadersAccessPhase("action");
|
|
try {
|
|
await Promise.resolve(updateTag("cart"));
|
|
} finally {
|
|
setHeadersAccessPhase(previousPhase);
|
|
}
|
|
expect(dataRevalidate).toHaveBeenCalledTimes(1);
|
|
expect(dataRevalidate.mock.calls[0][0]).toBe("cart");
|
|
expect(edge.purges).toEqual(["cart"]);
|
|
});
|
|
});
|