Files
next.js/test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts
Arian Tron 61f56f997c
Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled
first commit
2026-03-10 19:37:31 +03:30

499 lines
17 KiB
TypeScript

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')
})
// TODO: Re-enable once vary params tracking is implemented for runtime
// prefetch abort paths. The abort timing needs to resolve vary params before
// the abort signal fires. See static-siblings-infrastructure branch.
it.skip('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('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' }
)
})
})