next.js/packages/next/src/client/components/segment-cache/navigation.ts
navigation.ts997 lines33.0 KB
import type {
  CacheNodeSeedData,
  FlightRouterState,
  FlightSegmentPath,
  ScrollRef,
} from '../../../shared/lib/app-router-types'
import type { CacheNode } from '../../../shared/lib/app-router-types'
import type { HeadData } from '../../../shared/lib/app-router-types'
import type { NormalizedFlightData } from '../../flight-data-helpers'
import { fetchServerResponse } from '../router-reducer/fetch-server-response'
import {
  startPPRNavigation,
  spawnDynamicRequests,
  FreshnessPolicy,
  type NavigationRequestAccumulation,
} from '../router-reducer/ppr-navigations'
import { createHrefFromUrl } from '../router-reducer/create-href-from-url'
import { NEXT_NAV_DEPLOYMENT_ID_HEADER } from '../../../lib/constants'
import {
  EntryStatus,
  readRouteCacheEntry,
  deprecated_requestOptimisticRouteCacheEntry,
  convertRootFlightRouterStateToRouteTree,
  getStaleAt,
  writeStaticStageResponseIntoCache,
  processRuntimePrefetchStream,
  writeDynamicRenderResponseIntoCache,
  type RouteTree,
  type FulfilledRouteCacheEntry,
} from './cache'
import { discoverKnownRoute } from './optimistic-routes'
import { createCacheKey, type NormalizedSearch } from './cache-key'
import { schedulePrefetchTask } from './scheduler'
import { PrefetchPriority, FetchStrategy } from './types'
import { getLinkForCurrentNavigation } from '../links'
import type { PageVaryPath } from './vary-path'
import type { AppRouterState } from '../router-reducer/router-reducer-types'
import { ScrollBehavior } from '../router-reducer/router-reducer-types'
import { computeChangedPath } from '../router-reducer/compute-changed-path'
import { isJavaScriptURLString } from '../../lib/javascript-url'
import { UnknownDynamicStaleTime, computeDynamicStaleAt } from './bfcache'

/**
 * Navigate to a new URL, using the Segment Cache to construct a response.
 *
 * To allow for synchronous navigations whenever possible, this is not an async
 * function. It returns a promise only if there's no matching prefetch in
 * the cache. Otherwise it returns an immediate result and uses Suspense/RSC to
 * stream in any missing data.
 */
export function navigate(
  state: AppRouterState,
  url: URL,
  currentUrl: URL,
  currentRenderedSearch: string,
  currentCacheNode: CacheNode | null,
  currentFlightRouterState: FlightRouterState,
  nextUrl: string | null,
  freshnessPolicy: FreshnessPolicy,
  scrollBehavior: ScrollBehavior,
  navigateType: 'push' | 'replace'
): AppRouterState | Promise<AppRouterState> {
  // Instant Navigation Testing API: when the lock is active, ensure a
  // prefetch task has been initiated before proceeding with the navigation.
  // This guarantees that segment data requests are at least pending, even
  // for routes that already have a cached route tree. Without this, the
  // static shell might be incomplete because some segments were never
  // requested.
  if (process.env.__NEXT_EXPOSE_TESTING_API) {
    const { isNavigationLocked } =
      require('./navigation-testing-lock') as typeof import('./navigation-testing-lock')
    if (isNavigationLocked()) {
      return ensurePrefetchThenNavigate(
        state,
        url,
        currentUrl,
        currentRenderedSearch,
        currentCacheNode,
        currentFlightRouterState,
        nextUrl,
        freshnessPolicy,
        scrollBehavior,
        navigateType
      )
    }
  }

  return navigateImpl(
    state,
    url,
    currentUrl,
    currentRenderedSearch,
    currentCacheNode,
    currentFlightRouterState,
    nextUrl,
    freshnessPolicy,
    scrollBehavior,
    navigateType
  )
}

