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
665 lines
24 KiB
TypeScript
665 lines
24 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 { nextTestSetup } from 'e2e-utils'
|
|
import { instant } from '@next/playwright'
|
|
import type * as Playwright from 'playwright'
|
|
|
|
describe('instant-navigation-testing-api', () => {
|
|
const { next } = nextTestSetup({
|
|
files: __dirname,
|
|
// Skip deployment tests because the exposeTestingApiInProductionBuild flag
|
|
// doesn't exist in the production version of Next.js yet
|
|
skipDeployment: true,
|
|
})
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async function openPage(url: string): Promise<Playwright.Page> {
|
|
let page: Playwright.Page
|
|
await next.browser(url, {
|
|
beforePageLoad(p) {
|
|
page = p
|
|
},
|
|
})
|
|
return page!
|
|
}
|
|
|
|
it('renders prefetched loading shell instantly during navigation', async () => {
|
|
const page = await openPage('/')
|
|
|
|
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
|
|
const dynamicContent = page.locator('[data-testid="dynamic-content"]')
|
|
await dynamicContent.waitFor({ state: 'visible' })
|
|
expect(await dynamicContent.textContent()).toContain(
|
|
'Dynamic content loaded'
|
|
)
|
|
})
|
|
|
|
it('renders runtime-prefetched content instantly during navigation', async () => {
|
|
const page = await openPage('/')
|
|
|
|
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')
|
|
})
|
|
|
|
it('renders full prefetch content instantly when prefetch={true}', async () => {
|
|
const page = await openPage('/')
|
|
|
|
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('logs an error when attempting to nest instant scopes', async () => {
|
|
const page = await openPage('/')
|
|
|
|
// Listen for the specific error message
|
|
const consolePromise = page.waitForEvent('console', {
|
|
predicate: (msg) =>
|
|
msg.type() === 'error' && msg.text().includes('already acquired'),
|
|
timeout: 5000,
|
|
})
|
|
|
|
await instant(page, async () => {
|
|
// Attempt to acquire the lock again by nesting instant() calls.
|
|
// The inner call sets the cookie again, and the handler detects
|
|
// that the lock is already held, logging an error.
|
|
await instant(page, async () => {})
|
|
const msg = await consolePromise
|
|
expect(msg.text()).toContain('already acquired')
|
|
})
|
|
})
|
|
|
|
it('renders static shell on page reload', async () => {
|
|
const page = await openPage('/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)
|
|
})
|
|
|
|
// After exiting the instant scope, dynamic content streams in
|
|
await dynamicContent.waitFor({ state: 'visible' })
|
|
expect(await dynamicContent.textContent()).toContain(
|
|
'Dynamic content loaded'
|
|
)
|
|
})
|
|
|
|
it('renders static shell on MPA navigation via plain anchor', async () => {
|
|
const page = await openPage('/')
|
|
|
|
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('/')
|
|
|
|
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('/')
|
|
|
|
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 static 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'
|
|
)
|
|
})
|
|
|
|
// Verifies that runtime params (cookies, dynamic route params, search
|
|
// params) are excluded from the instant navigation shell. The shell should
|
|
// only contain static content — runtime param values should be blocked
|
|
// behind a Suspense boundary until the instant lock is released.
|
|
//
|
|
// Each test route reads a different runtime param inside a <Suspense>
|
|
// boundary without opting into `unstable_instant: { prefetch: 'runtime' }`.
|
|
// During the instant scope, the static page title should be visible and the
|
|
// Suspense fallback should be shown, but the resolved param value should
|
|
// NOT be present.
|
|
describe('runtime params are excluded from instant shell', () => {
|
|
it('does not include cookie values in instant shell during client navigation', async () => {
|
|
const page = await openPage('/')
|
|
|
|
// Set a test cookie
|
|
await page.evaluate(() => {
|
|
document.cookie = 'testCookie=hello; path=/'
|
|
})
|
|
|
|
await instant(page, async () => {
|
|
await page.click('#link-to-cookies-page')
|
|
|
|
// Static page title is visible
|
|
const title = page.locator('[data-testid="cookies-page-title"]')
|
|
await title.waitFor({ state: 'visible' })
|
|
|
|
// Suspense fallback is visible
|
|
const fallback = page.locator('[data-testid="cookies-fallback"]')
|
|
await fallback.waitFor({ state: 'visible' })
|
|
|
|
// Cookie value is NOT in the shell
|
|
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' })
|
|
expect(await cookieValue.textContent()).toContain('testCookie: hello')
|
|
})
|
|
|
|
it('does not include dynamic param values in instant shell during client navigation', async () => {
|
|
const page = await openPage('/')
|
|
|
|
await instant(page, async () => {
|
|
await page.click('#link-to-dynamic-params')
|
|
|
|
// Static page title is visible
|
|
const title = page.locator('[data-testid="dynamic-params-title"]')
|
|
await title.waitFor({ state: 'visible' })
|
|
|
|
// Suspense fallback is visible
|
|
const fallback = page.locator('[data-testid="params-fallback"]')
|
|
await fallback.waitFor({ state: 'visible' })
|
|
|
|
// Param value is NOT in the shell
|
|
const paramValue = page.locator('[data-testid="param-value"]')
|
|
expect(await paramValue.count()).toBe(0)
|
|
})
|
|
|
|
// After exiting instant scope, param value streams in
|
|
const paramValue = page.locator('[data-testid="param-value"]')
|
|
await paramValue.waitFor({ state: 'visible' })
|
|
expect(await paramValue.textContent()).toContain('slug: hello')
|
|
})
|
|
|
|
it('does not include search param values in instant shell during client navigation', async () => {
|
|
const page = await openPage('/')
|
|
|
|
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 cookie values in instant shell during page load', async () => {
|
|
const page = await openPage('/')
|
|
|
|
// Set a test cookie
|
|
await page.evaluate(() => {
|
|
document.cookie = 'testCookie=hello; path=/'
|
|
})
|
|
|
|
await instant(page, async () => {
|
|
await page.click('#plain-link-to-cookies-page')
|
|
|
|
// Static page title is visible
|
|
const title = page.locator('[data-testid="cookies-page-title"]')
|
|
await title.waitFor({ state: 'visible' })
|
|
|
|
// Suspense fallback is visible
|
|
const fallback = page.locator('[data-testid="cookies-fallback"]')
|
|
await fallback.waitFor({ state: 'visible' })
|
|
|
|
// Cookie value is NOT in the shell
|
|
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')
|
|
})
|
|
|
|
it('does not include dynamic param values in instant shell during page load', async () => {
|
|
const page = await openPage('/')
|
|
|
|
await instant(page, async () => {
|
|
await page.click('#plain-link-to-dynamic-params')
|
|
|
|
// Static page title is visible
|
|
const title = page.locator('[data-testid="dynamic-params-title"]')
|
|
await title.waitFor({ state: 'visible' })
|
|
|
|
// Suspense fallback is visible
|
|
const fallback = page.locator('[data-testid="params-fallback"]')
|
|
await fallback.waitFor({ state: 'visible' })
|
|
|
|
// Param value is NOT in the shell
|
|
const paramValue = page.locator('[data-testid="param-value"]')
|
|
expect(await paramValue.count()).toBe(0)
|
|
})
|
|
|
|
// After exiting instant scope, param value streams in
|
|
const paramValue = page.locator('[data-testid="param-value"]')
|
|
await paramValue.waitFor({ state: 'visible', timeout: 10000 })
|
|
expect(await paramValue.textContent()).toContain('slug: hello')
|
|
})
|
|
|
|
it('does not include search param values in instant shell during page load', async () => {
|
|
const page = await openPage('/')
|
|
|
|
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')
|
|
})
|
|
})
|
|
|
|
// In dev mode, hover/intent-based prefetches should not send requests
|
|
// that produce stale segment data. If a hover prefetch caches the route
|
|
// with resolved runtime data before the instant lock is acquired, params
|
|
// will leak into the shell when instant mode is later enabled.
|
|
it('does not leak runtime data from hover prefetch into instant shell', async () => {
|
|
const page = await openPage('/')
|
|
|
|
// Hover over the dynamic params link to trigger an intent prefetch
|
|
await page.hover('#link-to-dynamic-params')
|
|
|
|
// Wait for the prefetch to complete
|
|
await page.waitForTimeout(3000)
|
|
|
|
// Now enable instant mode and navigate
|
|
await instant(page, async () => {
|
|
await page.click('#link-to-dynamic-params')
|
|
|
|
// Static page title is visible
|
|
const title = page.locator('[data-testid="dynamic-params-title"]')
|
|
await title.waitFor({ state: 'visible' })
|
|
|
|
// Suspense fallback is visible
|
|
const fallback = page.locator('[data-testid="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="param-value"]')
|
|
expect(await paramValue.count()).toBe(0)
|
|
})
|
|
|
|
// After exiting instant scope, param value streams in
|
|
const paramValue = page.locator('[data-testid="param-value"]')
|
|
await paramValue.waitFor({ state: 'visible' })
|
|
expect(await paramValue.textContent()).toContain('slug: hello')
|
|
})
|
|
|
|
it('subsequent navigations after instant scope are not locked', async () => {
|
|
const page = await openPage('/')
|
|
|
|
// 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('/')
|
|
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('/')
|
|
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('/')
|
|
|
|
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('clears cookie even when callback throws', async () => {
|
|
const page = await openPage('/')
|
|
|
|
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()
|
|
})
|
|
})
|