next.js/packages/next/src/server/app-render/instant-validation/instant-config.tsx
instant-config.tsx218 lines6.8 KB
import { getLayoutOrPageModule } from '../../lib/app-dir-module'
import type { LoaderTree } from '../../lib/app-dir-module'
import { parseLoaderTree } from '../../../shared/lib/router/utils/parse-loader-tree'
import type {
  AppSegmentConfig,
  InstantSample,
} from '../../../build/segment-config/app/app-segment-config'
import {
  workAsyncStorage,
  type WorkStore,
} from '../work-async-storage.external'

export async function anySegmentHasRuntimePrefetchEnabled(
  tree: LoaderTree
): Promise<boolean> {
  const { mod: layoutOrPageMod } = await getLayoutOrPageModule(tree)

  // TODO(restart-on-cache-miss): Does this work correctly for client page/layout modules?
  const prefetchConfig = layoutOrPageMod
    ? (layoutOrPageMod as AppSegmentConfig).unstable_prefetch
    : undefined
  if (prefetchConfig === 'force-runtime') {
    return true
  }

  const { parallelRoutes } = parseLoaderTree(tree)
  for (const parallelRouteKey in parallelRoutes) {
    const parallelRoute = parallelRoutes[parallelRouteKey]
    const hasChildRuntimePrefetch =
      await anySegmentHasRuntimePrefetchEnabled(parallelRoute)
    if (hasChildRuntimePrefetch) {
      return true
    }
  }

  return false
}

export async function isPageAllowedToBlock(tree: LoaderTree): Promise<boolean> {
  const { mod: layoutOrPageMod } = await getLayoutOrPageModule(tree)

  // TODO(restart-on-cache-miss): Does this work correctly for client page/layout modules?
  const instantConfig = layoutOrPageMod
    ? (layoutOrPageMod as AppSegmentConfig).unstable_instant
    : undefined

  // If we encounter a non-false instant config before a instant=false,
  // the page isn't allowed to block. The config expresses a requirement for
  // instant UI, so we should make sure that a static shell exists.
  // (even if it'd use runtime prefetching for client navs)
  if (instantConfig !== undefined) {
    if (instantConfig === false) {
      return true
    } else {
      return false
    }
  }

  const { parallelRoutes } = parseLoaderTree(tree)
  for (const parallelRouteKey in parallelRoutes) {
    const parallelRoute = parallelRoutes[parallelRouteKey]
    const subtreeIsBlocking = await isPageAllowedToBlock(parallelRoute)
    if (subtreeIsBlocking) {
      return true
    }
  }

  return false
}

type FoundSegmentWithConfig = {
  path: string[]
  config: NonNullable<AppSegmentConfig['unstable_instant']>
}

/**
 * Checks if any segments in the loader tree have `instant` configs that need validating.
 * NOTE: Client navigations call this multiple times, so we cache it.
 * */
// Shared helper (not exported, not cached — called by the cached wrappers)
async function anySegmentNeedsInstantValidation(
  rootTree: LoaderTree,
  mode: 'dev' | 'build'
): Promise<boolean> {
  const segments = await findSegmentsWithInstantConfig(rootTree)

  // Check if there's any non-false configs that need validation.
  // (If there's only `false`, there's no need to run validation).
  // If any segment has `unstable_disableValidation`, we skip validation for the whole tree.
  let needsValidation = false
  for (const { config } of segments) {
    if (config === true) {
      needsValidation = true
    } else if (typeof config === 'object') {
      if (
        config.unstable_disableValidation === true ||
        (mode === 'dev' && config.unstable_disableDevValidation === true) ||
        (mode === 'build' && config.unstable_disableBuildValidation === true)
      ) {
        return false
      }
      // do not short-circuit, some other segment might still have `unstable_disableValidation`
      needsValidation = true
    }
  }
  return needsValidation
}

export const anySegmentNeedsInstantValidationInDev = cacheScopedToWorkStore(
  async (rootTree: LoaderTree): Promise<boolean> =>
    anySegmentNeedsInstantValidation(rootTree, 'dev')
)

export const anySegmentNeedsInstantValidationInBuild = cacheScopedToWorkStore(
  async (rootTree: LoaderTree): Promise<boolean> =>
    anySegmentNeedsInstantValidation(rootTree, 'build')
)

export const findSegmentsWithInstantConfig = cacheScopedToWorkStore(
  async (rootTree: LoaderTree): Promise<FoundSegmentWithConfig[]> => {
    const results: FoundSegmentWithConfig[] = []

    async function visit(tree: LoaderTree, path: string[]): Promise<void> {
      const { mod: layoutOrPageMod } = await getLayoutOrPageModule(tree)

      // TODO(restart-on-cache-miss): Does this work correctly for client page/layout modules?
      const instantConfig = layoutOrPageMod
        ? (layoutOrPageMod as AppSegmentConfig).unstable_instant
        : undefined
      if (instantConfig !== undefined) {
        results.push({
          path,
          config: instantConfig,
        })
      }

      const { parallelRoutes } = parseLoaderTree(tree)
      for (const parallelRouteKey in parallelRoutes) {
        const childTree = parallelRoutes[parallelRouteKey]
        await visit(childTree, [...path, parallelRouteKey])
      }
    }

    await visit(rootTree, [])
    return results
  }
)

export const resolveInstantConfigSamplesForPage = async (
  tree: LoaderTree
): Promise<InstantSample[] | null> => {
  const { mod: layoutOrPageMod } = await getLayoutOrPageModule(tree)

  const instantConfig = layoutOrPageMod
    ? (layoutOrPageMod as AppSegmentConfig).unstable_instant
    : undefined

  let samples: InstantSample[] | null = null
  if (
    instantConfig !== undefined &&
    typeof instantConfig === 'object' &&
    instantConfig.samples
  ) {
    samples = instantConfig.samples
  }

  // The samples from inner segments override samples from outer segments,
  // i.e. a page overrides the samples from a layout.
  // We do not perform any merging logic.
  const { parallelRoutes } = parseLoaderTree(tree)
  for (const parallelRouteKey in parallelRoutes) {
    if (parallelRouteKey !== 'children') {
      // TODO(instant-validation-build): do something with with samples from non-children slots?
      continue
    }
    const childTree = parallelRoutes[parallelRouteKey]
    const childSamples = await resolveInstantConfigSamplesForPage(childTree)
    if (childSamples !== null) {
      samples = childSamples
    }
  }

  return samples
}

/**
 * A simple cache wrapper for 1-argument functions.
 * The cache will live as long as the current WorkStore,
 * i.e. it's scoped to a single request.
 */
function cacheScopedToWorkStore<TArg extends WeakKey, TRes>(
  func: (arg: TArg) => TRes
): (arg: TArg) => TRes {
  const resultsPerWorkStore = new WeakMap<WorkStore, WeakMap<TArg, TRes>>()
  return (arg: TArg): TRes => {
    const workStore = workAsyncStorage.getStore()
    if (!workStore) {
      // No caching.
      return func(arg)
    }

    let results = resultsPerWorkStore.get(workStore)
    if (results && results.has(arg)) {
      return results.get(arg)!
    }

    const result = func(arg)

    if (!results) {
      results = new WeakMap()
      resultsPerWorkStore.set(workStore, results)
    }
    results.set(arg, result)

    return result
  }
}
Quest for Codev2.0.0
/
SIGN IN