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
21 changes: 18 additions & 3 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,7 +1362,11 @@ export function prefetchRscResponse(
entry.timestamp,
entry.snapshot,
behavior.fallbackTtlMs ?? PREFETCH_CACHE_TTL,
behavior.honorDynamicStaleTime !== false,
// A search-agnostic PPR shell contains no query-dependent dynamic
// data and is never navigation-consumable. Keep it on the prefetch
// freshness lattice so another search string can reuse the shell;
// the later navigation response still honors dynamicStaleTime.
behavior.honorDynamicStaleTime !== false && behavior.searchAgnosticShell !== true,
);
if (behavior.prepareSnapshot) {
try {
Expand Down Expand Up @@ -1498,6 +1502,7 @@ function consumeMatchedPrefetchResponse(
cacheKey: string,
entry: PrefetchCacheEntry,
mountedSlotsHeader: string | null,
allowExpiredInFlightHandoff: boolean = false,
): CachedRscResponse | null {
const cache = getPrefetchCache();
// Skip in-flight snapshots and error-path residue where pending cleared
Expand All @@ -1511,7 +1516,7 @@ function consumeMatchedPrefetchResponse(
// be safely reused.
return null;
}
if (resolvePrefetchCacheEntryExpiresAt(entry) <= Date.now()) {
if (!allowExpiredInFlightHandoff && resolvePrefetchCacheEntryExpiresAt(entry) <= Date.now()) {
// The entry aged out before navigation reached it — that *is* the
// invalidation `onInvalidate` subscribers are waiting for.
deletePrefetchCacheEntry(cache, getPrefetchedUrls(), cacheKey, entry, true);
Expand Down Expand Up @@ -1574,6 +1579,11 @@ export async function consumePrefetchResponseForNavigation(
// concurrency cap, where it would compete with the current navigation.
if (options?.shouldConsume?.() === false) return null;

// Claim only a request that was still in flight when this navigation began.
// A zero dynamic stale time may expire the completed entry immediately, but
// Next still lets the navigation already waiting on that request finish with
// it. Settled zero-stale entries remain unavailable to later navigations.
const allowExpiredInFlightHandoff = entry.pending !== undefined;
if (entry.pending !== undefined) {
// This navigation is about to wait on the prefetch's request. If that
// request is still queued behind the low-priority concurrency cap, waiting
Expand All @@ -1586,7 +1596,12 @@ export async function consumePrefetchResponseForNavigation(
if (options?.shouldConsume?.() === false) return null;
}

return consumeMatchedPrefetchResponse(cacheKey, entry, mountedSlotsHeader);
return consumeMatchedPrefetchResponse(
cacheKey,
entry,
mountedSlotsHeader,
allowExpiredInFlightHandoff,
);
}

// ---------------------------------------------------------------------------
Expand Down
98 changes: 98 additions & 0 deletions tests/prefetch-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,66 @@ describe("prefetch cache eviction", () => {
expect(consumePrefetchResponse(rscUrl, null, null)).toBeNull();
});

it("hands an in-flight zero-stale prefetch to its waiting navigation exactly once", async () => {
// Next applies the dynamic stale time to visited/BFCache reuse after the
// navigation. It does not discard the request that the navigation is
// already waiting for. The handoff is ownership transfer, not a later
// cache hit: a settled zero-stale entry must still be unavailable to a
// navigation that did not claim it while it was in flight.
const now = 1_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const rscUrl = "/zero-stale-in-flight-prefetch.rsc";
const deferred = createDeferredResponse();

prefetchRscResponse(rscUrl, deferred.promise, null, null, undefined, {
fallbackTtlMs: PREFETCH_CACHE_TTL,
});
const consumedPromise = consumePrefetchResponseForNavigation(rscUrl, null, null);

deferred.resolve(
new Response("flight", {
headers: {
"content-type": "text/x-component",
[VINEXT_DYNAMIC_STALE_TIME_HEADER]: "0",
},
}),
);

const consumed = await consumedPromise;
expect(consumed).not.toBeNull();
await expect(restoreRscResponse(consumed!).text()).resolves.toBe("flight");
expect(getPrefetchCache().has(rscUrl)).toBe(false);
expect(consumePrefetchResponse(rscUrl, null, null)).toBeNull();
});

it("does not transfer a zero-stale prefetch after its waiting navigation is superseded", async () => {
const now = 1_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const rscUrl = "/superseded-zero-stale-prefetch.rsc";
const deferred = createDeferredResponse();
let isCurrentNavigation = true;

prefetchRscResponse(rscUrl, deferred.promise, null, null, undefined, {
fallbackTtlMs: PREFETCH_CACHE_TTL,
});
const consumedPromise = consumePrefetchResponseForNavigation(rscUrl, null, null, {
shouldConsume: () => isCurrentNavigation,
});

isCurrentNavigation = false;
deferred.resolve(
new Response("flight", {
headers: {
"content-type": "text/x-component",
[VINEXT_DYNAMIC_STALE_TIME_HEADER]: "0",
},
}),
);

await expect(consumedPromise).resolves.toBeNull();
expect(consumePrefetchResponse(rscUrl, null, null)).toBeNull();
});

it("keeps the prefetch floor for an explicit full prefetch of dynamic content", async () => {
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/metadata/segment-cache-metadata.test.ts
Expand Down Expand Up @@ -1764,6 +1824,44 @@ describe("prefetch cache eviction", () => {
expect(hasPrefetchCacheEntryForNavigation(secondRscUrl, null, null)).toBe(false);
});

it("retains a zero-stale search-agnostic shell without making it navigation-reusable", async () => {
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/search-params/segment-cache-search-params.test.ts
// A search-agnostic PPR shell contains no query-dependent dynamic data.
// Reusing its route shell for another search string is safe, while the
// dynamic navigation response remains subject to staleTimes.dynamic: 0.
const now = 1_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const firstRscUrl = "/search-params/target-page?searchParam=a_PPR&_rsc=first";
const secondRscUrl = "/search-params/target-page?searchParam=c_PPR&_rsc=second";

prefetchRscResponse(
firstRscUrl,
Promise.resolve(
new Response("search-agnostic shell", {
headers: {
"content-type": "text/x-component",
[VINEXT_DYNAMIC_STALE_TIME_HEADER]: "0",
},
}),
),
null,
null,
undefined,
{
cacheForNavigation: false,
fallbackTtlMs: PREFETCH_CACHE_TTL,
optimisticRouteShell: true,
searchAgnosticShell: true,
},
);
await getPrefetchCache().get(firstRscUrl)?.pending;

expect(hasSearchAgnosticPrefetchShellForRoute(secondRscUrl, null, null)).toBe(true);
expect(hasPrefetchCacheEntryForNavigation(secondRscUrl, null, null)).toBe(false);
expect(consumePrefetchResponse(firstRscUrl, null, null)).toBeNull();
});

it("aliases full prefetch responses by their server-rendered path and search", async () => {
// Ported from Next.js:
// test/e2e/app-dir/segment-cache/search-params/segment-cache-search-params.test.ts
Expand Down
Loading