next.js/test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts
vary-params.test.ts762 lines25.7 KB
import { nextTestSetup } from 'e2e-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'

/**
 * Tests for the "vary params" optimization.
 *
 * Background: During prerendering, Next.js tracks which params each segment
 * actually accesses on the server. This enables the client cache to share
 * entries: when a segment doesn't access a param, different values of that
 * param can reuse the same cached segment.
 *
 * Core behavior under test:
 * - When a segment accesses a param, changing that param requires a new prefetch
 * - When a segment does NOT access a param, changing that param reuses the cache
 *
 * The first test (instant loading state) is the canonical demonstration of
 * the feature's user-facing benefit. Subsequent tests exercise various
 * combinations of features and edge cases.
 */
describe('segment cache - vary params', () => {
  const { next, isNextDev } = nextTestSetup({
    files: __dirname,
  })

  if (isNextDev) {
    test('prefetching is disabled in dev mode', () => {})
    return
  }

  it('renders cached loading state instantly during navigation', async () => {
    // Setup: All links share category='electronics' but different itemId values.
    // Layout only accesses 'category', page renders itemId dynamically.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/instant-loading', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // Prefetch the first link - layout is fetched
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/instant-loading/electronics/phone"]'
        )
        await toggle.click()
      },
      { includes: 'Category: electronics' }
    )

    // Prefetch remaining links - all cache hits (same category, layout cached)
    await act(async () => {
      const tablet = await browser.elementByCss(
        'input[data-link-accordion="/instant-loading/electronics/tablet"]'
      )
      await tablet.click()
      const laptop = await browser.elementByCss(
        'input[data-link-accordion="/instant-loading/electronics/laptop"]'
      )
      await laptop.click()
      const headphones = await browser.elementByCss(
        'input[data-link-accordion="/instant-loading/electronics/headphones"]'
      )
      await headphones.click()
    }, 'no-requests')

    // Navigate to headphones. The loading state renders synchronously from
    // the cached layout, before the dynamic request resolves. The assertion
    // runs inside act() during navigation, verifying it appears instantly.
    await act(async () => {
      const link = await browser.elementByCss(
        'a[href="/instant-loading/electronics/headphones"]'
      )
      await link.click()

      const loading = await browser.elementByCss('[data-loading="true"]')
      expect(await loading.text()).toContain('Loading item')
    })

    // Dynamic content eventually loads
    const page = await browser.elementById('instant-loading-page')
    expect(await page.text()).toContain('Item: headphones')
  })

  it('reuses prefetched page segment with in-page loading boundary across different params', async () => {
    // Setup: Page uses an in-page Suspense boundary instead of loading.tsx. The
    // page's default export wraps a child component in <Suspense>. The child
    // awaits params, but during prerendering the params are fallback params
    // (hanging promise), so the child suspends and the segment prefetch
    // contains only the Suspense fallback with empty varyParams — making it
    // reusable across all slug values.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/in-page-loading-boundary', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // Prefetch the first link - page segment is fetched
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/in-page-loading-boundary/phone"]'
        )
        await toggle.click()
      },
      { includes: 'Loading item' }
    )

    // Prefetch remaining links - all cache hits (page prefetch is shared)
    await act(async () => {
      const tablet = await browser.elementByCss(
        'input[data-link-accordion="/in-page-loading-boundary/tablet"]'
      )
      await tablet.click()
      const laptop = await browser.elementByCss(
        'input[data-link-accordion="/in-page-loading-boundary/laptop"]'
      )
      await laptop.click()
      const headphones = await browser.elementByCss(
        'input[data-link-accordion="/in-page-loading-boundary/headphones"]'
      )
      await headphones.click()
    }, 'no-requests')

    // Navigate to headphones. The loading state renders instantly from the
    // cached page shell (Suspense fallback), before the dynamic request
    // resolves.
    await act(async () => {
      const link = await browser.elementByCss(
        'a[href="/in-page-loading-boundary/headphones"]'
      )
      await link.click()

      const loading = await browser.elementByCss('[data-loading="true"]')
      expect(await loading.text()).toContain('Loading item')
    })

    // Dynamic content eventually loads
    const content = await browser.elementById(
      'in-page-loading-boundary-content'
    )
    expect(await content.text()).toContain('Item: headphones')
  })

  it('renders cached loading state instantly with runtime prefetching', async () => {
    // Setup: Page accesses `category` in static portion (tracked in varyParams),
    // but accesses `itemId` only after connection() (not tracked).
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/runtime-prefetch', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // Prefetch first link - static content fetched
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch/electronics/phone"]'
        )
        await toggle.click()
      },
      { includes: 'Static content - Category: electronics' }
    )

    // Prefetch remaining links with same category - all cache hits
    await act(async () => {
      const tablet = await browser.elementByCss(
        'input[data-link-accordion="/runtime-prefetch/electronics/tablet"]'
      )
      await tablet.click()
      const laptop = await browser.elementByCss(
        'input[data-link-accordion="/runtime-prefetch/electronics/laptop"]'
      )
      await laptop.click()
      const headphones = await browser.elementByCss(
        'input[data-link-accordion="/runtime-prefetch/electronics/headphones"]'
      )
      await headphones.click()
    }, 'no-requests')

    // Prefetch link with different category - triggers new prefetch
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch/clothing/shirt"]'
        )
        await toggle.click()
      },
      { includes: 'Static content - Category: clothing' }
    )

    // Navigate to headphones. Loading state renders synchronously from cache.
    await act(async () => {
      const link = await browser.elementByCss(
        'a[href="/runtime-prefetch/electronics/headphones"]'
      )
      await link.click()

      const loading = await browser.elementByCss('[data-loading="true"]')
      expect(await loading.text()).toContain('Loading item details')
    })

    // Dynamic content eventually loads
    const dynamicContent = await browser.elementByCss('[data-dynamic-content]')
    expect(await dynamicContent.text()).toContain('Item: headphones')
  })

  it('does not reuse prefetched segment when page accesses searchParams', async () => {
    // When a page awaits searchParams, the cache key includes the search
    // params, so different values require separate prefetches.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/search-params', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // Each prefetch triggers a new request (not cached)
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/search-params/target-page?foo=1"]'
        )
        await toggle.click()
      },
      { includes: 'Search params target - foo: 1' }
    )

    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/search-params/target-page?foo=2"]'
        )
        await toggle.click()
      },
      { includes: 'Search params target - foo: 2' }
    )

    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/search-params/target-page?foo=3"]'
        )
        await toggle.click()
      },
      { includes: 'Search params target - foo: 3' }
    )
  })

  it('reuses prefetched segment when page does not access searchParams', async () => {
    // When a page does NOT await searchParams, the cache key does NOT include
    // search params, so different values share cached prefetch data.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/search-params', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // First prefetch fetches the segment
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/search-params/static-target?foo=1"]'
        )
        await toggle.click()
      },
      { includes: 'Static target content - no searchParams access' }
    )

    // Subsequent prefetches are cache hits
    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/search-params/static-target?foo=2"]'
      )
      await toggle.click()
    }, 'no-requests')

    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/search-params/static-target?foo=3"]'
      )
      await toggle.click()
    }, 'no-requests')
  })

  it('tracks param access in generateMetadata', async () => {
    // Setup: generateMetadata accesses params, but the page body does NOT.
    // This tests that metadata param access is tracked separately.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/metadata', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // First prefetch fetches both head and body
    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/metadata/aaa"]'
      )
      await toggle.click()
    }, [{ includes: 'Page: aaa' }, { includes: 'Static page body' }])

    // Second prefetch: head re-fetched (metadata varies on slug),
    // but body is cached (body doesn't access slug)
    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/metadata/bbb"]'
      )
      await toggle.click()
    }, [
      { includes: 'Page: bbb' },
      { includes: 'Static page body', block: 'reject' },
    ])
  })

  it('caches head segment when generateMetadata does not access params', async () => {
    // When neither generateMetadata nor the page body access params,
    // both head and body are cached across different param values.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/metadata-no-params', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // First prefetch fetches content
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/metadata-no-params/aaa"]'
        )
        await toggle.click()
      },
      { includes: 'Page content' }
    )

    // Second prefetch is a cache hit
    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/metadata-no-params/bbb"]'
      )
      await toggle.click()
    }, 'no-requests')
  })

  it('reuses page segment when layout varies but page does not', async () => {
    // Setup: Layout accesses both `category` and `item`, page only accesses
    // `category`. When item changes but category stays the same, the layout
    // must be re-fetched but the page is cached.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/page-reuse', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // First prefetch fetches both layout and page
    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/page-reuse/electronics/phone"]'
      )
      await toggle.click()
    }, [
      { includes: 'Layout: electronics/phone' },
      { includes: 'Page category:' },
    ])

    // Second prefetch: layout re-fetched (varies on item),
    // page is cached (only varies on category)
    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/page-reuse/electronics/tablet"]'
      )
      await toggle.click()
    }, [
      { includes: 'Layout: electronics/tablet' },
      { includes: 'Page category:', block: 'reject' },
    ])

    // Navigate to verify cached page content renders correctly
    const link = await browser.elementByCss(
      'a[href="/page-reuse/electronics/tablet"]'
    )
    await link.click()

    const layout = await browser.elementByCss('[data-page-reuse-layout]')
    expect(await layout.text()).toContain('Layout: electronics/tablet')

    const page = await browser.elementById('page-reuse-page')
    expect(await page.text()).toContain('Page category: electronics')
  })

  it('does not reuse cached segment for optional catch-all when page accesses slug', async () => {
    // Setup: Page accesses params.slug directly. Prefetch the empty-slug
    // page first, then verify that prefetching a different slug value
    // triggers a new request (not a cache hit).
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/optional-catchall-index', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // Prefetch the empty-slug page first
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/optional-catchall"]'
        )
        await toggle.click()
      },
      { includes: 'Slug: none' }
    )

    // Prefetch a different slug — should trigger a new request because the
    // page varies on slug
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/optional-catchall/aaa"]'
        )
        await toggle.click()
      },
      { includes: 'Slug: aaa' }
    )

    // Navigate and verify correct content
    const link = await browser.elementByCss('a[href="/optional-catchall/aaa"]')
    await link.click()

    const page = await browser.elementById('optional-catchall-page')
    expect(await page.text()).toContain('Slug: aaa')
  })

  it('does not reuse cached segment for optional catch-all when page enumerates params', async () => {
    // Setup: Page accesses params via spread ({...params}). Enumeration
    // should cause the segment to vary on the optional catch-all param,
    // even when the param has no value.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/optional-catchall-enumeration-index', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // Prefetch the empty-slug page first
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/optional-catchall-enumeration"]'
        )
        await toggle.click()
      },
      { includes: 'Slug: none' }
    )

    // Prefetch a different slug — not cached
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/optional-catchall-enumeration/aaa"]'
        )
        await toggle.click()
      },
      { includes: 'Slug: aaa' }
    )

    const link = await browser.elementByCss(
      'a[href="/optional-catchall-enumeration/aaa"]'
    )
    await link.click()

    const page = await browser.elementById('optional-catchall-enumeration-page')
    expect(await page.text()).toContain('Slug: aaa')
  })

  it('does not reuse cached segment for optional catch-all when page checks slug with in operator', async () => {
    // Setup: Page checks for slug using `'slug' in params`. The `in`
    // operator should cause the segment to vary on the optional catch-all
    // param, even when the param has no value.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/optional-catchall-has-index', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // Prefetch the empty-slug page first
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/optional-catchall-has"]'
        )
        await toggle.click()
      },
      { includes: 'Slug: none' }
    )

    // Prefetch a different slug — not cached
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/optional-catchall-has/aaa"]'
        )
        await toggle.click()
      },
      { includes: 'Slug: aaa' }
    )

    const link = await browser.elementByCss(
      'a[href="/optional-catchall-has/aaa"]'
    )
    await link.click()

    const page = await browser.elementById('optional-catchall-has-page')
    expect(await page.text()).toContain('Slug: aaa')
  })

  it('shares cached segment across all params when none accessed statically (runtime prefetch)', async () => {
    // Both params are accessed only after connection(), so varyParams is
    // empty. ALL param combinations share the same cached loading shell.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/runtime-prefetch-no-vary', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // First prefetch fetches the segment
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch-no-vary/electronics/phone"]'
        )
        await toggle.click()
      },
      { includes: 'Loading all content dynamically' }
    )

    // All other combinations are cache hits — even different categories
    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/runtime-prefetch-no-vary/electronics/tablet"]'
      )
      await toggle.click()
    }, 'no-requests')

    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/runtime-prefetch-no-vary/clothing/shirt"]'
      )
      await toggle.click()
    }, 'no-requests')
  })

  it('does not share cached segment when all params accessed statically (runtime prefetch)', async () => {
    // Both params are accessed before connection(), so every unique
    // combination of (category, itemId) requires its own prefetch.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/runtime-prefetch-all-vary', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // Each prefetch triggers a new request
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch-all-vary/electronics/phone"]'
        )
        await toggle.click()
      },
      { includes: 'Static content - electronics/phone' }
    )

    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch-all-vary/electronics/tablet"]'
        )
        await toggle.click()
      },
      { includes: 'Static content - electronics/tablet' }
    )

    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch-all-vary/clothing/shirt"]'
        )
        await toggle.click()
      },
      { includes: 'Static content - clothing/shirt' }
    )
  })

  it('shares cached segment across search params when not accessed (runtime prefetch)', async () => {
    // Runtime prefetch page that does NOT access searchParams. Since '?'
    // is not in varyParams, different search param values share the cache.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/runtime-prefetch-search-params', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // First prefetch fetches the segment
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch-search-params/target-page?q=1"]'
        )
        await toggle.click()
      },
      { includes: 'Static content - searchParams not accessed' }
    )

    // Different search param values are cache hits
    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/runtime-prefetch-search-params/target-page?q=2"]'
      )
      await toggle.click()
    }, 'no-requests')

    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/runtime-prefetch-search-params/target-page?q=3"]'
      )
      await toggle.click()
    }, 'no-requests')
  })

  it('tracks metadata param access separately from body (runtime prefetch)', async () => {
    // generateMetadata accesses slug, but the page body does NOT.
    // Each slug triggers a new head prefetch because metadata varies on slug.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/runtime-prefetch-metadata', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // First prefetch triggers a request including the metadata
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch-metadata/aaa"]'
        )
        await toggle.click()
      },
      { includes: 'Runtime Metadata: aaa' }
    )

    // Second prefetch with different slug triggers a new request
    // (metadata varies on slug, so it can't reuse the cache)
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch-metadata/bbb"]'
        )
        await toggle.click()
      },
      { includes: 'Runtime Metadata: bbb' }
    )
  })

  it('tracks vary params per-segment with layout/page split (runtime prefetch)', async () => {
    // Layout accesses both category and itemId; page accesses only category.
    // When itemId changes but category stays the same, the page segment
    // should be reused from cache (only varies on category).
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/runtime-prefetch-layout-split', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // First prefetch fetches page segment
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch-layout-split/electronics/phone"]'
        )
        await toggle.click()
      },
      { includes: 'Page category:' }
    )

    // Second prefetch: same category, different itemId.
    // The page segment is a cache hit since it only varies on category.
    await act(async () => {
      const toggle = await browser.elementByCss(
        'input[data-link-accordion="/runtime-prefetch-layout-split/electronics/tablet"]'
      )
      await toggle.click()
    }, 'no-requests')

    // Different category triggers a new page segment fetch
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/runtime-prefetch-layout-split/clothing/shirt"]'
        )
        await toggle.click()
      },
      { includes: 'Page category:' }
    )

    // Navigate and verify correct content
    const link = await browser.elementByCss(
      'a[href="/runtime-prefetch-layout-split/electronics/tablet"]'
    )
    await link.click()

    const layout = await browser.elementByCss('[data-layout-content]')
    expect(await layout.text()).toContain('Layout: electronics/tablet')

    const page = await browser.elementById('runtime-prefetch-layout-split-page')
    expect(await page.text()).toContain('Page category: electronics')
  })

  it('tracks root param access via rootParams API', async () => {
    // Root params accessed via rootParams() are tracked in varyParams.
    // Different param values require separate prefetches.
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/root-params', {
      beforePageLoad(p: Playwright.Page) {
        act = createRouterAct(p)
      },
    })

    // First prefetch fetches content
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/aaa"]'
        )
        await toggle.click()
      },
      { includes: 'Root param page content - param: aaa' }
    )

    // Second prefetch triggers new fetch (not cached)
    await act(
      async () => {
        const toggle = await browser.elementByCss(
          'input[data-link-accordion="/bbb"]'
        )
        await toggle.click()
      },
      { includes: 'Root param page content - param: bbb' }
    )
  })
})
Quest for Codev2.0.0
/
SIGN IN