next.js/packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-nav-cookie.ts
instant-nav-cookie.ts172 lines5.3 KB
/**
 * Cookie reading and subscription for the instant navigation devtools panel.
 *
 * The cookie value is a JSON array:
 *   [0]        — pending (waiting to capture)
 *   [1, null]  — captured MPA page load
 *   [1, { from, to }] — captured SPA navigation (from/to route trees)
 *
 * The "to" tree may be null initially and updated after the prefetch resolves.
 */

import { useMemo } from 'react'
import { useSyncExternalStore } from 'react'
import type {
  FlightRouterState,
  Segment,
} from '../../../../shared/lib/app-router-types'

const COOKIE_NAME = 'next-instant-navigation-testing'

export type InstantNavCookieData =
  | { state: 'pending' }
  | { state: 'mpa' }
  | {
      state: 'spa'
      fromTree: FlightRouterState
      toTree: FlightRouterState | null
    }

function parseCookieValue(raw: string): InstantNavCookieData {
  try {
    const parsed = JSON.parse(raw)
    if (Array.isArray(parsed) && parsed.length >= 3) {
      const rawState = parsed[2]
      if (rawState === null) {
        return { state: 'mpa' }
      }
      // SPA capture: rawState is { from, to }
      if (typeof rawState === 'object' && rawState !== null) {
        const fromTree: FlightRouterState = rawState.from ?? ['', {}]
        const toTree: FlightRouterState | null = rawState.to ?? null
        return { state: 'spa', fromTree, toTree }
      }
      return { state: 'spa', fromTree: ['', {}], toTree: null }
    }
  } catch {}
  return { state: 'pending' }
}

export function readInstantNavCookieState():
  | InstantNavCookieData['state']
  | null {
  if (typeof document === 'undefined') return null
  const match = document.cookie.match(/next-instant-navigation-testing=([^;]*)/)
  if (!match) return null
  return parseCookieValue(match[1]).state
}

/**
 * Formats a FlightRouterState tree into a route pattern string for display.
 * Dynamic segments are shown with bracket syntax (e.g. [slug], [...params],
 * [[...optional]]) rather than their filled-in values. Search params are
 * omitted because they don't affect navigation.
 */
export function formatRoutePattern(tree: FlightRouterState): string {
  const segments: string[] = []
  let current: FlightRouterState | undefined = tree

  while (current) {
    const segment: Segment = current[0]
    const children: Record<string, FlightRouterState> | undefined = current[1]

    if (typeof segment === 'string') {
      // Skip root segment (''), page sentinels, default sentinels,
      // and route groups (parenthesized segments like "(marketing)")
      if (
        segment !== '' &&
        !segment.startsWith('__PAGE__') &&
        segment !== '__DEFAULT__' &&
        !(segment.startsWith('(') && segment.endsWith(')'))
      ) {
        segments.push(segment)
      }
    } else if (Array.isArray(segment)) {
      // Dynamic segment tuple: [paramName, paramCacheKey, dynamicParamType, staticSiblings]
      const paramName = segment[0]
      const dynamicParamType = segment[2]

      if (dynamicParamType === 'c' || dynamicParamType.startsWith('ci')) {
        // Catch-all: [...param]
        segments.push(`[...${paramName}]`)
      } else if (dynamicParamType === 'oc') {
        // Optional catch-all: [[...param]]
        segments.push(`[[...${paramName}]]`)
      } else {
        // Dynamic: [param]
        segments.push(`[${paramName}]`)
      }
    }

    // Follow the children parallel route
    current = children?.children
  }

  return '/' + segments.join('/')
}

// The raw cookie string is used as the useSyncExternalStore snapshot.
// Strings are compared by value, so no referential stability concerns.
// Parsing happens during render via useMemo.

// Cache the latest raw cookie value observed from CookieStore events.
// document.cookie may not reflect async cookieStore.set() writes
// immediately, so the snapshot reads this cache first.
let cachedRawValue: string | undefined = undefined

function readRawCookieValue(): string {
  if (typeof document === 'undefined') return ''
  const match = document.cookie.match(/next-instant-navigation-testing=([^;]*)/)
  return match ? match[1] : ''
}

function getSnapshot(): string {
  if (cachedRawValue !== undefined) {
    return cachedRawValue
  }
  return readRawCookieValue()
}

function subscribe(callback: () => void): () => void {
  if (typeof cookieStore === 'undefined') {
    return () => {}
  }
  function handler(event: CookieChangeEvent) {
    for (const cookie of event.changed) {
      if (cookie.name === COOKIE_NAME) {
        cachedRawValue = cookie.value ?? ''
        callback()
        return
      }
    }
    for (const cookie of event.deleted) {
      if (cookie.name === COOKIE_NAME) {
        cachedRawValue = ''
        callback()
        return
      }
    }
  }
  cookieStore.addEventListener('change', handler)
  return () => {
    cookieStore.removeEventListener('change', handler)
  }
}

/**
 * Subscribes to the instant navigation cookie value. The cookie is the
 * sole source of truth — this hook reads it via useSyncExternalStore.
 *
 * The raw cookie string is the snapshot (stable by value comparison).
 * Parsing into structured data happens during render via useMemo.
 *
 * Returns null when the cookie is absent.
 */
export function useInstantNavCookieState(): InstantNavCookieData | null {
  const rawValue = useSyncExternalStore(subscribe, getSnapshot)
  return useMemo(() => {
    if (!rawValue) return null
    return parseCookieValue(rawValue)
  }, [rawValue])
}
Quest for Codev2.0.0
/
SIGN IN