UNPKG

next

Version:

The React Framework

911 lines 149 kB
import { PrefetchHint } from '../../../shared/lib/app-router-types'; import { readVaryParams } from '../../../shared/lib/segment-cache/vary-params-decoding'; import { NEXT_DID_POSTPONE_HEADER, NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, NEXT_ROUTER_STALE_TIME_HEADER, NEXT_ROUTER_STATE_TREE_HEADER, NEXT_URL, RSC_CONTENT_TYPE_HEADER, RSC_HEADER } from '../app-router-headers'; import { createFetch, createFromNextReadableStream, decodeBufferedStage, resolveShellStageData } from '../router-reducer/fetch-server-response'; import { fetch } from './fetch'; import { pingPrefetchTask, isPrefetchTaskDirty } from './scheduler'; import { getRouteVaryPath, getFulfilledRouteVaryPath, getFulfilledSegmentVaryPath, getSegmentVaryPathForRequest, getShellSegmentVaryPath, appendLayoutVaryPath, finalizeLayoutVaryPath, finalizePageVaryPath, clonePageVaryPathWithNewSearchParams, finalizeMetadataVaryPath, getPartialPageVaryPath, getPartialLayoutVaryPath, getRenderedSearchFromVaryPath } from './vary-path'; import { createHrefFromUrl } from '../router-reducer/create-href-from-url'; import { createCacheKey as createPrefetchRequestKey } from './cache-key'; import { splitPathnameIntoParts } from './cache-key'; import { doesStaticSegmentAppearInURL, getCacheKeyForDynamicParam, getRenderedPathname, getRenderedSearch, parseDynamicParamFromURLPart } from '../../route-params'; import { createCacheMap, getFromCacheMap, setInCacheMap, setSizeInCacheMap, deleteFromCacheMap, isValueExpired, EntryStatus } from './cache-map'; export { EntryStatus } from './cache-map'; import { appendSegmentRequestKeyPart, convertSegmentPathToStaticExportFilename, createSegmentRequestKeyPart, HEAD_REQUEST_KEY, ROOT_SEGMENT_REQUEST_KEY } from '../../../shared/lib/segment-cache/segment-value-encoding'; import { normalizeFlightData, prepareFlightRouterStateForRequest } from '../../flight-data-helpers'; import { STATIC_STALETIME_MS } from '../router-reducer/reducers/navigate-reducer'; import { pingVisibleLinks } from '../links'; import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment'; import { FetchStrategy } from './types'; import { createPromiseWithResolvers } from '../../../shared/lib/promise-with-resolvers'; import { readFromBFCache, UnknownDynamicStaleTime } from './bfcache'; import { discoverKnownRoute, matchKnownRoute } from './optimistic-routes'; import { convertServerPatchToFullTree } from './navigation'; import { getNavigationBuildId } from '../../navigation-build-id'; import { NEXT_NAV_DEPLOYMENT_ID_HEADER } from '../../../lib/constants'; /** * Ensures a minimum stale time of 30s to avoid issues where the server sends a too * short-lived stale time, which would prevent anything from being prefetched. */ export function getStaleTimeMs(staleTimeSeconds) { return Math.max(staleTimeSeconds, 30) * 1000; } const isOutputExportMode = process.env.NODE_ENV === 'production' && process.env.__NEXT_CONFIG_OUTPUT === 'export'; export const MetadataOnlyRequestTree = [ '', {}, null, 'metadata-only' ]; let routeCacheMap = createCacheMap(); let segmentCacheMap = createCacheMap(); // All invalidation listeners for the whole cache are tracked in single set. // Since we don't yet support tag or path-based invalidation, there's no point // tracking them any more granularly than this. Once we add granular // invalidation, that may change, though generally the model is to just notify // the listeners and allow the caller to poll the prefetch cache with a new // prefetch task if desired. let invalidationListeners = null; // Incrementing counters used to track cache invalidations. Route and segment // caches have separate versions so they can be invalidated independently. // Invalidation does not eagerly evict anything from the cache; entries are // lazily evicted when read. let currentRouteCacheVersion = 0; let currentSegmentCacheVersion = 0; export function getCurrentRouteCacheVersion() { return currentRouteCacheVersion; } export function getCurrentSegmentCacheVersion() { return currentSegmentCacheVersion; } /** * Invalidates all prefetch cache entries (both route and segment caches). * * After invalidation, triggers re-prefetching of visible links and notifies * invalidation listeners. */ export function invalidateEntirePrefetchCache(nextUrl, tree) { currentRouteCacheVersion++; currentSegmentCacheVersion++; pingVisibleLinks(nextUrl, tree); pingInvalidationListeners(nextUrl, tree); } /** * Invalidates all route cache entries. Route entries contain the tree structure * (which segments exist at a given URL) but not the segment data itself. * * After invalidation, triggers re-prefetching of visible links and notifies * invalidation listeners. */ export function invalidateRouteCacheEntries(nextUrl, tree) { currentRouteCacheVersion++; pingVisibleLinks(nextUrl, tree); pingInvalidationListeners(nextUrl, tree); } /** * Invalidates all segment cache entries. Segment entries contain the actual * RSC data for each segment. * * After invalidation, triggers re-prefetching of visible links and notifies * invalidation listeners. */ export function invalidateSegmentCacheEntries(nextUrl, tree) { currentSegmentCacheVersion++; pingVisibleLinks(nextUrl, tree); pingInvalidationListeners(nextUrl, tree); } function attachInvalidationListener(task) { // This function is called whenever a prefetch task reads a cache entry. If // the task has an onInvalidate function associated with it — i.e. the one // optionally passed to router.prefetch(onInvalidate) — then we attach that // listener to the every cache entry that the task reads. Then, if an entry // is invalidated, we call the function. if (task.onInvalidate !== null) { if (invalidationListeners === null) { invalidationListeners = new Set([ task ]); } else { invalidationListeners.add(task); } } } function notifyInvalidationListener(task) { const onInvalidate = task.onInvalidate; if (onInvalidate !== null) { // Clear the callback from the task object to guarantee it's not called more // than once. task.onInvalidate = null; // This is a user-space function, so we must wrap in try/catch. try { onInvalidate(); } catch (error) { if (typeof reportError === 'function') { reportError(error); } else { console.error(error); } } } } export function pingInvalidationListeners(nextUrl, tree) { // The rough equivalent of pingVisibleLinks, but for onInvalidate callbacks. // This is called when the Next-Url or the base tree changes, since those // may affect the result of a prefetch task. It's also called after a // cache invalidation. if (invalidationListeners !== null) { const tasks = invalidationListeners; invalidationListeners = null; for (const task of tasks){ if (isPrefetchTaskDirty(task, nextUrl, tree)) { notifyInvalidationListener(task); } } } } export function readRouteCacheEntry(now, key) { const varyPath = getRouteVaryPath(key.pathname, key.search, key.nextUrl); const isRevalidation = false; const existingEntry = getFromCacheMap(now, getCurrentRouteCacheVersion(), routeCacheMap, varyPath, isRevalidation, false); if (existingEntry !== null) { return existingEntry; } // No cache hit. Attempt to construct from template using the new // optimistic routing mechanism (pattern-based matching). if (process.env.__NEXT_OPTIMISTIC_ROUTING) { return matchKnownRoute(now, key.pathname, key.search); } return null; } export function readSegmentCacheEntry(now, varyPath) { const isRevalidation = false; return getFromCacheMap(now, getCurrentSegmentCacheVersion(), segmentCacheMap, varyPath, isRevalidation, false); } /** * Like `readSegmentCacheEntry`, but prefers a Fulfilled entry over a * more-specific Pending or Rejected entry. Use this during a navigation, where * a less-specific shell entry (e.g. params -> Fallback) should be rendered * immediately rather than blocking on a more-specific Pending entry that may * still be in-flight. * * Performs up to two lookups: * 1. An `onlyMatchFulfilled` lookup that walks past Pending/Rejected entries * at more-specific keypaths to find a Fulfilled fallback (e.g. a cached * shell). * 2. If no Fulfilled entry is found, a regular lookup that returns the most * specific match regardless of status. */ export function readSegmentCacheEntryForNavigation(now, varyPath, restrictToShell = false) { const isRevalidation = false; if (process.env.__NEXT_EXPOSE_TESTING_API) { const { getCurrentNavigationLock } = require('./navigation-testing-lock'); const lock = getCurrentNavigationLock(); if (lock !== null) { // Instant Navigation Testing API // // Modify the lookup logic to simulate the behavior that we would expect // to mostly realistically happen in a production environment with a // warm prefetch cache. // If restrictToShell is true, it means we're navigating to a link that // 1) has Partial Prefetching enabled, and 2) does not have a prefetch // prop set. We should only allow the shell to render, not anything that // varies on concrete route params. const lookupVaryPath = restrictToShell ? getShellSegmentVaryPath(varyPath) : varyPath; // To prevent the test navigation from being "polluted" by earlier // prefetches, we'll also only match entries that were created during // the current lock scope. This is tracked by the `ownedEntries` set. const ownedEntries = lock.ownedEntries; // Besides that, the rest of the logic is the same as production. const fulfilled = getFromCacheMap(now, getCurrentSegmentCacheVersion(), segmentCacheMap, lookupVaryPath, isRevalidation, true); if (fulfilled !== null && ownedEntries.has(fulfilled)) { return fulfilled; } const entry = getFromCacheMap(now, getCurrentSegmentCacheVersion(), segmentCacheMap, lookupVaryPath, isRevalidation, false); if (entry !== null && ownedEntries.has(entry)) { return entry; } return null; } } // Prefer a Fulfilled entry (e.g. a cached shell) over a more-specific // Pending/Rejected one so it renders immediately instead of blocking on an // in-flight entry. const fulfilled = getFromCacheMap(now, getCurrentSegmentCacheVersion(), segmentCacheMap, varyPath, isRevalidation, true); if (fulfilled !== null) { return fulfilled; } return getFromCacheMap(now, getCurrentSegmentCacheVersion(), segmentCacheMap, varyPath, isRevalidation, false); } function readRevalidatingSegmentCacheEntry(now, varyPath) { const isRevalidation = true; return getFromCacheMap(now, getCurrentSegmentCacheVersion(), segmentCacheMap, varyPath, isRevalidation, false); } export function waitForSegmentCacheEntry(pendingEntry) { // Because the entry is pending, there's already a in-progress request. // Attach a promise to the entry that will resolve when the server responds. let promiseWithResolvers = pendingEntry.promise; if (promiseWithResolvers === null) { promiseWithResolvers = pendingEntry.promise = createPromiseWithResolvers(); } else { // There's already a promise we can use } return promiseWithResolvers.promise; } function createDetachedRouteCacheEntry() { return { canonicalUrl: null, status: EntryStatus.Empty, blockedTasks: null, tree: null, metadata: null, // This is initialized to true because we don't know yet whether the route // could be intercepted. It's only set to false once we receive a response // from the server. couldBeIntercepted: true, // Similarly, we don't yet know if the route supports PPR. supportsPerSegmentPrefetching: false, hasDynamicRewrite: false, renderedSearch: null, // Map-related fields ref: null, size: 0, // Since this is an empty entry, there's no reason to ever evict it. It will // be updated when the data is populated. staleAt: Infinity, version: getCurrentRouteCacheVersion() }; } /** * Checks if an entry for a route exists in the cache. If so, it returns the * entry, If not, it adds an empty entry to the cache and returns it. */ export function readOrCreateRouteCacheEntry(now, task, key) { attachInvalidationListener(task); const existingEntry = readRouteCacheEntry(now, key); if (existingEntry !== null) { return existingEntry; } // Create a pending entry and add it to the cache. const pendingEntry = createDetachedRouteCacheEntry(); const varyPath = getRouteVaryPath(key.pathname, key.search, key.nextUrl); const isRevalidation = false; setInCacheMap(routeCacheMap, varyPath, pendingEntry, isRevalidation); return pendingEntry; } // TODO: This function predates the new optimisticRouting feature and will be // removed once optimisticRouting is stable. The new mechanism (matchKnownRoute) // handles search param variations more robustly as part of the general route // prediction system. This fallback remains for when optimisticRouting is // disabled (staticChildren is null). export function deprecated_requestOptimisticRouteCacheEntry(now, requestedUrl, nextUrl) { // This function is called during a navigation when there was no matching // route tree in the prefetch cache. Before de-opting to a blocking, // unprefetched navigation, we will first attempt to construct an "optimistic" // route tree by checking the cache for similar routes. // // Check if there's a route with the same pathname, but with different // search params. We can then base our optimistic route tree on this entry. // // Conceptually, we are simulating what would happen if we did perform a // prefetch the requested URL, under the assumption that the server will // not redirect or rewrite the request in a different manner than the // base route tree. This assumption might not hold, in which case we'll have // to recover when we perform the dynamic navigation request. However, this // is what would happen if a route were dynamically rewritten/redirected // in between the prefetch and the navigation. So the logic needs to exist // to handle this case regardless. // Look for a route with the same pathname, but with an empty search string. // TODO: There's nothing inherently special about the empty search string; // it's chosen somewhat arbitrarily, with the rationale that it's the most // likely one to exist. But we should update this to match _any_ search // string. The plan is to generalize this logic alongside other improvements // related to "fallback" cache entries. const requestedSearch = requestedUrl.search; if (requestedSearch === '') { // The caller would have already checked if a route with an empty search // string is in the cache. So we can bail out here. return null; } const urlWithoutSearchParams = new URL(requestedUrl); urlWithoutSearchParams.search = ''; const routeWithNoSearchParams = readRouteCacheEntry(now, createPrefetchRequestKey(urlWithoutSearchParams.href, nextUrl)); if (routeWithNoSearchParams === null || routeWithNoSearchParams.status !== EntryStatus.Fulfilled) { // Bail out of constructing an optimistic route tree. This will result in // a blocking, unprefetched navigation. return null; } // Now we have a base route tree we can "patch" with our optimistic values. // Optimistically assume that redirects for the requested pathname do // not vary on the search string. Therefore, if the base route was // redirected to a different search string, then the optimistic route // should be redirected to the same search string. Otherwise, we use // the requested search string. const canonicalUrlForRouteWithNoSearchParams = new URL(routeWithNoSearchParams.canonicalUrl, requestedUrl.origin); const optimisticCanonicalSearch = canonicalUrlForRouteWithNoSearchParams.search !== '' ? canonicalUrlForRouteWithNoSearchParams.search : requestedSearch; // Similarly, optimistically assume that rewrites for the requested // pathname do not vary on the search string. Therefore, if the base // route was rewritten to a different search string, then the optimistic // route should be rewritten to the same search string. Otherwise, we use // the requested search string. const optimisticRenderedSearch = routeWithNoSearchParams.renderedSearch !== '' ? routeWithNoSearchParams.renderedSearch : requestedSearch; const optimisticUrl = new URL(routeWithNoSearchParams.canonicalUrl, location.origin); optimisticUrl.search = optimisticCanonicalSearch; const optimisticCanonicalUrl = createHrefFromUrl(optimisticUrl); const optimisticRouteTree = deprecated_createOptimisticRouteTree(routeWithNoSearchParams.tree, optimisticRenderedSearch); const optimisticMetadataTree = deprecated_createOptimisticRouteTree(routeWithNoSearchParams.metadata, optimisticRenderedSearch); // Clone the base route tree, and override the relevant fields with our // optimistic values. const optimisticEntry = { canonicalUrl: optimisticCanonicalUrl, status: EntryStatus.Fulfilled, // This isn't cloned because it's instance-specific blockedTasks: null, tree: optimisticRouteTree, metadata: optimisticMetadataTree, couldBeIntercepted: routeWithNoSearchParams.couldBeIntercepted, supportsPerSegmentPrefetching: routeWithNoSearchParams.supportsPerSegmentPrefetching, hasDynamicRewrite: routeWithNoSearchParams.hasDynamicRewrite, // Override the rendered search with the optimistic value. renderedSearch: optimisticRenderedSearch, // Map-related fields ref: null, size: 0, staleAt: routeWithNoSearchParams.staleAt, version: routeWithNoSearchParams.version }; // Do not insert this entry into the cache. It only exists so we can // perform the current navigation. Just return it to the caller. return optimisticEntry; } function deprecated_createOptimisticRouteTree(tree, newRenderedSearch) { // Create a new route tree that identical to the original one except for // the rendered search string, which is contained in the vary path. let clonedSlots = null; const originalSlots = tree.slots; if (originalSlots !== null) { clonedSlots = new Map(); for (const [parallelRouteKey, childTree] of originalSlots){ clonedSlots.set(parallelRouteKey, deprecated_createOptimisticRouteTree(childTree, newRenderedSearch)); } } // We only need to clone the vary path if the route is a page. if (tree.isPage) { // The shell vary path Fallbacks search params, so it's unaffected by the // new rendered search and can be reused as-is. return { requestKey: tree.requestKey, segment: tree.segment, shellVaryPath: tree.shellVaryPath, refreshState: tree.refreshState, varyPath: clonePageVaryPathWithNewSearchParams(tree.varyPath, newRenderedSearch), isPage: true, slots: clonedSlots, prefetchHints: tree.prefetchHints }; } return { requestKey: tree.requestKey, segment: tree.segment, shellVaryPath: tree.shellVaryPath, refreshState: tree.refreshState, varyPath: tree.varyPath, isPage: false, slots: clonedSlots, prefetchHints: tree.prefetchHints }; } /** * Checks if an entry for a segment exists in the cache. If so, it returns the * entry, If not, it adds an empty entry to the cache and returns it. */ export function readOrCreateSegmentCacheEntry(now, fetchStrategy, tree, // Non-null when this read is part of a locked navigation's prefetch (Instant // Navigation Testing API only; always null in production). See below. navigationLockPrefetch) { const existingEntry = readSegmentCacheEntry(now, tree.varyPath); if (existingEntry !== null) { if (process.env.__NEXT_EXPOSE_TESTING_API && navigationLockPrefetch !== null) { // Locked navigation: ignore entries that predate the lock so each // navigation reads only data (re)fetched within the lock scope — a // "clean read." But an entry we already created within this scope is // reused like normal; otherwise the prefetch would discard the entry it // just fetched on every scheduler pass and refetch forever. See // navigation-testing-lock.ts. const { getCurrentNavigationLock, trackNavigationLockPrefetchEntry } = require('./navigation-testing-lock'); const lock = getCurrentNavigationLock(); if (lock !== null && lock.ownedEntries.has(existingEntry)) { // Track-on-reuse: when this navigation reuses an in-flight (Pending) // entry it didn't spawn — e.g. a runtime-prefetch (PPRRuntime) upgrade // started by an earlier prefetch in the scope — register it on this // navigation's prefetch so the navigation awaits it before reading. // Without this, the navigation can read while that upgrade is still // pending and fall back to a less-specific fulfilled entry (the shell), // never surfacing the resolved value. // // This is content-neutral: the entry is found by the concrete vary-path // (not by strategy), so it's whatever the navigation would read at this // key anyway. Tracking only controls whether we await it now versus // suspend on it during the render, so it can't surface an entry the // navigation wouldn't otherwise read. Tracking is deduped, so it's a // no-op if we already spawned/tracked this entry. if (existingEntry.status === EntryStatus.Pending) { trackNavigationLockPrefetchEntry(navigationLockPrefetch, existingEntry); } return existingEntry; } } else { return existingEntry; } } // No reusable entry, or a locked navigation discarding a pre-lock entry. // Create a pending entry and add it to the cache. The stale time is set to a // default value; the actual stale time will be set when the entry is // fulfilled with data from the server response. const varyPathForRequest = getSegmentVaryPathForRequest(fetchStrategy, tree); const pendingEntry = createDetachedSegmentCacheEntry(now); const isRevalidation = false; setInCacheMap(segmentCacheMap, varyPathForRequest, pendingEntry, isRevalidation); return pendingEntry; } export function readOrCreateRevalidatingSegmentEntry(now, fetchStrategy, tree) { // This function is called when we've already confirmed that a particular // segment is cached, but we want to perform another request anyway in case it // returns more complete and/or fresher data than we already have. The logic // for deciding whether to replace the existing entry is handled elsewhere; // this function just handles retrieving a cache entry that we can use to // track the revalidation. // // The reason revalidations are stored in the cache is because we need to be // able to dedupe multiple revalidation requests. The reason they have to be // handled specially is because we shouldn't overwrite a "normal" entry if // one exists at the same keypath. So, for each internal cache location, there // is a special "revalidation" slot that is used solely for this purpose. // // You can think of it as if all the revalidation entries were stored in a // separate cache map from the canonical entries, and then transfered to the // canonical cache map once the request is complete — this isn't how it's // actually implemented, since it's more efficient to store them in the same // data structure as the normal entries, but that's how it's modeled // conceptually. // TODO: Once we implement Fallback behavior for params, where an entry is // re-keyed based on response information, we'll need to account for the // possibility that the keypath of the previous entry is more generic than // the keypath of the revalidating entry. In other words, the server could // return a less generic entry upon revalidation. For now, though, this isn't // a concern because the keypath is based solely on the prefetch strategy, // not on data contained in the response. const existingEntry = readRevalidatingSegmentCacheEntry(now, tree.varyPath); if (existingEntry !== null) { return existingEntry; } // Create a pending entry and add it to the cache. The stale time is set to a // default value; the actual stale time will be set when the entry is // fulfilled with data from the server response. const varyPathForRequest = getSegmentVaryPathForRequest(fetchStrategy, tree); const pendingEntry = createDetachedSegmentCacheEntry(now); const isRevalidation = true; setInCacheMap(segmentCacheMap, varyPathForRequest, pendingEntry, isRevalidation); return pendingEntry; } export function overwriteRevalidatingSegmentCacheEntry(now, fetchStrategy, tree) { // This function is called when we've already decided to replace an existing // revalidation entry. Create a new entry and write it into the cache, // overwriting the previous value. The stale time is set to a default value; // the actual stale time will be set when the entry is fulfilled with data // from the server response. const varyPathForRequest = getSegmentVaryPathForRequest(fetchStrategy, tree); const pendingEntry = createDetachedSegmentCacheEntry(now); const isRevalidation = true; setInCacheMap(segmentCacheMap, varyPathForRequest, pendingEntry, isRevalidation); return pendingEntry; } /** * Whether an existing cache entry is preferred over an incoming candidate — * i.e. the candidate does NOT supersede it. (On an exact tie — same fetch * strategy, same partialness — this returns false, so the candidate replaces * the existing entry.) This is the precedence rule used both when deciding * whether an upsert may replace the entry at its own keypath, and when * deciding whether an entry at a more specific keypath may be evicted because * it shadows a just-inserted candidate (see `evictShadowingSegmentEntries`). * * Note that "less/more specific" in the comments below refers to fetch * strategy content tiers (how much content a strategy can produce), not the * vary-path specificity the eviction docs are concerned with. */ function isExistingSegmentEntryPreferred(existingEntry, candidateEntry) { return(// We fetched the new segment using a different, less specific fetch // strategy than the segment we already have in the cache, so it can't // have more content. candidateEntry.fetchStrategy !== existingEntry.fetchStrategy && !canNewFetchStrategyProvideMoreContent(existingEntry.fetchStrategy, candidateEntry.fetchStrategy) || // The existing entry isn't partial, but the new one is. // (TODO: can this be true if `candidateEntry.fetchStrategy >= existingEntry.fetchStrategy`?) !existingEntry.isPartial && candidateEntry.isPartial); } export function upsertSegmentEntry(now, varyPath, candidateEntry, // The fully concrete vary path a read for this segment position resolves // against (all concrete param values, i.e. `tree.varyPath`) — the most // specific path a read would use. Note this is the opposite of the // generalized keying path that `getSegmentVaryPathForRequest` computes. // Used to detect and evict stale entries at more specific keypaths that // would otherwise shadow the candidate. Pass null when there's no request // context; the shadow check is skipped. lookupVaryPath) { // We have a new entry that has not yet been inserted into the cache. Before // we do so, we need to confirm whether it takes precedence over the existing // entry (if one exists). // TODO: We should not upsert an entry if its key was invalidated in the time // since the request was made. We can do that by passing the "owner" entry to // this function and confirming it's the same as `existingEntry`. if (isValueExpired(now, getCurrentSegmentCacheVersion(), candidateEntry)) { // The entry is expired. We cannot upsert it. return null; } const existingEntry = readSegmentCacheEntry(now, varyPath); if (existingEntry !== null) { // Don't replace a more specific segment with a less-specific one. A case where this // might happen is if the existing segment was fetched via // `<Link prefetch={true}>`. if (isExistingSegmentEntryPreferred(existingEntry, candidateEntry)) { // The candidate does not supersede the existing entry. Leave the // existing entry in place and discard the candidate by not inserting it. // // We must not mutate the candidate here (e.g. downgrade it to Rejected or // null out its `rsc`). The caller does not transfer exclusive ownership // of it: it may already have been fulfilled, resolving its promise to a // waiter that holds the entry and reads `rsc` off it later. A navigation // seed is such a waiter, via `waitForSegmentCacheEntry`. Nulling `rsc` // after the fact resolves that read to `null`, so the waiter loses the // data it was about to render. Declining to insert it is enough: the // existing entry stays canonical, and the candidate keeps its valid (if // less complete) data for any waiter that already took it. return null; } // Ping any tasks blocked on the existing entry before replacing it so they // re-run and pick up the new entry. Without this, tasks waiting on the // existing Empty/Pending entry would be stranded — the new fulfilled // candidate has no blockedTasks of its own. if (existingEntry.status === EntryStatus.Empty || existingEntry.status === EntryStatus.Pending) { pingBlockedTasks(existingEntry); } // Replace the existing entry by writing the candidate over its keypath // below (the same mechanism `overwriteRevalidatingSegmentCacheEntry` // uses). We intentionally do NOT call `deleteFromCacheMap` first: deleting // vacates the canonical slot, and `deleteMapEntry` promotes a pending // Revalidation-slot entry into the vacated slot — which the immediate // insert below would then silently overwrite. The in-flight revalidation // would vanish from the map, so the next scheduler pass would find an // empty revalidation slot and spawn a duplicate request instead of // deduping against it. Replacing in place never vacates the slot, so // promotion never runs and the pending revalidating entry stays in its // Revalidation slot where `readOrCreateRevalidatingSegmentEntry`'s dedupe // finds it. // // The displaced entry's map/LRU accounting is handled by the replacement // itself: `setMapEntryValue` drops the displaced value's `ref` and // `updateLruSize` swaps its size for the candidate's, which is exactly // what delete-then-insert did. } const isRevalidation = false; setInCacheMap(segmentCacheMap, varyPath, candidateEntry, isRevalidation); if (lookupVaryPath !== null) { evictShadowingSegmentEntries(now, lookupVaryPath, candidateEntry); } return candidateEntry; } /** * Evicts stale entries at more specific keypaths that shadow a just-inserted * candidate entry. * * A response can be written to the cache at a MORE GENERIC vary path than the * path the request was issued against — for example, the server may report * that a segment doesn't vary on a param, so the entry is re-keyed with that * param as Fallback. Meanwhile, an older, less useful entry can exist at a * more specific path within the same fallback chain — for example, a partial * shell entry keyed with root params concrete (see * `getShellSegmentVaryPath`). Because segment lookup is * most-specific-match-wins, every subsequent read at the concrete request * path keeps returning the stale specific entry, and the more complete * generic entry is unreachable from that URL. That both wastes the completed * request and can loop: a prefetch task that revalidated the segment reads * back the same stale entry, decides it needs to revalidate again, and * repeats forever. * * The upsert is the one moment we know the ordering between the two entries: * the candidate was produced by a request for this segment position, and * `lookupVaryPath` is the fully concrete path a read for that position * resolves against, so any entry that a read at that path would return in the * candidate's stead is directly comparable to it. If such an entry is settled * and the candidate supersedes it — under the same precedence rules the * upsert applies at its own keypath — we know we never want to match against * it again, so delete it, making the candidate reachable. * * Non-settled entries are never evicted here: a Pending entry is owned by an * in-flight request that will settle it, and an Empty entry is a placeholder * that a scheduler pass may still claim and upgrade. */ function evictShadowingSegmentEntries(now, lookupVaryPath, candidateEntry) { // There can in principle be multiple shadowing entries at successively less // specific keypaths, so loop until the read returns the candidate (or an // entry we don't supersede). Each iteration re-reads and re-checks from // scratch (in part because `deleteFromCacheMap` can promote a settled // Revalidation-slot value into the just-vacated slot, surfacing a new entry // at the same keypath). Each iteration deletes an entry from the map, so // the loop terminates naturally; the bound is defensive, and 32 is far // beyond any real fallback chain, which is bounded by the vary // path's length. for(let i = 0; i < 32; i++){ const shadowEntry = readSegmentCacheEntry(now, lookupVaryPath); if (shadowEntry === null || shadowEntry === candidateEntry) { // The candidate is reachable from the lookup path (or the read missed // entirely, e.g. because the candidate expired). Done. return; } if (shadowEntry.status !== EntryStatus.Fulfilled && shadowEntry.status !== EntryStatus.Rejected) { // Only settled entries may be evicted. A Pending entry is held by an // in-flight request and will settle on its own. return; } if (isExistingSegmentEntryPreferred(shadowEntry, candidateEntry)) { // The shadowing entry is preferred over the candidate (e.g. it's a // complete entry fetched with a more specific strategy). Leave it — // reads at this path should keep matching it. return; } // The candidate supersedes the shadowing entry. Evict it. Settled entries // shouldn't have blocked tasks (Fulfilled always has `blockedTasks: // null`, and Rejected entries were pinged at rejection), but ping // defensively before deleting, matching the upsert-evict pattern above. pingBlockedTasks(shadowEntry); deleteFromCacheMap(shadowEntry); } } export function createDetachedSegmentCacheEntry(now) { // Default stale time for pending segment cache entries. The actual stale time // is set when the entry is fulfilled with data from the server response. const staleAt = now + 30 * 1000; const emptyEntry = { status: EntryStatus.Empty, blockedTasks: null, // Default to assuming the fetch strategy will be PPR. This will be updated // when a fetch is actually initiated. fetchStrategy: FetchStrategy.PPR, rsc: null, isPartial: true, isUpgradeableISRFallback: false, promise: null, // Map-related fields ref: null, size: 0, staleAt, version: 0 }; if (process.env.__NEXT_EXPOSE_TESTING_API) { // Instant Navigation Testing API: mark entries created during a lock scope // as owned, so locked navigations match only data (re)fetched within the // scope. No-op when no lock is held (always in production). const { recordNavigationLockOwnedEntry } = require('./navigation-testing-lock'); recordNavigationLockOwnedEntry(emptyEntry); } return emptyEntry; } export function upgradeToPendingSegment(emptyEntry, fetchStrategy, navigationLockPrefetch) { const pendingEntry = emptyEntry; pendingEntry.status = EntryStatus.Pending; pendingEntry.fetchStrategy = fetchStrategy; if (fetchStrategy === FetchStrategy.Full) { // We can assume the response will contain the full segment data. Set this // to false so we know it's OK to omit this segment from any navigation // requests that may happen while the data is still pending. pendingEntry.isPartial = false; } // Set the version here, since this is right before the request is initiated. // The next time the segment cache version is incremented, the entry will // effectively be evicted. This happens before initiating the request, rather // than when receiving the response, because it's guaranteed to happen // before the data is read on the server. pendingEntry.version = getCurrentSegmentCacheVersion(); if (process.env.__NEXT_EXPOSE_TESTING_API && // Instant Navigation Testing API only. Non-null when the requesting // prefetch is driving a locked navigation, in which case the // freshly-spawned pending entry is tracked against that navigation's // prefetch state so the navigation waits for it to fulfill before reading // it. Null at non-scheduler call sites (BFCache fulfillment, response // processing), which don't spawn an in-flight request to wait on, and // always in production. navigationLockPrefetch !== null) { const { trackNavigationLockPrefetchEntry } = require('./navigation-testing-lock'); trackNavigationLockPrefetchEntry(navigationLockPrefetch, pendingEntry); } return pendingEntry; } export function attemptToFulfillDynamicSegmentFromBFCache(now, segment, tree) { // Attempts to fulfill an empty segment cache entry using data from the // bfcache. This is only valid during a Full prefetch (i.e. one that includes // dynamic data), because the bfcache stores data from navigations which // always include dynamic data. // We always use the canonical vary path when checking the bfcache. This is // the same operation we'd use to access the cache during a // regular navigation. const varyPath = tree.varyPath; // Read from the BFCache without expiring it (pass -1). We check freshness // ourselves using navigatedAt, because the BFCache's staleAt may have been // overridden by a per-page unstable_dynamicStaleTime and can't be used to // derive the original request time. const bfcacheEntry = readFromBFCache(varyPath); if (bfcacheEntry !== null) { // The stale time for dynamic prefetches (default: 5 mins) is different // from the stale time for regular navigations (default: 0 secs). Use // navigatedAt to compute the correct expiry for prefetch purposes. const dynamicPrefetchStaleAt = bfcacheEntry.navigatedAt + STATIC_STALETIME_MS; if (now > dynamicPrefetchStaleAt) { return null; } const pendingSegment = upgradeToPendingSegment(segment, FetchStrategy.Full, // Fulfilled synchronously from the BFCache; nothing for a locked // navigation to wait on. null); const isPartial = false; return fulfillSegmentCacheEntry(pendingSegment, bfcacheEntry.rsc, dynamicPrefetchStaleAt, isPartial, // bfcache data is concrete, never an ISR fallback. false, FetchStrategy.Full); } return null; } /** * Attempts to replace an existing segment cache entry with data from the * bfcache. Unlike `attemptToFulfillDynamicSegmentFromBFCache` (which fills an * empty entry), this creates a new entry and upserts it, so it works even when * the segment is already fulfilled. */ export function attemptToUpgradeSegmentFromBFCache(now, tree) { const varyPath = tree.varyPath; const bfcacheEntry = readFromBFCache(varyPath); if (bfcacheEntry !== null) { const dynamicPrefetchStaleAt = bfcacheEntry.navigatedAt + STATIC_STALETIME_MS; if (now > dynamicPrefetchStaleAt) { return null; } const pendingSegment = upgradeToPendingSegment(createDetachedSegmentCacheEntry(now), FetchStrategy.Full, // Fulfilled synchronously from the BFCache; nothing for a locked // navigation to wait on. null); const isPartial = false; const newEntry = fulfillSegmentCacheEntry(pendingSegment, bfcacheEntry.rsc, dynamicPrefetchStaleAt, isPartial, // bfcache data is concrete, never an ISR fallback. false, FetchStrategy.Full); const segmentVaryPath = getSegmentVaryPathForRequest(FetchStrategy.Full, tree); const upserted = upsertSegmentEntry(now, segmentVaryPath, newEntry, // The concrete lookup path this BFCache upgrade applies to. (In // practice a Full request path is already fully concrete, so nothing // can shadow the new entry and the shadow check is a no-op.) tree.varyPath); if (upserted !== null && upserted.status === EntryStatus.Fulfilled) { return upserted; } } return null; } function pingBlockedTasks(entry) { const blockedTasks = entry.blockedTasks; if (blockedTasks !== null) { for (const task of blockedTasks){ pingPrefetchTask(task); } entry.blockedTasks = null; } } export function createMetadataRouteTree(metadataVaryPath) { // The Head is not actually part of the route tree, but other than that, it's // fetched and cached like a segment. Some functions expect a RouteTree // object, so rather than fork the logic in all those places, we use this // "fake" one. const metadata = { requestKey: HEAD_REQUEST_KEY, segment: HEAD_REQUEST_KEY, shellVaryPath: getShellSegmentVaryPath(metadataVaryPath), refreshState: null, varyPath: metadataVaryPath, // The metadata isn't really a "page" (though it isn't really a "segment" // either) but for the purposes of how this field is used, it behaves like // one. If this logic ever gets more complex we can change this to an enum. isPage: true, slots: null, prefetchHints: 0 }; return metadata; } export function fulfillRouteCacheEntry(now, entry, tree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching) { // Get the rendered search from the vary path const renderedSearch = getRenderedSearchFromVaryPath(metadataVaryPath) ?? ''; const fulfilledEntry = entry; fulfilledEntry.status = EntryStatus.Fulfilled; fulfilledEntry.tree = tree; fulfilledEntry.metadata = createMetadataRouteTree(metadataVaryPath); // Route structure is essentially static — it only changes on deploy. // Always use the static stale time. // NOTE: An exception is rewrites/redirects in middleware or proxy, which can // change routes dynamically. We have other strategies for handling those. // // If the route tree has stale inlining hints (e.g. the initial RSC payload // for a build-time static page, generated before collectPrefetchHints ran), // immediately expire the entry so it gets re-fetched with correct hints. // The segment data itself is still valid — only the route tree (which // contains the hint bits) needs to be re-fetched. if (tree.prefetchHints & PrefetchHint.InliningHintsStale) { fulfilledEntry.staleAt = -1; } else { fulfilledEntry.staleAt = now + STATIC_STALETIME_MS; } fulfilledEntry.couldBeIntercepted = couldBeIntercepted; fulfilledEntry.canonicalUrl = canonicalUrl; fulfilledEntry.renderedSearch = renderedSearch; fulfilledEntry.supportsPerSegmentPrefetching = supportsPerSegmentPrefetching; fulfilledEntry.hasDynamicRewrite = false; pingBlockedTasks(entry); return fulfilledEntry; } export function writeRouteIntoCache(now, pathname, search, nextUrl, tree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching) { const pendingEntry = createDetachedRouteCacheEntry(); const fulfilledEntry = fulfillRouteCacheEntry(now, pendingEntry, tree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching); const varyPath = getFulfilledRouteVaryPath(pathname, search, nextUrl, couldBeIntercepted); const isRevalidation = false; setInCacheMap(routeCacheMap, varyPath, fulfilledEntry, isRevalidation); return fulfilledEntry; } /** * Marks a route cache entry as having a dynamic rewrite. Called when we * discover that a route pattern has dynamic rewrite behavior - i.e., we used * an optimistic route tree for prediction, but the server responded with a * different rendered pathname. * * Once marked, attempts to use this entry as a template for prediction will * bail out to server resolution. */ export function markRouteEntryAsDynamicRewrite(entry) { entry.hasDynamicRewrite = true; // Note: The caller is responsible for also calling invalidateRouteCacheEntries // to invalidate other entries that may have been derived from this template // before we knew it had a dynamic rewrite. } function fulfillSegmentCacheEntry(segmentCacheEntry, rsc, staleAt, isPartial, // Only static (per-segment PPR) responses can be ISR fallbacks; all other // callers pass false. Always assigned (even when false) so that re-fulfilling // a previously-fallback entry with a concrete response clears the flag and // ends the retry loop. isUpgradeableISRFallback, // The strategy tier describing the CONTENT this entry is fulfilled with — // which comes from the response, not the tier the entry was requested at. // Usually the two agree, but when a response's shell payload IS the full // response (no shell/full split), shell-spawned entries are fulfilled with // full-tier content and recorded as such (see the promotion in // writeSegmentBundleResponse). Always assigned, replacing // the spawn-time strategy set by upgradeToPendingSegment; the write walks' // matching and keying decisions all happen against the spawn-time // strategy, before fulfillment, so they are unaffected. See // SegmentCacheEntryShared['fetchStrategy']. fetchStrategy) { const fulfilledEntry = segmentCacheEntry; fulfilledEntry.status = EntryStatus.Fulfilled; fulfilledEntry.rsc = rsc; fulfilledEntry.staleAt = staleAt; fulfilledEntry.isPartial = isPartial; fulfilledEntry.isUpgradeableISRFallback = isUpgradeableISRFallback; fulfilledEntry.fetchStrategy = fetchStrategy; // Resolve any listeners that were waiting for this data. if (segmentCacheEntry.promise !== null) { segmentCacheEntry.promise.resolve(fulfilledEntry); // Free the promise for garbage collection. fulfilledEntry.promise = null; } pingBlockedTasks(segmentCacheEntry); return fulfilledEntry; } function rejectRouteCacheEntry(entry, staleAt) { const rejectedEntry = entry; rejectedEntry.status = EntryStatus.Rejected; rejectedEntry.staleAt = staleAt; pingBlockedTasks(entry); } function rejectSegmentCacheEntry(entry, staleAt) { const rejectedEntry = entry; rejectedEntry.status = EntryStatus.Rejected; rejectedEntry.staleAt = staleAt; if (entry.promise !== null) { // NOTE: We don't currently propagate the reason the prefetch was canceled // but we could by accepting a `reason` argument. entry.promise.resolve(null); entry.promise = null; } pingBlockedTasks(entry); } function convertRootTreePrefetchToRouteTree(rootTree, renderedPathname, renderedSearch, acc) { // Remove trailing and leading slashes const pathnameParts = splitPathnameIntoParts(renderedPathname); const index = 0; const rootSegment = ROOT_SEGMENT_REQUEST_KEY; return convertTreePrefetchToRouteTree(rootTree.tree, rootSegment, null, ROOT_SEGMENT_REQUEST_KEY, pathnameParts, index, renderedSearch, acc); } function convertTreePrefetchToRouteTree(prefetch, segment, partialVaryPath, requestKey, pathnameParts, pathnamePartsIndex, renderedSearch, acc) { // Converts the route tree sent by the server into the format used by the // cache. The cached version of the tree includes additional fields, such as a // cache key for each segment. Since this is frequently accessed, we compute // it once instead of on every access. This same cache key is also used to // request the segm