next.js/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts
prefetch-runtime.test.ts1095 lines36.1 KB
import { nextTestSetup } from 'e2e-utils'
import { waitFor } from 'next-test-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'

describe('runtime prefetching', () => {
  const { next, isNextDev, isNextDeploy } = nextTestSetup({
    files: __dirname,
  })
  if (isNextDev) {
    it('is skipped', () => {})
    return
  }

  let currentCliOutputIndex = 0
  beforeEach(() => {
    resetCliOutput()
  })

  const getCliOutput = () => {
    if (next.cliOutput.length < currentCliOutputIndex) {
      // cliOutput shrank since we started the test, so something (like a `sandbox`) reset the logs
      currentCliOutputIndex = 0
    }
    return next.cliOutput.slice(currentCliOutputIndex)
  }

  const resetCliOutput = () => {
    currentCliOutputIndex = next.cliOutput.length
  }

  describe.each([
    {
      description: 'in a page',
      prefix: 'in-page',
    },
    {
      description: 'in a private cache',
      prefix: 'in-private-cache',
    },
    {
      description: 'passed to a public cache',
      prefix: 'passed-to-public-cache',
    },
  ])('$description', ({ prefix }) => {
    it('includes dynamic params, but not dynamic content', async () => {
      let page: Playwright.Page
      const browser = await next.browser('/', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      const act = createRouterAct(page)

      // Reveal the link to trigger a runtime prefetch for one value of the dynamic param
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/${prefix}/dynamic-params/123"]`
        )
        await linkToggle.click()
      }, [
        // Should allow reading dynamic params
        {
          includes: 'Param: 123',
        },
        // Should not prefetch the dynamic content
        {
          includes: 'Dynamic content',
          block: 'reject',
        },
      ])

      // Reveal the link to trigger a runtime prefetch for a different value of the dynamic param
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/${prefix}/dynamic-params/456"]`
        )
        await linkToggle.click()
      }, [
        // Should allow reading dynamic params
        {
          includes: 'Param: 456',
        },
        // Should not prefetch the dynamic content
        {
          includes: 'Dynamic content',
          block: 'reject',
        },
      ])

      // Navigate to the page
      await act(async () => {
        await act(
          async () => {
            await browser
              .elementByCss(`a[href="/${prefix}/dynamic-params/123"]`)
              .click()
          },
          {
            // Temporarily block the navigation request.
            // The runtime-prefetched parts of the tree should be visible before it finishes.
            includes: 'Dynamic content',
            block: true,
          }
        )
        expect(await browser.elementById('param-value').text()).toEqual(
          'Param: 123'
        )
      })
      // After navigating, we should see both the parts that we prefetched and dynamic content.
      expect(await browser.elementById('param-value').text()).toEqual(
        'Param: 123'
      )
      expect(await browser.elementById('dynamic-content').text()).toEqual(
        'Dynamic content'
      )

      await browser.back()

      // Navigate to the other page
      await act(async () => {
        await act(
          async () => {
            await browser
              .elementByCss(`a[href="/${prefix}/dynamic-params/456"]`)
              .click()
          },
          {
            // Temporarily block the navigation request.
            // The runtime-prefetched parts of the tree should be visible before it finishes.
            includes: 'Dynamic content',
            block: true,
          }
        )
        expect(await browser.elementById('param-value').text()).toEqual(
          'Param: 456'
        )
      })
      // After navigating, we should see both the parts that we prefetched and dynamic content.
      expect(await browser.elementById('param-value').text()).toEqual(
        'Param: 456'
      )
      expect(await browser.elementById('dynamic-content').text()).toEqual(
        'Dynamic content'
      )
    })

    it('includes root params, but not dynamic content', async () => {
      let page: Playwright.Page
      const browser = await next.browser('/with-root-param/en', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      const act = createRouterAct(page)

      // Reveal the link to trigger a runtime prefetch for one value of the root param
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/with-root-param/en/${prefix}/root-params"]`
        )
        await linkToggle.click()
      }, [
        // Should allow reading root params
        {
          includes: 'Lang: en',
        },
        // Should not prefetch the dynamic content
        {
          includes: 'Dynamic content',
          block: 'reject',
        },
      ])

      // TODO(runtime-ppr) - visiting root params that weren't in generateStaticParams errors when deployed
      if (!isNextDeploy) {
        // Reveal the link to trigger a runtime prefetch for a different value of the root param
        await act(async () => {
          const linkToggle = await browser.elementByCss(
            `input[data-link-accordion="/with-root-param/de/${prefix}/root-params"]`
          )
          await linkToggle.click()
        }, [
          // Should allow reading root params
          {
            includes: 'Lang: de',
          },
          // Should not prefetch the dynamic content
          {
            includes: 'Dynamic content',
            block: 'reject',
          },
        ])
      }

      // Navigate to the first page
      await act(async () => {
        await act(
          async () => {
            await browser
              .elementByCss(
                `a[href="/with-root-param/en/${prefix}/root-params"]`
              )
              .click()
          },
          {
            // Temporarily block the navigation request.
            // The runtime-prefetched parts of the tree should be visible before it finishes.
            includes: 'Dynamic content',
            block: true,
          }
        )
        expect(await browser.elementById('root-param-value').text()).toEqual(
          'Lang: en'
        )
      })
      // After navigating, we should see both the parts that we prefetched and dynamic content.
      expect(await browser.elementById('root-param-value').text()).toEqual(
        'Lang: en'
      )
      expect(await browser.elementById('dynamic-content').text()).toEqual(
        'Dynamic content'
      )

      // TODO(runtime-ppr) - visiting root params that weren't in generateStaticParams errors when deployed
      if (!isNextDeploy) {
        await browser.back()

        // Navigate to the other page
        await act(async () => {
          await act(
            async () => {
              await browser
                .elementByCss(
                  `a[href="/with-root-param/de/${prefix}/root-params"]`
                )
                .click()
            },
            {
              // Temporarily block the navigation request.
              // The runtime-prefetched parts of the tree should be visible before it finishes.
              includes: 'Dynamic content',
              block: true,
            }
          )
          expect(await browser.elementById('root-param-value').text()).toEqual(
            'Lang: de'
          )
        })
        // After navigating, we should see both the parts that we prefetched and dynamic content.
        expect(await browser.elementById('root-param-value').text()).toEqual(
          'Lang: de'
        )
        expect(await browser.elementById('dynamic-content').text()).toEqual(
          'Dynamic content'
        )
      }
    })

    it('includes search params, but not dynamic content', async () => {
      let page: Playwright.Page
      const browser = await next.browser('/', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      const act = createRouterAct(page)

      // Reveal the link to trigger a runtime prefetch for one value of the search param
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/${prefix}/search-params?searchParam=123"]`
        )
        await linkToggle.click()
      }, [
        // Should allow reading search params
        {
          includes: 'Search param: 123',
        },
        // Should not prefetch the dynamic content
        {
          includes: 'Dynamic content',
          block: 'reject',
        },
      ])

      // Reveal the link to trigger a runtime prefetch for a different value of the search param
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/${prefix}/search-params?searchParam=456"]`
        )
        await linkToggle.click()
      }, [
        // Should allow reading search params
        {
          includes: 'Search param: 456',
        },
        // Should not prefetch the dynamic content
        {
          includes: 'Dynamic content',
          block: 'reject',
        },
      ])

      // Navigate to the page
      await act(async () => {
        await act(
          async () => {
            await browser
              .elementByCss(
                `a[href="/${prefix}/search-params?searchParam=123"]`
              )
              .click()
          },
          {
            // Temporarily block the navigation request.
            // The runtime-prefetched parts of the tree should be visible before it finishes.
            includes: 'Dynamic content',
            block: true,
          }
        )
        expect(await browser.elementById('search-param-value').text()).toEqual(
          'Search param: 123'
        )
      })
      // After navigating, we should see both the parts that we prefetched and dynamic content.
      expect(await browser.elementById('search-param-value').text()).toEqual(
        'Search param: 123'
      )
      expect(await browser.elementById('dynamic-content').text()).toEqual(
        'Dynamic content'
      )

      await browser.back()

      // Navigate to the other page
      await act(
        async () => {
          await browser
            .elementByCss(`a[href="/${prefix}/search-params?searchParam=456"]`)
            .click()
        },
        {
          // Now the dynamic content should be fetched
          includes: 'Dynamic content',
        }
      )
      expect(await browser.elementById('search-param-value').text()).toEqual(
        'Search param: 456'
      )
      expect(await browser.elementById('dynamic-content').text()).toEqual(
        'Dynamic content'
      )
    })

    it('includes headers, but not dynamic content', async () => {
      let page: Playwright.Page
      const browser = await next.browser('/', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      const act = createRouterAct(page)

      // Reveal the link to trigger a runtime prefetch for one value of the search param
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/${prefix}/headers"]`
        )
        await linkToggle.click()
      }, [
        // Should allow reading headers
        {
          includes: 'Header: present',
        },
        // Should not prefetch the dynamic content
        {
          includes: 'Dynamic content',
          block: 'reject',
        },
      ])

      // Navigate to the page
      await act(async () => {
        await act(
          async () => {
            await browser.elementByCss(`a[href="/${prefix}/headers"]`).click()
          },
          {
            // Temporarily block the navigation request.
            // The runtime-prefetched parts of the tree should be visible before it finishes.
            includes: 'Dynamic content',
            block: true,
          }
        )
        expect(await browser.elementById('header-value').text()).toEqual(
          'Header: present'
        )
      })
      // After navigating, we should see both the parts that we prefetched and dynamic content.
      expect(await browser.elementById('header-value').text()).toEqual(
        'Header: present'
      )
      expect(await browser.elementById('dynamic-content').text()).toEqual(
        'Dynamic content'
      )
    })

    it('includes cookies, but not dynamic content', async () => {
      let page: Playwright.Page
      const browser = await next.browser('/', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      // Clear cookies after the test. This currently doesn't happen automatically.
      await using _ = defer(() => browser.deleteCookies())

      const act = createRouterAct(page)

      await browser.addCookie({ name: 'testCookie', value: 'initialValue' })

      // Reveal the link to trigger a runtime prefetch for the initial cookie value
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/${prefix}/cookies"]`
        )
        await linkToggle.click()
      }, [
        // Should allow reading cookies
        {
          includes: 'Cookie: initialValue',
        },
        // Should not prefetch the dynamic content
        {
          includes: 'Dynamic content',
          block: 'reject',
        },
      ])

      // Navigate to the page
      await act(async () => {
        await act(
          async () => {
            await browser.elementByCss(`a[href="/${prefix}/cookies"]`).click()
          },
          {
            // Temporarily block the navigation request.
            // The runtime-prefetched parts of the tree should be visible before it finishes.
            includes: 'Dynamic content',
            block: true,
          }
        )
        expect(await browser.elementById('cookie-value').text()).toEqual(
          'Cookie: initialValue'
        )
      })
      // After navigating, we should see both the parts that we prefetched and dynamic content.
      expect(await browser.elementById('cookie-value').text()).toEqual(
        'Cookie: initialValue'
      )
      expect(await browser.elementById('dynamic-content').text()).toEqual(
        'Dynamic content'
      )

      // Update the cookie via a server action.
      // This should cause the client cache to be dropped,
      // so the page should get prefetched again when the link becomes visible
      await browser.elementByCss('input[name="cookie"]').type('updatedValue')
      await browser.elementByCss('[type="submit"]').click()

      // Go back to the previous page
      await browser.back()

      // wait a tick before navigating
      // TODO: Why does this need to be so long when deployed? What other signal do we have that we can wait on?
      await waitFor(2000)

      // Navigate to the page
      await act(async () => {
        await act(
          async () => {
            await browser.elementByCss(`a[href="/${prefix}/cookies"]`).click()
          },
          {
            includes: 'Dynamic content',
            block: true,
          }
        )
        expect(await browser.elementById('cookie-value').text()).toEqual(
          'Cookie: updatedValue'
        )
      })

      expect(await browser.elementById('cookie-value').text()).toEqual(
        'Cookie: updatedValue'
      )
      expect(await browser.elementById('dynamic-content').text()).toEqual(
        'Dynamic content'
      )
    })

    it('can completely prefetch a page that uses cookies and no uncached IO', async () => {
      let page: Playwright.Page
      const browser = await next.browser('/', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      // Clear cookies after the test. This currently doesn't happen automatically.
      await using _ = defer(() => browser.deleteCookies())

      const act = createRouterAct(page)

      await browser.addCookie({ name: 'testCookie', value: 'initialValue' })

      // Reveal the link to trigger a runtime prefetch for the initial cookie value
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/${prefix}/cookies-only"]`
        )
        await linkToggle.click()
      }, [
        // Should allow reading cookies
        {
          includes: 'Cookie: initialValue',
        },
      ])

      // Navigate to the page.
      await act(
        async () => {
          await browser
            .elementByCss(`a[href="/${prefix}/cookies-only"]`)
            .click()
        },
        // The page doesn't use any other IO, so we prefetched it completely, and shouldn't issue any more requests.
        'no-requests'
      )
      expect(await browser.elementById('cookie-value').text()).toEqual(
        'Cookie: initialValue'
      )
    })
  })

  describe('should not cache runtime prefetch responses in the browser cache or server-side', () => {
    // This is a bit difficult to test, but we can request the same thing repeatedly and expect different results.

    it.each([
      { description: 'in a page', prefix: 'in-page' },
      { description: 'in a private cache', prefix: 'in-private-cache' },
    ])(
      'different cookies should return different prefetch results - $description',
      async ({ prefix }) => {
        let page: Playwright.Page
        const browser = await next.browser('/', {
          beforePageLoad(p: Playwright.Page) {
            page = p
          },
        })
        // Clear cookies after the test. This currently doesn't happen automatically.
        await using _ = defer(() => browser.deleteCookies())

        const act = createRouterAct(page)

        await browser.addCookie({ name: 'testCookie', value: 'initialValue' })

        // Reveal the link to trigger a runtime prefetch for the initial cookie value
        await act(async () => {
          const linkToggle = await browser.elementByCss(
            `input[data-link-accordion="/${prefix}/cookies-only"]`
          )
          await linkToggle.click()
        }, [
          // Should allow reading cookies
          {
            includes: 'Cookie: initialValue',
          },
        ])

        // Reload the page with a new cookie value
        await browser.addCookie({ name: 'testCookie', value: 'updatedValue' })
        await browser.refresh()

        // Reveal the link to trigger a runtime prefetch for the updated cookie value.
        await act(async () => {
          const linkToggle = await browser.elementByCss(
            `input[data-link-accordion="/${prefix}/cookies-only"]`
          )
          await linkToggle.click()
        }, [
          // The response shouldn't be cached in the browser or on the server.
          // If it was, we'd get a stale value here.
          {
            includes: 'Cookie: updatedValue',
          },
        ])
      }
    )

    it('private caches should return new results on each request', async () => {
      let page: Playwright.Page
      const browser = await next.browser('/', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      // Clear cookies after the test. This currently doesn't happen automatically.
      await using _ = defer(() => browser.deleteCookies())

      const act = createRouterAct(page)

      // Reveal the link to trigger the first runtime prefetch
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/in-private-cache/date-now"]`
        )
        await linkToggle.click()
      }, [
        // The timestamp value is in a private cache, so it should be included
        {
          includes: 'Timestamp: ',
        },
      ])

      // Navigate to the page to reveal the runtime-prefetched content, and save the timestamp value it had
      let firstTimestampValue: string
      await act(async () => {
        await act(
          async () => {
            await browser
              .elementByCss(`a[href="/in-private-cache/date-now"]`)
              .click()
          },
          // Temporarily block the navigation request.
          // The prefetched parts of the tree should be visible before it finishes.
          'block'
        )
        firstTimestampValue = await browser.elementById('timestamp').text()
      })

      // Go back to the initial page and reload it to clear the client router cache
      await browser.back()
      await browser.refresh()

      // Reveal the link to trigger the second runtime prefetch
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/in-private-cache/date-now"]`
        )
        await linkToggle.click()
      }, [
        // The timestamp value is in a private cache, so it should be included
        {
          includes: 'Timestamp: ',
        },
      ])

      // Navigate to the page to reveal the runtime-prefetched content, and save the timestamp value it had
      let secondTimestampValue: string
      await act(async () => {
        await act(
          async () => {
            await browser
              .elementByCss(`a[href="/in-private-cache/date-now"]`)
              .click()
          },
          // Temporarily block the navigation request.
          // The prefetched parts of the tree should be visible before it finishes.
          'block'
        )
        secondTimestampValue = await browser.elementById('timestamp').text()
      })

      // If the runtime prefetch response wasn't cached, the responses should be different
      expect(firstTimestampValue).not.toEqual(secondTimestampValue)
    })
  })

  it('can completely prefetch a page that is fully static', async () => {
    let page: Playwright.Page
    const browser = await next.browser('/', {
      beforePageLoad(p: Playwright.Page) {
        page = p
      },
    })

    const act = createRouterAct(page)

    // Reveal the link to trigger a runtime prefetch for the page
    await act(async () => {
      const linkToggle = await browser.elementByCss(
        `input[data-link-accordion="/fully-static"]`
      )
      await linkToggle.click()
    }, [
      {
        includes: 'Hello from a fully static page!',
      },
    ])

    // Navigate to the page.
    await act(
      async () => {
        await browser.elementByCss(`a[href="/fully-static"]`).click()
      },
      // The page doesn't use any IO, so we prefetched it completely, and shouldn't issue any more requests.
      'no-requests'
    )
    expect(await browser.elementByCss('p#intro').text()).toBe(
      'Hello from a fully static page!'
    )
  })

  describe('cache stale time handling', () => {
    it.each([
      {
        // If a cache has an expiration time under 5min (DYNAMIC_EXPIRE), we omit it from static prerenders.
        // However, it should still be included in a runtime prefetch if its stale time is >=30s. (RUNTIME_PREFETCH_DYNAMIC_STALE)
        description:
          'includes short-lived public caches with a long enough staleTime',
        staticContent: 'This page uses a short-lived public cache',
        path: '/caches/public-short-expire-long-stale',
      },
      {
        // If a cache has an expiration time under 5min (DYNAMIC_EXPIRE), we omit it from static prerenders.
        // However, it should still be included in a runtime prefetch if its stale time is >=30s. (RUNTIME_PREFETCH_DYNAMIC_STALE)
        // `cacheLife("seconds")` is deliberately set to have a stale time of 30s to stay above this treshold.
        description: 'includes public caches with cacheLife("seconds")',
        staticContent: 'This page uses a short-lived public cache',
        path: '/caches/public-seconds',
      },
      {
        // A Private cache will always be omitted from static prerenders.
        // However, it should still be included in a runtime prefetch if its stale time is >=30s. (RUNTIME_PREFETCH_DYNAMIC_STALE)
        // `cacheLife("seconds")` is deliberately set to have a stale time of 30s to stay above this treshold.
        description: 'includes private caches with cacheLife("seconds")',
        staticContent: 'This page uses a short-lived private cache',
        path: '/caches/private-seconds',
      },
    ])('$description', async ({ path, staticContent }) => {
      let page: Playwright.Page
      const browser = await next.browser('/', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      const act = createRouterAct(page)

      const DYNAMICALLY_PREFETCHABLE_CONTENT = 'Short-lived cached content'

      // Reveal the link to trigger a runtime prefetch
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="${path}"]`
        )
        await linkToggle.click()
      }, [
        {
          includes: staticContent,
        },
        // Should include the short-lived cache
        {
          includes: DYNAMICALLY_PREFETCHABLE_CONTENT,
        },
      ])

      // Navigate to the page. We didn't include any uncached IO, so the page is fully prefetched,
      // and this shouldn't issue any more requests
      await act(async () => {
        await browser.elementByCss(`a[href="${path}"]`).click()
      }, 'no-requests')

      expect(await browser.elementByCss('main').text()).toInclude(
        DYNAMICALLY_PREFETCHABLE_CONTENT
      )
    })

    it('omits short-lived public caches with a short enough staleTime', async () => {
      // If a cache has a stale time below 30s (RUNTIME_PREFETCH_DYNAMIC_STALE), we should omit it from runtime prefetches.

      let page: Playwright.Page
      const browser = await next.browser('/', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      const act = createRouterAct(page)

      const STATIC_CONTENT = 'This page uses a short-lived public cache'
      const DYNAMIC_CONTENT = 'Short-lived cached content'

      // Reveal the link to trigger a runtime prefetch.
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/caches/public-short-expire-short-stale"]`
        )
        await linkToggle.click()
      }, [
        // Should include the shell
        {
          includes: STATIC_CONTENT,
        },
        // Should not include the short-lived cache
        // (We set the `stale` value to be under 30s, so it will be excluded from runtime prerenders)
        {
          includes: DYNAMIC_CONTENT,
          block: 'reject',
        },
      ])

      // Navigate to the page
      await act(async () => {
        await act(
          async () => {
            await browser
              .elementByCss(`a[href="/caches/public-short-expire-short-stale"]`)
              .click()
          },
          {
            // Temporarily block the navigation request.
            // The prefetched parts of the tree should be visible before it finishes.
            includes: DYNAMIC_CONTENT,
            block: true,
          }
        )
        expect(await browser.elementById('intro').text()).toInclude(
          STATIC_CONTENT
        )
      })

      // After navigating, we should see both the parts that we prefetched and the short lived cache.
      expect(await browser.elementById('intro').text()).toInclude(
        STATIC_CONTENT
      )
      expect(await browser.elementById('cached-value').text()).toMatch(/\d+/)
    })

    it('omits private caches with a short enough staleTime', async () => {
      // If a cache has a stale time below 30s (RUNTIME_PREFETCH_DYNAMIC_STALE), we should omit it from runtime prefetches.

      let page: Playwright.Page
      const browser = await next.browser('/', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      const act = createRouterAct(page)

      const STATIC_CONTENT = 'This page uses a short-lived private cache'
      const DYNAMIC_CONTENT = 'Short-lived cached content'

      // Reveal the link to trigger a runtime prefetch
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/caches/private-short-stale"]`
        )
        await linkToggle.click()
      }, [
        // Should include the shell
        {
          includes: STATIC_CONTENT,
        },
        // Should not prefetch the short-lived cache
        // (We set the `stale` value to be under 30s, so it will be excluded from runtime prefetches)
        {
          includes: DYNAMIC_CONTENT,
          block: 'reject',
        },
      ])

      // Navigate to the page
      await act(async () => {
        await act(
          async () => {
            await browser
              .elementByCss(`a[href="/caches/private-short-stale"]`)
              .click()
          },
          {
            // Temporarily block the navigation request.
            // The runtime-prefetched parts of the tree should be visible before it finishes.
            includes: DYNAMIC_CONTENT,
            block: true,
          }
        )
        expect(await browser.elementById('intro').text()).toInclude(
          STATIC_CONTENT
        )
      })

      // After navigating, we should see both the parts that we prefetched and dynamic content.
      expect(await browser.elementById('intro').text()).toInclude(
        STATIC_CONTENT
      )
      const cachedValue1 = await browser.elementById('cached-value').text()
      expect(cachedValue1).toMatch(/\d+/)

      // Try navigating again. The cache is private, so we should see a different timestamp
      await browser.back()

      // Hover the link again. The prefetch should be cached, so we shouldn't see any requests
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/caches/private-short-stale"]`
        )
        await linkToggle.hover()
      }, 'no-requests')

      // Navigate to the page again
      await act(async () => {
        await act(
          async () => {
            await browser
              .elementByCss(`a[href="/caches/private-short-stale"]`)
              .click()
          },
          {
            // Temporarily block the navigation request.
            // The runtime-prefetched parts of the tree should be visible before it finishes.
            includes: 'Short-lived cached content',
            block: true,
          }
        )
        expect(await browser.elementById('intro').text()).toInclude(
          STATIC_CONTENT
        )
      })

      // After navigating, we should see both the parts that we prefetched and dynamic content.
      // The private cache was omitted from the runtime prefetch, so we didn't cache it in the router,
      // and it was not cached server-side either, so we should get a different value than the previous request.
      const cachedValue2 = await browser.elementById('cached-value').text()
      expect(cachedValue2).toMatch(/\d+/)

      expect(cachedValue1).not.toEqual(cachedValue2)
    })
  })

  describe('errors', () => {
    it.each([
      {
        description: 'when sync IO is used after awaiting cookies()',
        path: '/errors/sync-io-after-runtime-api/cookies',
      },
      {
        description: 'when sync IO is used after awaiting headers()',
        path: '/errors/sync-io-after-runtime-api/headers',
      },
      {
        description: 'when sync IO is used after awaiting dynamic params',
        path: '/errors/sync-io-after-runtime-api/dynamic-params/123',
      },
      {
        description: 'when sync IO is used after awaiting searchParams',
        path: '/errors/sync-io-after-runtime-api/search-params?foo=bar',
      },
      {
        description: 'when sync IO is used after awaiting a private cache',
        path: '/errors/sync-io-after-runtime-api/private-cache',
      },
      {
        description:
          'when sync IO is used after awaiting a quickly-expiring public cache',
        path: '/errors/sync-io-after-runtime-api/quickly-expiring-public-cache',
      },
    ])(
      'aborts the prerender without logging an error $description',
      async ({ path }) => {
        // In a runtime prefetch, we might encounter sync IO usages that weren't caught during build,
        // because they were hidden behind e.g. a cookies() call.
        // We currently have no way to catch these statically.
        // In that case, we should abort the prerender, but still return partial content.

        // TODO: this doesn't work as well as it could, see comment before the navigation

        let page: Playwright.Page
        const browser = await next.browser('/errors', {
          beforePageLoad(p: Playwright.Page) {
            page = p
          },
        })
        const act = createRouterAct(page)

        const STATIC_CONTENT = 'This page performs sync IO after'

        // Reveal the link to trigger a runtime prefetch
        await act(async () => {
          const linkToggle = await browser.elementByCss(
            `input[data-link-accordion="${path}"]`
          )
          await linkToggle.click()
        }, [
          // Should include the shell
          {
            includes: STATIC_CONTENT,
          },
          // Should abort the render when sync IO is encountered,
          // so this should never be included
          {
            includes: 'Timestamp',
            block: 'reject',
          },
        ])

        if (!isNextDeploy) {
          expect(getCliOutput()).not.toMatch(`Date.now()`)
        }

        // Navigate to the page
        await act(async () => {
          await act(
            async () => {
              await browser.elementByCss(`a[href="${path}"]`).click()
            },
            {
              // Temporarily block the navigation request.
              includes: 'Timestamp',
              block: true,
            }
          )
          // We aborted the render because of sync IO, so we won't display the timestamp,
          // but due to the way we sequence tasks, we should've at least finished rendering the static parts.
          expect(await browser.elementsByCss('#timestamp')).toHaveLength(0)
          expect(await browser.elementById('intro').text()).toInclude(
            STATIC_CONTENT
          )
        })

        // After navigating, we should see the sync IO result that we omitted from the prefetch.
        expect(await browser.elementById('intro').text()).toInclude(
          STATIC_CONTENT
        )
        expect(await browser.elementById('timestamp').text()).toMatch(
          /Timestamp: \d+/
        )
      }
    )

    it('should trigger error boundaries for errors that occurred in runtime-prefetched content', async () => {
      // A thrown error in the prerender should not stop us from sending a prefetch response.
      // This should work without any extra effort, but I'm adding a test for it as a sanity check.

      let page: Playwright.Page
      const browser = await next.browser('/errors', {
        beforePageLoad(p: Playwright.Page) {
          page = p
        },
      })
      const act = createRouterAct(page)

      const STATIC_CONTENT = 'This page errors after a cookies call'

      // Reveal the link to trigger a runtime prefetch
      await act(async () => {
        const linkToggle = await browser.elementByCss(
          `input[data-link-accordion="/errors/error-after-cookies"]`
        )
        await linkToggle.click()
      }, [
        // Should include the shell
        {
          includes: STATIC_CONTENT,
        },
      ])

      if (!isNextDeploy) {
        expect(getCliOutput()).toContain('Error: Kaboom')
      }

      // Navigate to the page. We already have the paged cached.
      // Even though the render errored, we shouldn't fetch it again.
      await act(async () => {
        await browser
          .elementByCss(`a[href="/errors/error-after-cookies"]`)
          .click()
      }, 'no-requests')

      // After navigating, we should see the sync IO result that we omitted from the prefetch.
      expect(await browser.elementById('intro').text()).toInclude(
        STATIC_CONTENT
      )
      expect(await browser.elementById('error-boundary').text()).toInclude(
        'Error boundary: Minified React error #441;'
      )
    })
  })
})

function defer(callback: () => Promise<void>) {
  return {
    [Symbol.asyncDispose]: callback,
  }
}
Quest for Codev2.0.0
/
SIGN IN