UNPKG

next

Version:

The React Framework

915 lines 60.9 kB
import { DEFAULT_SEGMENT_KEY, NOT_FOUND_SEGMENT_KEY } from '../../../shared/lib/segment'; import { matchSegment } from '../match-segments'; import { createHrefFromUrl } from './create-href-from-url'; import { createRouterCacheKey } from './create-router-cache-key'; import { fetchServerResponse } from './fetch-server-response'; import { dispatchAppRouterAction } from '../use-action-queue'; import { ACTION_SERVER_PATCH } from './router-reducer-types'; import { isNavigatingToNewRootLayout } from './is-navigating-to-new-root-layout'; import { DYNAMIC_STALETIME_MS } from './reducers/navigate-reducer'; import { convertServerPatchToFullTree } from '../segment-cache/navigation'; export var FreshnessPolicy = /*#__PURE__*/ function(FreshnessPolicy) { FreshnessPolicy[FreshnessPolicy["Default"] = 0] = "Default"; FreshnessPolicy[FreshnessPolicy["Hydration"] = 1] = "Hydration"; FreshnessPolicy[FreshnessPolicy["HistoryTraversal"] = 2] = "HistoryTraversal"; FreshnessPolicy[FreshnessPolicy["RefreshAll"] = 3] = "RefreshAll"; FreshnessPolicy[FreshnessPolicy["HMRRefresh"] = 4] = "HMRRefresh"; return FreshnessPolicy; }({}); const noop = ()=>{}; export function createInitialCacheNodeForHydration(navigatedAt, initialTree, seedData, seedHead) { // Create the initial cache node tree, using the data embedded into the // HTML document. const accumulation = { scrollableSegments: null, separateRefreshUrls: null }; const task = createCacheNodeOnNavigation(navigatedAt, initialTree, undefined, 1, seedData, seedHead, null, null, false, null, null, false, accumulation); // NOTE: We intentionally don't check if any data needs to be fetched from the // server. We assume the initial hydration payload is sufficient to render // the page. // // The completeness of the initial data is an important property that we rely // on as a last-ditch mechanism for recovering the app; we must always be able // to reload a fresh HTML document to get to a consistent state. // // In the future, there may be cases where the server intentionally sends // partial data and expects the client to fill in the rest, in which case this // logic may change. (There already is a similar case where the server sends // _no_ hydration data in the HTML document at all, and the client fetches it // separately, but that's different because we still end up hydrating with a // complete tree.) return task.node; } // Creates a new Cache Node tree (i.e. copy-on-write) that represents the // optimistic result of a navigation, using both the current Cache Node tree and // data that was prefetched prior to navigation. // // At the moment we call this function, we haven't yet received the navigation // response from the server. It could send back something completely different // from the tree that was prefetched — due to rewrites, default routes, parallel // routes, etc. // // But in most cases, it will return the same tree that we prefetched, just with // the dynamic holes filled in. So we optimistically assume this will happen, // and accept that the real result could be arbitrarily different. // // We'll reuse anything that was already in the previous tree, since that's what // the server does. // // New segments (ones that don't appear in the old tree) are assigned an // unresolved promise. The data for these promises will be fulfilled later, when // the navigation response is received. // // The tree can be rendered immediately after it is created (that's why this is // a synchronous function). Any new trees that do not have prefetch data will // suspend during rendering, until the dynamic data streams in. // // Returns a Task object, which contains both the updated Cache Node and a path // to the pending subtrees that need to be resolved by the navigation response. // // A return value of `null` means there were no changes, and the previous tree // can be reused without initiating a server request. export function startPPRNavigation(navigatedAt, oldUrl, oldCacheNode, oldRouterState, newRouterState, freshness, seedData, seedHead, prefetchData, prefetchHead, isPrefetchHeadPartial, isSamePageNavigation, accumulation) { const didFindRootLayout = false; const parentNeedsDynamicRequest = false; const parentRefreshUrl = null; return updateCacheNodeOnNavigation(navigatedAt, oldUrl, oldCacheNode !== null ? oldCacheNode : undefined, oldRouterState, newRouterState, freshness, didFindRootLayout, seedData, seedHead, prefetchData, prefetchHead, isPrefetchHeadPartial, isSamePageNavigation, null, null, parentNeedsDynamicRequest, parentRefreshUrl, accumulation); } function updateCacheNodeOnNavigation(navigatedAt, oldUrl, oldCacheNode, oldRouterState, newRouterState, freshness, didFindRootLayout, seedData, seedHead, prefetchData, prefetchHead, isPrefetchHeadPartial, isSamePageNavigation, parentSegmentPath, parentParallelRouteKey, parentNeedsDynamicRequest, parentRefreshUrl, accumulation) { // Check if this segment matches the one in the previous route. const oldSegment = oldRouterState[0]; const newSegment = newRouterState[0]; if (!matchSegment(newSegment, oldSegment)) { // This segment does not match the previous route. We're now entering the // new part of the target route. Switch to the "create" path. if (// Check if the route tree changed before we reached a layout. (The // highest-level layout in a route tree is referred to as the "root" // layout.) This could mean that we're navigating between two different // root layouts. When this happens, we perform a full-page (MPA-style) // navigation. // // However, the algorithm for deciding where to start rendering a route // (i.e. the one performed in order to reach this function) is stricter // than the one used to detect a change in the root layout. So just // because we're re-rendering a segment outside of the root layout does // not mean we should trigger a full-page navigation. // // Specifically, we handle dynamic parameters differently: two segments // are considered the same even if their parameter values are different. // // Refer to isNavigatingToNewRootLayout for details. // // Note that we only have to perform this extra traversal if we didn't // already discover a root layout in the part of the tree that is // unchanged. We also only need to compare the subtree that is not // shared. In the common case, this branch is skipped completely. !didFindRootLayout && isNavigatingToNewRootLayout(oldRouterState, newRouterState) || // The global Not Found route (app/global-not-found.tsx) is a special // case, because it acts like a root layout, but in the router tree, it // is rendered in the same position as app/layout.tsx. // // Any navigation to the global Not Found route should trigger a // full-page navigation. // // TODO: We should probably model this by changing the key of the root // segment when this happens. Then the root layout check would work // as expected, without a special case. newSegment === NOT_FOUND_SEGMENT_KEY) { return null; } if (parentSegmentPath === null || parentParallelRouteKey === null) { // The root should never mismatch. If it does, it suggests an internal // Next.js error, or a malformed server response. Trigger a full- // page navigation. return null; } return createCacheNodeOnNavigation(navigatedAt, newRouterState, oldCacheNode, freshness, seedData, seedHead, prefetchData, prefetchHead, isPrefetchHeadPartial, parentSegmentPath, parentParallelRouteKey, parentNeedsDynamicRequest, accumulation); } // TODO: The segment paths are tracked so that LayoutRouter knows which // segments to scroll to after a navigation. But we should just mark this // information on the CacheNode directly. It used to be necessary to do this // separately because CacheNodes were created lazily during render, not when // rather than when creating the route tree. const segmentPath = parentParallelRouteKey !== null && parentSegmentPath !== null ? parentSegmentPath.concat([ parentParallelRouteKey, newSegment ]) : []; const newRouterStateChildren = newRouterState[1]; const oldRouterStateChildren = oldRouterState[1]; const seedDataChildren = seedData !== null ? seedData[1] : null; const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null; // We're currently traversing the part of the tree that was also part of // the previous route. If we discover a root layout, then we don't need to // trigger an MPA navigation. const isRootLayout = newRouterState[4] === true; const childDidFindRootLayout = didFindRootLayout || isRootLayout; const oldParallelRoutes = oldCacheNode !== undefined ? oldCacheNode.parallelRoutes : undefined; // Clone the current set of segment children, even if they aren't active in // the new tree. // TODO: We currently retain all the inactive segments indefinitely, until // there's an explicit refresh, or a parent layout is lazily refreshed. We // rely on this for popstate navigations, which update the Router State Tree // but do not eagerly perform a data fetch, because they expect the segment // data to already be in the Cache Node tree. For highly static sites that // are mostly read-only, this may happen only rarely, causing memory to // leak. We should figure out a better model for the lifetime of inactive // segments, so we can maintain instant back/forward navigations without // leaking memory indefinitely. let shouldDropSiblingCaches = false; let shouldRefreshDynamicData = false; switch(freshness){ case 0: case 2: case 1: // We should never drop dynamic data in shared layouts, except during // a refresh. shouldDropSiblingCaches = false; shouldRefreshDynamicData = false; break; case 3: case 4: shouldDropSiblingCaches = true; shouldRefreshDynamicData = true; break; default: freshness; break; } const newParallelRoutes = new Map(shouldDropSiblingCaches ? undefined : oldParallelRoutes); // TODO: We're not consistent about how we do this check. Some places // check if the segment starts with PAGE_SEGMENT_KEY, but most seem to // check if there any any children, which is why I'm doing it here. We // should probably encode an empty children set as `null` though. Either // way, we should update all the checks to be consistent. const isLeafSegment = Object.keys(newRouterStateChildren).length === 0; // Get the data for this segment. Since it was part of the previous route, // usually we just clone the data from the old CacheNode. However, during a // refresh or a revalidation, there won't be any existing CacheNode. So we // may need to consult the prefetch cache, like we would for a new segment. let newCacheNode; let needsDynamicRequest; if (oldCacheNode !== undefined && !shouldRefreshDynamicData && // During a same-page navigation, we always refetch the page segments !(isLeafSegment && isSamePageNavigation)) { // Reuse the existing CacheNode const dropPrefetchRsc = false; newCacheNode = reuseDynamicCacheNode(dropPrefetchRsc, oldCacheNode, newParallelRoutes); needsDynamicRequest = false; } else if (seedData !== null && seedData[0] !== null) { // If this navigation was the result of an action, then check if the // server sent back data in the action response. We should favor using // that, rather than performing a separate request. This is both better // for performance and it's more likely to be consistent with any // writes that were just performed by the action, compared to a // separate request. const seedRsc = seedData[0]; const seedLoading = seedData[2]; const isSeedRscPartial = false; const isSeedHeadPartial = seedHead === null; newCacheNode = readCacheNodeFromSeedData(seedRsc, seedLoading, isSeedRscPartial, seedHead, isSeedHeadPartial, isLeafSegment, newParallelRoutes, navigatedAt); needsDynamicRequest = isLeafSegment && isSeedHeadPartial; } else if (prefetchData !== null) { // Consult the prefetch cache. const prefetchRsc = prefetchData[0]; const prefetchLoading = prefetchData[2]; const isPrefetchRSCPartial = prefetchData[3]; newCacheNode = readCacheNodeFromSeedData(prefetchRsc, prefetchLoading, isPrefetchRSCPartial, prefetchHead, isPrefetchHeadPartial, isLeafSegment, newParallelRoutes, navigatedAt); needsDynamicRequest = isPrefetchRSCPartial || isLeafSegment && isPrefetchHeadPartial; } else { // Spawn a request to fetch new data from the server. newCacheNode = spawnNewCacheNode(newParallelRoutes, isLeafSegment, navigatedAt, freshness); needsDynamicRequest = true; } // During a refresh navigation, there's a special case that happens when // entering a "default" slot. The default slot may not be part of the // current route; it may have been reused from an older route. If so, // we need to fetch its data from the old route's URL rather than current // route's URL. Keep track of this as we traverse the tree. const href = newRouterState[2]; const refreshUrl = typeof href === 'string' && newRouterState[3] === 'refresh' ? // refresh URL as we continue traversing the tree. href : parentRefreshUrl; // If this segment itself needs to fetch new data from the server, then by // definition it is being refreshed. Track its refresh URL so we know which // URL to request the data from. if (needsDynamicRequest && refreshUrl !== null) { accumulateRefreshUrl(accumulation, refreshUrl); } // As we diff the trees, we may sometimes modify (copy-on-write, not mutate) // the Route Tree that was returned by the server — for example, in the case // of default parallel routes, we preserve the currently active segment. To // avoid mutating the original tree, we clone the router state children along // the return path. let patchedRouterStateChildren = {}; let taskChildren = null; // Most navigations require a request to fetch additional data from the // server, either because the data was not already prefetched, or because the // target route contains dynamic data that cannot be prefetched. // // However, if the target route is fully static, and it's already completely // loaded into the segment cache, then we can skip the server request. // // This starts off as `false`, and is set to `true` if any of the child // routes requires a dynamic request. let childNeedsDynamicRequest = false; // As we traverse the children, we'll construct a FlightRouterState that can // be sent to the server to request the dynamic data. If it turns out that // nothing in the subtree is dynamic (i.e. childNeedsDynamicRequest is false // at the end), then this will be discarded. // TODO: We can probably optimize the format of this data structure to only // include paths that are dynamic. Instead of reusing the // FlightRouterState type. let dynamicRequestTreeChildren = {}; for(let parallelRouteKey in newRouterStateChildren){ let newRouterStateChild = newRouterStateChildren[parallelRouteKey]; const oldRouterStateChild = oldRouterStateChildren[parallelRouteKey]; if (oldRouterStateChild === undefined) { // This should never happen, but if it does, it suggests a malformed // server response. Trigger a full-page navigation. return null; } const oldSegmentMapChild = oldParallelRoutes !== undefined ? oldParallelRoutes.get(parallelRouteKey) : undefined; let seedDataChild = seedDataChildren !== null ? seedDataChildren[parallelRouteKey] : null; let prefetchDataChild = prefetchDataChildren !== null ? prefetchDataChildren[parallelRouteKey] : null; let newSegmentChild = newRouterStateChild[0]; let seedHeadChild = seedHead; let prefetchHeadChild = prefetchHead; let isPrefetchHeadPartialChild = isPrefetchHeadPartial; if (// Skip this branch during a history traversal. We restore the tree that // was stashed in the history entry as-is. freshness !== 2 && newSegmentChild === DEFAULT_SEGMENT_KEY) { // This is a "default" segment. These are never sent by the server during // a soft navigation; instead, the client reuses whatever segment was // already active in that slot on the previous route. newRouterStateChild = reuseActiveSegmentInDefaultSlot(oldUrl, oldRouterStateChild); newSegmentChild = newRouterStateChild[0]; // Since we're switching to a different route tree, these are no // longer valid, because they correspond to the outer tree. seedDataChild = null; seedHeadChild = null; prefetchDataChild = null; prefetchHeadChild = null; isPrefetchHeadPartialChild = false; } const newSegmentKeyChild = createRouterCacheKey(newSegmentChild); const oldCacheNodeChild = oldSegmentMapChild !== undefined ? oldSegmentMapChild.get(newSegmentKeyChild) : undefined; const taskChild = updateCacheNodeOnNavigation(navigatedAt, oldUrl, oldCacheNodeChild, oldRouterStateChild, newRouterStateChild, freshness, childDidFindRootLayout, seedDataChild ?? null, seedHeadChild, prefetchDataChild ?? null, prefetchHeadChild, isPrefetchHeadPartialChild, isSamePageNavigation, segmentPath, parallelRouteKey, parentNeedsDynamicRequest || needsDynamicRequest, refreshUrl, accumulation); if (taskChild === null) { // One of the child tasks discovered a change to the root layout. // Immediately unwind from this recursive traversal. This will trigger a // full-page navigation. return null; } // Recursively propagate up the child tasks. if (taskChildren === null) { taskChildren = new Map(); } taskChildren.set(parallelRouteKey, taskChild); const newCacheNodeChild = taskChild.node; if (newCacheNodeChild !== null) { const newSegmentMapChild = new Map(shouldDropSiblingCaches ? undefined : oldSegmentMapChild); newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild); newParallelRoutes.set(parallelRouteKey, newSegmentMapChild); } // The child tree's route state may be different from the prefetched // route sent by the server. We need to clone it as we traverse back up // the tree. const taskChildRoute = taskChild.route; patchedRouterStateChildren[parallelRouteKey] = taskChildRoute; const dynamicRequestTreeChild = taskChild.dynamicRequestTree; if (dynamicRequestTreeChild !== null) { // Something in the child tree is dynamic. childNeedsDynamicRequest = true; dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild; } else { dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute; } } return { status: needsDynamicRequest ? 0 : 1, route: patchRouterStateWithNewChildren(newRouterState, patchedRouterStateChildren), node: newCacheNode, dynamicRequestTree: createDynamicRequestTree(newRouterState, dynamicRequestTreeChildren, needsDynamicRequest, childNeedsDynamicRequest, parentNeedsDynamicRequest), refreshUrl, children: taskChildren }; } function createCacheNodeOnNavigation(navigatedAt, newRouterState, oldCacheNode, freshness, seedData, seedHead, prefetchData, prefetchHead, isPrefetchHeadPartial, parentSegmentPath, parentParallelRouteKey, parentNeedsDynamicRequest, accumulation) { // Same traversal as updateCacheNodeNavigation, but simpler. We switch to this // path once we reach the part of the tree that was not in the previous route. // We don't need to diff against the old tree, we just need to create a new // one. We also don't need to worry about any refresh-related logic. // // For the most part, this is a subset of updateCacheNodeOnNavigation, so any // change that happens in this function likely needs to be applied to that // one, too. However there are some places where the behavior intentionally // diverges, which is why we keep them separate. const newSegment = newRouterState[0]; const segmentPath = parentParallelRouteKey !== null && parentSegmentPath !== null ? parentSegmentPath.concat([ parentParallelRouteKey, newSegment ]) : []; const newRouterStateChildren = newRouterState[1]; const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null; const seedDataChildren = seedData !== null ? seedData[1] : null; const oldParallelRoutes = oldCacheNode !== undefined ? oldCacheNode.parallelRoutes : undefined; let shouldDropSiblingCaches = false; let shouldRefreshDynamicData = false; let dropPrefetchRsc = false; switch(freshness){ case 0: // We should never drop dynamic data in sibling caches except during // a refresh. shouldDropSiblingCaches = false; // Only reuse the dynamic data if experimental.staleTimes.dynamic config // is set, and the data is not stale. (This is not a recommended API with // Cache Components, but it's supported for backwards compatibility. Use // cacheLife instead.) // // DYNAMIC_STALETIME_MS defaults to 0, but it can be increased. shouldRefreshDynamicData = oldCacheNode === undefined || navigatedAt - oldCacheNode.navigatedAt >= DYNAMIC_STALETIME_MS; dropPrefetchRsc = false; break; case 1: // During hydration, we assume the data sent by the server is both // consistent and complete. shouldRefreshDynamicData = false; shouldDropSiblingCaches = false; dropPrefetchRsc = false; break; case 2: // During back/forward navigations, we reuse the dynamic data regardless // of how stale it may be. shouldRefreshDynamicData = false; shouldRefreshDynamicData = false; // Only show prefetched data if the dynamic data is still pending. This // avoids a flash back to the prefetch state in a case where it's highly // likely to have already streamed in. // // Tehnically, what we're actually checking is whether the dynamic network // response was received. But since it's a streaming response, this does // not mean that all the dynamic data has fully streamed in. It just means // that _some_ of the dynamic data was received. But as a heuristic, we // assume that the rest dynamic data will stream in quickly, so it's still // better to skip the prefetch state. if (oldCacheNode !== undefined) { const oldRsc = oldCacheNode.rsc; const oldRscDidResolve = !isDeferredRsc(oldRsc) || oldRsc.status !== 'pending'; dropPrefetchRsc = oldRscDidResolve; } else { dropPrefetchRsc = false; } break; case 3: case 4: // Drop all dynamic data. shouldRefreshDynamicData = true; shouldDropSiblingCaches = true; dropPrefetchRsc = false; break; default: freshness; break; } const newParallelRoutes = new Map(shouldDropSiblingCaches ? undefined : oldParallelRoutes); const isLeafSegment = Object.keys(newRouterStateChildren).length === 0; if (isLeafSegment) { // The segment path of every leaf segment (i.e. page) is collected into // a result array. This is used by the LayoutRouter to scroll to ensure that // new pages are visible after a navigation. // // This only happens for new pages, not for refreshed pages. // // TODO: We should use a string to represent the segment path instead of // an array. We already use a string representation for the path when // accessing the Segment Cache, so we can use the same one. if (accumulation.scrollableSegments === null) { accumulation.scrollableSegments = []; } accumulation.scrollableSegments.push(segmentPath); } let newCacheNode; let needsDynamicRequest; if (!shouldRefreshDynamicData && oldCacheNode !== undefined) { // Reuse the existing CacheNode newCacheNode = reuseDynamicCacheNode(dropPrefetchRsc, oldCacheNode, newParallelRoutes); needsDynamicRequest = false; } else if (seedData !== null && seedData[0] !== null) { // If this navigation was the result of an action, then check if the // server sent back data in the action response. We should favor using // that, rather than performing a separate request. This is both better // for performance and it's more likely to be consistent with any // writes that were just performed by the action, compared to a // separate request. const seedRsc = seedData[0]; const seedLoading = seedData[2]; const isSeedRscPartial = false; const isSeedHeadPartial = seedHead === null && freshness !== 1; newCacheNode = readCacheNodeFromSeedData(seedRsc, seedLoading, isSeedRscPartial, seedHead, isSeedHeadPartial, isLeafSegment, newParallelRoutes, navigatedAt); needsDynamicRequest = isLeafSegment && isSeedHeadPartial; } else if (freshness === 1 && isLeafSegment && seedHead !== null) { // This is another weird case related to "not found" pages and hydration. // There will be a head sent by the server, but no page seed data. // TODO: We really should get rid of all these "not found" specific quirks // and make sure the tree is always consistent. const seedRsc = null; const seedLoading = null; const isSeedRscPartial = false; const isSeedHeadPartial = false; newCacheNode = readCacheNodeFromSeedData(seedRsc, seedLoading, isSeedRscPartial, seedHead, isSeedHeadPartial, isLeafSegment, newParallelRoutes, navigatedAt); needsDynamicRequest = false; } else if (freshness !== 1 && prefetchData !== null) { // Consult the prefetch cache. const prefetchRsc = prefetchData[0]; const prefetchLoading = prefetchData[2]; const isPrefetchRSCPartial = prefetchData[3]; newCacheNode = readCacheNodeFromSeedData(prefetchRsc, prefetchLoading, isPrefetchRSCPartial, prefetchHead, isPrefetchHeadPartial, isLeafSegment, newParallelRoutes, navigatedAt); needsDynamicRequest = isPrefetchRSCPartial || isLeafSegment && isPrefetchHeadPartial; } else { // Spawn a request to fetch new data from the server. newCacheNode = spawnNewCacheNode(newParallelRoutes, isLeafSegment, navigatedAt, freshness); needsDynamicRequest = true; } let patchedRouterStateChildren = {}; let taskChildren = null; let childNeedsDynamicRequest = false; let dynamicRequestTreeChildren = {}; for(let parallelRouteKey in newRouterStateChildren){ const newRouterStateChild = newRouterStateChildren[parallelRouteKey]; const oldSegmentMapChild = oldParallelRoutes !== undefined ? oldParallelRoutes.get(parallelRouteKey) : undefined; const seedDataChild = seedDataChildren !== null ? seedDataChildren[parallelRouteKey] : null; const prefetchDataChild = prefetchDataChildren !== null ? prefetchDataChildren[parallelRouteKey] : null; const newSegmentChild = newRouterStateChild[0]; const newSegmentKeyChild = createRouterCacheKey(newSegmentChild); const oldCacheNodeChild = oldSegmentMapChild !== undefined ? oldSegmentMapChild.get(newSegmentKeyChild) : undefined; const taskChild = createCacheNodeOnNavigation(navigatedAt, newRouterStateChild, oldCacheNodeChild, freshness, seedDataChild ?? null, seedHead, prefetchDataChild ?? null, prefetchHead, isPrefetchHeadPartial, segmentPath, parallelRouteKey, parentNeedsDynamicRequest || needsDynamicRequest, accumulation); if (taskChildren === null) { taskChildren = new Map(); } taskChildren.set(parallelRouteKey, taskChild); const newCacheNodeChild = taskChild.node; if (newCacheNodeChild !== null) { const newSegmentMapChild = new Map(shouldDropSiblingCaches ? undefined : oldSegmentMapChild); newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild); newParallelRoutes.set(parallelRouteKey, newSegmentMapChild); } const taskChildRoute = taskChild.route; patchedRouterStateChildren[parallelRouteKey] = taskChildRoute; const dynamicRequestTreeChild = taskChild.dynamicRequestTree; if (dynamicRequestTreeChild !== null) { childNeedsDynamicRequest = true; dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild; } else { dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute; } } return { status: needsDynamicRequest ? 0 : 1, route: patchRouterStateWithNewChildren(newRouterState, patchedRouterStateChildren), node: newCacheNode, dynamicRequestTree: createDynamicRequestTree(newRouterState, dynamicRequestTreeChildren, needsDynamicRequest, childNeedsDynamicRequest, parentNeedsDynamicRequest), // This route is not part of the current tree, so there's no reason to // track the refresh URL. refreshUrl: null, children: taskChildren }; } function patchRouterStateWithNewChildren(baseRouterState, newChildren) { const clone = [ baseRouterState[0], newChildren ]; // 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. if (2 in baseRouterState) { clone[2] = baseRouterState[2]; } if (3 in baseRouterState) { clone[3] = baseRouterState[3]; } if (4 in baseRouterState) { clone[4] = baseRouterState[4]; } return clone; } function createDynamicRequestTree(newRouterState, dynamicRequestTreeChildren, needsDynamicRequest, childNeedsDynamicRequest, parentNeedsDynamicRequest) { // Create a FlightRouterState that instructs the server how to render the // requested segment. // // Or, if neither this segment nor any of the children require a new data, // then we return `null` to skip the request. let dynamicRequestTree = null; if (needsDynamicRequest) { dynamicRequestTree = patchRouterStateWithNewChildren(newRouterState, dynamicRequestTreeChildren); // The "refetch" marker is set on the top-most segment that requires new // data. We can omit it if a parent was already marked. if (!parentNeedsDynamicRequest) { dynamicRequestTree[3] = 'refetch'; } } else if (childNeedsDynamicRequest) { // This segment does not request new data, but at least one of its // children does. dynamicRequestTree = patchRouterStateWithNewChildren(newRouterState, dynamicRequestTreeChildren); } else { dynamicRequestTree = null; } return dynamicRequestTree; } function accumulateRefreshUrl(accumulation, refreshUrl) { // This is a refresh navigation, and we're inside a "default" slot that's // not part of the current route; it was reused from an older route. In // order to get fresh data for this reused route, we need to issue a // separate request using the old route's URL. // // Track these extra URLs in the accumulated result. Later, we'll construct // an appropriate request for each unique URL in the final set. The reason // we don't do it immediately here is so we can deduplicate multiple // instances of the same URL into a single request. See // listenForDynamicRequest for more details. const separateRefreshUrls = accumulation.separateRefreshUrls; if (separateRefreshUrls === null) { accumulation.separateRefreshUrls = new Set([ refreshUrl ]); } else { separateRefreshUrls.add(refreshUrl); } } function reuseActiveSegmentInDefaultSlot(oldUrl, oldRouterState) { // This is a "default" segment. These are never sent by the server during a // soft navigation; instead, the client reuses whatever segment was already // active in that slot on the previous route. This means if we later need to // refresh the segment, it will have to be refetched from the previous route's // URL. We store it in the Flight Router State. // // TODO: We also mark the segment with a "refresh" marker but I think we can // get rid of that eventually by making sure we only add URLs to page segments // that are reused. Then the presence of the URL alone is enough. let reusedRouterState; const oldRefreshMarker = oldRouterState[3]; if (oldRefreshMarker === 'refresh') { // This segment was already reused from an even older route. Keep its // existing URL and refresh marker. reusedRouterState = oldRouterState; } else { // This segment was not previously reused, and it's not on the new route. // So it must have been delivered in the old route. reusedRouterState = patchRouterStateWithNewChildren(oldRouterState, oldRouterState[1]); reusedRouterState[2] = createHrefFromUrl(oldUrl); reusedRouterState[3] = 'refresh'; } return reusedRouterState; } function reuseDynamicCacheNode(dropPrefetchRsc, existingCacheNode, parallelRoutes) { // Clone an existing CacheNode's data, with (possibly) new children. const cacheNode = { rsc: existingCacheNode.rsc, prefetchRsc: dropPrefetchRsc ? null : existingCacheNode.prefetchRsc, head: existingCacheNode.head, prefetchHead: dropPrefetchRsc ? null : existingCacheNode.prefetchHead, loading: existingCacheNode.loading, parallelRoutes, // Don't update the navigatedAt timestamp, since we're reusing // existing data. navigatedAt: existingCacheNode.navigatedAt }; return cacheNode; } function readCacheNodeFromSeedData(seedRsc, seedLoading, isSeedRscPartial, seedHead, isSeedHeadPartial, isPageSegment, parallelRoutes, navigatedAt) { // TODO: Currently this is threaded through the navigation logic using the // CacheNodeSeedData type, but in the future this will read directly from // the Segment Cache. See readRenderSnapshotFromCache. let rsc; let prefetchRsc; if (isSeedRscPartial) { // The prefetched data contains dynamic holes. Create a pending promise that // will be fulfilled when the dynamic data is received from the server. prefetchRsc = seedRsc; rsc = createDeferredRsc(); } else { // The prefetched data is complete. Use it directly. prefetchRsc = null; rsc = seedRsc; } // If this is a page segment, also read the head. let prefetchHead; let head; if (isPageSegment) { if (isSeedHeadPartial) { prefetchHead = seedHead; head = createDeferredRsc(); } else { prefetchHead = null; head = seedHead; } } else { prefetchHead = null; head = null; } const cacheNode = { rsc, prefetchRsc, head, prefetchHead, // TODO: Technically, a loading boundary could contain dynamic data. We // should have separate `loading` and `prefetchLoading` fields to handle // this, like we do for the segment data and head. loading: seedLoading, parallelRoutes, navigatedAt }; return cacheNode; } function spawnNewCacheNode(parallelRoutes, isLeafSegment, navigatedAt, freshness) { // We should never spawn network requests during hydration. We must treat the // initial payload as authoritative, because the initial page load is used // as a last-ditch mechanism for recovering the app. // // This is also an important safety check because if this leaks into the // server rendering path (which theoretically it never should because // the server payload should be consistent), the server would hang because // these promises would never resolve. // // TODO: There is an existing case where the global "not found" boundary // triggers this path. But it does render correctly despite that. That's an // unusual render path so it's not surprising, but we should look into // modeling it in a more consistent way. See also the /_notFound special // case in updateCacheNodeOnNavigation. const isHydration = freshness === 1; const cacheNode = { rsc: !isHydration ? createDeferredRsc() : null, prefetchRsc: null, head: !isHydration && isLeafSegment ? createDeferredRsc() : null, prefetchHead: null, loading: !isHydration ? createDeferredRsc() : null, parallelRoutes, navigatedAt }; return cacheNode; } // Represents whether the previuos navigation resulted in a route tree mismatch. // A mismatch results in a refresh of the page. If there are two successive // mismatches, we will fall back to an MPA navigation, to prevent a retry loop. let previousNavigationDidMismatch = false; // Writes a dynamic server response into the tree created by // updateCacheNodeOnNavigation. All pending promises that were spawned by the // navigation will be resolved, either with dynamic data from the server, or // `null` to indicate that the data is missing. // // A `null` value will trigger a lazy fetch during render, which will then patch // up the tree using the same mechanism as the non-PPR implementation // (serverPatchReducer). // // Usually, the server will respond with exactly the subset of data that we're // waiting for — everything below the nearest shared layout. But technically, // the server can return anything it wants. // // This does _not_ create a new tree; it modifies the existing one in place. // Which means it must follow the Suspense rules of cache safety. export function spawnDynamicRequests(task, primaryUrl, nextUrl, freshnessPolicy, accumulation) { const dynamicRequestTree = task.dynamicRequestTree; if (dynamicRequestTree === null) { // This navigation was fully cached. There are no dynamic requests to spawn. previousNavigationDidMismatch = false; return; } // This is intentionally not an async function to discourage the caller from // awaiting the result. Any subsequent async operations spawned by this // function should result in a separate navigation task, rather than // block the original one. // // In this function we spawn (but do not await) all the network requests that // block the navigation, and collect the promises. The next function, // `finishNavigationTask`, can await the promises in any order without // accidentally introducing a network waterfall. const primaryRequestPromise = fetchMissingDynamicData(task, dynamicRequestTree, primaryUrl, nextUrl, freshnessPolicy); const separateRefreshUrls = accumulation.separateRefreshUrls; let refreshRequestPromises = null; if (separateRefreshUrls !== null) { // There are multiple URLs that we need to request the data from. This // happens when a "default" parallel route slot is present in the tree, and // its data cannot be fetched from the current route. We need to split the // combined dynamic request tree into separate requests per URL. // TODO: Create a scoped dynamic request tree that omits anything that // is not relevant to the given URL. Without doing this, the server may // sometimes render more data than necessary; this is not a regression // compared to the pre-Segment Cache implementation, though, just an // optimization we can make in the future. // Construct a request tree for each additional refresh URL. This will // prune away everything except the parts of the tree that match the // given refresh URL. refreshRequestPromises = []; const canonicalUrl = createHrefFromUrl(primaryUrl); for (const refreshUrl of separateRefreshUrls){ if (refreshUrl === canonicalUrl) { continue; } // TODO: Create a scoped dynamic request tree that omits anything that // is not relevant to the given URL. Without doing this, the server may // sometimes render more data than necessary; this is not a regression // compared to the pre-Segment Cache implementation, though, just an // optimization we can make in the future. // const scopedDynamicRequestTree = splitTaskByURL(task, refreshUrl) const scopedDynamicRequestTree = dynamicRequestTree; if (scopedDynamicRequestTree !== null) { refreshRequestPromises.push(fetchMissingDynamicData(task, scopedDynamicRequestTree, new URL(refreshUrl, location.origin), // TODO: Just noticed that this should actually the Next-Url at the // time the refresh URL was set, not the current Next-Url. Need to // start tracking this alongside the refresh URL. In the meantime, // if a refresh fails due to a mismatch, it will trigger a // hard refresh. nextUrl, freshnessPolicy)); } } } // Further async operations are moved into this separate function to // discourage sequential network requests. const voidPromise = finishNavigationTask(task, nextUrl, primaryRequestPromise, refreshRequestPromises); // `finishNavigationTask` is responsible for error handling, so we can attach // noop callbacks to this promise. voidPromise.then(noop, noop); } async function finishNavigationTask(task, nextUrl, primaryRequestPromise, refreshRequestPromises) { // Wait for all the requests to finish, or for the first one to fail. let exitStatus = await waitForRequestsToFinish(primaryRequestPromise, refreshRequestPromises); // Once the all the requests have finished, check the tree for any remaining // pending tasks. If anything is still pending, it means the server response // does not match the client, and we must refresh to get back to a consistent // state. We can skip this step if we already detected a mismatch during the // first phase; it doesn't matter in that case because we're going to refresh // the whole tree regardless. if (exitStatus === 0) { exitStatus = abortRemainingPendingTasks(task, null, null); } switch(exitStatus){ case 0: { // The task has completely finished. There's no missing data. Exit. previousNavigationDidMismatch = false; return; } case 1: { // Some data failed to finish loading. Trigger a soft retry. // TODO: As an extra precaution against soft retry loops, consider // tracking whether a navigation was itself triggered by a retry. If two // happen in a row, fall back to a hard retry. const isHardRetry = false; const primaryRequestResult = await primaryRequestPromise; dispatchRetryDueToTreeMismatch(isHardRetry, primaryRequestResult.url, nextUrl, primaryRequestResult.seed, task.route); return; } case 2: { // Some data failed to finish loading in a non-recoverable way, such as a // network error. Trigger an MPA navigation. // // Hard navigating/refreshing is how we prevent an infinite retry loop // caused by a network error — when the network fails, we fall back to the // browser behavior for offline navigations. In the future, Next.js may // introduce its own custom handling of offline navigations, but that // doesn't exist yet. const isHardRetry = true; const primaryRequestResult = await primaryRequestPromise; dispatchRetryDueToTreeMismatch(isHardRetry, primaryRequestResult.url, nextUrl, primaryRequestResult.seed, task.route); return; } default: { return exitStatus; } } } function waitForRequestsToFinish(primaryRequestPromise, refreshRequestPromises) { // Custom async combinator logic. This could be replaced by Promise.any but // we don't assume that's available. // // Each promise resolves once the server responsds and the data is written // into the CacheNode tree. Resolve the combined promise once all the // requests finish. // // Or, resolve as soon as one of the requests fails, without waiting for the // others to finish. return new Promise((resolve)=>{ const onFulfill = (result)=>{ if (result.exitStatus === 0) { remainingCount--; if (remainingCount === 0) { // All the requests finished successfully. resolve(0); } } else { // One of the requests failed. Exit with a failing status. // NOTE: It's possible for one of the requests to fail with SoftRetry // and a later one to fail with HardRetry. In this case, we choose to // retry immediately, rather than delay the retry until all the requests // finish. If it fails again, we will hard retry on the next // attempt, anyway. resolve(result.exitStatus); } }; // onReject shouldn't ever be called because fetchMissingDynamicData's // entire body is wrapped in a try/catch. This is just defensive. const onReject = ()=>resolve(2); // Attach the listeners to the promises. let remainingCount = 1; primaryRequestPromise.then(onFulfill, onReject); if (refreshRequestPromises !== null) { remainingCount += refreshRequestPromises.length; refreshRequestPromises.forEach((refreshRequestPromise)=>refreshRequestPromise.then(onFulfill, onReject)); } }); } function dispatchRetryDueToTreeMismatch(isHardRetry, retryUrl, retryNextUrl, seed, baseTree) { // If this is the second time in a row that a navigation resulted in a // mismatch, fall back to a hard (MPA) refresh. isHardRetry = isHardRetry || previousNavigationDidMismatch; previousNavigationDidMismatch = true; const retryAction = { type: ACTION_SERVER_PATCH, previousTree: baseTree, url: retryUrl, nextUrl: retryNextUrl, seed, mpa: isHardRetry }; dispatchAppRouterAction(retryAction); } async function fetchMissingDynamicData(task, dynamicRequestTree, url, nextUrl, freshnessPolicy) { try { const result = await fetchServerResponse(url, { flightRouterState: dynamicRequestTree, nextUrl, isHmrRefresh: freshnessPolicy === 4 }); if (typeof result === 'string') { // fetchServerResponse will return an href to indicate that the SPA // navigation failed. For example, if the server triggered a hard // redirect, or the fetch request errored. Initiate an MPA navigation // to the given href. return { exitStatus: 2, url: new URL(result, location.origin), seed: null }; } const seed = convertServerPatchToFullTree(task.route, result.flightData, result.renderedSearch); const didReceiveUnknownParallelRoute = writeDynamicDataIntoNavigationTask(task, seed.tree, seed.data, seed.head, result.debugInfo); return { exitStatus: didReceiveUnknownParallelRoute ? 1 : 0, url: new URL(result.canonicalUrl, location.origin), seed }; } catch { // This shouldn't happen because fetchServerResponse's entire body is // wrapped in a try/catch. If it does, though, it implies the server failed // to respond with any tree at all. So we must fall back to a hard retry. return { exitStatus: 2, url: url, seed: null }; } } function writeDynamicDataIntoNavigationTask(task, serverRouterState, dynamicData, dynamicHead, debugInfo) { if (task.status === 0 && dynamicData !== null) { task.status = 1; finishPendingCacheNode