function navigateImpl(
  state: AppRouterState,
  url: URL,
  currentUrl: URL,
  currentRenderedSearch: string,
  currentCacheNode: CacheNode | null,
  currentFlightRouterState: FlightRouterState,
  nextUrl: string | null,
  freshnessPolicy: FreshnessPolicy,
  scrollBehavior: ScrollBehavior,
  navigateType: 'push' | 'replace'
): AppRouterState | Promise<AppRouterState> {
  const now = Date.now()
  const href = url.href

  const cacheKey = createCacheKey(href, nextUrl)
  const route = readRouteCacheEntry(now, cacheKey)
  if (route !== null && route.status === EntryStatus.Fulfilled) {
    // We have a matching prefetch.
    return navigateUsingPrefetchedRouteTree(
      now,
      state,
      url,
      currentUrl,
      currentRenderedSearch,
      nextUrl,
      currentCacheNode,
      currentFlightRouterState,
      freshnessPolicy,
      scrollBehavior,
      navigateType,
      route
    )
  }

  // There was no matching route tree in the cache. Let's see if we can
  // construct an "optimistic" route tree using the deprecated search-params
  // based matching. This is only used when the new optimisticRouting flag is
  // disabled.
  //
  // Do not construct an optimistic route tree if there was a cache hit, but
  // the entry has a rejected status, since it may have been rejected due to a
  // rewrite or redirect based on the search params.
  //
  // TODO: There are multiple reasons a prefetch might be rejected; we should
  // track them explicitly and choose what to do here based on that.
  if (!process.env.__NEXT_OPTIMISTIC_ROUTING) {
    if (route === null || route.status !== EntryStatus.Rejected) {
      const optimisticRoute = deprecated_requestOptimisticRouteCacheEntry(
        now,
        url,
        nextUrl
      )
      if (optimisticRoute !== null) {
        // We have an optimistic route tree. Proceed with the normal flow.
        return navigateUsingPrefetchedRouteTree(
          now,
          state,
          url,
          currentUrl,
          currentRenderedSearch,
          nextUrl,
          currentCacheNode,
          currentFlightRouterState,
          freshnessPolicy,
          scrollBehavior,
          navigateType,
          optimisticRoute
        )
      }
    }
  }

  // There's no matching prefetch for this route in the cache. We must lazily
  // fetch it from the server before we can perform the navigation.
  //
  // TODO: If this is a gesture navigation, instead of performing a
  // dynamic request, we should do a runtime prefetch.
  return navigateToUnknownRoute(
    now,
    state,
    url,
    currentUrl,
    currentRenderedSearch,
    nextUrl,
    currentCacheNode,
    currentFlightRouterState,
    freshnessPolicy,
    scrollBehavior,
    navigateType
  ).catch(() => {
    // If the navigation fails, return the current state
    return state
  })
}

export function navigateToKnownRoute(
  now: number,
  state: AppRouterState,
  url: URL,
  canonicalUrl: string,
  navigationSeed: NavigationSeed,
  currentUrl: URL,
  currentRenderedSearch: string,
  currentCacheNode: CacheNode | null,
  currentFlightRouterState: FlightRouterState,
  freshnessPolicy: FreshnessPolicy,
  nextUrl: string | null,
  scrollBehavior: ScrollBehavior,
  navigateType: 'push' | 'replace',
  debugInfo: Array<unknown> | null,
  // The route cache entry used for this navigation, if it came from route
  // prediction. Passed through so it can be marked as having a dynamic rewrite
  // if the server returns a different pathname (indicating dynamic rewrite
  // behavior).
  //
  // When null, the navigation did not use route prediction - either because
  // the route was already fully cached, or it's a navigation that doesn't
  // involve prediction (refresh, history traversal, server action, etc.).
  // In these cases, if a mismatch occurs, we still mark the route as having a
  // dynamic rewrite by traversing the known route tree (see
  // dispatchRetryDueToTreeMismatch).
  routeCacheEntry: FulfilledRouteCacheEntry | null
): AppRouterState {
  // A version of navigate() that accepts the target route tree as an argument
  // rather than reading it from the prefetch cache.
  const accumulation: NavigationRequestAccumulation = {
    separateRefreshUrls: null,
    scrollRef: null,
  }
  // We special case navigations to the exact same URL as the current location.
  // It's a common UI pattern for apps to refresh when you click a link to the
  // current page. So when this happens, we refresh the dynamic data in the page
  // segments.
  //
  // Note that this does not apply if the any part of the hash or search query
  // has changed. This might feel a bit weird but it makes more sense when you
  // consider that the way to trigger this behavior is to click the same link
  // multiple times.
  //
  // TODO: We should probably refresh the *entire* route when this case occurs,
  // not just the page segments. Essentially treating it the same as a refresh()
  // triggered by an action, which is the more explicit way of modeling the UI
  // pattern described above.
  //
  // Also note that this only refreshes the dynamic data, not static/ cached
  // data. If the page segment is fully static and prefetched, the request is
  // skipped. (This is also how refresh() works.)
  const isSamePageNavigation = url.href === currentUrl.href
  const task = startPPRNavigation(
    now,
    currentUrl,
    currentRenderedSearch,
    currentCacheNode,
    currentFlightRouterState,
    navigationSeed.routeTree,
    navigationSeed.metadataVaryPath,
    freshnessPolicy,
    navigationSeed.data,
    navigationSeed.head,
    navigationSeed.dynamicStaleAt,
    isSamePageNavigation,
    accumulation
  )
  if (task !== null) {
    if (freshnessPolicy !== FreshnessPolicy.Gesture) {
      spawnDynamicRequests(
        task,
        url,
        nextUrl,
        freshnessPolicy,
        accumulation,
        routeCacheEntry,
        navigateType
      )
    }
    return completeSoftNavigation(
      state,
      url,
      nextUrl,
      task.route,
      task.node,
      navigationSeed.renderedSearch,
      canonicalUrl,
      navigateType,
      scrollBehavior,
      accumulation.scrollRef,
      debugInfo
    )
  }
  // Could not perform a SPA navigation. Revert to a full-page (MPA) navigation.
  return completeHardNavigation(state, url, navigateType)
}

