next.js/test/e2e/next-form/default/next-form-prefetch.test.ts
next-form-prefetch.test.ts259 lines7.7 KB
import { nextTestSetup, isNextDev } from 'e2e-utils'
import {
  NEXT_ROUTER_PREFETCH_HEADER,
  NEXT_RSC_UNION_QUERY,
  RSC_HEADER,
} from 'next/src/client/components/app-router-headers'
import type { Page, Request as PlaywrightRequest } from 'playwright'
import { WebdriverOptions } from 'next-webdriver'
import { retry } from 'next-test-utils'

const _describe =
  // prefetching is disabled in dev.
  isNextDev ? describe.skip : describe

_describe('app dir - form prefetching', () => {
  const { next } = nextTestSetup({
    files: __dirname,
    nextConfig: {
      typescript: {
        ignoreBuildErrors: true,
      },
    },
  })

  it("should prefetch a loading state for the form's target", async () => {
    const interceptor = new RequestInterceptor({
      pattern: '**/search*',
      requestFilter: async (request) => {
        // only capture RSC requests that *aren't* prefetches
        const headers = await request.allHeaders()
        const isRSC = headers[RSC_HEADER] === '1'
        const isPrefetch = !!headers[NEXT_ROUTER_PREFETCH_HEADER]
        return isRSC && !isPrefetch
      },
      log: true,
    })

    const session = await next.browser('/forms/basic', {
      beforePageLoad: interceptor.beforePageLoad,
    })

    await session.waitForIdleNetwork()
    interceptor.start()

    const searchInput = await session.elementByCss('input[name="query"]')
    const query = 'my search'
    await searchInput.fill(query)

    const submitButton = await session.elementByCss('[type="submit"]')
    await submitButton.click()

    const targetHref = '/search?' + new URLSearchParams({ query })

    // we're blocking requests, so the navigation won't go through,
    // but we should still see the prefetched loading state
    expect(await session.waitForElementByCss('#loading').text()).toBe(
      'Loading...'
    )

    // We only resolve the dynamic request after we've confirmed loading exists,
    // to avoid a race where the dynamic request handles the loading state instead.
    const navigationRequest = interceptor.pendingRequests.get(targetHref)
    expect(navigationRequest).toBeDefined()
    // allow the navigation to continue
    await navigationRequest.resolve()

    const result = await session.waitForElementByCss('#search-results').text()
    expect(result).toInclude(`query: "${query}"`)
  })

  it('should not prefetch when prefetch is set to false`', async () => {
    const interceptor = new RequestInterceptor({
      pattern: '**/search*',
      requestFilter: async (request) => {
        // capture all RSC requests, prefetch or not
        const headers = await request.allHeaders()
        const isRSC = headers[RSC_HEADER] === '1'
        return isRSC
      },
      log: true,
    })

    interceptor.start()

    const session = await next.browser('/forms/prefetch-false', {
      beforePageLoad: interceptor.beforePageLoad,
    })

    await session.waitForIdleNetwork()

    // no prefetches should have been captured
    expect(interceptor.pendingRequests).toBeEmpty()

    const searchInput = await session.elementByCss('input[name="query"]')
    const query = 'my search'
    await searchInput.fill(query)

    const submitButton = await session.elementByCss('[type="submit"]')
    await submitButton.click()

    const targetHref = '/search?' + new URLSearchParams({ query })

    // wait for the pending request to appear
    const navigationRequest = await retry(
      async () => {
        const request = interceptor.pendingRequests.get(targetHref)
        expect(request).toBeDefined()
        return request
      },
      undefined,
      100
    )

    // the loading state should not be visible, because we didn't prefetch it
    expect(await session.elementsByCss('#loading')).toEqual([])

    // allow the navigation to continue
    navigationRequest.resolve()

    // now, the loading state should stream in dynamically
    expect(await session.waitForElementByCss('#loading').text()).toBe(
      'Loading...'
    )

    const result = await session.waitForElementByCss('#search-results').text()
    expect(result).toInclude(`query: "${query}"`)
  })
})

// =====================================================================

type PlaywrightRoutePattern = Parameters<Page['route']>[0]
type PlaywrightRouteOptions = Parameters<Page['route']>[2]
type BeforePageLoadFn = NonNullable<WebdriverOptions['beforePageLoad']>

type RequestInterceptorOptions = {
  pattern: PlaywrightRoutePattern
  requestFilter?: (request: PlaywrightRequest) => boolean | Promise<boolean>
  timeout?: number
  log?: boolean
  routeOptions?: PlaywrightRouteOptions
}

type InterceptedRequest = {
  request: PlaywrightRequest
  resolve(): Promise<void>
}

class RequestInterceptor {
  pendingRequests: Map<string, InterceptedRequest> = new Map()
  isEnabled = false
  constructor(private opts: RequestInterceptorOptions) {}

  private getRequestKey(request: PlaywrightRequest) {
    // could cause issues for a generic util,
    // but it's fine for the purposes of intercepting navigations.
    // if we wanted this to be more generic, we could expose a `getRequest(...)` method
    // to allow partial matching (e.g. by pathname + search only)
    const url = new URL(request.url())
    url.searchParams.delete(NEXT_RSC_UNION_QUERY)
    return url.pathname + url.search
  }

  start() {
    this.isEnabled = true
  }

  stop() {
    this.isEnabled = false
  }

  beforePageLoad: BeforePageLoadFn = (page) => {
    const { opts } = this
    page.route(
      opts.pattern,
      async (route, request) => {
        if (!this.isEnabled) {
          return route.continue()
        }

        const shouldIntercept = opts.requestFilter
          ? opts.requestFilter(request)
          : true

        const url = request.url()

        if (!shouldIntercept) {
          if (opts.log) {
            console.log('[interceptor] not intercepting:', url)
          }
          return route.continue()
        }

        const requestKey = this.getRequestKey(request)

        if (opts.log) {
          console.log(
            '[interceptor] intercepting request:',
            url,
            `(key: ${requestKey})`
          )
        }

        if (this.pendingRequests.has(requestKey)) {
          throw new Error(
            `Interceptor received duplicate request (key: ${JSON.stringify(requestKey)}, url: ${JSON.stringify(url)})`
          )
        }

        // Create a promise that will be resolved by the later test code
        const blocked = promiseWithResolvers<void>()

        this.pendingRequests.set(requestKey, {
          request,
          resolve: async () => {
            if (opts.log) {
              console.log('[interceptor] resolving intercepted request:', url)
            }
            this.pendingRequests.delete(requestKey)
            await route.continue()
            // wait a moment to ensure the response is received
            await new Promise((res) => setTimeout(res, 500))
            blocked.resolve()
          },
        })

        // Await the promise to effectively stall the request
        const timeout = opts.timeout ?? 5000
        await Promise.race([
          blocked.promise,
          timeoutPromise(
            opts.timeout ?? 5000,
            `Intercepted request for '${url}' was not resolved within ${timeout}ms`
          ),
        ])
      },
      opts.routeOptions
    )
  }
}

function promiseWithResolvers<T>() {
  let resolve: (value: T) => void = undefined!
  let reject: (error: unknown) => void = undefined!
  const promise = new Promise((_resolve, _reject) => {
    resolve = _resolve
    reject = _reject
  })
  return { promise, resolve, reject }
}

function timeoutPromise(duration: number, message = 'Timeout') {
  return new Promise<never>((_, reject) =>
    AbortSignal.timeout(duration).addEventListener('abort', () =>
      reject(new Error(message))
    )
  )
}
Quest for Codev2.0.0
/
SIGN IN