Files
next.js/test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts
Andrew ClarkandGitHub 244fac6eec Decouple route and segment cache lifecycles (#88989)
Based on:

- https://github.com/vercel/next.js/pull/88834

---

Follows from the previous commits that decoupled route stale time from
segment data. Now that routes have their own lifecycle, we can simplify
the implementation: route stale times are always the static constant,
rather than being derived from server-provided values.

Route structure is essentially static — it only changes on deploy. The
exception is rewrites/redirects in middleware or proxy, which can change
routes dynamically based on request state. But we have other strategies
for handling those cases (e.g., route interception headers, cache
invalidation on auth state changes).

This simplification removes the need to thread stale time through
various call sites. The `fulfillRouteCacheEntry` function now computes
the stale time internally from a `now` timestamp parameter, following
the convention used elsewhere in the codebase.
2026-01-28 10:55:39 -05:00

285 lines
8.4 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
describe('segment cache (staleness)', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
test('disabled in development / deployment', () => {})
return
}
it('entry expires when its stale time has elapsed', async () => {
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page)
await page.clock.install()
// Reveal the link to trigger a prefetch
const toggle2MinutesLink = await browser.elementByCss(
'input[data-link-accordion="/stale-2-minutes"]'
)
const toggle4MinutesLink = await browser.elementByCss(
'input[data-link-accordion="/stale-4-minutes"]'
)
await act(
async () => {
await toggle2MinutesLink.click()
await browser.elementByCss('a[href="/stale-2-minutes"]')
},
{
includes: 'Content with stale time of 2 minutes',
}
)
await act(
async () => {
await toggle4MinutesLink.click()
await browser.elementByCss('a[href="/stale-4-minutes"]')
},
{
includes: 'Content with stale time of 4 minutes',
}
)
// Hide the links
await toggle2MinutesLink.click()
await toggle4MinutesLink.click()
// Fast forward 2 minutes and 1 millisecond
await page.clock.fastForward(2 * 60 * 1000 + 1)
// Reveal the links again to trigger new prefetch tasks
await act(
async () => {
await toggle2MinutesLink.click()
await browser.elementByCss('a[href="/stale-2-minutes"]')
},
// The page with a stale time of 2 minutes is requested again
// because its stale time elapsed.
{
includes: 'Content with stale time of 2 minutes',
}
)
await act(
async () => {
await toggle4MinutesLink.click()
await browser.elementByCss('a[href="/stale-4-minutes"]')
},
// The page with a stale time of 4 minutes is *not* requested again
// because it's still fresh.
'no-requests'
)
})
it('expires runtime prefetches when their stale time has elapsed', async () => {
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page)
await page.clock.install()
// Reveal the links to trigger a runtime prefetch
const toggle2MinutesLink = await browser.elementByCss(
'input[data-link-accordion="/runtime-stale-2-minutes"]'
)
const toggle4MinutesLink = await browser.elementByCss(
'input[data-link-accordion="/runtime-stale-4-minutes"]'
)
await act(
async () => {
await toggle2MinutesLink.click()
await browser.elementByCss('a[href="/runtime-stale-2-minutes"]')
},
{
includes: 'Content with stale time of 2 minutes',
}
)
await act(
async () => {
await toggle4MinutesLink.click()
await browser.elementByCss('a[href="/runtime-stale-4-minutes"]')
},
{
includes: 'Content with stale time of 4 minutes',
}
)
// Hide the links
await toggle2MinutesLink.click()
await toggle4MinutesLink.click()
// Fast forward 2 minutes and 1 millisecond
await page.clock.fastForward(2 * 60 * 1000 + 1)
// Reveal the links again to trigger new prefetch tasks
await act(
async () => {
await toggle2MinutesLink.click()
await browser.elementByCss('a[href="/runtime-stale-2-minutes"]')
},
// The page with a stale time of 2 minutes is requested again
// because its stale time elapsed.
{
includes: 'Content with stale time of 2 minutes',
}
)
await act(
async () => {
await toggle4MinutesLink.click()
await browser.elementByCss('a[href="/runtime-stale-4-minutes"]')
},
// The page with a stale time of 4 minutes is *not* requested again
// because it's still fresh.
'no-requests'
)
})
it('reuses dynamic data up to the staleTimes.dynamic threshold', async () => {
let page: Playwright.Page
const startDate = Date.now()
const browser = await next.browser('/', {
async beforePageLoad(p: Playwright.Page) {
page = p
await page.clock.install()
await page.clock.setFixedTime(startDate)
},
})
const act = createRouterAct(page)
// Navigate to the dynamic page
await act(
async () => {
const toggle = await browser.elementByCss(
'input[data-link-accordion="/dynamic"]'
)
await toggle.click()
const link = await browser.elementByCss('a[href="/dynamic"]')
await link.click()
},
{
includes: 'Dynamic content',
}
)
expect(await browser.elementById('dynamic-content').text()).toBe(
'Dynamic content'
)
await browser.back()
// Advance time by 29 seconds. staleTimes.dynamic is configured as 30s, so
// if we navigate to the same link again, the old data should be reused
// without a new network request.
await page.clock.setFixedTime(startDate + 29 * 1000)
await act(async () => {
const link = await browser.elementByCss('a[href="/dynamic"]')
await link.click()
// The next page is immediately rendered
expect(await browser.elementById('dynamic-content').text()).toBe(
'Dynamic content'
)
}, 'no-requests')
await browser.back()
// Advance an additional second. This time, if we navigate to the link
// again, the data is stale, so we issue a new request.
await page.clock.setFixedTime(startDate + 30 * 1000)
await act(
async () => {
const link = await browser.elementByCss('a[href="/dynamic"]')
await link.click()
},
{ includes: 'Dynamic content' }
)
expect(await browser.elementById('dynamic-content').text()).toBe(
'Dynamic content'
)
})
it('caches omitted from the prerender should not affect when the prefetch is expired', async () => {
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page)
await page.clock.install()
// Reveal the link to trigger a prefetch
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/seconds"]')
.click()
await browser.elementByCss('a[href="/seconds"]')
},
{
// cacheLife("seconds") should be excluded from a static prerender
includes: 'Short-lived cached content',
block: 'reject',
}
)
// Hide the link
await browser.elementByCss('input[data-link-accordion="/seconds"]').click()
// Fast forward 30 seconds and 1 millisecond
// (matching the staleness of the "seconds" profile)
const timeStep = 30 * 1000 + 1
await page.clock.fastForward(timeStep)
// Reveal the link again to trigger new prefetch tasks.
// The cache with `cacheLife('seconds'`) should not affect the stale time of the prefetch,
// because we omit it from the prerender, so we shouldn't refetch anything yet.
await act(async () => {
await browser
.elementByCss('input[data-link-accordion="/seconds"]')
.click()
await browser.elementByCss('a[href="/seconds"]')
}, 'no-requests')
// Hide the link again
await browser.elementByCss('input[data-link-accordion="/seconds"]').click()
// Fast forward to 5 minutes and 1 millisecond after the prefetch.
// (matching the staleness of the "minutes" profile)
// Note that we should exclude the timestep we've already done.
await page.clock.fastForward(5 * 60 * 1000 + 1 - timeStep)
// Reveal the link to trigger a prefetch.
// The longer-lived cache we used on the page should make the previous prefetch expire,
// so we should issue a new request.
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/seconds"]')
.click()
await browser.elementByCss('a[href="/seconds"]')
},
{
includes: 'Short-lived cached content',
block: 'reject',
}
)
})
})