// Prefetched page payloads for client-side navigation.
//
// `` warms a destination on hover; the click then consumes what the
// hover fetched instead of opening its own request. Before this existed the
// prefetch was a `` that could never be reused — SSR
// pages are sent "private, no-store", so nothing was storable, and an
// `as="document"` prefetch only feeds real navigations anyway, not `fetch()`.
// Every prefetch was therefore a full server render, executed and discarded,
// and the click still paid full price.
//
// So the cache lives HERE, in memory, for the length of the tab: honoring
// no-store means never handing the response to a disk cache, and it means a
// reload always re-renders.
//
// Kept in its own module (not inlined in CLIENT_RUNTIME_SOURCE) so its
// expiry, eviction and single-use rules are unit-testable — the same reason
// client-boundary and route-match were pulled out. The bundler stages a copy
// next to the generated runtime, which imports it as "./nav-cache".
export interface NavPayloadCacheDeps {
/** Fetch a page, resolving to its HTML or null when it shouldn't be reused. */
fetchPage: (target: string) => Promise;
/** Injected so tests drive expiry without sleeping. */
now: () => number;
/** Long enough to cover hover-to-click, short enough that nobody reads a
* page rendered from data this old. */
ttlMs?: number;
/** Cap on retained payloads; the oldest is evicted first. Pages are whole
* HTML documents, so this bounds memory on a link-dense page. */
max?: number;
}
export interface NavPayloadCache {
/** Warm `target`, unless a fresh entry is already present or in flight. */
prefetch(target: string): void;
/** Hand over `target`'s payload, or null. Single-use: a prefetch
* accelerates the NEXT click, and holding it past that would serve
* navigations from an increasingly stale render. */
take(target: string): Promise | null;
/** Drop everything — called when a navigation commits, since entries were
* rendered against the page the user just left. */
clear(): void;
/** Retained entry count (tests + diagnostics). */
size(): number;
}
export function createNavPayloadCache(
deps: NavPayloadCacheDeps,
): NavPayloadCache {
const { fetchPage, now } = deps;
const ttlMs = deps.ttlMs ?? 15000;
const max = deps.max ?? 8;
// Holds the in-flight PROMISE, not just the resolved text, so a click that
// lands mid-prefetch joins that request rather than starting a second one —
// the common case, since hover-to-click is usually shorter than the render.
const entries = new Map }>();
return {
prefetch(target: string): void {
const hit = entries.get(target);
if (hit && now() - hit.at < ttlMs) return;
// Re-warming an expired target: drop it first so the refreshed entry
// re-enters at the END of the insertion order. Map.set on an existing
// key keeps its original position, which would make a repeatedly
// re-warmed target the first one evicted. No test covers this: a
// re-warm only happens after expiry, so everything ahead of it in the
// order has expired too and evicting it costs nothing today. Keeping
// the LRU order honest anyway, so this stays true if the TTL does not.
entries.delete(target);
while (entries.size >= max) {
const oldest = entries.keys().next();
if (oldest.done) break;
entries.delete(oldest.value);
}
// A rejected fetch must not surface as an unhandled rejection when
// nobody ends up clicking; resolve to null and let the click refetch.
const promise = fetchPage(target).catch(() => null);
entries.set(target, { at: now(), promise });
},
take(target: string): Promise | null {
const hit = entries.get(target);
if (!hit) return null;
entries.delete(target);
if (now() - hit.at >= ttlMs) return null;
return hit.promise;
},
clear(): void {
entries.clear();
},
size(): number {
return entries.size;
},
};
}