function navigateUsingPrefetchedRouteTree(
  now: number,
  state: AppRouterState,
  url: URL,
  currentUrl: URL,
  currentRenderedSearch: string,
  nextUrl: string | null,
  currentCacheNode: CacheNode | null,
  currentFlightRouterState: FlightRouterState,
  freshnessPolicy: FreshnessPolicy,
  scrollBehavior: ScrollBehavior,
  navigateType: 'push' | 'replace',
  route: FulfilledRouteCacheEntry
): AppRouterState {
  const routeTree = route.tree
  const canonicalUrl = route.canonicalUrl + url.hash
  const renderedSearch = route.renderedSearch
  const prefetchSeed: NavigationSeed = {
    renderedSearch,
    routeTree,
    metadataVaryPath: route.metadata.varyPath as any,
    data: null,
    head: null,
    dynamicStaleAt: computeDynamicStaleAt(now, UnknownDynamicStaleTime),
  }
  return navigateToKnownRoute(
    now,
    state,
    url,
    canonicalUrl,
    prefetchSeed,
    currentUrl,
    currentRenderedSearch,
    currentCacheNode,
    currentFlightRouterState,
    freshnessPolicy,
    nextUrl,
    scrollBehavior,
    navigateType,
    null,
    route
  )
}

// Used to request all the dynamic data for a route, rather than just a subset,
// e.g. during a refresh or a revalidation. Typically this gets constructed
// during the normal flow when diffing the route tree, but for an unprefetched
// navigation, where we don't know the structure of the target route, we use
// this instead.
const DynamicRequestTreeForEntireRoute: FlightRouterState = [
  '',
  {},
  null,
  'refetch',
]

