next.js/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts
navigation-testing-lock.ts296 lines9.5 KB
/**
 * Navigation lock for the Instant Navigation Testing API.
 *
 * Manages the in-memory lock (a promise) that gates dynamic data writes
 * during instant navigation captures, and owns all cookie state
 * transitions (pending → captured-MPA, pending → captured-SPA).
 *
 * External actors (Playwright, devtools) set [0] to start a lock scope
 * and delete the cookie to end one. Next.js writes captured values.
 * The CookieStore handler distinguishes them by value: pending = external,
 * captured = self-write (ignored).
 */

import type { FlightRouterState } from '../../../shared/lib/app-router-types'
import { NEXT_INSTANT_TEST_COOKIE } from '../app-router-headers'
import { refreshOnInstantNavigationUnlock } from '../use-action-queue'

type InstantNavCookieState = 'empty' | 'pending' | 'mpa' | 'spa'

type InstantCookie =
  // pending (waiting to capture)
  | [captured: 0, id: string]
  // captured MPA page load
  | [captured: 1, id: string, state: null]
  // captured SPA navigation (from/to route trees)
  | [
      captured: 1,
      id: string,
      state: { from: FlightRouterState; to: FlightRouterState | null },
    ]

function parseCookieValue(raw: string): InstantNavCookieState {
  if (raw === '') {
    return 'empty'
  }
  try {
    const parsed = JSON.parse(raw)
    if (Array.isArray(parsed) && parsed.length >= 3) {
      const rawState = parsed[2]
      return rawState === null ? 'mpa' : 'spa'
    }
  } catch {}
  return 'pending'
}

function writeCookieValue(value: InstantCookie): void {
  if (typeof cookieStore === 'undefined') {
    return
  }
  // Read the existing cookie to preserve its attributes (domain, path),
  // then write back with the new value. This updates the same cookie
  // entry that the external actor created, regardless of how it was
  // scoped.
  //
  // Capture the current lockState and compare it in the callback so we
  // only write if the lock we observed at call time is still held. This
  // guards against two races: (a) the scope ended between get and set
  // (lockState is now null), and (b) the scope ended and a new one was
  // acquired in the same gap (lockState is a different object). In
  // either case we must not write — doing so would leak stale state
  // into the next scope or outlive the current one.
  const lockAtCall = lockState
  cookieStore.get(NEXT_INSTANT_TEST_COOKIE).then((existing: any) => {
    if (existing && lockState === lockAtCall && lockAtCall !== null) {
      const options: any = {
        name: NEXT_INSTANT_TEST_COOKIE,
        value: JSON.stringify(value),
        path: existing.path ?? '/',
      }
      if (existing.domain) {
        options.domain = existing.domain
      }
      cookieStore.set(options)
    }
  })
}

type NavigationLockState = {
  promise: Promise<void>
  resolve: () => void
  // The pre-lock `window.fetch`, captured at `acquireLock` time and
  // restored at `releaseLock`. Internal Next.js code reads this via
  // `getPreLockFetch` to bypass the override we install on `window.fetch`
  // during a lock scope.
  fetch: typeof fetch
}

let lockState: NavigationLockState | null = null

export function getPreLockFetch(): typeof fetch | null {
  return lockState !== null ? lockState.fetch : null
}

function acquireLock(): void {
  if (lockState !== null) {
    return
  }
  let resolve: () => void
  const promise = new Promise<void>((r) => {
    resolve = r
  })
  lockState = { promise, resolve: resolve!, fetch: window.fetch }

  // Install the fetch blocker. We only intercept `window.fetch` for the
  // duration of the lock so that — outside of a testing scope — user-
  // installed overrides of `window.fetch` are untouched.
  if (process.env.__NEXT_EXPOSE_TESTING_API) {
    window.fetch = globalFetchOverride
  }
}

function releaseLock(): void {
  if (lockState === null) {
    return
  }
  // Restore the pre-lock `window.fetch` before resolving the lock promise
  // so any fetches queued on the promise see the restored fetch.
  if (process.env.__NEXT_EXPOSE_TESTING_API) {
    window.fetch = lockState.fetch
  }
  const { resolve } = lockState
  lockState = null
  resolve()
}

/**
 * Global fetch override
 *
 * While the navigation lock is active, we install this as `window.fetch` so
 * out-of-band client-side fetches (e.g. `fetch('/api/data')` inside a
 * useEffect) are blocked until the lock is released. Next.js internals
 * bypass the override by importing `fetch` from `./fetch`, which reads the
 * captured pre-lock fetch via `getPreLockFetch`.
 *
 * NOTE: This override only affects environments where the Instant Navigation
 * Testing API is enabled. It has no impact on live production behavior.
 */
