Files
e9c4f4289d Fix route cache keying for unprefetched navigations (#90400)
When navigating to a route that wasn't previously prefetched (e.g. via a
Link with prefetch={false}, or router.push()), the client stores the
route data from the server response in the cache so it can be reused by
future navigations and prefetches to the same URL.

These entries were being stored with a null "nextUrl" cache key, but
lookups use the real nextUrl from router state, so every subsequent
lookup missed. This caused redundant requests for route data the client
already had.

The fix threads the nextUrl through the route discovery path so it
reaches the cache write with the correct value. For non-intercepted
routes the entry is keyed with a wildcard (reusable regardless of
nextUrl); for intercepted routes it uses the specific nextUrl, since the
route structure varies by referrer.

Also cleans up how interception routes interact with the optimistic
route matching system: patterns are now always stored during discovery
(previously they were skipped), and the interception bailout happens in
a single place during matching rather than being duplicated across
multiple check sites.

Closes #88863 

Co-authored-by: Hendrik Liebau <[email protected]>
Co-authored-by: Hamidreza Hanafi <[email protected]>

---------

Co-authored-by: Hendrik Liebau <[email protected]>
Co-authored-by: Hamidreza Hanafi <[email protected]>
2026-02-24 07:39:39 +01:00

101 lines
4.3 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { createRouterAct } from 'router-act'
describe('optimistic routing - route cache keying regression', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
test('skipped in dev mode', () => {})
return
}
// Regression test for https://github.com/vercel/next.js/pull/88863
//
// When navigating to a route that was not previously prefetched (e.g. via
// a Link with prefetch={false}, or router.push()), the client creates a
// route cache entry from the server response so it can be reused by future
// navigations and prefetches to the same URL.
//
// A bug caused these entries to be stored with an incorrect cache key: the
// "nextUrl" dimension (which tracks the referring page for interception
// route purposes) was always set to null, even though the rest of the
// system uses the real nextUrl value when looking up entries. This meant
// every subsequent cache lookup missed, and the client would make
// redundant requests for route data it already had.
//
// To reproduce:
//
// 1. Navigate to a dynamic page via a non-prefetched link. The client
// receives the route tree and segment data from the server and stores
// them in the cache.
//
// 2. Navigate back to the original page.
//
// 3. Reveal a prefetched link pointing to the same URL from step 1. The
// prefetch system should find the route tree and segment data already
// in the cache — no new network requests needed.
//
// Without the fix, step 3 triggers a redundant route tree prefetch because
// the cache lookup misses due to the key mismatch.
//
// NOTE: This test relies on the staleTimes.dynamic config to keep route
// cache entries alive across navigations. This is necessary because the
// client segment cache currently only writes segment data during
// prefetches, not during navigations — with the exception of the stale
// times feature, which preserves entries for reuse. Once the client cache
// writes segment data during navigations more broadly, this test could be
// rewritten using a more idiomatic pattern without the staleTimes config.
//
// The target page calls connection() to opt into dynamic rendering, which
// is what makes the staleTimes.dynamic config relevant (static pages use a
// different, longer stale time that would mask the bug).
it('regression: route cache entries from navigation are reusable by subsequent prefetches', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/feed', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Navigate to the photo page. This link has prefetch={false}, so the
// client has no cached data for this route. It fetches the route tree
// and page data from the server in a single request, then stores both
// in the cache for future use.
const unprefetchedLink = await browser.elementByCss('#link-no-prefetch')
await act(async () => {
await unprefetchedLink.click()
})
const photoPage = await browser.elementById('photo-page')
expect(await photoPage.text()).toBe('Photo 1')
// Navigate back to the feed page.
const backLink = await browser.elementByCss('#link-back-to-feed')
await backLink.click()
await browser.elementByCss('#feed-page')
// Reveal a prefetched link to the same photo page. This triggers the
// prefetch system to look up the route cache. If the entry from the
// first navigation was stored correctly, the prefetch finds it (along
// with the segment data in the back/forward cache) and no network
// requests are needed. If the cache key was wrong, this would trigger
// a redundant route tree prefetch.
await act(async () => {
const reveal = await browser.elementByCss(
'input[data-link-accordion="/photo/1"]'
)
await reveal.click()
}, 'no-requests')
// Navigate using the now-prefetched link. The page should render
// immediately from the cache.
const prefetchedLink = await browser.elementByCss('a[href="/photo/1"]')
await act(async () => {
await prefetchedLink.click()
const page = await browser.elementById('photo-page')
expect(await page.text()).toBe('Photo 1')
}, 'no-requests')
})
})