async function navigateToUnknownRoute(
  now: number,
  state: AppRouterState,
  url: URL,
  currentUrl: URL,
  currentRenderedSearch: string,
  nextUrl: string | null,
  currentCacheNode: CacheNode | null,
  currentFlightRouterState: FlightRouterState,
  freshnessPolicy: FreshnessPolicy,
  scrollBehavior: ScrollBehavior,
  navigateType: 'push' | 'replace'
): Promise<AppRouterState> {
  // Runs when a navigation happens but there's no cached prefetch we can use.
  // Don't bother to wait for a prefetch response; go straight to a full
  // navigation that contains both static and dynamic data in a single stream.
  // (This is unlike the old navigation implementation, which instead blocks
  // the dynamic request until a prefetch request is received.)
  //
  // To avoid duplication of logic, we're going to pretend that the tree
  // returned by the dynamic request is, in fact, a prefetch tree. Then we can
  // use the same server response to write the actual data into the CacheNode
  // tree. So it's the same flow as the "happy path" (prefetch, then
  // navigation), except we use a single server response for both stages.

  let dynamicRequestTree: FlightRouterState
  switch (freshnessPolicy) {
    case FreshnessPolicy.Default:
    case FreshnessPolicy.HistoryTraversal:
    case FreshnessPolicy.Gesture:
      dynamicRequestTree = currentFlightRouterState
      break
    case FreshnessPolicy.Hydration: // <- shouldn't happen during client nav
    case FreshnessPolicy.RefreshAll:
    case FreshnessPolicy.HMRRefresh:
      dynamicRequestTree = DynamicRequestTreeForEntireRoute
      break
    default:
      freshnessPolicy satisfies never
      dynamicRequestTree = currentFlightRouterState
      break
  }

  const promiseForDynamicServerResponse = fetchServerResponse(url, {
    flightRouterState: dynamicRequestTree,
    nextUrl,
  })
  const result = await promiseForDynamicServerResponse
  if (typeof result === 'string') {
    // This is an MPA navigation.
    const redirectUrl = new URL(result, location.origin)
    return completeHardNavigation(state, redirectUrl, navigateType)
  }

  const {
    flightData,
    canonicalUrl,
    renderedSearch,
    couldBeIntercepted,
    supportsPerSegmentPrefetching,
    dynamicStaleTime,
    staticStageData,
    runtimePrefetchStream,
    responseHeaders,
    debugInfo,
  } = result

  // Since the response format of dynamic requests and prefetches is slightly
  // different, we'll need to massage the data a bit. Create FlightRouterState
  // tree that simulates what we'd receive as the result of a prefetch.
  const navigationSeed = convertServerPatchToFullTree(
    now,
    currentFlightRouterState,
    flightData,
    renderedSearch,
    dynamicStaleTime
  )

  // Learn the route pattern so we can predict it for future navigations.
  // hasDynamicRewrite is false because this is a fresh navigation to an
  // unknown route - any rewrite detection happens during the traversal inside
  // discoverKnownRoute. The hasDynamicRewrite param is only set to true when
  // retrying after a tree mismatch (see dispatchRetryDueToTreeMismatch).
  const metadataVaryPath = navigationSeed.metadataVaryPath
  if (metadataVaryPath !== null) {
    discoverKnownRoute(
      now,
      url.pathname,
      nextUrl,
      null, // No pending entry
      navigationSeed.routeTree,
      metadataVaryPath,
      couldBeIntercepted,
      createHrefFromUrl(canonicalUrl),
      supportsPerSegmentPrefetching,
      false // hasDynamicRewrite - not a retry, rewrite detection happens during traversal
    )

    if (staticStageData !== null) {
      const { response: staticStageResponse, isResponsePartial } =
        staticStageData

      // Write the static stage of the response into the segment cache so that
      // subsequent navigations can serve cached static segments instantly.
      getStaleAt(now, staticStageResponse.s)
        .then((staleAt) => {
          const buildId =
            responseHeaders.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ??
            staticStageResponse.b

          writeStaticStageResponseIntoCache(
            now,
            staticStageResponse.f,
            buildId,
            staticStageResponse.h,
            staleAt,
            currentFlightRouterState,
            renderedSearch,
            isResponsePartial
          )
        })
        .catch(() => {
          // The static stage processing failed. Not fatal — the navigation
          // completed normally, we just won't write into the cache.
        })
    }

    if (runtimePrefetchStream !== null) {
      processRuntimePrefetchStream(
        now,
        runtimePrefetchStream,
        currentFlightRouterState,
        renderedSearch
      )
        .then((processed) => {
          if (processed !== null) {
            writeDynamicRenderResponseIntoCache(
              now,
              FetchStrategy.PPRRuntime,
              processed.flightDatas,
              processed.buildId,
              processed.isResponsePartial,
              processed.headVaryParams,
              processed.staleAt,
              processed.navigationSeed,
              null
            )
          }
        })
        .catch(() => {
          // The runtime prefetch cache write failed. Not fatal — the
          // navigation completed normally, we just won't cache runtime data.
        })
    }
  }

  return navigateToKnownRoute(
    now,
    state,
    url,
    createHrefFromUrl(canonicalUrl),
    navigationSeed,
    currentUrl,
    currentRenderedSearch,
    currentCacheNode,
    currentFlightRouterState,
    freshnessPolicy,
    nextUrl,
    scrollBehavior,
    navigateType,
    debugInfo,
    // Unknown route navigations don't use route prediction - the route tree
    // came directly from the server. If a mismatch occurs during dynamic data
    // fetch, the retry handler will traverse the known route tree to mark the
    // entry as having a dynamic rewrite.
    null
  )
}

