Files
next.js/test/e2e/app-dir/instant-navigation-testing-api/instant-navigation-testing-api.test.ts

1343 lines
54 KiB
TypeScript

/**
* Tests for the Instant Navigation Testing API.
*
* The `instant` helper allows tests to assert on the prefetched UI state
* before dynamic data streams in. This enables deterministic testing of
* loading states without race conditions.
*
* Usage example:
*
* await instant(page, async () => {
* await page.click('a[href="/products/123"]')
* // Assert on the prefetched loading UI
* await expect(page.locator('[data-testid="loading-shell"]')).toBeVisible()
* // Dynamic content hasn't streamed in yet
* expect(await page.locator('[data-testid="price"]').count()).toBe(0)
* })
* // After exiting instant(), dynamic content streams in
* await expect(page.locator('[data-testid="price"]')).toBeVisible()
*
* NOTE: This API is not exposed in production builds by default. These tests
* use the experimental.exposeTestingApiInProductionBuild flag to enable the
* API in production mode for testing purposes.
*/
import {
NextInstance,
nextTestSetup,
isNextDev,
type Playwright as NextBrowser,
} from 'e2e-utils'
import { instant } from '@next/playwright'
import { assertNoConsoleErrors } from 'next-test-utils'
import type * as Playwright from 'playwright'
import { join } from 'node:path'
/**
* Opens a browser and returns the underlying Playwright Page instance.
*
* We use this pattern so our test assertions look as close as possible to
* what users would write with the actual Playwright helper package. The
* Next.js test infra wraps Playwright with its own BrowserInterface, but
* the Instant Navigation Testing API is designed to work with native Playwright.
*/
// The browser and full-document navigation requests for the currently running
// test. openPage populates these so a shared afterEach can assert there were no
// console errors (e.g. a failed hydration) and individual tests can assert that
// releasing the instant lock resolves client-side instead of hard reloading.
let activeBrowser: NextBrowser | undefined
let navigationRequests: string[] = []
async function openPage(
next: NextInstance,
url: string,
options?: { cookies?: Array<{ name: string; value: string }> }
): Promise<Playwright.Page> {
let page: Playwright.Page
navigationRequests = []
activeBrowser = await next.browser(url, {
// Surface uncaught page errors (e.g. a failed hydration) as console logs so
// assertNoConsoleErrors fails the test on them.
pushErrorAsConsoleLog: true,
beforePageLoad(p) {
page = p
// Record full-document navigations so tests can assert that releasing the
// instant lock does not trigger a hard reload.
p.on('request', (request) => {
if (request.isNavigationRequest()) {
navigationRequests.push(new URL(request.url()).pathname)
}
})
if (options?.cookies) {
const { hostname } = new URL(next.url)
p.context().addCookies(
options.cookies.map((c) => ({
...c,
domain: hostname,
path: '/',
}))
)
}
},
})
// Lower the per-action timeout below jest's 60s test timeout so a stalling
// waitFor fails with its own locator (naming the element that never appeared)
// instead of a bare test-level timeout that hides which assertion hung.
page.setDefaultTimeout(20_000)
return page
}
afterEach(async () => {
if (activeBrowser) {
// Console-error checking targets production minified React errors (e.g. a
// failed hydration on deploy, which is how the instant bootstrap regression
// surfaced). Development legitimately logs warnings here (such as a
// blocking-route prerender insight), so only assert in production.
if (!isNextDev) {
await assertNoConsoleErrors(activeBrowser)
}
activeBrowser = undefined
}
})
describe('instant-navigation-testing-api', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures', 'default'),
})
it('renders prefetched loading shell instantly during navigation', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-target')
// The loading shell appears immediately, without waiting for dynamic data
const loadingShell = page.locator('[data-testid="loading-shell"]')
await loadingShell.waitFor({ state: 'visible' })
expect(await loadingShell.textContent()).toContain(
'Loading target page...'
)
// Dynamic content has not streamed in yet
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
expect(await dynamicContent.count()).toBe(0)
})
// After exiting the instant scope, dynamic content streams in. Releasing
// the lock must resolve client-side rather than hard reloading the
// document.
const navigationsAtUnlock = navigationRequests.length
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
await dynamicContent.waitFor({ state: 'visible' })
expect(await dynamicContent.textContent()).toContain(
'Dynamic content loaded'
)
expect(navigationRequests.length).toBe(navigationsAtUnlock)
})
it('renders runtime-prefetched content instantly during navigation', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-runtime-prefetch')
// Content that depends on search params appears immediately because
// it was included in the runtime prefetch
const searchParamValue = page.locator(
'[data-testid="search-param-value"]'
)
await searchParamValue.waitFor({ state: 'visible' })
expect(await searchParamValue.textContent()).toContain(
'myParam: testValue'
)
// The loading state for dynamic content is visible
const innerLoading = page.locator('[data-testid="inner-loading"]')
await innerLoading.waitFor({ state: 'visible' })
expect(await innerLoading.textContent()).toContain(
'Loading dynamic content...'
)
// Dynamic content has not streamed in yet
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
expect(await dynamicContent.count()).toBe(0)
})
// After exiting the instant scope, dynamic content streams in
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
await dynamicContent.waitFor({ state: 'visible' })
expect(await dynamicContent.textContent()).toContain(
'Dynamic content loaded'
)
// Search param content remains visible
const searchParamValue = page.locator('[data-testid="search-param-value"]')
expect(await searchParamValue.textContent()).toContain('myParam: testValue')
})
// Navigate deeper under the lock. Loading `/blocking-fallback/en` commits the
// parent layout (which owns the only <Suspense> boundary) and the landing
// page. `/blocking-fallback/en/s1` is a fallback route (`[scope]` has no
// generateStaticParams) whose deeper page awaits the uncovered `scope` param
// and reads `cookies()`. Awaiting the withheld param is what keeps the
// segment out of the app shell (the app shell is allowed to read cookies, so
// a cookie read alone would not park it). Navigating into it must stay
// parked: the committed parent and landing page stay on screen, the deeper
// page (its title and its cookie value) must not commit while the lock is
// held, and they stream in only after release.
it('keeps the committed layout and defers a deeper blocking segment under instant()', async () => {
const page = await openPage(next, '/blocking-fallback/en', {
cookies: [{ name: 'testCookie', value: 'hello' }],
})
await page
.locator('[data-testid="blocking-landing"]')
.waitFor({ state: 'visible' })
await instant(page, async () => {
await page.click('#to-blocking-scope')
const secret = page.locator('[data-testid="blocking-secret"]')
// Poll past the point where the navigation previously committed,
// breaking early if the destination leaks so a regression fails fast.
for (let i = 0; i < 8; i++) {
await page.waitForTimeout(1000)
if (await secret.count()) break
}
// The parent layout's Suspense already resolved on the landing page, so
// navigating deeper does NOT re-show its fallback; React keeps the
// previous page on screen until the blocking destination resolves. So
// under the lock the committed parent and landing page stay put, and the
// deeper blocking page (and its cookie read) must not commit.
expect(
await page.locator('[data-testid="blocking-parent"]').count()
).toBe(1)
expect(
await page.locator('[data-testid="blocking-landing"]').count()
).toBe(1)
expect(
await page.locator('[data-testid="blocking-scope-title"]').count()
).toBe(0)
expect(await secret.count()).toBe(0)
})
// After the lock releases, the deeper page and cookie value stream in.
const secret = page.locator('[data-testid="blocking-secret"]')
await secret.waitFor({ state: 'visible' })
expect(await secret.textContent()).toContain('testCookie: hello')
})
it('renders full prefetch content instantly when prefetch={true}', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-full-prefetch')
// With prefetch={true}, the dynamic content is included in the prefetch
// response, so it appears immediately without a loading state
const content = page.locator('[data-testid="full-prefetch-content"]')
await content.waitFor({ state: 'visible' })
expect(await content.textContent()).toContain(
'Full prefetch content loaded'
)
})
})
it('throws when attempting to nest instant scopes', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
// Attempt to acquire the lock again by nesting instant() calls.
// The inner call detects the cookie is already set and throws
// before touching the browser state.
let caughtError: Error | undefined
try {
await instant(page, async () => {})
} catch (e) {
caughtError = e as Error
}
expect(caughtError).toBeDefined()
expect(caughtError!.message).toContain('already active')
})
})
it('recovers from a stale instant cookie left by a prior scope', async () => {
const page = await openPage(next, '/')
// Simulate a cookie leaked by a previous instant() scope. A locked MPA page
// load re-writes the cookie asynchronously and can resurrect it right after
// a prior scope's release deletes it, leaving a captured-state entry in the
// shared browser context. Because the context is reused across tests, a new
// instant() call must treat that residue as stale (clearing it) rather than
// reporting an active scope, or every following test would cascade-fail.
const { hostname } = new URL(next.url)
await page.context().addCookies([
{
name: 'next-instant-navigation-testing',
value: JSON.stringify([1, 'c-stale', null]),
domain: hostname,
path: '/',
},
])
let ranCallback = false
await instant(page, async () => {
ranCallback = true
await page.click('#link-to-target')
const loadingShell = page.locator('[data-testid="loading-shell"]')
await loadingShell.waitFor({ state: 'visible' })
})
expect(ranCallback).toBe(true)
// After exiting the scope the cookie is gone again, so a normal navigation
// is not locked and dynamic content streams in.
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
await dynamicContent.waitFor({ state: 'visible' })
})
it('allows concurrent instant scopes across separate browser contexts', async () => {
const page = await openPage(next, '/')
// A second, independent browser context. Its cookie jar and its page's
// navigation lock are separate from the first context's, so a concurrent
// instant() scope here must not be reported as already active against the
// first. This guards against tracking the active scope per-process instead
// of per-context.
const browser = page.context().browser()
if (!browser) {
throw new Error('Expected the page context to expose a browser instance')
}
const otherContext = await browser.newContext()
try {
const otherPage = await otherContext.newPage()
await otherPage.goto(next.url)
let ranFirst = false
let ranSecond = false
await Promise.all([
instant(page, async () => {
ranFirst = true
await page.click('#link-to-target')
await page
.locator('[data-testid="loading-shell"]')
.waitFor({ state: 'visible' })
}),
instant(otherPage, async () => {
ranSecond = true
await otherPage.click('#link-to-target')
await otherPage
.locator('[data-testid="loading-shell"]')
.waitFor({ state: 'visible' })
}),
])
expect(ranFirst).toBe(true)
expect(ranSecond).toBe(true)
} finally {
await otherContext.close()
}
})
it('renders shell on page reload', async () => {
const page = await openPage(next, '/target-page')
// Wait for the page to fully load with dynamic content
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
await dynamicContent.waitFor({ state: 'visible' })
await instant(page, async () => {
// Reload the page while in instant mode
await page.reload()
// The loading shell appears, but dynamic content is blocked
const loadingShell = page.locator('[data-testid="loading-shell"]')
await loadingShell.waitFor({ state: 'visible' })
expect(await loadingShell.textContent()).toContain(
'Loading target page...'
)
// Dynamic content has not streamed in yet
expect(await dynamicContent.count()).toBe(0)
// Wait for the instant-mode hydration to finish before releasing the
// lock. This mirrors real usage, where the cookie is cleared (e.g. via
// DevTools) after the page is interactive, so the unlock resolves
// client-side. Releasing before hydration completes intentionally falls
// back to a hard reload (see refreshOnInstantNavigationUnlock).
await page.waitForFunction(
() => (globalThis as any).__NEXT_HYDRATED === true
)
})
// After exiting the instant scope, dynamic content streams in. Releasing
// the lock must resolve client-side rather than hard reloading the
// document.
const navigationsAtUnlock = navigationRequests.length
await dynamicContent.waitFor({ state: 'visible' })
expect(await dynamicContent.textContent()).toContain(
'Dynamic content loaded'
)
expect(navigationRequests.length).toBe(navigationsAtUnlock)
})
it('renders shell on MPA navigation via plain anchor', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
// Navigate using a plain anchor (triggers full page load)
await page.click('#plain-link-to-target')
// The loading shell appears, but dynamic content is blocked
const loadingShell = page.locator('[data-testid="loading-shell"]')
await loadingShell.waitFor({ state: 'visible' })
expect(await loadingShell.textContent()).toContain(
'Loading target page...'
)
// Dynamic content has not streamed in yet
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
expect(await dynamicContent.count()).toBe(0)
})
// After exiting the instant scope, dynamic content streams in
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
await dynamicContent.waitFor({ state: 'visible', timeout: 10000 })
expect(await dynamicContent.textContent()).toContain(
'Dynamic content loaded'
)
})
it('reload followed by MPA navigation, both block dynamic data', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
// Reload the page while in instant mode
await page.reload()
// Home page should be visible (static content)
const homeTitle = page.locator('[data-testid="home-title"]')
await homeTitle.waitFor({ state: 'visible' })
// Navigate via plain anchor (MPA navigation)
await page.click('#plain-link-to-target')
// The loading shell appears, but dynamic content is blocked
const loadingShell = page.locator('[data-testid="loading-shell"]')
await loadingShell.waitFor({ state: 'visible' })
// Dynamic content has not streamed in yet
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
expect(await dynamicContent.count()).toBe(0)
})
// After exiting the instant scope, dynamic content streams in
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
await dynamicContent.waitFor({ state: 'visible' })
expect(await dynamicContent.textContent()).toContain(
'Dynamic content loaded'
)
})
it('successive MPA navigations within instant scope', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
// First MPA navigation: reload
await page.reload()
const homeTitle = page.locator('[data-testid="home-title"]')
await homeTitle.waitFor({ state: 'visible' })
// Second MPA navigation: go to target page
await page.click('#plain-link-to-target')
// Static shell is visible
const loadingShell = page.locator('[data-testid="loading-shell"]')
await loadingShell.waitFor({ state: 'visible' })
// Dynamic content is blocked
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
expect(await dynamicContent.count()).toBe(0)
// Third MPA navigation: go back to home
await page.goBack()
await homeTitle.waitFor({ state: 'visible' })
// Fourth MPA navigation: go to target page again
await page.click('#plain-link-to-target')
// Still shows shell, dynamic content still blocked
await loadingShell.waitFor({ state: 'visible' })
expect(await dynamicContent.count()).toBe(0)
})
// After exiting instant scope, dynamic content streams in
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
await dynamicContent.waitFor({ state: 'visible' })
expect(await dynamicContent.textContent()).toContain(
'Dynamic content loaded'
)
})
// Cookies are session data carried by the app shell, so on a client
// navigation they ARE available in the instant shell — unless a param is read
// before the cookie, which makes the app-shell render suspend at the
// (withheld) param before it reaches the cookie. On a full page load the
// document render defers the cookie regardless.
describe('cookies in the instant shell', () => {
// Skipped: `/cookies-page` is a non-partial route (it opts into neither
// `partialPrefetching` nor a per-route `prefetch`/`instant` config), so its
// speculative static prefetch is fuller than the app-shell render and
// supersedes it in the segment cache if it's applied last. The cookie that
// only the app shell carries is dropped before it reaches the instant
// shell, and #95150's shell handling only engages under partial
// prefetching, so it does not cover this case.
//
// If we later detect that a route reads cookies during app-shell
// generation, we should opt it into either partial prefetching, so that
// only the app shell is fetched on the client, or runtime prefetching, so
// that a speculative prefetch can never regress the content an app shell
// initially showed, and cookie-dependent content is not removed once a
// speculative prefetch (for a link whose instant app shell we already
// displayed) has settled.
it.skip('includes app-shell cookie values in the instant shell during client navigation', async () => {
const page = await openPage(next, '/', {
cookies: [{ name: 'testCookie', value: 'hello' }],
})
await instant(page, async () => {
await page.click('#link-to-cookies-page')
const title = page.locator('[data-testid="cookies-page-title"]')
await title.waitFor({ state: 'visible' })
// Cookies are session data in the app shell, and nothing gates them,
// so the value should be available inside the instant scope.
const cookieValue = page.locator('[data-testid="cookie-value"]')
// Expected-failing today (see the note above): the cookie never reaches
// the shell, so cap the wait rather than spend the default 60s on a
// known timeout. A real fix puts the cookie in the same captured shell
// as the title above, so it surfaces well within this.
await cookieValue.waitFor({ state: 'visible', timeout: 3000 })
expect(await cookieValue.textContent()).toContain('testCookie: hello')
})
})
it('excludes a cookie read after a param access from the instant shell during client navigation', async () => {
const page = await openPage(next, '/', {
cookies: [{ name: 'testCookie', value: 'hello' }],
})
await instant(page, async () => {
await page.click('#link-to-cookies-with-param')
const title = page.locator('[data-testid="cookies-param-title"]')
await title.waitFor({ state: 'visible' })
// The component awaits `params` before `cookies()`. The app shell
// withholds params, so it suspends before the cookie read — the cookie
// value is NOT in the instant shell; its Suspense fallback is shown.
const fallback = page.locator('[data-testid="cookies-param-fallback"]')
await fallback.waitFor({ state: 'visible' })
expect(
await page.locator('[data-testid="cookies-param-value"]').count()
).toBe(0)
})
// After exiting the instant scope, the cookie value streams in.
const cookieValue = page.locator('[data-testid="cookies-param-value"]')
await cookieValue.waitFor({ state: 'visible' })
expect(await cookieValue.textContent()).toContain('testCookie: hello')
})
it('does not include cookie values in instant shell during page load', async () => {
const page = await openPage(next, '/', {
cookies: [{ name: 'testCookie', value: 'hello' }],
})
await instant(page, async () => {
await page.click('#plain-link-to-cookies-page')
const title = page.locator('[data-testid="cookies-page-title"]')
await title.waitFor({ state: 'visible' })
const fallback = page.locator('[data-testid="cookies-fallback"]')
await fallback.waitFor({ state: 'visible' })
const cookieValue = page.locator('[data-testid="cookie-value"]')
expect(await cookieValue.count()).toBe(0)
})
// After exiting instant scope, cookie value streams in
const cookieValue = page.locator('[data-testid="cookie-value"]')
await cookieValue.waitFor({ state: 'visible', timeout: 10000 })
expect(await cookieValue.textContent()).toContain('testCookie: hello')
})
})
// Verifies that search params are excluded from the instant navigation shell.
// The shell should only contain static content — these runtime values should
// be blocked behind a Suspense boundary until the instant lock is released.
// (Cookies are session data carried by the app shell, and dynamic route
// params without `generateStaticParams` are covered separately below.)
//
// The route reads the search param inside a <Suspense> boundary without
// opting into `instant: { prefetch: 'runtime' }`. During the instant scope,
// the static page title should be visible and the Suspense fallback should be
// shown, but the resolved value should NOT be present.
describe('search params are excluded from instant shell', () => {
it('does not include search param values in instant shell during client navigation', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-search-params')
// Static page title is visible
const title = page.locator('[data-testid="search-params-title"]')
await title.waitFor({ state: 'visible' })
// Suspense fallback is visible
const fallback = page.locator('[data-testid="search-params-fallback"]')
await fallback.waitFor({ state: 'visible' })
// Search param content is NOT in the shell
const searchParamContent = page.locator(
'[data-testid="search-param-content"]'
)
expect(await searchParamContent.count()).toBe(0)
})
// After exiting instant scope, search param content streams in
const searchParamContent = page.locator(
'[data-testid="search-param-content"]'
)
await searchParamContent.waitFor({ state: 'visible' })
expect(await searchParamContent.textContent()).toContain('foo: bar')
})
it('does not include search param values in instant shell during page load', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#plain-link-to-search-params')
// Static page title is visible
const title = page.locator('[data-testid="search-params-title"]')
await title.waitFor({ state: 'visible' })
// Suspense fallback is visible
const fallback = page.locator('[data-testid="search-params-fallback"]')
await fallback.waitFor({ state: 'visible' })
// Search param content is NOT in the shell
const searchParamContent = page.locator(
'[data-testid="search-param-content"]'
)
expect(await searchParamContent.count()).toBe(0)
})
// After exiting instant scope, search param content streams in
const searchParamContent = page.locator(
'[data-testid="search-param-content"]'
)
await searchParamContent.waitFor({ state: 'visible', timeout: 10000 })
expect(await searchParamContent.textContent()).toContain('foo: bar')
})
})
describe('statically generated params are included in instant shell', () => {
it('includes statically generated param values in instant shell during client navigation', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-static-params')
// Static page title is visible
const title = page.locator('[data-testid="static-params-title"]')
await title.waitFor({ state: 'visible' })
// Param value IS in the shell (slug 'hello' is in generateStaticParams)
const paramValue = page.locator('[data-testid="static-param-value"]')
await paramValue.waitFor({ state: 'visible' })
expect(await paramValue.textContent()).toContain('slug: hello')
// Suspense fallback is NOT visible
const fallback = page.locator('[data-testid="static-params-fallback"]')
expect(await fallback.count()).toBe(0)
})
})
it('includes statically generated param values in instant shell during page load', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#plain-link-to-static-params')
// Static page title is visible
const title = page.locator('[data-testid="static-params-title"]')
await title.waitFor({ state: 'visible' })
// Param value IS in the shell (slug 'hello' is in generateStaticParams)
const paramValue = page.locator('[data-testid="static-param-value"]')
await paramValue.waitFor({ state: 'visible' })
expect(await paramValue.textContent()).toContain('slug: hello')
// Suspense fallback is NOT visible
const fallback = page.locator('[data-testid="static-params-fallback"]')
expect(await fallback.count()).toBe(0)
})
})
})
// A route with no `generateStaticParams` is never fallback-upgradeable, so a
// concrete param render is never cached back into the static prefetch shell.
// The param read therefore stays a dynamic write behind the Suspense boundary
// and is gated by the instant lock — it must never appear in the instant
// shell, regardless of how the navigation is triggered. (A route that does
// define `generateStaticParams` is exercised above; there a non-listed param
// legitimately upgrades to static after its first render, so it is not a
// reliable runtime value to assert exclusion on.)
describe('dynamic route params without generateStaticParams are excluded from instant shell', () => {
it('during client navigation', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-ungenerated-params')
// Suspense fallback is visible in the instant shell
const fallback = page.locator(
'[data-testid="ungenerated-params-fallback"]'
)
await fallback.waitFor({ state: 'visible' })
// The resolved param value must not be present in the shell
const paramValue = page.locator(
'[data-testid="ungenerated-param-value"]'
)
expect(await paramValue.count()).toBe(0)
})
// After the instant scope exits, the param value streams in normally
const paramValue = page.locator('[data-testid="ungenerated-param-value"]')
await paramValue.waitFor({ state: 'visible' })
expect(await paramValue.textContent()).toContain('slug: anything')
})
it('during page load', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#plain-link-to-ungenerated-params')
// Static page title is visible
const title = page.locator('[data-testid="ungenerated-params-title"]')
await title.waitFor({ state: 'visible' })
// Suspense fallback is visible
const fallback = page.locator(
'[data-testid="ungenerated-params-fallback"]'
)
await fallback.waitFor({ state: 'visible' })
// The resolved param value must not be present in the shell
const paramValue = page.locator(
'[data-testid="ungenerated-param-value"]'
)
expect(await paramValue.count()).toBe(0)
})
// After exiting instant scope, the param value streams in
const paramValue = page.locator('[data-testid="ungenerated-param-value"]')
await paramValue.waitFor({ state: 'visible', timeout: 10000 })
expect(await paramValue.textContent()).toContain('slug: anything')
})
// A hover/intent prefetch must not cache resolved runtime data that then
// leaks into the shell once the instant lock is acquired.
it('does not leak runtime data from a hover prefetch', async () => {
const page = await openPage(next, '/')
// Hover over the link to trigger an intent prefetch, then wait for it.
await page.hover('#link-to-ungenerated-params')
await page.waitForTimeout(3000)
await instant(page, async () => {
await page.click('#link-to-ungenerated-params')
// Static page title is visible
const title = page.locator('[data-testid="ungenerated-params-title"]')
await title.waitFor({ state: 'visible' })
// Suspense fallback is visible
const fallback = page.locator(
'[data-testid="ungenerated-params-fallback"]'
)
await fallback.waitFor({ state: 'visible' })
// Param value is NOT in the shell, even though a hover prefetch ran
// before the instant lock was acquired
const paramValue = page.locator(
'[data-testid="ungenerated-param-value"]'
)
expect(await paramValue.count()).toBe(0)
})
// After exiting instant scope, the param value streams in
const paramValue = page.locator('[data-testid="ungenerated-param-value"]')
await paramValue.waitFor({ state: 'visible' })
expect(await paramValue.textContent()).toContain('slug: anything')
})
})
it('does include dynamic route params in the instant shell when runtime prefetching is enabled', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-ungenerated-params-runtime')
// The param value IS in the shell because the route opts into runtime
// prefetching, so the prefetch resolves `slug` rather than returning
// the generic fallback.
const paramValue = page.locator(
'[data-testid="ungenerated-param-runtime-value"]'
)
await paramValue.waitFor({ state: 'visible' })
expect(await paramValue.textContent()).toContain('slug: anything')
// Suspense fallback is NOT visible
const fallback = page.locator(
'[data-testid="ungenerated-params-runtime-fallback"]'
)
expect(await fallback.count()).toBe(0)
})
})
// Two routes mix a `generateStaticParams`-covered param (`lang`) with an
// uncovered one (`slug`); they differ in prefetch capability, which is what
// each test exercises.
//
// `mixed-params` does NOT opt into runtime prefetching, so a normal (no
// `prefetch` prop) navigation carries only the covered `lang` in the static
// shell. Inside the instant scope `lang` is shown while the uncovered `slug`
// and the request-time `connection()` sibling both stay deferred behind their
// Suspense fallbacks. Because the route can never runtime-prefetch, `slug` is
// deferred here.
it('shows only the static param in the instant shell on a normal navigation to a mixed route', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-mixed-params')
// The covered `lang` comes from the static shell.
const lang = page.locator('[data-testid="mixed-lang"]')
await lang.waitFor({ state: 'visible' })
expect(await lang.textContent()).toContain('lang: en')
// The uncovered `slug` is a fallback param and there is no runtime
// prefetch, so it stays on its Suspense fallback and its value is absent.
await page
.locator('[data-testid="mixed-slug-fallback"]')
.waitFor({ state: 'visible' })
expect(
await page.locator('[data-testid="mixed-slug-value"]').count()
).toBe(0)
// The request-time `connection()` sibling likewise stays on its fallback
// and must not leak into the instant scope.
await page
.locator('[data-testid="mixed-dynamic-fallback"]')
.waitFor({ state: 'visible' })
expect(
await page.locator('[data-testid="mixed-dynamic-value"]').count()
).toBe(0)
})
// After the lock releases, the uncovered `slug` and the request-time
// content stream in.
const slug = page.locator('[data-testid="mixed-slug-value"]')
await slug.waitFor({ state: 'visible' })
expect(await slug.textContent()).toContain('slug: anything')
await page
.locator('[data-testid="mixed-dynamic-value"]')
.waitFor({ state: 'visible' })
})
// With `prefetch` on the link, the runtime prefetch ('2') resolves ALL
// params, so inside the instant scope both `lang` (static shell) and `slug`
// (runtime prefetch) are shown, while the genuinely request-time
// `connection()` sibling stays deferred until the lock releases.
it('resolves the covered param from the static shell and the uncovered param from the runtime prefetch in a mixed route', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-mixed-params-runtime')
// The generateStaticParams-covered `lang` comes from the static shell.
const lang = page.locator('[data-testid="mixed-lang"]')
await lang.waitFor({ state: 'visible' })
expect(await lang.textContent()).toContain('lang: en')
// The uncovered `slug` is resolved by the runtime prefetch and surfaces
// inside the instant scope.
const slug = page.locator('[data-testid="mixed-slug-value"]')
await slug.waitFor({ state: 'visible' })
expect(await slug.textContent()).toContain('slug: anything')
// The request-time dynamic sibling stays on its fallback under the lock.
await page
.locator('[data-testid="mixed-dynamic-fallback"]')
.waitFor({ state: 'visible' })
expect(
await page.locator('[data-testid="mixed-dynamic-value"]').count()
).toBe(0)
})
// After the lock releases, the request-time content streams in while both
// params remain visible.
await page
.locator('[data-testid="mixed-dynamic-value"]')
.waitFor({ state: 'visible' })
expect(
await page.locator('[data-testid="mixed-lang"]').textContent()
).toContain('lang: en')
expect(
await page.locator('[data-testid="mixed-slug-value"]').textContent()
).toContain('slug: anything')
})
it('subsequent navigations after instant scope are not locked', async () => {
const page = await openPage(next, '/')
// First, do an MPA navigation within an instant scope
await instant(page, async () => {
await page.reload()
const homeTitle = page.locator('[data-testid="home-title"]')
await homeTitle.waitFor({ state: 'visible' })
})
// After exiting the instant scope, navigations work normally again
// Client-side navigation should load dynamic content
await page.click('#link-to-target')
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
await dynamicContent.waitFor({ state: 'visible' })
expect(await dynamicContent.textContent()).toContain(
'Dynamic content loaded'
)
// Navigate back to home
await page.goBack()
const homeTitle = page.locator('[data-testid="home-title"]')
await homeTitle.waitFor({ state: 'visible' })
// Another MPA navigation (reload) should also work normally
await page.goto(page.url().replace(/\/$/, '') + '/target-page')
await dynamicContent.waitFor({ state: 'visible' })
expect(await dynamicContent.textContent()).toContain(
'Dynamic content loaded'
)
})
it('throws descriptive error on fresh page without baseURL', async () => {
const page = await openPage(next, '/')
const freshPage = await page.context().newPage()
try {
let caughtError: Error | undefined
try {
await instant(freshPage, async () => {})
} catch (e) {
caughtError = e as Error
}
// Snapshot the error message
expect(caughtError!.message).toMatchInlineSnapshot(`
"Could not infer the base URL of the application.
instant() needs to know the base URL so it can configure the
browser before the first page load. If the page is already
loaded, the base URL is detected automatically.
Otherwise, you can fix this in one of two ways:
1. Pass a baseURL option:
await instant(page, async () => {
await page.goto('http://localhost:3000')
// ...
}, { baseURL: 'http://localhost:3000' })
Tip: If you use baseURL in your Playwright config, you can
get it from the test fixture:
test('my test', async ({ page, baseURL }) => {
await instant(page, async () => {
// ...
}, { baseURL })
})
2. Navigate to a page before calling instant():
await page.goto('http://localhost:3000')
await instant(page, async () => {
// ...
})"
`)
// Verify the stack trace points at the caller, not at the
// internals of the instant() helper.
const firstFrame = caughtError!
.stack!.split('\n')
.find((line) => line.trimStart().startsWith('at '))
expect(firstFrame).not.toContain('resolveURL')
expect(firstFrame).not.toContain('at instant ')
} finally {
await freshPage.close()
}
})
it('sets cookie before first navigation when using baseURL', async () => {
const page = await openPage(next, '/')
const freshPage = await page.context().newPage()
try {
await instant(
freshPage,
async () => {
// Navigate to a page for the first time within the instant scope.
// The cookie was set via addCookies before this navigation, so
// the server sees it on the initial request and blocks dynamic data.
await freshPage.goto(next.url + '/target-page')
// The loading shell appears immediately
const loadingShell = freshPage.locator(
'[data-testid="loading-shell"]'
)
await loadingShell.waitFor({ state: 'visible' })
expect(await loadingShell.textContent()).toContain(
'Loading target page...'
)
// Dynamic content has not streamed in yet
const dynamicContent = freshPage.locator(
'[data-testid="dynamic-content"]'
)
expect(await dynamicContent.count()).toBe(0)
},
{ baseURL: next.url }
)
// After exiting the instant scope, dynamic content streams in
const dynamicContent = freshPage.locator(
'[data-testid="dynamic-content"]'
)
await dynamicContent.waitFor({ state: 'visible' })
expect(await dynamicContent.textContent()).toContain(
'Dynamic content loaded'
)
} finally {
await freshPage.close()
}
})
it('clears cookie after instant scope exits', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.reload()
const homeTitle = page.locator('[data-testid="home-title"]')
await homeTitle.waitFor({ state: 'visible' })
})
// The instant cookie should be cleaned up
const cookies = await page.context().cookies()
const instantCookie = cookies.find(
(c) => c.name === 'next-instant-navigation-testing'
)
expect(instantCookie).toBeUndefined()
})
it('blocks out-of-band client fetch during instant scope (SPA)', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-client-fetch')
// The page title appears (it's a client component, rendered immediately)
const title = page.locator('[data-testid="client-fetch-title"]')
await title.waitFor({ state: 'visible' })
// The fetch to /api/data is blocked, so the loading state persists
const loading = page.locator('[data-testid="fetched-data-loading"]')
await loading.waitFor({ state: 'visible' })
// The fetched data has NOT arrived
const fetchedData = page.locator('[data-testid="fetched-data"]')
expect(await fetchedData.count()).toBe(0)
})
// After exiting the instant scope, the fetch completes
const fetchedData = page.locator('[data-testid="fetched-data"]')
await fetchedData.waitFor({ state: 'visible' })
expect(await fetchedData.textContent()).toContain('api response')
})
it('blocks out-of-band client fetch during instant scope (MPA)', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#plain-link-to-client-fetch')
// The page title appears
const title = page.locator('[data-testid="client-fetch-title"]')
await title.waitFor({ state: 'visible' })
// The fetch to /api/data is blocked, so the loading state persists
const loading = page.locator('[data-testid="fetched-data-loading"]')
await loading.waitFor({ state: 'visible' })
// The fetched data has NOT arrived
const fetchedData = page.locator('[data-testid="fetched-data"]')
expect(await fetchedData.count()).toBe(0)
})
// After exiting the instant scope, the fetch completes
const fetchedData = page.locator('[data-testid="fetched-data"]')
await fetchedData.waitFor({ state: 'visible' })
expect(await fetchedData.textContent()).toContain('api response')
})
if (isNextDev) {
it('lets dev-server requests through during instant scope', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#link-to-client-fetch')
// The out-of-band fetch to /api/data is blocked by the lock
const loading = page.locator('[data-testid="fetched-data-loading"]')
await loading.waitFor({ state: 'visible' })
// But dev-server requests (hot-reloader middleware endpoints like the
// error overlay and source maps) bypass the lock and resolve while the
// scope is still active. Without the bypass this evaluate would hang
// until the scope ends.
const status = await page.evaluate(() =>
fetch('/__nextjs_server_status').then((res) => res.status)
)
expect(status).toBe(200)
// The blocked fetch still hasn't resolved
const fetchedData = page.locator('[data-testid="fetched-data"]')
expect(await fetchedData.count()).toBe(0)
})
})
}
it('clears cookie even when callback throws', async () => {
const page = await openPage(next, '/')
await expect(
instant(page, async () => {
throw new Error('test error')
})
).rejects.toThrow('test error')
// The instant cookie should still be cleaned up
const cookies = await page.context().cookies()
const instantCookie = cookies.find(
(c) => c.name === 'next-instant-navigation-testing'
)
expect(instantCookie).toBeUndefined()
})
// A page that reads a dynamic value (e.g. `await cookies()`) at the root with
// no Suspense boundary above it produces an empty static shell. Serving that
// shell under the instant lock would be a blank document with no way to
// release the lock, so every reload would render the same empty shell and
// leave the user stuck. The framework breaks that loop: it surfaces the error
// and clears the instant cookie so the next reload renders the route
// normally. We assert the user-facing outcome, not transport details (status
// code, Set-Cookie) which legitimately differ across dev, `next start`, and
// deploy.
it('recovers on reload for a blocking route under the instant lock', async () => {
const browser = await next.browser('/root-blocking-page', {
beforePageLoad(p: Playwright.Page) {
const { hostname } = new URL(next.url)
p.context().addCookies([
{
name: 'next-instant-navigation-testing',
value: '[0]',
domain: hostname,
path: '/',
},
])
},
})
// In development the empty shell surfaces as an error overlay so the
// developer sees why the route is blocking.
if (isNextDev) {
await expect(browser).toDisplayRedbox(`
{
"code": "E1387",
"description": "The Navigation Inspector was active, but you attempted to load a blocking route. Reload the page to reset the inspector.
To identify why this route is blocking, refer to the Instant Navigation docs: https://preview.nextjs.org/docs/app/guides/instant-navigation",
"environmentLabel": null,
"label": "Runtime Error",
"source": null,
"stack": [],
}
`)
}
// The empty shell can render nothing useful, but the instant cookie is
// cleared, so reloading renders the route normally instead of the blank
// shell.
await browser.refresh()
const content = await browser
.elementByCss('[data-testid="root-blocking-content"]')
.text()
expect(content).toContain('testCookie:')
})
})
describe('instant-navigation-testing-api - root params', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures', 'root-params'),
})
it('includes root param in instant shell', async () => {
const page = await openPage(next, '/en')
const langValue = page.locator('[data-testid="lang-value"]')
await langValue.waitFor({ state: 'visible' })
expect(await langValue.textContent()).toContain('lang: en')
await instant(page, async () => {
await page.reload()
// The root param value is still visible (it's statically known)
await langValue.waitFor({ state: 'visible' })
expect(await langValue.textContent()).toContain('lang: en')
})
})
})
describe('instant-navigation-testing-api - partial prefetching (App Shells)', () => {
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures', 'partial-prefetch'),
// Skew protection (deployment-id asset versioning) is orthogonal to the
// shell-restriction behavior under test. Disable it so the suite exercises
// only the navigation-lock behavior.
disableAutoSkewProtection: true,
})
// Under Partial Prefetching with App Shells, an auto (partial) prefetch only
// warms the shell — the concrete-param entry is not speculatively
// prefetched. The testing lock simulates that warm cache: a navigation may
// only match the shell entry, never an entry that varies on concrete route
// params, even if such an entry happens to be warm in the cache (here, from a
// sibling prefetch={true} link). Without the restriction, the navigation
// would match the warm `slug: hello` entry and show it instantly.
it('restricts navigation to the shell even when a concrete-param entry is warm', async () => {
const page = await openPage(next, '/')
// Warm both entries for /dynamic-params/hello:
// - the prefetch={true} sibling link does a speculative (whole-route)
// prefetch, warming the concrete slug=hello segment entry, and
// - the default link does a partial prefetch, warming the shell entry
// (params -> Fallback) that the restricted navigation should render.
await page.hover('#full-link')
await page.hover('#partial-link')
await page.waitForTimeout(3000)
await instant(page, async () => {
// Navigate via the default (partial) link. Even though the concrete
// slug=hello entry is warm, the lock restricts the read to the shell.
await page.click('#partial-link')
// A shell boundary is shown — either the route-level loading.tsx or the
// page's inner Suspense fallback — proving the navigation rendered the
// shell rather than the warm concrete entry.
const shell = page.locator(
'[data-testid="route-loading"], [data-testid="params-fallback"]'
)
await shell.first().waitFor({ state: 'visible' })
// The concrete param value is NOT matched.
const paramValue = page.locator('[data-testid="param-value"]')
expect(await paramValue.count()).toBe(0)
})
// After the instant scope exits, the concrete value streams in normally.
const paramValue = page.locator('[data-testid="param-value"]')
await paramValue.waitFor({ state: 'visible' })
expect(await paramValue.textContent()).toContain('slug: hello')
})
// A prefetch={true} link triggers a speculative (whole-route) prefetch, so
// the concrete-param entry IS prefetched. In that case the lock must NOT
// restrict to the shell — the navigation is allowed to match the concrete
// entry, since that's what a warm cache would actually contain.
it('allows matching concrete params when the link uses prefetch={true}', async () => {
const page = await openPage(next, '/')
await instant(page, async () => {
await page.click('#full-link')
// The concrete param value is matched and shown instantly.
const paramValue = page.locator('[data-testid="param-value"]')
await paramValue.waitFor({ state: 'visible' })
expect(await paramValue.textContent()).toContain('slug: hello')
// The Suspense fallback (shell) is NOT shown.
const fallback = page.locator('[data-testid="params-fallback"]')
expect(await fallback.count()).toBe(0)
})
})
})
// A route with no static shell — `cookies()` read outside any <Suspense>, no
// `instant = false` — is a hard `next build` error (it can't be prerendered),
// so it can't live in a fixture that gets production-built. The Instant
// Navigation behavior it exercises (the destination must not commit under the
// lock) is a dev-time concern, so this suite uses a dedicated fixture and runs
// in dev only; `next start`/deploy register a single placeholder so the build
// is never attempted.
describe('instant-navigation-testing-api - blocking routes (dev only)', () => {
if (!isNextDev) {
it('skips blocking route tests outside dev (route cannot be production-built)', () => {})
return
}
const { next } = nextTestSetup({
files: join(__dirname, 'fixtures', 'blocking'),
skipDeployment: true,
})
// The cookie value must not commit while the instant lock is held; it only
// streams in after the lock releases.
it('does not include blocking cookies read outside a Suspense in the instant shell', async () => {
const page = await openPage(next, '/', {
cookies: [{ name: 'testCookie', value: 'hello' }],
})
await page
.locator('[data-testid="home-title"]')
.waitFor({ state: 'visible' })
await instant(page, async () => {
await page.click('#link-to-blocking-cookies')
const cookieValue = page.locator('[data-testid="blocking-cookie-value"]')
// Poll past the point where a leak previously committed (~10s), breaking
// early so a regression fails fast.
for (let i = 0; i < 8; i++) {
await page.waitForTimeout(1000)
if (await cookieValue.count()) break
}
expect(await cookieValue.count()).toBe(0)
})
// After exiting the instant scope, the cookie value streams in.
const cookieValue = page.locator('[data-testid="blocking-cookie-value"]')
await cookieValue.waitFor({ state: 'visible' })
expect(await cookieValue.textContent()).toContain('testCookie: hello')
})
})