import type * as Playwright from 'playwright'
import { isNextDev, isNextDeploy, createNext } from 'e2e-utils'
import { createRouterAct } from 'router-act'
import { createTestDataServer } from 'test-data-service/writer'
import { createTestLog } from 'test-log'
import { findPort } from 'next-test-utils'
describe('segment cache (revalidation)', () => {
if (isNextDev || isNextDeploy) {
test('disabled in development / deployment', () => {})
return
}
let port = -1
let server
let dataVersions = new Map()
let TestLog = createTestLog()
let next
beforeAll(async () => {
port = await findPort()
server = createTestDataServer(async (key, res) => {
const currentVersion = dataVersions.get(key)
// Increment the version number each time to track how often the
// server renders.
const nextVersion = currentVersion === undefined ? 1 : currentVersion + 1
dataVersions.set(key, nextVersion)
// Append the version number to the response
const response = `${key} [${nextVersion}]`
TestLog.log('REQUEST: ' + key)
res.resolve(response)
})
server.listen(port)
next = await createNext({
files: __dirname,
env: { TEST_DATA_SERVICE_URL: `http://localhost:${port}` },
})
})
beforeEach(async () => {
dataVersions = new Map()
TestLog = createTestLog()
})
afterAll(async () => {
await next?.destroy()
server?.close()
})
it('evict client cache when Server Action calls revalidatePath', async () => {
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const linkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/greeting"]'
)
// Reveal the link the target page to trigger a prefetch
await act(
async () => {
await linkVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers — freezes the 300ms cooldown setTimeout
await page.clock.install()
// Perform an action that calls revalidatePath. This should cause the
// corresponding entry to be evicted from the client cache, and a new
// prefetch to be requested.
await act(async () => {
const revalidateByPath = await browser.elementById('revalidate-by-path')
await revalidateByPath.click()
})
// Advance past cooldown inside act() to intercept the re-prefetch
await act(
async () => {
await page.clock.fastForward(300)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const link = await browser.elementByCss('a[href="/greeting"]')
await link.click()
// Navigation should finish immedately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
it('refetch visible Form components after cache is revalidated', async () => {
// This is the same as the previous test, but for forms. Since the
// prefetching implementation is shared between Link and Form, we don't
// bother to test every feature using both Link and Form; this test should
// be sufficient.
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const formVisibilityToggle = await browser.elementByCss(
'input[data-form-accordion="/greeting"]'
)
// Reveal the form that points to the target page to trigger a prefetch
await act(
async () => {
await formVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers — freezes the 300ms cooldown setTimeout
await page.clock.install()
// Perform an action that calls revalidatePath. This should cause the
// corresponding entry to be evicted from the client cache, and a new
// prefetch to be requested.
await act(async () => {
const revalidateByPath = await browser.elementById('revalidate-by-path')
await revalidateByPath.click()
})
// Advance past cooldown inside act() to intercept the re-prefetch
await act(
async () => {
await page.clock.fastForward(300)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const button = await browser.elementByCss(
'form[action="/greeting"] button'
)
await button.click()
// Navigation should finish immedately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
it('call router.prefetch(..., {onInvalidate}) after cache is revalidated', async () => {
// This is the similar to the previous tests, but uses a custom Link
// implementation that calls router.prefetch manually. It demonstrates it's
// possible to simulate the revalidating behavior of Link using the manual
// prefetch API.
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const linkVisibilityToggle = await browser.elementByCss(
'input[data-manual-prefetch-link-accordion="/greeting"]'
)
// Reveal the link that points to the target page to trigger a prefetch
await act(
async () => {
await linkVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers — freezes the 300ms cooldown setTimeout
await page.clock.install()
// Perform an action that calls revalidatePath. This should cause the
// corresponding entry to be evicted from the client cache, and a new
// prefetch to be requested.
await act(async () => {
const revalidateByPath = await browser.elementById('revalidate-by-path')
await revalidateByPath.click()
})
// Advance past cooldown inside act() to intercept the re-prefetch
await act(
async () => {
await page.clock.fastForward(300)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const link = await browser.elementByCss('a[href="/greeting"]')
await link.click()
// Navigation should finish immedately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
it('evict client cache when Server Action calls revalidateTag', async () => {
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const linkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/greeting"]'
)
// Reveal the link the target page to trigger a prefetch.
await act(
async () => {
await linkVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers — freezes the 300ms cooldown setTimeout
await page.clock.install()
// Perform an action that calls revalidateTag. This should cause the
// corresponding entry to be evicted from the client cache, and a new
// prefetch to be requested.
await act(async () => {
const revalidateByTag = await browser.elementById('revalidate-by-tag')
await revalidateByTag.click()
})
// Advance past cooldown inside act() to intercept the re-prefetch
await act(
async () => {
await page.clock.fastForward(300)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const link = await browser.elementByCss('a[href="/greeting"]')
await link.click()
// Navigation should finish immedately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
it('re-fetch visible links after a navigation, if needed', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/refetch-on-new-base-tree/a', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
const linkALinkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/refetch-on-new-base-tree/a"]'
)
const linkBLinkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/refetch-on-new-base-tree/b"]'
)
// Reveal the links to trigger prefetches
await act(async () => {
await linkALinkVisibilityToggle.click()
await linkBLinkVisibilityToggle.click()
}, [
// Page B's content should have been prefetched
{
includes: 'Page B content',
},
// Page A's content should not be prefetched because we're already on that
// page. When prefetching with `prefetch='unstable_forceStale'`, we only prefetch the
// delta between the current route and the target route.
{
includes: 'Page A content',
block: 'reject',
},
])
// Navigate to page B
await act(
async () => {
const link = await browser.elementByCss(
'a[href="/refetch-on-new-base-tree/b"]'
)
await link.click()
const content = await browser.elementById('page-b-content')
expect(await content.innerHTML()).toBe('Page B content')
},
// The link for page A is re-prefetched again, even though it's an
// existing link, because the delta between the current route and the
// target route has changed.
//
// This time, the response does include the content for page A.
//
// TODO: The request is actually skipped entirely because <Link
// prefetch={true} /> now reads from the bfcache before issuing a prefetch
// request, which wasn't true before the test was written. I'm leaving
// the test here for now, though, since we may want to re-write it in
// terms of runtime prefetching at some point. There's other coverage of
// this behavior though so it might be fine to just remove the whole test.
// {
// includes: 'Page A content',
// }
'no-requests'
)
// Navigate to page A
await act(
async () => {
const link = await browser.elementByCss(
'a[href="/refetch-on-new-base-tree/a"]'
)
await link.click()
const content = await browser.elementById('page-a-content')
expect(await content.innerHTML()).toBe('Page A content')
},
// There should be no new requests because everything is fully prefetched.
'no-requests'
)
})
it('delay re-prefetch after revalidation to allow CDN propagation', async () => {
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const linkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/greeting"]'
)
// Reveal the link the target page to trigger a prefetch
await act(
async () => {
await linkVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers so the 300ms cooldown setTimeout in the
// browser is frozen until we explicitly advance the clock.
await page.clock.install()
// Perform an action that calls revalidatePath. This triggers a 300ms
// cooldown before any new prefetch requests can be made.
await act(async () => {
const revalidateByPath = await browser.elementById('revalidate-by-path')
await revalidateByPath.click()
})
// The cooldown timer is frozen, so no prefetch should have occurred.
TestLog.assert([])
// Advance partway through the cooldown — still no prefetch.
await page.clock.fastForward(150)
TestLog.assert([])
// Advance past the cooldown (300ms total). This fires the cooldown
// callback, which triggers a re-prefetch. Use act() to intercept
// the prefetch request and ensure the response is fully delivered
// to the browser.
await act(
async () => {
await page.clock.fastForward(150)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const link = await browser.elementByCss('a[href="/greeting"]')
await link.click()
// Navigation should finish immediately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
})