export function completeHardNavigation(
  state: AppRouterState,
  url: URL,
  navigateType: 'push' | 'replace'
): AppRouterState {
  if (isJavaScriptURLString(url.href)) {
    console.error(
      'Next.js has blocked a javascript: URL as a security precaution.'
    )
    return state
  }
  const newState: AppRouterState = {
    canonicalUrl:
      url.origin === location.origin ? createHrefFromUrl(url) : url.href,
    pushRef: {
      pendingPush: navigateType === 'push',
      mpaNavigation: true,
      preserveCustomHistoryState: false,
    },
    // TODO: None of the rest of these values are consistent with the incoming
    // navigation. We rely on the fact that AppRouter will suspend and trigger
    // a hard navigation before it accesses any of these values. But instead
    // we should trigger the hard navigation and blocking any subsequent
    // router updates without updating React.
    renderedSearch: state.renderedSearch,
    focusAndScrollRef: state.focusAndScrollRef,
    cache: state.cache,
    tree: state.tree,
    nextUrl: state.nextUrl,
    previousNextUrl: state.previousNextUrl,
    debugInfo: null,
  }
  return newState
}

export function completeSoftNavigation(
  oldState: AppRouterState,
  url: URL,
  referringNextUrl: string | null,
  tree: FlightRouterState,
  cache: CacheNode,
  renderedSearch: string,
  canonicalUrl: string,
  navigateType: 'push' | 'replace',
  scrollBehavior: ScrollBehavior,
  scrollRef: ScrollRef | null,
  collectedDebugInfo: Array<unknown> | null
) {
  // The "Next-Url" is a special representation of the URL that Next.js
  // uses to implement interception routes.
  // TODO: Get rid of this extra traversal by computing this during the
  // same traversal that computes the tree itself. We should also figure out
  // what is the minimum information needed for the server to correctly
  // intercept the route.
  const changedPath = computeChangedPath(oldState.tree, tree)
  const nextUrlForNewRoute = changedPath ? changedPath : oldState.nextUrl

  // This value is stored on the state as `previousNextUrl`; the naming is
  // confusing. What it represents is the "Next-Url" header that was used to
  // fetch the incoming route. It's essentially the refererer URL, but in a
  // Next.js specific format. During refreshes, this is sent back to the server
  // instead of the current route's "Next-Url" so that the same interception
  // logic is applied as during the original navigation.
  const previousNextUrl = referringNextUrl

  // Check if the only thing that changed was the hash fragment.
  const oldUrl = new URL(oldState.canonicalUrl, url)
  const onlyHashChange =
    // We don't need to compare the origins, because client-driven
    // navigations are always same-origin.
    url.pathname === oldUrl.pathname &&
    url.search === oldUrl.search &&
    url.hash !== oldUrl.hash

  // Determine whether and how the page should scroll after this
  // navigation.
  //
  // By default, we scroll to the segments that were navigated to — i.e.
  // segments in the new part of the route, as opposed to shared segments
  // that were already part of the previous route. All newly navigated
  // segments share a single ScrollRef. When they mount, the first one
  // to mount initiates the scroll. They share a ref so that only one
  // scroll happens per navigation.
  //
  // If a subsequent navigation produces new segments, those supersede
  // any pending scroll from the previous navigation by invalidating its
  // ScrollRef. If a navigation doesn't produce any new segments (e.g.
  // a refresh where the route structure didn't change), any pending
  // scrolls from previous navigations are unaffected.
  //
  // The branches below handle special cases layered on top of this
  // default model.
  let activeScrollRef: ScrollRef | null
  let forceScroll: boolean
  if (scrollBehavior === ScrollBehavior.NoScroll) {
    // The user explicitly opted out of scrolling (e.g. scroll={false}
    // on a Link or router.push).
    //
    // If this navigation created new scroll targets (scrollRef !== null),
    // neutralize them. If it didn't, any prior scroll targets carried
    // forward on the cache nodes via reuseSharedCacheNode remain active.
    if (scrollRef !== null) {
      scrollRef.current = false
    }
    activeScrollRef = oldState.focusAndScrollRef.scrollRef
    forceScroll = false
  } else if (onlyHashChange) {
    // Hash-only navigations should scroll regardless of per-node state.
    // Create a fresh ref so the first segment to scroll consumes it.
    //
    // Invalidate any scroll ref from a prior navigation that hasn't
    // been consumed yet.
    const oldScrollRef = oldState.focusAndScrollRef.scrollRef
    if (oldScrollRef !== null) {
      oldScrollRef.current = false
    }
    // Also invalidate any per-node refs that were accumulated during
    // this navigation's tree construction — the hash-only ref
    // supersedes them.
    if (scrollRef !== null) {
      scrollRef.current = false
    }
    activeScrollRef = { current: true }
    forceScroll = true
  } else {
    // Default case. Use the accumulated scrollRef (may be null if no
    // new segments were created). The handler checks per-node refs, so
    // unchanged parallel route slots won't scroll.
    activeScrollRef = scrollRef

    // If this navigation created new scroll targets, invalidate any
    // pending scroll from a previous navigation.
    if (scrollRef !== null) {
      const oldScrollRef = oldState.focusAndScrollRef.scrollRef
      if (oldScrollRef !== null) {
        oldScrollRef.current = false
      }
    }
    forceScroll = false
  }

  const newState: AppRouterState = {
    canonicalUrl,
    renderedSearch,
    pushRef: {
      pendingPush: navigateType === 'push',
      mpaNavigation: false,
      preserveCustomHistoryState: false,
    },
    focusAndScrollRef: {
      scrollRef: activeScrollRef,
      forceScroll,
      onlyHashChange,
      hashFragment:
        // Remove leading # and decode hash to make non-latin hashes work.
        //
        // Empty hash should trigger default behavior of scrolling layout into
        // view. #top is handled in layout-router.
        //
        // Refer to `ScrollAndFocusHandler` for details on how this is used.
        scrollBehavior !== ScrollBehavior.NoScroll && url.hash !== ''
          ? decodeURIComponent(url.hash.slice(1))
          : oldState.focusAndScrollRef.hashFragment,
    },
    cache,
    tree,
    nextUrl: nextUrlForNewRoute,
    previousNextUrl,
    debugInfo: collectedDebugInfo,
  }
  return newState
}

