import { nextTestSetup } from 'e2e-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
/**
* Tests for the "vary params" optimization.
*
* Background: During prerendering, Next.js tracks which params each segment
* actually accesses on the server. This enables the client cache to share
* entries: when a segment doesn't access a param, different values of that
* param can reuse the same cached segment.
*
* Core behavior under test:
* - When a segment accesses a param, changing that param requires a new prefetch
* - When a segment does NOT access a param, changing that param reuses the cache
*
* The first test (instant loading state) is the canonical demonstration of
* the feature's user-facing benefit. Subsequent tests exercise various
* combinations of features and edge cases.
*/
describe('segment cache - vary params', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
test('prefetching is disabled in dev mode', () => {})
return
}
it('renders cached loading state instantly during navigation', async () => {
// Setup: All links share category='electronics' but different itemId values.
// Layout only accesses 'category', page renders itemId dynamically.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/instant-loading', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// Prefetch the first link - layout is fetched
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/instant-loading/electronics/phone"]'
)
await toggle.click()
},
{ includes: 'Category: electronics' }
)
// Prefetch remaining links - all cache hits (same category, layout cached)
await act(async () => {
const tablet = await browser.elementByCss(
'input[data-link-accordion="/instant-loading/electronics/tablet"]'
)
await tablet.click()
const laptop = await browser.elementByCss(
'input[data-link-accordion="/instant-loading/electronics/laptop"]'
)
await laptop.click()
const headphones = await browser.elementByCss(
'input[data-link-accordion="/instant-loading/electronics/headphones"]'
)
await headphones.click()
}, 'no-requests')
// Navigate to headphones. The loading state renders synchronously from
// the cached layout, before the dynamic request resolves. The assertion
// runs inside act() during navigation, verifying it appears instantly.
await act(async () => {
const link = await browser.elementByCss(
'a[href="/instant-loading/electronics/headphones"]'
)
await link.click()
const loading = await browser.elementByCss('[data-loading="true"]')
expect(await loading.text()).toContain('Loading item')
})
// Dynamic content eventually loads
const page = await browser.elementById('instant-loading-page')
expect(await page.text()).toContain('Item: headphones')
})
it('reuses prefetched page segment with in-page loading boundary across different params', async () => {
// Setup: Page uses an in-page Suspense boundary instead of loading.tsx. The
// page's default export wraps a child component in <Suspense>. The child
// awaits params, but during prerendering the params are fallback params
// (hanging promise), so the child suspends and the segment prefetch
// contains only the Suspense fallback with empty varyParams — making it
// reusable across all slug values.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/in-page-loading-boundary', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// Prefetch the first link - page segment is fetched
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/in-page-loading-boundary/phone"]'
)
await toggle.click()
},
{ includes: 'Loading item' }
)
// Prefetch remaining links - all cache hits (page prefetch is shared)
await act(async () => {
const tablet = await browser.elementByCss(
'input[data-link-accordion="/in-page-loading-boundary/tablet"]'
)
await tablet.click()
const laptop = await browser.elementByCss(
'input[data-link-accordion="/in-page-loading-boundary/laptop"]'
)
await laptop.click()
const headphones = await browser.elementByCss(
'input[data-link-accordion="/in-page-loading-boundary/headphones"]'
)
await headphones.click()
}, 'no-requests')
// Navigate to headphones. The loading state renders instantly from the
// cached page shell (Suspense fallback), before the dynamic request
// resolves.
await act(async () => {
const link = await browser.elementByCss(
'a[href="/in-page-loading-boundary/headphones"]'
)
await link.click()
const loading = await browser.elementByCss('[data-loading="true"]')
expect(await loading.text()).toContain('Loading item')
})
// Dynamic content eventually loads
const content = await browser.elementById(
'in-page-loading-boundary-content'
)
expect(await content.text()).toContain('Item: headphones')
})
it('renders cached loading state instantly with runtime prefetching', async () => {
// Setup: Page accesses `category` in static portion (tracked in varyParams),
// but accesses `itemId` only after connection() (not tracked).
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/runtime-prefetch', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// Prefetch first link - static content fetched
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch/electronics/phone"]'
)
await toggle.click()
},
{ includes: 'Static content - Category: electronics' }
)
// Prefetch remaining links with same category - all cache hits
await act(async () => {
const tablet = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch/electronics/tablet"]'
)
await tablet.click()
const laptop = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch/electronics/laptop"]'
)
await laptop.click()
const headphones = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch/electronics/headphones"]'
)
await headphones.click()
}, 'no-requests')
// Prefetch link with different category - triggers new prefetch
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch/clothing/shirt"]'
)
await toggle.click()
},
{ includes: 'Static content - Category: clothing' }
)
// Navigate to headphones. Loading state renders synchronously from cache.
await act(async () => {
const link = await browser.elementByCss(
'a[href="/runtime-prefetch/electronics/headphones"]'
)
await link.click()
const loading = await browser.elementByCss('[data-loading="true"]')
expect(await loading.text()).toContain('Loading item details')
})
// Dynamic content eventually loads
const dynamicContent = await browser.elementByCss('[data-dynamic-content]')
expect(await dynamicContent.text()).toContain('Item: headphones')
})
it('does not reuse prefetched segment when page accesses searchParams', async () => {
// When a page awaits searchParams, the cache key includes the search
// params, so different values require separate prefetches.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/search-params', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// Each prefetch triggers a new request (not cached)
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/search-params/target-page?foo=1"]'
)
await toggle.click()
},
{ includes: 'Search params target - foo: 1' }
)
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/search-params/target-page?foo=2"]'
)
await toggle.click()
},
{ includes: 'Search params target - foo: 2' }
)
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/search-params/target-page?foo=3"]'
)
await toggle.click()
},
{ includes: 'Search params target - foo: 3' }
)
})
it('reuses prefetched segment when page does not access searchParams', async () => {
// When a page does NOT await searchParams, the cache key does NOT include
// search params, so different values share cached prefetch data.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/search-params', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// First prefetch fetches the segment
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/search-params/static-target?foo=1"]'
)
await toggle.click()
},
{ includes: 'Static target content - no searchParams access' }
)
// Subsequent prefetches are cache hits
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/search-params/static-target?foo=2"]'
)
await toggle.click()
}, 'no-requests')
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/search-params/static-target?foo=3"]'
)
await toggle.click()
}, 'no-requests')
})
it('tracks param access in generateMetadata', async () => {
// Setup: generateMetadata accesses params, but the page body does NOT.
// This tests that metadata param access is tracked separately.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/metadata', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// First prefetch fetches both head and body
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/metadata/aaa"]'
)
await toggle.click()
}, [{ includes: 'Page: aaa' }, { includes: 'Static page body' }])
// Second prefetch: head re-fetched (metadata varies on slug),
// but body is cached (body doesn't access slug)
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/metadata/bbb"]'
)
await toggle.click()
}, [
{ includes: 'Page: bbb' },
{ includes: 'Static page body', block: 'reject' },
])
})
it('caches head segment when generateMetadata does not access params', async () => {
// When neither generateMetadata nor the page body access params,
// both head and body are cached across different param values.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/metadata-no-params', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// First prefetch fetches content
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/metadata-no-params/aaa"]'
)
await toggle.click()
},
{ includes: 'Page content' }
)
// Second prefetch is a cache hit
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/metadata-no-params/bbb"]'
)
await toggle.click()
}, 'no-requests')
})
it('reuses page segment when layout varies but page does not', async () => {
// Setup: Layout accesses both `category` and `item`, page only accesses
// `category`. When item changes but category stays the same, the layout
// must be re-fetched but the page is cached.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/page-reuse', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// First prefetch fetches both layout and page
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/page-reuse/electronics/phone"]'
)
await toggle.click()
}, [
{ includes: 'Layout: electronics/phone' },
{ includes: 'Page category:' },
])
// Second prefetch: layout re-fetched (varies on item),
// page is cached (only varies on category)
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/page-reuse/electronics/tablet"]'
)
await toggle.click()
}, [
{ includes: 'Layout: electronics/tablet' },
{ includes: 'Page category:', block: 'reject' },
])
// Navigate to verify cached page content renders correctly
const link = await browser.elementByCss(
'a[href="/page-reuse/electronics/tablet"]'
)
await link.click()
const layout = await browser.elementByCss('[data-page-reuse-layout]')
expect(await layout.text()).toContain('Layout: electronics/tablet')
const page = await browser.elementById('page-reuse-page')
expect(await page.text()).toContain('Page category: electronics')
})
it('does not reuse cached segment for optional catch-all when page accesses slug', async () => {
// Setup: Page accesses params.slug directly. Prefetch the empty-slug
// page first, then verify that prefetching a different slug value
// triggers a new request (not a cache hit).
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/optional-catchall-index', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// Prefetch the empty-slug page first
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/optional-catchall"]'
)
await toggle.click()
},
{ includes: 'Slug: none' }
)
// Prefetch a different slug — should trigger a new request because the
// page varies on slug
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/optional-catchall/aaa"]'
)
await toggle.click()
},
{ includes: 'Slug: aaa' }
)
// Navigate and verify correct content
const link = await browser.elementByCss('a[href="/optional-catchall/aaa"]')
await link.click()
const page = await browser.elementById('optional-catchall-page')
expect(await page.text()).toContain('Slug: aaa')
})
it('does not reuse cached segment for optional catch-all when page enumerates params', async () => {
// Setup: Page accesses params via spread ({...params}). Enumeration
// should cause the segment to vary on the optional catch-all param,
// even when the param has no value.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/optional-catchall-enumeration-index', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// Prefetch the empty-slug page first
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/optional-catchall-enumeration"]'
)
await toggle.click()
},
{ includes: 'Slug: none' }
)
// Prefetch a different slug — not cached
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/optional-catchall-enumeration/aaa"]'
)
await toggle.click()
},
{ includes: 'Slug: aaa' }
)
const link = await browser.elementByCss(
'a[href="/optional-catchall-enumeration/aaa"]'
)
await link.click()
const page = await browser.elementById('optional-catchall-enumeration-page')
expect(await page.text()).toContain('Slug: aaa')
})
it('does not reuse cached segment for optional catch-all when page checks slug with in operator', async () => {
// Setup: Page checks for slug using `'slug' in params`. The `in`
// operator should cause the segment to vary on the optional catch-all
// param, even when the param has no value.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/optional-catchall-has-index', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// Prefetch the empty-slug page first
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/optional-catchall-has"]'
)
await toggle.click()
},
{ includes: 'Slug: none' }
)
// Prefetch a different slug — not cached
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/optional-catchall-has/aaa"]'
)
await toggle.click()
},
{ includes: 'Slug: aaa' }
)
const link = await browser.elementByCss(
'a[href="/optional-catchall-has/aaa"]'
)
await link.click()
const page = await browser.elementById('optional-catchall-has-page')
expect(await page.text()).toContain('Slug: aaa')
})
it('shares cached segment across all params when none accessed statically (runtime prefetch)', async () => {
// Both params are accessed only after connection(), so varyParams is
// empty. ALL param combinations share the same cached loading shell.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/runtime-prefetch-no-vary', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// First prefetch fetches the segment
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-no-vary/electronics/phone"]'
)
await toggle.click()
},
{ includes: 'Loading all content dynamically' }
)
// All other combinations are cache hits — even different categories
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-no-vary/electronics/tablet"]'
)
await toggle.click()
}, 'no-requests')
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-no-vary/clothing/shirt"]'
)
await toggle.click()
}, 'no-requests')
})
it('does not share cached segment when all params accessed statically (runtime prefetch)', async () => {
// Both params are accessed before connection(), so every unique
// combination of (category, itemId) requires its own prefetch.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/runtime-prefetch-all-vary', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// Each prefetch triggers a new request
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-all-vary/electronics/phone"]'
)
await toggle.click()
},
{ includes: 'Static content - electronics/phone' }
)
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-all-vary/electronics/tablet"]'
)
await toggle.click()
},
{ includes: 'Static content - electronics/tablet' }
)
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-all-vary/clothing/shirt"]'
)
await toggle.click()
},
{ includes: 'Static content - clothing/shirt' }
)
})
it('shares cached segment across search params when not accessed (runtime prefetch)', async () => {
// Runtime prefetch page that does NOT access searchParams. Since '?'
// is not in varyParams, different search param values share the cache.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/runtime-prefetch-search-params', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// First prefetch fetches the segment
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-search-params/target-page?q=1"]'
)
await toggle.click()
},
{ includes: 'Static content - searchParams not accessed' }
)
// Different search param values are cache hits
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-search-params/target-page?q=2"]'
)
await toggle.click()
}, 'no-requests')
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-search-params/target-page?q=3"]'
)
await toggle.click()
}, 'no-requests')
})
it('tracks metadata param access separately from body (runtime prefetch)', async () => {
// generateMetadata accesses slug, but the page body does NOT.
// Each slug triggers a new head prefetch because metadata varies on slug.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/runtime-prefetch-metadata', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// First prefetch triggers a request including the metadata
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-metadata/aaa"]'
)
await toggle.click()
},
{ includes: 'Runtime Metadata: aaa' }
)
// Second prefetch with different slug triggers a new request
// (metadata varies on slug, so it can't reuse the cache)
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-metadata/bbb"]'
)
await toggle.click()
},
{ includes: 'Runtime Metadata: bbb' }
)
})
it('tracks vary params per-segment with layout/page split (runtime prefetch)', async () => {
// Layout accesses both category and itemId; page accesses only category.
// When itemId changes but category stays the same, the page segment
// should be reused from cache (only varies on category).
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/runtime-prefetch-layout-split', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// First prefetch fetches page segment
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-layout-split/electronics/phone"]'
)
await toggle.click()
},
{ includes: 'Page category:' }
)
// Second prefetch: same category, different itemId.
// The page segment is a cache hit since it only varies on category.
await act(async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-layout-split/electronics/tablet"]'
)
await toggle.click()
}, 'no-requests')
// Different category triggers a new page segment fetch
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/runtime-prefetch-layout-split/clothing/shirt"]'
)
await toggle.click()
},
{ includes: 'Page category:' }
)
// Navigate and verify correct content
const link = await browser.elementByCss(
'a[href="/runtime-prefetch-layout-split/electronics/tablet"]'
)
await link.click()
const layout = await browser.elementByCss('[data-layout-content]')
expect(await layout.text()).toContain('Layout: electronics/tablet')
const page = await browser.elementById('runtime-prefetch-layout-split-page')
expect(await page.text()).toContain('Page category: electronics')
})
it('tracks root param access via rootParams API', async () => {
// Root params accessed via rootParams() are tracked in varyParams.
// Different param values require separate prefetches.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/root-params', {
beforePageLoad(p: Playwright.Page) {
act = createRouterAct(p)
},
})
// First prefetch fetches content
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/aaa"]'
)
await toggle.click()
},
{ includes: 'Root param page content - param: aaa' }
)
// Second prefetch triggers new fetch (not cached)
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/bbb"]'
)
await toggle.click()
},
{ includes: 'Root param page content - param: bbb' }
)
})
})