next.js/test/e2e/app-dir/instant-navigation-testing-api/instant-navigation-testing-api.test.ts
instant-navigation-testing-api.test.ts835 lines31.0 KB
/**
 * 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 } from 'e2e-utils'
import { instant } from '@next/playwright'
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.
 */
async function openPage(
  next: NextInstance,
  url: string,
  options?: { cookies?: Array<{ name: string; value: string }> }
): Promise<Playwright.Page> {
  let page: Playwright.Page
  await next.browser(url, {
    beforePageLoad(p) {
      page = p
      if (options?.cookies) {
        const { hostname } = new URL(next.url)
        p.context().addCookies(
          options.cookies.map((c) => ({
            ...c,
            domain: hostname,
            path: '/',
          }))
        )
      }
    },
  })
  return page!
}

describe('instant-navigation-testing-api', () => {
  const { next } = nextTestSetup({
    files: join(__dirname, 'fixtures', 'default'),
    // Skip deployment tests because the exposeTestingApiInProductionBuild flag
    // doesn't exist in the production version of Next.js yet
    skipDeployment: true,
  })

  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
    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(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')
  })

  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('renders static 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)
    })

    // 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(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 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(next, '/', {
        cookies: [{ name: 'testCookie', value: 'hello' }],
      })

      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(next, '/')

      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: unknown')
    })

    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 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')

        // 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(next, '/')

      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: unknown')
    })

    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-dynamic-params')

        // Static page title is visible
        const title = page.locator('[data-testid="dynamic-params-title"]')
        await title.waitFor({ state: 'visible' })

        // Param value IS in the shell (slug 'hello' is in generateStaticParams)
        const paramValue = page.locator('[data-testid="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="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-dynamic-params')

        // Static page title is visible
        const title = page.locator('[data-testid="dynamic-params-title"]')
        await title.waitFor({ state: 'visible' })

        // Param value IS in the shell (slug 'hello' is in generateStaticParams)
        const paramValue = page.locator('[data-testid="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="params-fallback"]')
        expect(await fallback.count()).toBe(0)
      })
    })
  })

  it('does not bake dynamic route params into the instant shell when no generateStaticParams is defined', 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('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)
    })
  })

  // 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(next, '/')

    // 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: unknown')
  })

  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')
  })

  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()
  })
})

describe('instant-navigation-testing-api - root params', () => {
  const { next } = nextTestSetup({
    files: join(__dirname, 'fixtures', 'root-params'),
    skipDeployment: true,
  })

  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')
    })
  })
})
Quest for Codev2.0.0
/
SIGN IN