export function completeTraverseNavigation(
  state: AppRouterState,
  url: URL,
  renderedSearch: string,
  cache: CacheNode,
  tree: FlightRouterState,
  nextUrl: string | null
) {
  return {
    // Set canonical url
    canonicalUrl: createHrefFromUrl(url),
    renderedSearch,
    pushRef: {
      pendingPush: false,
      mpaNavigation: false,
      // Ensures that the custom history state that was set is preserved when applying this update.
      preserveCustomHistoryState: true,
    },
    focusAndScrollRef: state.focusAndScrollRef,
    cache,
    // Restore provided tree
    tree,
    nextUrl,
    // TODO: We need to restore previousNextUrl, too, which represents the
    // Next-Url that was used to fetch the data. Anywhere we fetch using the
    // canonical URL, there should be a corresponding Next-Url.
    previousNextUrl: null,
    debugInfo: null,
  }
}

// TODO: The rest of this file is related to converting the server response into
// the data structures used by the client. Probably should move to a
// separate module.

export type NavigationSeed = {
  renderedSearch: string
  routeTree: RouteTree
  metadataVaryPath: PageVaryPath | null
  data: CacheNodeSeedData | null
  head: HeadData | null
  dynamicStaleAt: number
}

export function convertServerPatchToFullTree(
  now: number,
  currentTree: FlightRouterState,
  flightData: Array<NormalizedFlightData> | null,
  renderedSearch: string,
  dynamicStaleTimeSeconds: number
): NavigationSeed {
  // During a client navigation or prefetch, the server sends back only a patch
  // for the parts of the tree that have changed.
  //
  // This applies the patch to the base tree to create a full representation of
  // the resulting tree.
  //
  // The return type includes a full FlightRouterState tree and a full
  // CacheNodeSeedData tree. (Conceptually these are the same tree, and should
  // eventually be unified, but there's still lots of existing code that
  // operates on FlightRouterState trees alone without the CacheNodeSeedData.)
  //
  // TODO: This similar to what apply-router-state-patch-to-tree does. It
  // will eventually fully replace it. We should get rid of all the remaining
  // places where we iterate over the server patch format. This should also
  // eventually replace normalizeFlightData.

  let baseTree: FlightRouterState = currentTree
  let baseData: CacheNodeSeedData | null = null
  let head: HeadData | null = null
  if (flightData !== null) {
    for (const {
      segmentPath,
      tree: treePatch,
      seedData: dataPatch,
      head: headPatch,
    } of flightData) {
      const result = convertServerPatchToFullTreeImpl(
        baseTree,
        baseData,
        treePatch,
        dataPatch,
        segmentPath,
        renderedSearch,
        0
      )
      baseTree = result.tree
      baseData = result.data
      // This is the same for all patches per response, so just pick an
      // arbitrary one
      head = headPatch
    }
  }

  const finalFlightRouterState = baseTree

  // Convert the final FlightRouterState into a RouteTree type.
  //
  // TODO: Eventually, FlightRouterState will evolve to being a transport format
  // only. The RouteTree type will become the main type used for dealing with
  // routes on the client, and we'll store it in the state directly.
  const acc = { metadataVaryPath: null }
  const routeTree = convertRootFlightRouterStateToRouteTree(
    finalFlightRouterState,
    renderedSearch as NormalizedSearch,
    acc
  )

  return {
    routeTree,
    metadataVaryPath: acc.metadataVaryPath,
    data: baseData,
    renderedSearch,
    head,
    dynamicStaleAt: computeDynamicStaleAt(now, dynamicStaleTimeSeconds),
  }
}

