next.js/test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts
segment-cache-revalidation.test.ts434 lines13.6 KB
import type * as Playwright from 'playwright'
import { isNextDev, isNextDeploy, createNext } from 'e2e-utils'
import { createRouterAct } from 'router-act'
import { createTestDataServer } from 'test-data-service/writer'
import { createTestLog } from 'test-log'
import { findPort } from 'next-test-utils'

describe('segment cache (revalidation)', () => {
  if (isNextDev || isNextDeploy) {
    test('disabled in development / deployment', () => {})
    return
  }

  let port = -1
  let server
  let dataVersions = new Map()
  let TestLog = createTestLog()

  let next
  beforeAll(async () => {
    port = await findPort()
    server = createTestDataServer(async (key, res) => {
      const currentVersion = dataVersions.get(key)

      // Increment the version number each time to track how often the
      // server renders.
      const nextVersion = currentVersion === undefined ? 1 : currentVersion + 1
      dataVersions.set(key, nextVersion)

      // Append the version number to the response
      const response = `${key} [${nextVersion}]`
      TestLog.log('REQUEST: ' + key)
      res.resolve(response)
    })
    server.listen(port)

    next = await createNext({
      files: __dirname,
      env: { TEST_DATA_SERVICE_URL: `http://localhost:${port}` },
    })
  })

  beforeEach(async () => {
    dataVersions = new Map()
    TestLog = createTestLog()
  })

  afterAll(async () => {
    await next?.destroy()
    server?.close()
  })

  it('evict client cache when Server Action calls revalidatePath', async () => {
    let act: ReturnType<typeof createRouterAct>
    let page: Playwright.Page
    const browser = await next.browser('/', {
      beforePageLoad(p: Playwright.Page) {
        page = p
        act = createRouterAct(p)
      },
    })

    const linkVisibilityToggle = await browser.elementByCss(
      'input[data-link-accordion="/greeting"]'
    )

    // Reveal the link the target page to trigger a prefetch
    await act(
      async () => {
        await linkVisibilityToggle.click()
      },
      {
        includes: 'random-greeting',
      }
    )

    // Install fake timers — freezes the 300ms cooldown setTimeout
    await page.clock.install()

    // Perform an action that calls revalidatePath. This should cause the
    // corresponding entry to be evicted from the client cache, and a new
    // prefetch to be requested.
    await act(async () => {
      const revalidateByPath = await browser.elementById('revalidate-by-path')
      await revalidateByPath.click()
    })

    // Advance past cooldown inside act() to intercept the re-prefetch
    await act(
      async () => {
        await page.clock.fastForward(300)
      },
      {
        includes: 'random-greeting [1]',
      }
    )
    TestLog.assert(['REQUEST: random-greeting'])

    // Navigate to the target page.
    await act(async () => {
      const link = await browser.elementByCss('a[href="/greeting"]')
      await link.click()
      // Navigation should finish immedately because the page is
      // fully prefetched.
      const greeting = await browser.elementById('greeting')
      expect(await greeting.innerHTML()).toBe('random-greeting [1]')
    }, 'no-requests')
  })

  it('refetch visible Form components after cache is revalidated', async () => {
    // This is the same as the previous test, but for forms. Since the
    // prefetching implementation is shared between Link and Form, we don't
    // bother to test every feature using both Link and Form; this test should
    // be sufficient.
    let act: ReturnType<typeof createRouterAct>
    let page: Playwright.Page
    const browser = await next.browser('/', {
      beforePageLoad(p: Playwright.Page) {
        page = p
        act = createRouterAct(p)
      },
    })

    const formVisibilityToggle = await browser.elementByCss(
      'input[data-form-accordion="/greeting"]'
    )

    // Reveal the form that points to the target page to trigger a prefetch
    await act(
      async () => {
        await formVisibilityToggle.click()
      },
      {
        includes: 'random-greeting',
      }
    )

    // Install fake timers — freezes the 300ms cooldown setTimeout
    await page.clock.install()

    // Perform an action that calls revalidatePath. This should cause the
    // corresponding entry to be evicted from the client cache, and a new
    // prefetch to be requested.
    await act(async () => {
      const revalidateByPath = await browser.elementById('revalidate-by-path')
      await revalidateByPath.click()
    })

    // Advance past cooldown inside act() to intercept the re-prefetch
    await act(
      async () => {
        await page.clock.fastForward(300)
      },
      {
        includes: 'random-greeting [1]',
      }
    )
    TestLog.assert(['REQUEST: random-greeting'])

    // Navigate to the target page.
    await act(async () => {
      const button = await browser.elementByCss(
        'form[action="/greeting"] button'
      )
      await button.click()
      // Navigation should finish immedately because the page is
      // fully prefetched.
      const greeting = await browser.elementById('greeting')
      expect(await greeting.innerHTML()).toBe('random-greeting [1]')
    }, 'no-requests')
  })

  it('call router.prefetch(..., {onInvalidate}) after cache is revalidated', async () => {
    // This is the similar to the previous tests, but uses a custom Link
    // implementation that calls router.prefetch manually. It demonstrates it's
    // possible to simulate the revalidating behavior of Link using the manual
    // prefetch API.
    let act: ReturnType<typeof createRouterAct>
    let page: Playwright.Page
    const browser = await next.browser('/', {
      beforePageLoad(p: Playwright.Page) {
        page = p
        act = createRouterAct(p)
      },
    })

    const linkVisibilityToggle = await browser.elementByCss(
      'input[data-manual-prefetch-link-accordion="/greeting"]'
    )

    // Reveal the link that points to the target page to trigger a prefetch
    await act(
      async () => {
        await linkVisibilityToggle.click()
      },
      {
        includes: 'random-greeting',
      }
    )

    // Install fake timers — freezes the 300ms cooldown setTimeout
    await page.clock.install()

    // Perform an action that calls revalidatePath. This should cause the
    // corresponding entry to be evicted from the client cache, and a new
    // prefetch to be requested.
    await act(async () => {
      const revalidateByPath = await browser.elementById('revalidate-by-path')
      await revalidateByPath.click()
    })

    // Advance past cooldown inside act() to intercept the re-prefetch
    await act(
      async () => {
        await page.clock.fastForward(300)
      },
      {
        includes: 'random-greeting [1]',
      }
    )
    TestLog.assert(['REQUEST: random-greeting'])

    // Navigate to the target page.
    await act(async () => {
      const link = await browser.elementByCss('a[href="/greeting"]')
      await link.click()
      // Navigation should finish immedately because the page is
      // fully prefetched.
      const greeting = await browser.elementById('greeting')
      expect(await greeting.innerHTML()).toBe('random-greeting [1]')
    }, 'no-requests')
  })

  it('evict client cache when Server Action calls revalidateTag', async () => {
    let act: ReturnType<typeof createRouterAct>
    let page: Playwright.Page
    const browser = await next.browser('/', {
      beforePageLoad(p: Playwright.Page) {
        page = p
        act = createRouterAct(p)
      },
    })

    const linkVisibilityToggle = await browser.elementByCss(
      'input[data-link-accordion="/greeting"]'
    )

    // Reveal the link the target page to trigger a prefetch.
    await act(
      async () => {
        await linkVisibilityToggle.click()
      },
      {
        includes: 'random-greeting',
      }
    )

    // Install fake timers — freezes the 300ms cooldown setTimeout
    await page.clock.install()

    // Perform an action that calls revalidateTag. This should cause the
    // corresponding entry to be evicted from the client cache, and a new
    // prefetch to be requested.
    await act(async () => {
      const revalidateByTag = await browser.elementById('revalidate-by-tag')
      await revalidateByTag.click()
    })

    // Advance past cooldown inside act() to intercept the re-prefetch
    await act(
      async () => {
        await page.clock.fastForward(300)
      },
      {
        includes: 'random-greeting [1]',
      }
    )
    TestLog.assert(['REQUEST: random-greeting'])

    // Navigate to the target page.
    await act(async () => {
      const link = await browser.elementByCss('a[href="/greeting"]')
      await link.click()
      // Navigation should finish immedately because the page is
      // fully prefetched.
      const greeting = await browser.elementById('greeting')
      expect(await greeting.innerHTML()).toBe('random-greeting [1]')
    }, 'no-requests')
  })

  it('re-fetch visible links after a navigation, if needed', async () => {
    let act: ReturnType<typeof createRouterAct>
    const browser = await next.browser('/refetch-on-new-base-tree/a', {
      beforePageLoad(page) {
        act = createRouterAct(page)
      },
    })

    const linkALinkVisibilityToggle = await browser.elementByCss(
      'input[data-link-accordion="/refetch-on-new-base-tree/a"]'
    )
    const linkBLinkVisibilityToggle = await browser.elementByCss(
      'input[data-link-accordion="/refetch-on-new-base-tree/b"]'
    )

    // Reveal the links to trigger prefetches
    await act(async () => {
      await linkALinkVisibilityToggle.click()
      await linkBLinkVisibilityToggle.click()
    }, [
      // Page B's content should have been prefetched
      {
        includes: 'Page B content',
      },
      // Page A's content should not be prefetched because we're already on that
      // page. When prefetching with `prefetch='unstable_forceStale'`, we only prefetch the
      // delta between the current route and the target route.
      {
        includes: 'Page A content',
        block: 'reject',
      },
    ])

    // Navigate to page B
    await act(
      async () => {
        const link = await browser.elementByCss(
          'a[href="/refetch-on-new-base-tree/b"]'
        )
        await link.click()
        const content = await browser.elementById('page-b-content')
        expect(await content.innerHTML()).toBe('Page B content')
      },
      // The link for page A is re-prefetched again, even though it's an
      // existing link, because the delta between the current route and the
      // target route has changed.
      //
      // This time, the response does include the content for page A.
      //
      // TODO: The request is actually skipped entirely because <Link
      // prefetch={true} /> now reads from the bfcache before issuing a prefetch
      // request, which wasn't true before the test was written. I'm leaving
      // the test here for now, though, since we may want to re-write it in
      // terms of runtime prefetching at some point. There's other coverage of
      // this behavior though so it might be fine to just remove the whole test.
      // {
      //   includes: 'Page A content',
      // }
      'no-requests'
    )

    // Navigate to page A
    await act(
      async () => {
        const link = await browser.elementByCss(
          'a[href="/refetch-on-new-base-tree/a"]'
        )
        await link.click()
        const content = await browser.elementById('page-a-content')
        expect(await content.innerHTML()).toBe('Page A content')
      },
      // There should be no new requests because everything is fully prefetched.
      'no-requests'
    )
  })

  it('delay re-prefetch after revalidation to allow CDN propagation', async () => {
    let act: ReturnType<typeof createRouterAct>
    let page: Playwright.Page
    const browser = await next.browser('/', {
      beforePageLoad(p: Playwright.Page) {
        page = p
        act = createRouterAct(p)
      },
    })

    const linkVisibilityToggle = await browser.elementByCss(
      'input[data-link-accordion="/greeting"]'
    )

    // Reveal the link the target page to trigger a prefetch
    await act(
      async () => {
        await linkVisibilityToggle.click()
      },
      {
        includes: 'random-greeting',
      }
    )

    // Install fake timers so the 300ms cooldown setTimeout in the
    // browser is frozen until we explicitly advance the clock.
    await page.clock.install()

    // Perform an action that calls revalidatePath. This triggers a 300ms
    // cooldown before any new prefetch requests can be made.
    await act(async () => {
      const revalidateByPath = await browser.elementById('revalidate-by-path')
      await revalidateByPath.click()
    })

    // The cooldown timer is frozen, so no prefetch should have occurred.
    TestLog.assert([])

    // Advance partway through the cooldown — still no prefetch.
    await page.clock.fastForward(150)
    TestLog.assert([])

    // Advance past the cooldown (300ms total). This fires the cooldown
    // callback, which triggers a re-prefetch. Use act() to intercept
    // the prefetch request and ensure the response is fully delivered
    // to the browser.
    await act(
      async () => {
        await page.clock.fastForward(150)
      },
      {
        includes: 'random-greeting [1]',
      }
    )
    TestLog.assert(['REQUEST: random-greeting'])

    // Navigate to the target page.
    await act(async () => {
      const link = await browser.elementByCss('a[href="/greeting"]')
      await link.click()
      // Navigation should finish immediately because the page is
      // fully prefetched.
      const greeting = await browser.elementById('greeting')
      expect(await greeting.innerHTML()).toBe('random-greeting [1]')
    }, 'no-requests')
  })
})
Quest for Codev2.0.0
/
SIGN IN