export function globalFetchOverride(
  input: RequestInfo | URL,
  init?: RequestInit
): Promise<Response> {
  if (lockState === null) {
    // Lock is not active. Fall through to the global fetch — we reach this
    // only if a caller captured a reference to this function during a lock
    // scope and invoked it after release.
    return fetch(input, init)
  }
  // Block user-initiated fetches until the lock is released, then dispatch
  // through the fetch captured at acquire time. Reading from `lockState`
  // (rather than `window.fetch`) pins to the capture even if `window.fetch`
  // is reassigned after release.
  const currentLock = lockState
  return currentLock.promise.then(() => {
    const preLockFetch = currentLock.fetch
    return preLockFetch(input, init)
  })
}

/**
 * Sets up the cookie-based lock. Handles the initial page load state and
 * registers a CookieStore listener for runtime changes.
 *
 * Called once during page initialization from app-globals.ts.
 */
export function startListeningForInstantNavigationCookie(): void {
  if (process.env.__NEXT_EXPOSE_TESTING_API) {
    // If the server served a static shell, this is an MPA page load
    // while the lock is held. Transition to captured-MPA and acquire.
    if (self.__next_instant_test) {
      if (typeof cookieStore !== 'undefined') {
        // If the cookie was already cleared during the MPA page
        // transition, reload to get the full dynamic page.
        cookieStore.get(NEXT_INSTANT_TEST_COOKIE).then((cookie: any) => {
          if (!cookie) {
            window.location.reload()
          }
        })
      }

      writeCookieValue([1, `c${Math.random()}`, null])
      acquireLock()
    }

    if (typeof cookieStore === 'undefined') {
      return
    }

    cookieStore.addEventListener('change', (event: CookieChangeEvent) => {
      for (const cookie of event.changed) {
        if (cookie.name === NEXT_INSTANT_TEST_COOKIE) {
          const state = parseCookieValue(cookie.value ?? '')

          if (state === 'pending') {
            // External actor starting a new lock scope.
            if (lockState !== null) {
              releaseLock()
            }
            acquireLock()
          }
          // Captured value (our own transition) or empty. Ignore.
          return
        }
      }

      for (const cookie of event.deleted) {
        if (cookie.name === NEXT_INSTANT_TEST_COOKIE) {
          releaseLock()
          refreshOnInstantNavigationUnlock()
          return
        }
      }
    })
  }
}

/**
 * Transitions the cookie from pending to captured-SPA. Called when a
 * client-side navigation is captured by the lock.
 *
 * @param fromTree - The flight router state of the from-route
 * @param toTree - The flight router state of the to-route (null if not yet known)
 */
export function transitionToCapturedSPA(
  fromTree: FlightRouterState,
  toTree: FlightRouterState | null
): void {
  if (process.env.__NEXT_EXPOSE_TESTING_API) {
    writeCookieValue([1, `c${Math.random()}`, { from: fromTree, to: toTree }])
  }
}

/**
 * Updates the captured-SPA cookie with the resolved route trees.
 * Called after the prefetch resolves and the target route tree is known.
 */
export function updateCapturedSPAToTree(
  fromTree: FlightRouterState,
  toTree: FlightRouterState
): void {
  if (process.env.__NEXT_EXPOSE_TESTING_API) {
    writeCookieValue([1, `c${Math.random()}`, { from: fromTree, to: toTree }])
  }
}

/**
 * Returns true if the navigation lock is currently active.
 */
export function isNavigationLocked(): boolean {
  if (process.env.__NEXT_EXPOSE_TESTING_API) {
    if (lockState !== null) {
      return true
    }

    // If `lockState` is null, fall back to reading the test cookie
    // synchronously from `document.cookie`. This accounts for a small race
    // between `cookieStore.set(...)` and its corresponding `change` event.
    // During that gap `lockState` is still null even though the cookie
    // indicates a new lock scope is starting.
    if (typeof document === 'undefined') {
      return false
    }
    const allCookies = document.cookie
    if (!allCookies.includes(NEXT_INSTANT_TEST_COOKIE)) {
      // Fast bail-out: in almost every navigation the test cookie is not
      // set at all.
      return false
    }
    const target = NEXT_INSTANT_TEST_COOKIE + '='
    for (const segment of allCookies.split(';')) {
      const trimmed = segment.trim()
      if (
        trimmed.startsWith(target) &&
        parseCookieValue(trimmed.slice(target.length)) === 'pending'
      ) {
        // The cookie was set by an external actor but the change event was not
        // yet dispatched. Acquire the lock synchronously.
        acquireLock()
        return true
      }
    }
  }
  return false
}

/**
 * Waits for the navigation lock to be released, if it's currently held.
 * No-op if the lock is not acquired.
 */
export async function waitForNavigationLockIfActive(): Promise<void> {
  if (process.env.__NEXT_EXPOSE_TESTING_API) {
    if (lockState !== null) {
      await lockState.promise
    }
  }
}
Quest for Codev2.0.0
/
SIGN IN