function convertServerPatchToFullTreeImpl(
  baseRouterState: FlightRouterState,
  baseData: CacheNodeSeedData | null,
  treePatch: FlightRouterState,
  dataPatch: CacheNodeSeedData | null,
  segmentPath: FlightSegmentPath,
  renderedSearch: string,
  index: number
): { tree: FlightRouterState; data: CacheNodeSeedData | null } {
  if (index === segmentPath.length) {
    // We reached the part of the tree that we need to patch.
    return {
      tree: treePatch,
      data: dataPatch,
    }
  }

  // segmentPath represents the parent path of subtree. It's a repeating
  // pattern of parallel route key and segment:
  //
  //   [string, Segment, string, Segment, string, Segment, ...]
  //
  // This path tells us which part of the base tree to apply the tree patch.
  //
  // NOTE: We receive the FlightRouterState patch in the same request as the
  // seed data patch. Therefore we don't need to worry about diffing the segment
  // values; we can assume the server sent us a correct result.
  const updatedParallelRouteKey: string = segmentPath[index]
  // const segment: Segment = segmentPath[index + 1] <-- Not used, see note above

  const baseTreeChildren = baseRouterState[1]
  const baseSeedDataChildren = baseData !== null ? baseData[1] : null
  const newTreeChildren: Record<string, FlightRouterState> = {}
  const newSeedDataChildren: Record<string, CacheNodeSeedData | null> = {}
  for (const parallelRouteKey in baseTreeChildren) {
    const childBaseRouterState = baseTreeChildren[parallelRouteKey]
    const childBaseSeedData =
      baseSeedDataChildren !== null
        ? (baseSeedDataChildren[parallelRouteKey] ?? null)
        : null
    if (parallelRouteKey === updatedParallelRouteKey) {
      const result = convertServerPatchToFullTreeImpl(
        childBaseRouterState,
        childBaseSeedData,
        treePatch,
        dataPatch,
        segmentPath,
        renderedSearch,
        // Advance the index by two and keep cloning until we reach
        // the end of the segment path.
        index + 2
      )

      newTreeChildren[parallelRouteKey] = result.tree
      newSeedDataChildren[parallelRouteKey] = result.data
    } else {
      // This child is not being patched. Copy it over as-is.
      newTreeChildren[parallelRouteKey] = childBaseRouterState
      newSeedDataChildren[parallelRouteKey] = childBaseSeedData
    }
  }

  let clonedTree: FlightRouterState
  let clonedSeedData: CacheNodeSeedData
  // Clone all the fields except the children.

  // Clone the FlightRouterState tree. Based on equivalent logic in
  // apply-router-state-patch-to-tree, but should confirm whether we need to
  // copy all of these fields. Not sure the server ever sends, e.g. the
  // refetch marker.
  clonedTree = [baseRouterState[0], newTreeChildren]
  if (2 in baseRouterState) {
    const compressedRefreshState = baseRouterState[2]
    if (
      compressedRefreshState !== undefined &&
      compressedRefreshState !== null
    ) {
      // Since this part of the tree was patched with new data, any parent
      // refresh states should be updated to reflect the new rendered search
      // value. (The refresh state acts like a "context provider".) All pages
      // within the same server response share the same renderedSearch value,
      // but the same RouteTree could be composed from multiple different
      // routes, and multiple responses.
      clonedTree[2] = [compressedRefreshState[0], renderedSearch]
    }
  }
  if (3 in baseRouterState) {
    clonedTree[3] = baseRouterState[3]
  }
  if (4 in baseRouterState) {
    clonedTree[4] = baseRouterState[4]
  }

  // Clone the CacheNodeSeedData tree.
  const isEmptySeedDataPartial = true
  clonedSeedData = [
    null,
    newSeedDataChildren,
    null,
    isEmptySeedDataPartial,
    null,
  ]

  return {
    tree: clonedTree,
    data: clonedSeedData,
  }
}

