UNPKG

next

Version:

The React Framework

543 lines (541 loc) 25.7 kB
/** * Optimistic Routing (Known Routes) * * This module enables the client to predict route structure for URLs that * haven't been prefetched yet, based on previously learned route patterns. * When successful, this allows skipping the route tree prefetch request * entirely. * * The core idea is that many URLs map to the same route structure. For example, * /blog/post-1 and /blog/post-2 both resolve to /blog/[slug]. Once we've * prefetched one, we can predict the structure of the other. * * However, we can't always make this prediction. Static siblings (like * /blog/featured alongside /blog/[slug]) have different route structures. * When we learn a dynamic route, we also learn its static siblings so we * know when NOT to apply the prediction. * * Main entry points: * * 1. discoverKnownRoute: Called after receiving a route tree from the server. * Traverses the route tree, compares URL parts to segments, and populates * the known route tree if they match. Routes are always inserted into the * cache. * * 2. matchKnownRoute: Called when looking up a route with no cache entry. * Matches the candidate URL against learned patterns. Returns a synthetic * cache entry if successful, or null to fall back to server resolution. * * Rewrite detection happens during traversal: if a URL path part doesn't match * the corresponding route segment, we stop populating the known route tree * (since the mapping is incorrect) but still insert the route into the cache. * * The known route tree is append-only with no eviction. Route patterns are * derived from the filesystem, so they don't become stale within a session. * Cache invalidation on deploy clears everything anyway. * * Current limitations (deopt to server resolution): * - Rewrites: Detected during traversal (tree not populated, but route cached) * - Intercepted routes: The route tree varies by referrer (Next-Url header), * so we can't predict the correct structure from the URL alone. Patterns are * still stored during discovery (so the trie stays populated for non- * intercepted siblings), but matching bails out when the pattern is marked * as interceptable. */ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); 0 && (module.exports = { discoverKnownRoute: null, matchKnownRoute: null, resetKnownRoutes: null }); function _export(target, all) { for(var name in all)Object.defineProperty(target, name, { enumerable: true, get: all[name] }); } _export(exports, { discoverKnownRoute: function() { return discoverKnownRoute; }, matchKnownRoute: function() { return matchKnownRoute; }, resetKnownRoutes: function() { return resetKnownRoutes; } }); const _cache = require("./cache"); const _routeparams = require("../../route-params"); const _varypath = require("./vary-path"); function createEmptyPart() { return { staticChildren: null, dynamicChild: null, dynamicChildParamName: null, dynamicChildParamType: null, pattern: null }; } // The root of the known route tree. let knownRouteTreeRoot = createEmptyPart(); function discoverKnownRoute(now, pathname, nextUrl, pendingEntry, routeTree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching, hasDynamicRewrite) { const tree = routeTree; const pathnameParts = pathname.split('/').filter((p)=>p !== ''); const firstPart = pathnameParts.length > 0 ? pathnameParts[0] : null; const remainingParts = pathnameParts.length > 0 ? pathnameParts.slice(1) : []; if (pendingEntry !== null) { // Fulfill the pending entry first const fulfilledEntry = (0, _cache.fulfillRouteCacheEntry)(now, pendingEntry, tree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching); if (hasDynamicRewrite) { fulfilledEntry.hasDynamicRewrite = true; } // Populate the known route tree (handles rewrite detection internally). // The entry is already in the cache; this just stores it as a pattern // if the URL matches the route structure. discoverKnownRoutePart(knownRouteTreeRoot, tree, firstPart, remainingParts, fulfilledEntry, now, pathname, nextUrl, tree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching, hasDynamicRewrite); return fulfilledEntry; } // No pending entry - discoverKnownRoutePart will create one and insert it // into the cache, or return an existing pattern if one exists. return discoverKnownRoutePart(knownRouteTreeRoot, tree, firstPart, remainingParts, null, now, pathname, nextUrl, tree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching, hasDynamicRewrite); } /** * Gets or creates the dynamic child node for a KnownRoutePart. * A node can have at most one dynamic child (you can't have both [slug] and * [id] at the same route level), so we either return existing or create new. */ function discoverDynamicChild(part, paramName, paramType) { if (part.dynamicChild !== null) { return part.dynamicChild; } const newChild = createEmptyPart(); // Type assertion needed because we're converting from "without" to "with" // dynamic child variant. const mutablePart = part; mutablePart.dynamicChild = newChild; mutablePart.dynamicChildParamName = paramName; mutablePart.dynamicChildParamType = paramType; return newChild; } /** * Recursive workhorse for discoverKnownRoute. * * Walks the route tree and URL parts in parallel, building out the known * route tree as it goes. At each step: * 1. Determines if the current segment appears in the URL (dynamic/static) * 2. Validates URL matches route structure (detects rewrites) * 3. Creates/updates the corresponding KnownRoutePart node * 4. Records static siblings for future matching * 5. Recurses into child slots (parallel routes) * * If a URL/route mismatch is detected (rewrite), we stop building the known * route tree but still cache the route entry for direct lookup. */ function discoverKnownRoutePart(parentKnownRoutePart, routeTree, urlPart, remainingParts, existingEntry, // These are passed through unchanged for entry creation at the leaf now, pathname, nextUrl, fullTree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching, hasDynamicRewrite) { const segment = routeTree.segment; let segmentAppearsInURL; let paramName = null; let paramType = null; let staticSiblings = null; if (typeof segment === 'string') { segmentAppearsInURL = (0, _routeparams.doesStaticSegmentAppearInURL)(segment); } else { // Dynamic segment tuple: [paramName, paramCacheKey, paramType, staticSiblings] paramName = segment[0]; paramType = segment[2]; staticSiblings = segment[3]; segmentAppearsInURL = true; } let knownRoutePart = parentKnownRoutePart; let nextUrlPart = urlPart; let nextRemainingParts = remainingParts; if (segmentAppearsInURL) { // Check for mismatch: if this is a static segment, the URL part must match if (paramName === null && urlPart !== segment) { // URL doesn't match route structure (likely a rewrite). // Don't populate the known route tree, just write the route into the // cache and return immediately. if (existingEntry !== null) { return existingEntry; } return (0, _cache.writeRouteIntoCache)(now, pathname, nextUrl, fullTree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching); } // URL matches route structure. Build the known route tree. if (paramName !== null && paramType !== null) { // Dynamic segment knownRoutePart = discoverDynamicChild(parentKnownRoutePart, paramName, paramType); // Record static siblings as placeholder parts. // IMPORTANT: We use the null vs Map distinction to track whether // siblings are known at this level: // - staticChildren: null = siblings unknown (can't safely match dynamic) // - staticChildren: Map = siblings known (even if empty) // This matters in dev mode where webpack may not know all siblings yet. if (staticSiblings !== null) { // Siblings are known - ensure we have a Map (even if empty) if (parentKnownRoutePart.staticChildren === null) { parentKnownRoutePart.staticChildren = new Map(); } for (const sibling of staticSiblings){ if (!parentKnownRoutePart.staticChildren.has(sibling)) { parentKnownRoutePart.staticChildren.set(sibling, createEmptyPart()); } } } } else { // Static segment if (parentKnownRoutePart.staticChildren === null) { parentKnownRoutePart.staticChildren = new Map(); } let existingChild = parentKnownRoutePart.staticChildren.get(urlPart); if (existingChild === undefined) { existingChild = createEmptyPart(); parentKnownRoutePart.staticChildren.set(urlPart, existingChild); } knownRoutePart = existingChild; } // Advance to next URL part nextUrlPart = remainingParts.length > 0 ? remainingParts[0] : null; nextRemainingParts = remainingParts.length > 0 ? remainingParts.slice(1) : []; } // else: Transparent segment (route group, __PAGE__, etc.) // Stay at the same known route part, don't advance URL parts // Recurse into child routes. A route tree can have multiple parallel routes // (e.g., @modal alongside children). Each parallel route is a separate // branch, but they all share the same URL - we just need to traverse all // branches to build out the known route tree. const slots = routeTree.slots; let resultFromChildren = null; if (slots !== null) { for(const parallelRouteKey in slots){ const childRouteTree = slots[parallelRouteKey]; // Skip branches with refreshState set - these were reused from a // different route (e.g., a "default" parallel slot) and don't represent // the actual route structure for this URL. if (childRouteTree.refreshState !== null) { continue; } const result = discoverKnownRoutePart(knownRoutePart, childRouteTree, nextUrlPart, nextRemainingParts, existingEntry, now, pathname, nextUrl, fullTree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching, hasDynamicRewrite); // All parallel route branches share the same URL, so they should all // reach compatible leaf nodes. We capture any result. resultFromChildren = result; } if (resultFromChildren !== null) { return resultFromChildren; } // Defensive fallback: no children returned a result. This shouldn't happen // for valid route trees, but handle it gracefully. if (existingEntry !== null) { return existingEntry; } return (0, _cache.writeRouteIntoCache)(now, pathname, nextUrl, fullTree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching); } // Reached a page node. Create/get the route cache entry and store as a // pattern. First, check if there's already a pattern for this route. if (knownRoutePart.pattern !== null) { // If this route has a dynamic rewrite, mark the existing pattern. if (hasDynamicRewrite) { knownRoutePart.pattern.hasDynamicRewrite = true; } return knownRoutePart.pattern; } // Get or create the entry let entry; if (existingEntry !== null) { // Already have a fulfilled entry, use it directly. It's already in the // route cache map. entry = existingEntry; } else { // Create the entry and insert it into the route cache map. entry = (0, _cache.writeRouteIntoCache)(now, pathname, nextUrl, fullTree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching); } if (hasDynamicRewrite) { entry.hasDynamicRewrite = true; } // Store as pattern knownRoutePart.pattern = entry; return entry; } function matchKnownRoute(pathname, search) { const pathnameParts = pathname.split('/').filter((p)=>p !== ''); const resolvedParams = new Map(); const match = matchKnownRoutePart(knownRouteTreeRoot, pathnameParts, 0, resolvedParams); if (match === null) { return null; } const matchedPart = match.part; const pattern = match.pattern; // If the pattern could be intercepted, we can't safely use it for prediction. // Interception routes resolve to different route trees depending on the // referrer (the Next-Url header), which means the same URL can map to // different page components depending on where the navigation originated. // Since the known route tree only stores a single pattern per URL shape, we // can't distinguish between the intercepted and non-intercepted cases, so we // bail out to server resolution. // // TODO: We could store interception behavior in the known route tree itself // (e.g., which segments use interception markers and what they resolve to). // With enough information embedded in the trie, we could match interception // routes entirely on the client without a server round-trip. if (pattern.couldBeIntercepted) { return null; } // "Reify" the pattern: clone the template tree with concrete param values. // This substitutes resolved params (e.g., slug: "hello") into dynamic // segments and recomputes vary paths for correct segment cache keying. const acc = { metadataVaryPath: null }; const reifiedTree = reifyRouteTree(pattern.tree, resolvedParams, search, null, acc); // The metadata tree is a flat page node without the intermediate layout // structure. Clone it with the updated metadata vary path collected during // the main tree traversal. const metadataVaryPath = acc.metadataVaryPath; if (metadataVaryPath === null) { // This shouldn't be reachable for a valid route tree. return null; } const reifiedMetadata = (0, _cache.createMetadataRouteTree)(metadataVaryPath); // Create a synthetic (predicted) entry and store it as the new pattern. // // Why replace the pattern? We intentionally update the pattern with this // synthetic entry so that if our prediction was wrong (server returns a // different pathname due to dynamic rewrite), the entry gets marked with // hasDynamicRewrite. Future predictions for this route will see the flag // and bail out to server resolution instead of making the same mistake. const syntheticEntry = { canonicalUrl: pathname + search, status: _cache.EntryStatus.Fulfilled, blockedTasks: null, tree: reifiedTree, metadata: reifiedMetadata, couldBeIntercepted: pattern.couldBeIntercepted, supportsPerSegmentPrefetching: pattern.supportsPerSegmentPrefetching, hasDynamicRewrite: false, renderedSearch: search, ref: null, size: pattern.size, staleAt: pattern.staleAt, version: pattern.version }; matchedPart.pattern = syntheticEntry; return syntheticEntry; } /** * Recursively matches a URL against the known route tree. * * Matching priority (most specific first): * 1. Static children - exact path segment match * 2. Dynamic child - [param], [...param], [[...param]] * 3. Direct pattern - when no more URL parts remain * * Collects resolved param values in resolvedParams as it traverses. * Returns null if no match found (caller should fall back to server). */ function matchKnownRoutePart(part, pathnameParts, partIndex, resolvedParams) { const urlPart = partIndex < pathnameParts.length ? pathnameParts[partIndex] : null; // If staticChildren is null, we don't know what static routes exist at this // level. This happens in webpack dev mode where routes are compiled // on-demand. We can't safely match a dynamicChild because the URL part might // be a static sibling we haven't discovered yet. Example: We know // /blog/[slug] exists, but haven't compiled /blog/featured. A request for // /blog/featured would incorrectly match /blog/[slug]. if (part.staticChildren === null) { // The only safe match is a direct pattern when no URL parts remain. if (urlPart === null) { const pattern = part.pattern; if (pattern !== null && !pattern.hasDynamicRewrite) { return { part, pattern }; } } return null; } // Static children take priority over dynamic. This ensures /blog/featured // matches its own route rather than /blog/[slug]. if (urlPart !== null) { const staticChild = part.staticChildren.get(urlPart); if (staticChild !== undefined) { // Check if this is an "unknown" placeholder part. These are created when // we learn about static siblings (from the route tree's staticSiblings // field) but haven't prefetched them yet. We know the path exists but // don't know its structure, so we can't predict it. if (staticChild.pattern === null && staticChild.dynamicChild === null && staticChild.staticChildren === null) { // Bail out - server must resolve this route. return null; } const match = matchKnownRoutePart(staticChild, pathnameParts, partIndex + 1, resolvedParams); if (match !== null) { return match; } // Static child is a real node (not a placeholder) but its subtree // didn't match the remaining URL parts. This means the route exists // in the static subtree but hasn't been fully discovered yet. Do not // fall through to try the dynamic child — the static match is // authoritative. Bail out to server resolution. return null; } } // Try dynamic child if (part.dynamicChild !== null) { const dynamicPart = part.dynamicChild; const paramName = part.dynamicChildParamName; const paramType = part.dynamicChildParamType; const dynamicPattern = dynamicPart.pattern; switch(paramType){ case 'c': // Required catch-all [...param]: consumes 1+ URL parts if (dynamicPattern !== null && !dynamicPattern.hasDynamicRewrite && urlPart !== null) { resolvedParams.set(paramName, pathnameParts.slice(partIndex)); return { part: dynamicPart, pattern: dynamicPattern }; } break; case 'oc': // Optional catch-all [[...param]]: consumes 0+ URL parts if (dynamicPattern !== null && !dynamicPattern.hasDynamicRewrite) { if (urlPart !== null) { resolvedParams.set(paramName, pathnameParts.slice(partIndex)); return { part: dynamicPart, pattern: dynamicPattern }; } // urlPart is null - can match with zero parts, but a direct pattern // (e.g., page.tsx alongside [[...param]]) takes precedence. if (part.pattern === null || part.pattern.hasDynamicRewrite) { resolvedParams.set(paramName, []); return { part: dynamicPart, pattern: dynamicPattern }; } } break; case 'd': // Regular dynamic [param]: consumes exactly 1 URL part. // Unlike catch-all which terminates here, regular dynamic must // continue recursing to find the leaf pattern. if (urlPart !== null) { resolvedParams.set(paramName, urlPart); return matchKnownRoutePart(dynamicPart, pathnameParts, partIndex + 1, resolvedParams); } break; // Intercepted routes use relative path markers like (.), (..), (...) // Their behavior depends on navigation context (soft vs hard nav), // so we can't predict them client-side. Defer to server. case 'ci(..)(..)': case 'ci(.)': case 'ci(..)': case 'ci(...)': case 'di(..)(..)': case 'di(.)': case 'di(..)': case 'di(...)': return null; default: paramType; } } // No children matched. If we've consumed all URL parts, check for a direct // pattern at this node (the route terminates here). if (urlPart === null) { const pattern = part.pattern; if (pattern !== null && !pattern.hasDynamicRewrite) { return { part, pattern }; } } return null; } /** * "Reify" means to make concrete - we take an abstract pattern (the template * route tree) and produce a concrete instance with actual param values. * * This function clones a RouteTree, substituting dynamic segment values from * resolvedParams and computing new vary paths. The vary path encodes param * values so segment cache entries can be correctly keyed. * * Example: Pattern for /blog/[slug] with resolvedParams { slug: "hello" } * produces a tree where segment [slug] has cacheKey "hello". */ function reifyRouteTree(pattern, resolvedParams, search, parentPartialVaryPath, acc) { const originalSegment = pattern.segment; let newSegment = originalSegment; let partialVaryPath; if (typeof originalSegment !== 'string') { // Dynamic segment: compute new cache key and append to partial vary path const paramName = originalSegment[0]; const paramType = originalSegment[2]; const staticSiblings = originalSegment[3]; const newValue = resolvedParams.get(paramName); if (newValue !== undefined) { const newCacheKey = Array.isArray(newValue) ? newValue.join('/') : newValue; newSegment = [ paramName, newCacheKey, paramType, staticSiblings ]; partialVaryPath = (0, _varypath.appendLayoutVaryPath)(parentPartialVaryPath, newCacheKey, paramName); } else { // Param not found in resolvedParams - keep original and inherit partial // TODO: This should never happen. Bail out with null. partialVaryPath = parentPartialVaryPath; } } else { // Static segment: inherit partial vary path from parent partialVaryPath = parentPartialVaryPath; } // Recurse into children with the (possibly updated) partial vary path let newSlots = null; if (pattern.slots !== null) { newSlots = {}; for(const key in pattern.slots){ newSlots[key] = reifyRouteTree(pattern.slots[key], resolvedParams, search, partialVaryPath, acc); } } if (pattern.isPage) { // Page segment: finalize with search params const newVaryPath = (0, _varypath.finalizePageVaryPath)(pattern.requestKey, search, partialVaryPath); // Collect metadata vary path (first page wins, same as original algorithm) if (acc.metadataVaryPath === null) { acc.metadataVaryPath = (0, _varypath.finalizeMetadataVaryPath)(pattern.requestKey, search, partialVaryPath); } return { requestKey: pattern.requestKey, segment: newSegment, refreshState: pattern.refreshState, slots: newSlots, prefetchHints: pattern.prefetchHints, isPage: true, varyPath: newVaryPath }; } else { // Layout segment: finalize without search params const newVaryPath = (0, _varypath.finalizeLayoutVaryPath)(pattern.requestKey, partialVaryPath); return { requestKey: pattern.requestKey, segment: newSegment, refreshState: pattern.refreshState, slots: newSlots, prefetchHints: pattern.prefetchHints, isPage: false, varyPath: newVaryPath }; } } function resetKnownRoutes() { knownRouteTreeRoot = createEmptyPart(); } if ((typeof exports.default === 'function' || (typeof exports.default === 'object' && exports.default !== null)) && typeof exports.default.__esModule === 'undefined') { Object.defineProperty(exports.default, '__esModule', { value: true }); Object.assign(exports.default, exports); module.exports = exports.default; } //# sourceMappingURL=optimistic-routes.js.map