diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index f11dcbb991..a698766125 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -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 { @@ -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 @@ -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); @@ -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 @@ -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, + ); } // --------------------------------------------------------------------------- diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index 7d963648c7..390cf514da 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -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 @@ -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