/**
 * Instant Navigation Testing API: ensures a prefetch task has been initiated
 * and completed before proceeding with the navigation. This guarantees that
 * segment data requests are at least pending, even for routes whose route
 * tree is already cached.
 *
 * After the prefetch completes, delegates to the normal navigation flow.
 */
async function ensurePrefetchThenNavigate(
  state: AppRouterState,
  url: URL,
  currentUrl: URL,
  currentRenderedSearch: string,
  currentCacheNode: CacheNode | null,
  currentFlightRouterState: FlightRouterState,
  nextUrl: string | null,
  freshnessPolicy: FreshnessPolicy,
  scrollBehavior: ScrollBehavior,
  navigateType: 'push' | 'replace'
): Promise<AppRouterState> {
  const link = getLinkForCurrentNavigation()
  const fetchStrategy = link !== null ? link.fetchStrategy : FetchStrategy.PPR

  // Transition the cookie to captured-SPA immediately, before waiting
  // for the prefetch. This ensures the devtools panel can update its UI
  // right away, even if the prefetch takes time (e.g. dev compilation).
  // The "to" tree starts as null and is filled in after the prefetch
  // resolves and the navigation produces a new router state.
  const { transitionToCapturedSPA, updateCapturedSPAToTree } =
    require('./navigation-testing-lock') as typeof import('./navigation-testing-lock')
  transitionToCapturedSPA(currentFlightRouterState, null)

  const cacheKey = createCacheKey(url.href, nextUrl)

  await new Promise<void>((resolve) => {
    schedulePrefetchTask(
      cacheKey,
      currentFlightRouterState,
      fetchStrategy,
      PrefetchPriority.Default,
      null, // onInvalidate
      resolve // _onComplete callback
    )
  })

  // Prefetch is complete. Proceed with the normal navigation flow, which
  // will now find the route in the cache.
  const result = await navigateImpl(
    state,
    url,
    currentUrl,
    currentRenderedSearch,
    currentCacheNode,
    currentFlightRouterState,
    nextUrl,
    freshnessPolicy,
    scrollBehavior,
    navigateType
  )

  // Update the cookie with the resolved "to" tree so the devtools
  // panel can display both routes immediately.
  updateCapturedSPAToTree(currentFlightRouterState, result.tree)

  return result
}
Quest for Codev2.0.0
/
SIGN IN