.
This commit is contained in:
+88
@@ -0,0 +1,88 @@
|
||||
import { DYNAMIC_STALETIME_MS } from '../router-reducer/reducers/navigate-reducer';
|
||||
/**
|
||||
* Sentinel value indicating that no per-page dynamic stale time was provided.
|
||||
* When this is the dynamicStaleTime, the default DYNAMIC_STALETIME_MS is used.
|
||||
*/ export const UnknownDynamicStaleTime = -1;
|
||||
/**
|
||||
* Converts a dynamic stale time (in seconds, as sent by the server in the `d`
|
||||
* field of the Flight response) to an absolute staleAt timestamp. When the
|
||||
* value is unknown, falls back to the global DYNAMIC_STALETIME_MS.
|
||||
*/ export function computeDynamicStaleAt(now, dynamicStaleTimeSeconds) {
|
||||
return dynamicStaleTimeSeconds !== UnknownDynamicStaleTime ? now + dynamicStaleTimeSeconds * 1000 : now + DYNAMIC_STALETIME_MS;
|
||||
}
|
||||
import { setInCacheMap, getFromCacheMap, createCacheMap } from './cache-map';
|
||||
const bfcacheMap = createCacheMap();
|
||||
let currentBfCacheVersion = 0;
|
||||
export function invalidateBfCache() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
currentBfCacheVersion++;
|
||||
}
|
||||
export function writeToBFCache(now, varyPath, rsc, prefetchRsc, head, prefetchHead, dynamicStaleAt) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const entry = {
|
||||
rsc,
|
||||
prefetchRsc,
|
||||
// TODO: These fields will be removed from both BFCacheEntry and
|
||||
// SegmentCacheEntry. The head has its own separate cache entry.
|
||||
head,
|
||||
prefetchHead,
|
||||
ref: null,
|
||||
// TODO: This is just a heuristic. Getting the actual size of the segment
|
||||
// isn't feasible because it's part of a larger streaming response. The
|
||||
// LRU will still evict it, we just won't have a fully accurate total
|
||||
// LRU size. However, we'll probably remove the size tracking from the LRU
|
||||
// entirely and use memory pressure events instead.
|
||||
size: 100,
|
||||
navigatedAt: now,
|
||||
// A back/forward navigation will disregard the stale time. This field is
|
||||
// only relevant when staleTimes.dynamic is enabled or unstable_dynamicStaleTime
|
||||
// is exported by a page.
|
||||
staleAt: dynamicStaleAt,
|
||||
version: currentBfCacheVersion
|
||||
};
|
||||
const isRevalidation = false;
|
||||
setInCacheMap(bfcacheMap, varyPath, entry, isRevalidation);
|
||||
}
|
||||
export function writeHeadToBFCache(now, varyPath, head, prefetchHead, dynamicStaleAt) {
|
||||
// Read the special "segment" that represents the head data.
|
||||
writeToBFCache(now, varyPath, head, prefetchHead, null, null, dynamicStaleAt);
|
||||
}
|
||||
/**
|
||||
* Update the staleAt of an existing BFCache entry. Used after a dynamic
|
||||
* response arrives with a per-page stale time from `unstable_dynamicStaleTime`.
|
||||
* The per-page value is authoritative — it overrides whatever staleAt was set
|
||||
* by the default DYNAMIC_STALETIME_MS.
|
||||
*/ export function updateBFCacheEntryStaleAt(varyPath, newStaleAt) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const isRevalidation = false;
|
||||
// Read with staleness bypass (-1) so we can update even stale entries
|
||||
const entry = getFromCacheMap(-1, currentBfCacheVersion, bfcacheMap, varyPath, isRevalidation);
|
||||
if (entry !== null) {
|
||||
entry.staleAt = newStaleAt;
|
||||
}
|
||||
}
|
||||
export function readFromBFCache(varyPath) {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const isRevalidation = false;
|
||||
return getFromCacheMap(// During a back/forward navigation, it doesn't matter how stale the data
|
||||
// might be. Pass -1 instead of the actual current time to bypass
|
||||
// staleness checks.
|
||||
-1, currentBfCacheVersion, bfcacheMap, varyPath, isRevalidation);
|
||||
}
|
||||
export function readFromBFCacheDuringRegularNavigation(now, varyPath) {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const isRevalidation = false;
|
||||
return getFromCacheMap(now, currentBfCacheVersion, bfcacheMap, varyPath, isRevalidation);
|
||||
}
|
||||
|
||||
//# sourceMappingURL=bfcache.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+12
@@ -0,0 +1,12 @@
|
||||
// TypeScript trick to simulate opaque types, like in Flow.
|
||||
export function createCacheKey(originalHref, nextUrl) {
|
||||
const originalUrl = new URL(originalHref);
|
||||
const cacheKey = {
|
||||
pathname: originalUrl.pathname,
|
||||
search: originalUrl.search,
|
||||
nextUrl: nextUrl
|
||||
};
|
||||
return cacheKey;
|
||||
}
|
||||
|
||||
//# sourceMappingURL=cache-key.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../../../src/client/components/segment-cache/cache-key.ts"],"sourcesContent":["// TypeScript trick to simulate opaque types, like in Flow.\ntype Opaque<K, T> = T & { __brand: K }\n\n// Only functions in this module should be allowed to create CacheKeys.\nexport type NormalizedPathname = Opaque<'NormalizedPathname', string>\nexport type NormalizedSearch = Opaque<'NormalizedSearch', string>\nexport type NormalizedNextUrl = Opaque<'NormalizedNextUrl', string>\n\nexport type RouteCacheKey = Opaque<\n 'RouteCacheKey',\n {\n pathname: NormalizedPathname\n search: NormalizedSearch\n nextUrl: NormalizedNextUrl | null\n\n // TODO: Eventually the dynamic params will be added here, too.\n }\n>\n\nexport function createCacheKey(\n originalHref: string,\n nextUrl: string | null\n): RouteCacheKey {\n const originalUrl = new URL(originalHref)\n const cacheKey = {\n pathname: originalUrl.pathname as NormalizedPathname,\n search: originalUrl.search as NormalizedSearch,\n nextUrl: nextUrl as NormalizedNextUrl | null,\n } as RouteCacheKey\n return cacheKey\n}\n"],"names":["createCacheKey","originalHref","nextUrl","originalUrl","URL","cacheKey","pathname","search"],"mappings":"AAAA,2DAA2D;AAmB3D,OAAO,SAASA,eACdC,YAAoB,EACpBC,OAAsB;IAEtB,MAAMC,cAAc,IAAIC,IAAIH;IAC5B,MAAMI,WAAW;QACfC,UAAUH,YAAYG,QAAQ;QAC9BC,QAAQJ,YAAYI,MAAM;QAC1BL,SAASA;IACX;IACA,OAAOG;AACT","ignoreList":[0]}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import { lruPut, updateLruSize, deleteFromLru } from './lru';
|
||||
export const Fallback = {};
|
||||
// This is a special internal key that is used for "revalidation" entries. It's
|
||||
// an implementation detail that shouldn't leak outside of this module.
|
||||
const Revalidation = {};
|
||||
export function createCacheMap() {
|
||||
const cacheMap = {
|
||||
parent: null,
|
||||
key: null,
|
||||
value: null,
|
||||
map: null,
|
||||
// LRU-related fields
|
||||
prev: null,
|
||||
next: null,
|
||||
size: 0
|
||||
};
|
||||
return cacheMap;
|
||||
}
|
||||
function getOrInitialize(cacheMap, keys, isRevalidation) {
|
||||
// Go through each level of keys until we find the entry that matches, or
|
||||
// create a new entry if one doesn't exist.
|
||||
//
|
||||
// This function will only return entries that match the keypath _exactly_.
|
||||
// Unlike getWithFallback, it will not access fallback entries unless it's
|
||||
// explicitly part of the keypath.
|
||||
let entry = cacheMap;
|
||||
let remainingKeys = keys;
|
||||
let key = null;
|
||||
while(true){
|
||||
const previousKey = key;
|
||||
if (remainingKeys !== null) {
|
||||
key = remainingKeys.value;
|
||||
remainingKeys = remainingKeys.parent;
|
||||
} else if (isRevalidation && previousKey !== Revalidation) {
|
||||
// During a revalidation, we append an internal "Revalidation" key to
|
||||
// the end of the keypath. The "normal" entry is its parent.
|
||||
// However, if the parent entry is currently empty, we don't need to store
|
||||
// this as a revalidation entry. Just insert the revalidation into the
|
||||
// normal slot.
|
||||
if (entry.value === null) {
|
||||
return entry;
|
||||
}
|
||||
// Otheriwse, create a child entry.
|
||||
key = Revalidation;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
let map = entry.map;
|
||||
if (map !== null) {
|
||||
const existingEntry = map.get(key);
|
||||
if (existingEntry !== undefined) {
|
||||
// Found a match. Keep going.
|
||||
entry = existingEntry;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
map = new Map();
|
||||
entry.map = map;
|
||||
}
|
||||
// No entry exists yet at this level. Create a new one.
|
||||
const newEntry = {
|
||||
parent: entry,
|
||||
key,
|
||||
value: null,
|
||||
map: null,
|
||||
// LRU-related fields
|
||||
prev: null,
|
||||
next: null,
|
||||
size: 0
|
||||
};
|
||||
map.set(key, newEntry);
|
||||
entry = newEntry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
export function getFromCacheMap(now, currentCacheVersion, rootEntry, keys, isRevalidation) {
|
||||
const entry = getEntryWithFallbackImpl(now, currentCacheVersion, rootEntry, keys, isRevalidation, 0);
|
||||
if (entry === null || entry.value === null) {
|
||||
return null;
|
||||
}
|
||||
// This is an LRU access. Move the entry to the front of the list.
|
||||
lruPut(entry);
|
||||
return entry.value;
|
||||
}
|
||||
export function isValueExpired(now, currentCacheVersion, value) {
|
||||
return value.staleAt <= now || value.version < currentCacheVersion;
|
||||
}
|
||||
function lazilyEvictIfNeeded(now, currentCacheVersion, entry) {
|
||||
// We have a matching entry, but before we can return it, we need to check if
|
||||
// it's still fresh. Otherwise it should be treated the same as a cache miss.
|
||||
if (entry.value === null) {
|
||||
// This entry has no value, so there's nothing to evict.
|
||||
return entry;
|
||||
}
|
||||
const value = entry.value;
|
||||
if (isValueExpired(now, currentCacheVersion, value)) {
|
||||
// The value expired. Lazily evict it from the cache, and return null. This
|
||||
// is conceptually the same as a cache miss.
|
||||
deleteMapEntry(entry);
|
||||
return null;
|
||||
}
|
||||
// The matched entry has not expired. Return it.
|
||||
return entry;
|
||||
}
|
||||
function getEntryWithFallbackImpl(now, currentCacheVersion, entry, keys, isRevalidation, previousKey) {
|
||||
// This is similar to getExactEntry, but if an exact match is not found for
|
||||
// a key, it will return the fallback entry instead. This is recursive at
|
||||
// every level, e.g. an entry with keypath [a, Fallback, c, Fallback] is
|
||||
// valid match for [a, b, c, d].
|
||||
//
|
||||
// It will return the most specific match available.
|
||||
let key;
|
||||
let remainingKeys;
|
||||
if (keys !== null) {
|
||||
key = keys.value;
|
||||
remainingKeys = keys.parent;
|
||||
} else if (isRevalidation && previousKey !== Revalidation) {
|
||||
// During a revalidation, we append an internal "Revalidation" key to
|
||||
// the end of the keypath.
|
||||
key = Revalidation;
|
||||
remainingKeys = null;
|
||||
} else {
|
||||
// There are no more keys. This is the terminal entry.
|
||||
// TODO: When performing a lookup during a navigation, as opposed to a
|
||||
// prefetch, we may want to skip entries that are Pending if there's also
|
||||
// a Fulfilled fallback entry. Tricky to say, though, since if it's
|
||||
// already pending, it's likely to stream in soon. Maybe we could do this
|
||||
// just on slow connections and offline mode.
|
||||
return lazilyEvictIfNeeded(now, currentCacheVersion, entry);
|
||||
}
|
||||
const map = entry.map;
|
||||
if (map !== null) {
|
||||
const existingEntry = map.get(key);
|
||||
if (existingEntry !== undefined) {
|
||||
// Found an exact match for this key. Keep searching.
|
||||
const result = getEntryWithFallbackImpl(now, currentCacheVersion, existingEntry, remainingKeys, isRevalidation, key);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// No match found for this key. Check if there's a fallback.
|
||||
const fallbackEntry = map.get(Fallback);
|
||||
if (fallbackEntry !== undefined) {
|
||||
// Found a fallback for this key. Keep searching.
|
||||
return getEntryWithFallbackImpl(now, currentCacheVersion, fallbackEntry, remainingKeys, isRevalidation, key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function setInCacheMap(cacheMap, keys, value, isRevalidation) {
|
||||
// Add a value to the map at the given keypath. If the value is already
|
||||
// part of the map, it's removed from its previous keypath. (NOTE: This is
|
||||
// unlike a regular JS map, but the behavior is intentional.)
|
||||
const entry = getOrInitialize(cacheMap, keys, isRevalidation);
|
||||
setMapEntryValue(entry, value);
|
||||
// This is an LRU access. Move the entry to the front of the list.
|
||||
lruPut(entry);
|
||||
updateLruSize(entry, value.size);
|
||||
}
|
||||
function setMapEntryValue(entry, value) {
|
||||
if (entry.value !== null) {
|
||||
// There's already a value at the given keypath. Disconnect the old value
|
||||
// from the map. We're not calling `deleteMapEntry` here because the
|
||||
// entry itself is still in the map. We just want to overwrite its value.
|
||||
dropRef(entry.value);
|
||||
entry.value = null;
|
||||
}
|
||||
// This value may already be in the map at a different keypath.
|
||||
// Grab a reference before we overwrite it.
|
||||
const oldEntry = value.ref;
|
||||
entry.value = value;
|
||||
value.ref = entry;
|
||||
updateLruSize(entry, value.size);
|
||||
if (oldEntry !== null && oldEntry !== entry && oldEntry.value === value) {
|
||||
// This value is already in the map at a different keypath in the map.
|
||||
// Values only exist at a single keypath at a time. Remove it from the
|
||||
// previous keypath.
|
||||
//
|
||||
// Note that only the internal map entry is garbage collected; we don't
|
||||
// call `dropRef` here because it's still in the map, just
|
||||
// at a new keypath (the one we just set, above).
|
||||
deleteMapEntry(oldEntry);
|
||||
}
|
||||
}
|
||||
export function deleteFromCacheMap(value) {
|
||||
const entry = value.ref;
|
||||
if (entry === null) {
|
||||
// This value is not a member of any map.
|
||||
return;
|
||||
}
|
||||
dropRef(value);
|
||||
deleteMapEntry(entry);
|
||||
}
|
||||
function dropRef(value) {
|
||||
// Drop the value from the map by setting its `ref` backpointer to
|
||||
// null. This is a separate operation from `deleteMapEntry` because when
|
||||
// re-keying a value we need to be able to delete the old, internal map
|
||||
// entry without garbage collecting the value itself.
|
||||
value.ref = null;
|
||||
}
|
||||
export function deleteMapEntry(entry) {
|
||||
// Delete the entry from the cache.
|
||||
entry.value = null;
|
||||
deleteFromLru(entry);
|
||||
// Check if we can garbage collect the entry.
|
||||
const map = entry.map;
|
||||
if (map === null) {
|
||||
// Since this entry has no value, and also no child entries, we can
|
||||
// garbage collect it. Remove it from its parent, and keep garbage
|
||||
// collecting the parents until we reach a non-empty entry.
|
||||
let parent = entry.parent;
|
||||
let key = entry.key;
|
||||
while(parent !== null){
|
||||
const parentMap = parent.map;
|
||||
if (parentMap !== null) {
|
||||
parentMap.delete(key);
|
||||
if (parentMap.size === 0) {
|
||||
// We just removed the last entry in the parent map.
|
||||
parent.map = null;
|
||||
if (parent.value === null) {
|
||||
// The parent node has no child entries, nor does it have a value
|
||||
// on itself. It can be garbage collected. Keep going.
|
||||
key = parent.key;
|
||||
parent = parent.parent;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Check if there's a revalidating entry. If so, promote it to a
|
||||
// "normal" entry, since the normal one was just deleted.
|
||||
const revalidatingEntry = map.get(Revalidation);
|
||||
if (revalidatingEntry !== undefined && revalidatingEntry.value !== null) {
|
||||
setMapEntryValue(entry, revalidatingEntry.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
export function setSizeInCacheMap(value, size) {
|
||||
const entry = value.ref;
|
||||
if (entry === null) {
|
||||
// This value is not a member of any map.
|
||||
return;
|
||||
}
|
||||
// Except during initialization (when the size is set to 0), this is the only
|
||||
// place the `size` field should be updated, to ensure it's in sync with the
|
||||
// the LRU.
|
||||
value.size = size;
|
||||
updateLruSize(entry, size);
|
||||
}
|
||||
|
||||
//# sourceMappingURL=cache-map.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+1867
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+116
@@ -0,0 +1,116 @@
|
||||
import { deleteMapEntry } from './cache-map';
|
||||
import { pingPrefetchScheduler } from './scheduler';
|
||||
// We use an LRU for memory management. We must update this whenever we add or
|
||||
// remove a new cache entry, or when an entry changes size.
|
||||
let head = null;
|
||||
let lruSize = 0;
|
||||
// TODO: I chose the max size somewhat arbitrarily. Consider setting this based
|
||||
// on navigator.deviceMemory, or some other heuristic. We should make this
|
||||
// customizable via the Next.js config, too.
|
||||
const maxLruSize = 50 * 1024 * 1024 // 50 MB
|
||||
;
|
||||
export function lruPut(node) {
|
||||
if (head === node) {
|
||||
// Already at the head
|
||||
return;
|
||||
}
|
||||
const prev = node.prev;
|
||||
const next = node.next;
|
||||
if (next === null || prev === null) {
|
||||
// This is an insertion
|
||||
lruSize += node.size;
|
||||
// Whenever we add an entry, we need to check if we've exceeded the
|
||||
// max size. We don't evict entries immediately; they're evicted later in
|
||||
// an asynchronous task.
|
||||
ensureCleanupIsScheduled();
|
||||
} else {
|
||||
// This is a move. Remove from its current position.
|
||||
prev.next = next;
|
||||
next.prev = prev;
|
||||
}
|
||||
// Move to the front of the list
|
||||
if (head === null) {
|
||||
// This is the first entry
|
||||
node.prev = node;
|
||||
node.next = node;
|
||||
} else {
|
||||
// Add to the front of the list
|
||||
const tail = head.prev;
|
||||
node.prev = tail;
|
||||
// In practice, this is never null, but that isn't encoded in the type
|
||||
if (tail !== null) {
|
||||
tail.next = node;
|
||||
}
|
||||
node.next = head;
|
||||
head.prev = node;
|
||||
}
|
||||
head = node;
|
||||
}
|
||||
export function updateLruSize(node, newNodeSize) {
|
||||
// This is a separate function from `put` so that we can resize the entry
|
||||
// regardless of whether it's currently being tracked by the LRU.
|
||||
const prevNodeSize = node.size;
|
||||
node.size = newNodeSize;
|
||||
if (node.next === null) {
|
||||
// This entry is not currently being tracked by the LRU.
|
||||
return;
|
||||
}
|
||||
// Update the total LRU size
|
||||
lruSize = lruSize - prevNodeSize + newNodeSize;
|
||||
ensureCleanupIsScheduled();
|
||||
}
|
||||
export function deleteFromLru(deleted) {
|
||||
const next = deleted.next;
|
||||
const prev = deleted.prev;
|
||||
if (next !== null && prev !== null) {
|
||||
lruSize -= deleted.size;
|
||||
deleted.next = null;
|
||||
deleted.prev = null;
|
||||
// Remove from the list
|
||||
if (head === deleted) {
|
||||
// Update the head
|
||||
if (next === head) {
|
||||
// This was the last entry
|
||||
head = null;
|
||||
} else {
|
||||
head = next;
|
||||
prev.next = next;
|
||||
next.prev = prev;
|
||||
}
|
||||
} else {
|
||||
prev.next = next;
|
||||
next.prev = prev;
|
||||
}
|
||||
} else {
|
||||
// Already deleted
|
||||
}
|
||||
}
|
||||
function ensureCleanupIsScheduled() {
|
||||
if (lruSize <= maxLruSize) {
|
||||
return;
|
||||
}
|
||||
// To schedule cleanup, ping the prefetch scheduler. At the end of its work
|
||||
// loop, once there are no queued tasks and no in-progress requests, it will
|
||||
// call cleanup().
|
||||
pingPrefetchScheduler();
|
||||
}
|
||||
export function cleanup() {
|
||||
if (lruSize <= maxLruSize) {
|
||||
return;
|
||||
}
|
||||
// Evict entries until we're at 90% capacity. We can assume this won't
|
||||
// infinite loop because even if `maxLruSize` were 0, eventually
|
||||
// `deleteFromLru` sets `head` to `null` when we run out entries.
|
||||
const ninetyPercentMax = maxLruSize * 0.9;
|
||||
while(lruSize > ninetyPercentMax && head !== null){
|
||||
const tail = head.prev;
|
||||
// In practice, this is never null, but that isn't encoded in the type
|
||||
if (tail !== null) {
|
||||
// Delete the entry from the map. In turn, this will remove it from
|
||||
// the LRU.
|
||||
deleteMapEntry(tail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//# sourceMappingURL=lru.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Generated
Vendored
+173
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Navigation lock for the Instant Navigation Testing API.
|
||||
*
|
||||
* Manages the in-memory lock (a promise) that gates dynamic data writes
|
||||
* during instant navigation captures, and owns all cookie state
|
||||
* transitions (pending → captured-MPA, pending → captured-SPA).
|
||||
*
|
||||
* External actors (Playwright, devtools) set [0] to start a lock scope
|
||||
* and delete the cookie to end one. Next.js writes captured values.
|
||||
* The CookieStore handler distinguishes them by value: pending = external,
|
||||
* captured = self-write (ignored).
|
||||
*/ import { NEXT_INSTANT_TEST_COOKIE } from '../app-router-headers';
|
||||
import { refreshOnInstantNavigationUnlock } from '../use-action-queue';
|
||||
function parseCookieValue(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
const rawState = parsed[2];
|
||||
return rawState === null ? 'mpa' : 'spa';
|
||||
}
|
||||
} catch {}
|
||||
return 'pending';
|
||||
}
|
||||
function writeCookieValue(value) {
|
||||
if (typeof cookieStore === 'undefined') {
|
||||
return;
|
||||
}
|
||||
// Read the existing cookie to preserve its attributes (domain, path),
|
||||
// then write back with the new value. This updates the same cookie
|
||||
// entry that the external actor created, regardless of how it was
|
||||
// scoped.
|
||||
cookieStore.get(NEXT_INSTANT_TEST_COOKIE).then((existing)=>{
|
||||
if (existing) {
|
||||
const options = {
|
||||
name: NEXT_INSTANT_TEST_COOKIE,
|
||||
value: JSON.stringify(value),
|
||||
path: existing.path ?? '/'
|
||||
};
|
||||
if (existing.domain) {
|
||||
options.domain = existing.domain;
|
||||
}
|
||||
cookieStore.set(options);
|
||||
}
|
||||
});
|
||||
}
|
||||
let lockState = null;
|
||||
function acquireLock() {
|
||||
if (lockState !== null) {
|
||||
return;
|
||||
}
|
||||
let resolve;
|
||||
const promise = new Promise((r)=>{
|
||||
resolve = r;
|
||||
});
|
||||
lockState = {
|
||||
promise,
|
||||
resolve: resolve
|
||||
};
|
||||
}
|
||||
function releaseLock() {
|
||||
if (lockState !== null) {
|
||||
lockState.resolve();
|
||||
lockState = null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Sets up the cookie-based lock. Handles the initial page load state and
|
||||
* registers a CookieStore listener for runtime changes.
|
||||
*
|
||||
* Called once during page initialization from app-globals.ts.
|
||||
*/ export function startListeningForInstantNavigationCookie() {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
// If the server served a static shell, this is an MPA page load
|
||||
// while the lock is held. Transition to captured-MPA and acquire.
|
||||
if (self.__next_instant_test) {
|
||||
if (typeof cookieStore !== 'undefined') {
|
||||
// If the cookie was already cleared during the MPA page
|
||||
// transition, reload to get the full dynamic page.
|
||||
cookieStore.get(NEXT_INSTANT_TEST_COOKIE).then((cookie)=>{
|
||||
if (!cookie) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
writeCookieValue([
|
||||
1,
|
||||
`c${Math.random()}`,
|
||||
null
|
||||
]);
|
||||
acquireLock();
|
||||
}
|
||||
if (typeof cookieStore === 'undefined') {
|
||||
return;
|
||||
}
|
||||
cookieStore.addEventListener('change', (event)=>{
|
||||
for (const cookie of event.changed){
|
||||
if (cookie.name === NEXT_INSTANT_TEST_COOKIE) {
|
||||
const state = parseCookieValue(cookie.value ?? '');
|
||||
if (state !== 'pending') {
|
||||
// Captured value — our own transition. Ignore.
|
||||
return;
|
||||
}
|
||||
// Pending value — external actor starting a new lock scope.
|
||||
if (lockState !== null) {
|
||||
releaseLock();
|
||||
}
|
||||
acquireLock();
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const cookie of event.deleted){
|
||||
if (cookie.name === NEXT_INSTANT_TEST_COOKIE) {
|
||||
releaseLock();
|
||||
refreshOnInstantNavigationUnlock();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Transitions the cookie from pending to captured-SPA. Called when a
|
||||
* client-side navigation is captured by the lock.
|
||||
*
|
||||
* @param fromTree - The flight router state of the from-route
|
||||
* @param toTree - The flight router state of the to-route (null if not yet known)
|
||||
*/ export function transitionToCapturedSPA(fromTree, toTree) {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
writeCookieValue([
|
||||
1,
|
||||
`c${Math.random()}`,
|
||||
{
|
||||
from: fromTree,
|
||||
to: toTree
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Updates the captured-SPA cookie with the resolved route trees.
|
||||
* Called after the prefetch resolves and the target route tree is known.
|
||||
*/ export function updateCapturedSPAToTree(fromTree, toTree) {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
writeCookieValue([
|
||||
1,
|
||||
`c${Math.random()}`,
|
||||
{
|
||||
from: fromTree,
|
||||
to: toTree
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns true if the navigation lock is currently active.
|
||||
*/ export function isNavigationLocked() {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
return lockState !== null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Waits for the navigation lock to be released, if it's currently held.
|
||||
* No-op if the lock is not acquired.
|
||||
*/ export async function waitForNavigationLockIfActive() {
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
if (lockState !== null) {
|
||||
await lockState.promise;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//# sourceMappingURL=navigation-testing-lock.js.map
|
||||
Generated
Vendored
+1
File diff suppressed because one or more lines are too long
+560
@@ -0,0 +1,560 @@
|
||||
import { fetchServerResponse } from '../router-reducer/fetch-server-response';
|
||||
import { startPPRNavigation, spawnDynamicRequests, FreshnessPolicy } from '../router-reducer/ppr-navigations';
|
||||
import { createHrefFromUrl } from '../router-reducer/create-href-from-url';
|
||||
import { NEXT_NAV_DEPLOYMENT_ID_HEADER } from '../../../lib/constants';
|
||||
import { EntryStatus, readRouteCacheEntry, deprecated_requestOptimisticRouteCacheEntry, convertRootFlightRouterStateToRouteTree, getStaleAt, writeStaticStageResponseIntoCache, processRuntimePrefetchStream, writeDynamicRenderResponseIntoCache } from './cache';
|
||||
import { discoverKnownRoute } from './optimistic-routes';
|
||||
import { createCacheKey } from './cache-key';
|
||||
import { schedulePrefetchTask } from './scheduler';
|
||||
import { PrefetchPriority, FetchStrategy } from './types';
|
||||
import { getLinkForCurrentNavigation } from '../links';
|
||||
import { ScrollBehavior } from '../router-reducer/router-reducer-types';
|
||||
import { computeChangedPath } from '../router-reducer/compute-changed-path';
|
||||
import { isJavaScriptURLString } from '../../lib/javascript-url';
|
||||
import { UnknownDynamicStaleTime, computeDynamicStaleAt } from './bfcache';
|
||||
/**
|
||||
* Navigate to a new URL, using the Segment Cache to construct a response.
|
||||
*
|
||||
* To allow for synchronous navigations whenever possible, this is not an async
|
||||
* function. It returns a promise only if there's no matching prefetch in
|
||||
* the cache. Otherwise it returns an immediate result and uses Suspense/RSC to
|
||||
* stream in any missing data.
|
||||
*/ export function navigate(state, url, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, nextUrl, freshnessPolicy, scrollBehavior, navigateType) {
|
||||
// Instant Navigation Testing API: when the lock is active, ensure a
|
||||
// prefetch task has been initiated before proceeding with the navigation.
|
||||
// This guarantees that segment data requests are at least pending, even
|
||||
// for routes that already have a cached route tree. Without this, the
|
||||
// static shell might be incomplete because some segments were never
|
||||
// requested.
|
||||
if (process.env.__NEXT_EXPOSE_TESTING_API) {
|
||||
const { isNavigationLocked } = require('./navigation-testing-lock');
|
||||
if (isNavigationLocked()) {
|
||||
return ensurePrefetchThenNavigate(state, url, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, nextUrl, freshnessPolicy, scrollBehavior, navigateType);
|
||||
}
|
||||
}
|
||||
return navigateImpl(state, url, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, nextUrl, freshnessPolicy, scrollBehavior, navigateType);
|
||||
}
|
||||
function navigateImpl(state, url, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, nextUrl, freshnessPolicy, scrollBehavior, navigateType) {
|
||||
const now = Date.now();
|
||||
const href = url.href;
|
||||
const cacheKey = createCacheKey(href, nextUrl);
|
||||
const route = readRouteCacheEntry(now, cacheKey);
|
||||
if (route !== null && route.status === EntryStatus.Fulfilled) {
|
||||
// We have a matching prefetch.
|
||||
return navigateUsingPrefetchedRouteTree(now, state, url, currentUrl, currentRenderedSearch, nextUrl, currentCacheNode, currentFlightRouterState, freshnessPolicy, scrollBehavior, navigateType, route);
|
||||
}
|
||||
// There was no matching route tree in the cache. Let's see if we can
|
||||
// construct an "optimistic" route tree using the deprecated search-params
|
||||
// based matching. This is only used when the new optimisticRouting flag is
|
||||
// disabled.
|
||||
//
|
||||
// Do not construct an optimistic route tree if there was a cache hit, but
|
||||
// the entry has a rejected status, since it may have been rejected due to a
|
||||
// rewrite or redirect based on the search params.
|
||||
//
|
||||
// TODO: There are multiple reasons a prefetch might be rejected; we should
|
||||
// track them explicitly and choose what to do here based on that.
|
||||
if (!process.env.__NEXT_OPTIMISTIC_ROUTING) {
|
||||
if (route === null || route.status !== EntryStatus.Rejected) {
|
||||
const optimisticRoute = deprecated_requestOptimisticRouteCacheEntry(now, url, nextUrl);
|
||||
if (optimisticRoute !== null) {
|
||||
// We have an optimistic route tree. Proceed with the normal flow.
|
||||
return navigateUsingPrefetchedRouteTree(now, state, url, currentUrl, currentRenderedSearch, nextUrl, currentCacheNode, currentFlightRouterState, freshnessPolicy, scrollBehavior, navigateType, optimisticRoute);
|
||||
}
|
||||
}
|
||||
}
|
||||
// There's no matching prefetch for this route in the cache. We must lazily
|
||||
// fetch it from the server before we can perform the navigation.
|
||||
//
|
||||
// TODO: If this is a gesture navigation, instead of performing a
|
||||
// dynamic request, we should do a runtime prefetch.
|
||||
return navigateToUnknownRoute(now, state, url, currentUrl, currentRenderedSearch, nextUrl, currentCacheNode, currentFlightRouterState, freshnessPolicy, scrollBehavior, navigateType).catch(()=>{
|
||||
// If the navigation fails, return the current state
|
||||
return state;
|
||||
});
|
||||
}
|
||||
export function navigateToKnownRoute(now, state, url, canonicalUrl, navigationSeed, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, freshnessPolicy, nextUrl, scrollBehavior, navigateType, debugInfo, // The route cache entry used for this navigation, if it came from route
|
||||
// prediction. Passed through so it can be marked as having a dynamic rewrite
|
||||
// if the server returns a different pathname (indicating dynamic rewrite
|
||||
// behavior).
|
||||
//
|
||||
// When null, the navigation did not use route prediction - either because
|
||||
// the route was already fully cached, or it's a navigation that doesn't
|
||||
// involve prediction (refresh, history traversal, server action, etc.).
|
||||
// In these cases, if a mismatch occurs, we still mark the route as having a
|
||||
// dynamic rewrite by traversing the known route tree (see
|
||||
// dispatchRetryDueToTreeMismatch).
|
||||
routeCacheEntry) {
|
||||
// A version of navigate() that accepts the target route tree as an argument
|
||||
// rather than reading it from the prefetch cache.
|
||||
const accumulation = {
|
||||
separateRefreshUrls: null,
|
||||
scrollRef: null
|
||||
};
|
||||
// We special case navigations to the exact same URL as the current location.
|
||||
// It's a common UI pattern for apps to refresh when you click a link to the
|
||||
// current page. So when this happens, we refresh the dynamic data in the page
|
||||
// segments.
|
||||
//
|
||||
// Note that this does not apply if the any part of the hash or search query
|
||||
// has changed. This might feel a bit weird but it makes more sense when you
|
||||
// consider that the way to trigger this behavior is to click the same link
|
||||
// multiple times.
|
||||
//
|
||||
// TODO: We should probably refresh the *entire* route when this case occurs,
|
||||
// not just the page segments. Essentially treating it the same as a refresh()
|
||||
// triggered by an action, which is the more explicit way of modeling the UI
|
||||
// pattern described above.
|
||||
//
|
||||
// Also note that this only refreshes the dynamic data, not static/ cached
|
||||
// data. If the page segment is fully static and prefetched, the request is
|
||||
// skipped. (This is also how refresh() works.)
|
||||
const isSamePageNavigation = url.href === currentUrl.href;
|
||||
const task = startPPRNavigation(now, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, navigationSeed.routeTree, navigationSeed.metadataVaryPath, freshnessPolicy, navigationSeed.data, navigationSeed.head, navigationSeed.dynamicStaleAt, isSamePageNavigation, accumulation);
|
||||
if (task !== null) {
|
||||
if (freshnessPolicy !== FreshnessPolicy.Gesture) {
|
||||
spawnDynamicRequests(task, url, nextUrl, freshnessPolicy, accumulation, routeCacheEntry, navigateType);
|
||||
}
|
||||
return completeSoftNavigation(state, url, nextUrl, task.route, task.node, navigationSeed.renderedSearch, canonicalUrl, navigateType, scrollBehavior, accumulation.scrollRef, debugInfo);
|
||||
}
|
||||
// Could not perform a SPA navigation. Revert to a full-page (MPA) navigation.
|
||||
return completeHardNavigation(state, url, navigateType);
|
||||
}
|
||||
function navigateUsingPrefetchedRouteTree(now, state, url, currentUrl, currentRenderedSearch, nextUrl, currentCacheNode, currentFlightRouterState, freshnessPolicy, scrollBehavior, navigateType, route) {
|
||||
const routeTree = route.tree;
|
||||
const canonicalUrl = route.canonicalUrl + url.hash;
|
||||
const renderedSearch = route.renderedSearch;
|
||||
const prefetchSeed = {
|
||||
renderedSearch,
|
||||
routeTree,
|
||||
metadataVaryPath: route.metadata.varyPath,
|
||||
data: null,
|
||||
head: null,
|
||||
dynamicStaleAt: computeDynamicStaleAt(now, UnknownDynamicStaleTime)
|
||||
};
|
||||
return navigateToKnownRoute(now, state, url, canonicalUrl, prefetchSeed, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, freshnessPolicy, nextUrl, scrollBehavior, navigateType, null, route);
|
||||
}
|
||||
// Used to request all the dynamic data for a route, rather than just a subset,
|
||||
// e.g. during a refresh or a revalidation. Typically this gets constructed
|
||||
// during the normal flow when diffing the route tree, but for an unprefetched
|
||||
// navigation, where we don't know the structure of the target route, we use
|
||||
// this instead.
|
||||
const DynamicRequestTreeForEntireRoute = [
|
||||
'',
|
||||
{},
|
||||
null,
|
||||
'refetch'
|
||||
];
|
||||
async function navigateToUnknownRoute(now, state, url, currentUrl, currentRenderedSearch, nextUrl, currentCacheNode, currentFlightRouterState, freshnessPolicy, scrollBehavior, navigateType) {
|
||||
// Runs when a navigation happens but there's no cached prefetch we can use.
|
||||
// Don't bother to wait for a prefetch response; go straight to a full
|
||||
// navigation that contains both static and dynamic data in a single stream.
|
||||
// (This is unlike the old navigation implementation, which instead blocks
|
||||
// the dynamic request until a prefetch request is received.)
|
||||
//
|
||||
// To avoid duplication of logic, we're going to pretend that the tree
|
||||
// returned by the dynamic request is, in fact, a prefetch tree. Then we can
|
||||
// use the same server response to write the actual data into the CacheNode
|
||||
// tree. So it's the same flow as the "happy path" (prefetch, then
|
||||
// navigation), except we use a single server response for both stages.
|
||||
let dynamicRequestTree;
|
||||
switch(freshnessPolicy){
|
||||
case FreshnessPolicy.Default:
|
||||
case FreshnessPolicy.HistoryTraversal:
|
||||
case FreshnessPolicy.Gesture:
|
||||
dynamicRequestTree = currentFlightRouterState;
|
||||
break;
|
||||
case FreshnessPolicy.Hydration:
|
||||
case FreshnessPolicy.RefreshAll:
|
||||
case FreshnessPolicy.HMRRefresh:
|
||||
dynamicRequestTree = DynamicRequestTreeForEntireRoute;
|
||||
break;
|
||||
default:
|
||||
freshnessPolicy;
|
||||
dynamicRequestTree = currentFlightRouterState;
|
||||
break;
|
||||
}
|
||||
const promiseForDynamicServerResponse = fetchServerResponse(url, {
|
||||
flightRouterState: dynamicRequestTree,
|
||||
nextUrl
|
||||
});
|
||||
const result = await promiseForDynamicServerResponse;
|
||||
if (typeof result === 'string') {
|
||||
// This is an MPA navigation.
|
||||
const redirectUrl = new URL(result, location.origin);
|
||||
return completeHardNavigation(state, redirectUrl, navigateType);
|
||||
}
|
||||
const { flightData, canonicalUrl, renderedSearch, couldBeIntercepted, supportsPerSegmentPrefetching, dynamicStaleTime, staticStageData, runtimePrefetchStream, responseHeaders, debugInfo } = result;
|
||||
// Since the response format of dynamic requests and prefetches is slightly
|
||||
// different, we'll need to massage the data a bit. Create FlightRouterState
|
||||
// tree that simulates what we'd receive as the result of a prefetch.
|
||||
const navigationSeed = convertServerPatchToFullTree(now, currentFlightRouterState, flightData, renderedSearch, dynamicStaleTime);
|
||||
// Learn the route pattern so we can predict it for future navigations.
|
||||
// hasDynamicRewrite is false because this is a fresh navigation to an
|
||||
// unknown route - any rewrite detection happens during the traversal inside
|
||||
// discoverKnownRoute. The hasDynamicRewrite param is only set to true when
|
||||
// retrying after a tree mismatch (see dispatchRetryDueToTreeMismatch).
|
||||
const metadataVaryPath = navigationSeed.metadataVaryPath;
|
||||
if (metadataVaryPath !== null) {
|
||||
discoverKnownRoute(now, url.pathname, nextUrl, null, navigationSeed.routeTree, metadataVaryPath, couldBeIntercepted, createHrefFromUrl(canonicalUrl), supportsPerSegmentPrefetching, false // hasDynamicRewrite - not a retry, rewrite detection happens during traversal
|
||||
);
|
||||
if (staticStageData !== null) {
|
||||
const { response: staticStageResponse, isResponsePartial } = staticStageData;
|
||||
// Write the static stage of the response into the segment cache so that
|
||||
// subsequent navigations can serve cached static segments instantly.
|
||||
getStaleAt(now, staticStageResponse.s).then((staleAt)=>{
|
||||
const buildId = responseHeaders.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ?? staticStageResponse.b;
|
||||
writeStaticStageResponseIntoCache(now, staticStageResponse.f, buildId, staticStageResponse.h, staleAt, currentFlightRouterState, renderedSearch, isResponsePartial);
|
||||
}).catch(()=>{
|
||||
// The static stage processing failed. Not fatal — the navigation
|
||||
// completed normally, we just won't write into the cache.
|
||||
});
|
||||
}
|
||||
if (runtimePrefetchStream !== null) {
|
||||
processRuntimePrefetchStream(now, runtimePrefetchStream, currentFlightRouterState, renderedSearch).then((processed)=>{
|
||||
if (processed !== null) {
|
||||
writeDynamicRenderResponseIntoCache(now, FetchStrategy.PPRRuntime, processed.flightDatas, processed.buildId, processed.isResponsePartial, processed.headVaryParams, processed.staleAt, processed.navigationSeed, null);
|
||||
}
|
||||
}).catch(()=>{
|
||||
// The runtime prefetch cache write failed. Not fatal — the
|
||||
// navigation completed normally, we just won't cache runtime data.
|
||||
});
|
||||
}
|
||||
}
|
||||
return navigateToKnownRoute(now, state, url, createHrefFromUrl(canonicalUrl), navigationSeed, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, freshnessPolicy, nextUrl, scrollBehavior, navigateType, debugInfo, // Unknown route navigations don't use route prediction - the route tree
|
||||
// came directly from the server. If a mismatch occurs during dynamic data
|
||||
// fetch, the retry handler will traverse the known route tree to mark the
|
||||
// entry as having a dynamic rewrite.
|
||||
null);
|
||||
}
|
||||
export function completeHardNavigation(state, url, navigateType) {
|
||||
if (isJavaScriptURLString(url.href)) {
|
||||
console.error('Next.js has blocked a javascript: URL as a security precaution.');
|
||||
return state;
|
||||
}
|
||||
const newState = {
|
||||
canonicalUrl: url.origin === location.origin ? createHrefFromUrl(url) : url.href,
|
||||
pushRef: {
|
||||
pendingPush: navigateType === 'push',
|
||||
mpaNavigation: true,
|
||||
preserveCustomHistoryState: false
|
||||
},
|
||||
// TODO: None of the rest of these values are consistent with the incoming
|
||||
// navigation. We rely on the fact that AppRouter will suspend and trigger
|
||||
// a hard navigation before it accesses any of these values. But instead
|
||||
// we should trigger the hard navigation and blocking any subsequent
|
||||
// router updates without updating React.
|
||||
renderedSearch: state.renderedSearch,
|
||||
focusAndScrollRef: state.focusAndScrollRef,
|
||||
cache: state.cache,
|
||||
tree: state.tree,
|
||||
nextUrl: state.nextUrl,
|
||||
previousNextUrl: state.previousNextUrl,
|
||||
debugInfo: null
|
||||
};
|
||||
return newState;
|
||||
}
|
||||
export function completeSoftNavigation(oldState, url, referringNextUrl, tree, cache, renderedSearch, canonicalUrl, navigateType, scrollBehavior, scrollRef, collectedDebugInfo) {
|
||||
// The "Next-Url" is a special representation of the URL that Next.js
|
||||
// uses to implement interception routes.
|
||||
// TODO: Get rid of this extra traversal by computing this during the
|
||||
// same traversal that computes the tree itself. We should also figure out
|
||||
// what is the minimum information needed for the server to correctly
|
||||
// intercept the route.
|
||||
const changedPath = computeChangedPath(oldState.tree, tree);
|
||||
const nextUrlForNewRoute = changedPath ? changedPath : oldState.nextUrl;
|
||||
// This value is stored on the state as `previousNextUrl`; the naming is
|
||||
// confusing. What it represents is the "Next-Url" header that was used to
|
||||
// fetch the incoming route. It's essentially the refererer URL, but in a
|
||||
// Next.js specific format. During refreshes, this is sent back to the server
|
||||
// instead of the current route's "Next-Url" so that the same interception
|
||||
// logic is applied as during the original navigation.
|
||||
const previousNextUrl = referringNextUrl;
|
||||
// Check if the only thing that changed was the hash fragment.
|
||||
const oldUrl = new URL(oldState.canonicalUrl, url);
|
||||
const onlyHashChange = // We don't need to compare the origins, because client-driven
|
||||
// navigations are always same-origin.
|
||||
url.pathname === oldUrl.pathname && url.search === oldUrl.search && url.hash !== oldUrl.hash;
|
||||
// Determine whether and how the page should scroll after this
|
||||
// navigation.
|
||||
//
|
||||
// By default, we scroll to the segments that were navigated to — i.e.
|
||||
// segments in the new part of the route, as opposed to shared segments
|
||||
// that were already part of the previous route. All newly navigated
|
||||
// segments share a single ScrollRef. When they mount, the first one
|
||||
// to mount initiates the scroll. They share a ref so that only one
|
||||
// scroll happens per navigation.
|
||||
//
|
||||
// If a subsequent navigation produces new segments, those supersede
|
||||
// any pending scroll from the previous navigation by invalidating its
|
||||
// ScrollRef. If a navigation doesn't produce any new segments (e.g.
|
||||
// a refresh where the route structure didn't change), any pending
|
||||
// scrolls from previous navigations are unaffected.
|
||||
//
|
||||
// The branches below handle special cases layered on top of this
|
||||
// default model.
|
||||
let activeScrollRef;
|
||||
let forceScroll;
|
||||
if (scrollBehavior === ScrollBehavior.NoScroll) {
|
||||
// The user explicitly opted out of scrolling (e.g. scroll={false}
|
||||
// on a Link or router.push).
|
||||
//
|
||||
// If this navigation created new scroll targets (scrollRef !== null),
|
||||
// neutralize them. If it didn't, any prior scroll targets carried
|
||||
// forward on the cache nodes via reuseSharedCacheNode remain active.
|
||||
if (scrollRef !== null) {
|
||||
scrollRef.current = false;
|
||||
}
|
||||
activeScrollRef = oldState.focusAndScrollRef.scrollRef;
|
||||
forceScroll = false;
|
||||
} else if (onlyHashChange) {
|
||||
// Hash-only navigations should scroll regardless of per-node state.
|
||||
// Create a fresh ref so the first segment to scroll consumes it.
|
||||
//
|
||||
// Invalidate any scroll ref from a prior navigation that hasn't
|
||||
// been consumed yet.
|
||||
const oldScrollRef = oldState.focusAndScrollRef.scrollRef;
|
||||
if (oldScrollRef !== null) {
|
||||
oldScrollRef.current = false;
|
||||
}
|
||||
// Also invalidate any per-node refs that were accumulated during
|
||||
// this navigation's tree construction — the hash-only ref
|
||||
// supersedes them.
|
||||
if (scrollRef !== null) {
|
||||
scrollRef.current = false;
|
||||
}
|
||||
activeScrollRef = {
|
||||
current: true
|
||||
};
|
||||
forceScroll = true;
|
||||
} else {
|
||||
// Default case. Use the accumulated scrollRef (may be null if no
|
||||
// new segments were created). The handler checks per-node refs, so
|
||||
// unchanged parallel route slots won't scroll.
|
||||
activeScrollRef = scrollRef;
|
||||
// If this navigation created new scroll targets, invalidate any
|
||||
// pending scroll from a previous navigation.
|
||||
if (scrollRef !== null) {
|
||||
const oldScrollRef = oldState.focusAndScrollRef.scrollRef;
|
||||
if (oldScrollRef !== null) {
|
||||
oldScrollRef.current = false;
|
||||
}
|
||||
}
|
||||
forceScroll = false;
|
||||
}
|
||||
const newState = {
|
||||
canonicalUrl,
|
||||
renderedSearch,
|
||||
pushRef: {
|
||||
pendingPush: navigateType === 'push',
|
||||
mpaNavigation: false,
|
||||
preserveCustomHistoryState: false
|
||||
},
|
||||
focusAndScrollRef: {
|
||||
scrollRef: activeScrollRef,
|
||||
forceScroll,
|
||||
onlyHashChange,
|
||||
hashFragment: // Remove leading # and decode hash to make non-latin hashes work.
|
||||
//
|
||||
// Empty hash should trigger default behavior of scrolling layout into
|
||||
// view. #top is handled in layout-router.
|
||||
//
|
||||
// Refer to `ScrollAndFocusHandler` for details on how this is used.
|
||||
scrollBehavior !== ScrollBehavior.NoScroll && url.hash !== '' ? decodeURIComponent(url.hash.slice(1)) : oldState.focusAndScrollRef.hashFragment
|
||||
},
|
||||
cache,
|
||||
tree,
|
||||
nextUrl: nextUrlForNewRoute,
|
||||
previousNextUrl,
|
||||
debugInfo: collectedDebugInfo
|
||||
};
|
||||
return newState;
|
||||
}
|
||||
export function completeTraverseNavigation(state, url, renderedSearch, cache, tree, nextUrl) {
|
||||
return {
|
||||
// Set canonical url
|
||||
canonicalUrl: createHrefFromUrl(url),
|
||||
renderedSearch,
|
||||
pushRef: {
|
||||
pendingPush: false,
|
||||
mpaNavigation: false,
|
||||
// Ensures that the custom history state that was set is preserved when applying this update.
|
||||
preserveCustomHistoryState: true
|
||||
},
|
||||
focusAndScrollRef: state.focusAndScrollRef,
|
||||
cache,
|
||||
// Restore provided tree
|
||||
tree,
|
||||
nextUrl,
|
||||
// TODO: We need to restore previousNextUrl, too, which represents the
|
||||
// Next-Url that was used to fetch the data. Anywhere we fetch using the
|
||||
// canonical URL, there should be a corresponding Next-Url.
|
||||
previousNextUrl: null,
|
||||
debugInfo: null
|
||||
};
|
||||
}
|
||||
export function convertServerPatchToFullTree(now, currentTree, flightData, renderedSearch, dynamicStaleTimeSeconds) {
|
||||
// During a client navigation or prefetch, the server sends back only a patch
|
||||
// for the parts of the tree that have changed.
|
||||
//
|
||||
// This applies the patch to the base tree to create a full representation of
|
||||
// the resulting tree.
|
||||
//
|
||||
// The return type includes a full FlightRouterState tree and a full
|
||||
// CacheNodeSeedData tree. (Conceptually these are the same tree, and should
|
||||
// eventually be unified, but there's still lots of existing code that
|
||||
// operates on FlightRouterState trees alone without the CacheNodeSeedData.)
|
||||
//
|
||||
// TODO: This similar to what apply-router-state-patch-to-tree does. It
|
||||
// will eventually fully replace it. We should get rid of all the remaining
|
||||
// places where we iterate over the server patch format. This should also
|
||||
// eventually replace normalizeFlightData.
|
||||
let baseTree = currentTree;
|
||||
let baseData = null;
|
||||
let head = null;
|
||||
if (flightData !== null) {
|
||||
for (const { segmentPath, tree: treePatch, seedData: dataPatch, head: headPatch } of flightData){
|
||||
const result = convertServerPatchToFullTreeImpl(baseTree, baseData, treePatch, dataPatch, segmentPath, renderedSearch, 0);
|
||||
baseTree = result.tree;
|
||||
baseData = result.data;
|
||||
// This is the same for all patches per response, so just pick an
|
||||
// arbitrary one
|
||||
head = headPatch;
|
||||
}
|
||||
}
|
||||
const finalFlightRouterState = baseTree;
|
||||
// Convert the final FlightRouterState into a RouteTree type.
|
||||
//
|
||||
// TODO: Eventually, FlightRouterState will evolve to being a transport format
|
||||
// only. The RouteTree type will become the main type used for dealing with
|
||||
// routes on the client, and we'll store it in the state directly.
|
||||
const acc = {
|
||||
metadataVaryPath: null
|
||||
};
|
||||
const routeTree = convertRootFlightRouterStateToRouteTree(finalFlightRouterState, renderedSearch, acc);
|
||||
return {
|
||||
routeTree,
|
||||
metadataVaryPath: acc.metadataVaryPath,
|
||||
data: baseData,
|
||||
renderedSearch,
|
||||
head,
|
||||
dynamicStaleAt: computeDynamicStaleAt(now, dynamicStaleTimeSeconds)
|
||||
};
|
||||
}
|
||||
function convertServerPatchToFullTreeImpl(baseRouterState, baseData, treePatch, dataPatch, segmentPath, renderedSearch, index) {
|
||||
if (index === segmentPath.length) {
|
||||
// We reached the part of the tree that we need to patch.
|
||||
return {
|
||||
tree: treePatch,
|
||||
data: dataPatch
|
||||
};
|
||||
}
|
||||
// segmentPath represents the parent path of subtree. It's a repeating
|
||||
// pattern of parallel route key and segment:
|
||||
//
|
||||
// [string, Segment, string, Segment, string, Segment, ...]
|
||||
//
|
||||
// This path tells us which part of the base tree to apply the tree patch.
|
||||
//
|
||||
// NOTE: We receive the FlightRouterState patch in the same request as the
|
||||
// seed data patch. Therefore we don't need to worry about diffing the segment
|
||||
// values; we can assume the server sent us a correct result.
|
||||
const updatedParallelRouteKey = segmentPath[index];
|
||||
// const segment: Segment = segmentPath[index + 1] <-- Not used, see note above
|
||||
const baseTreeChildren = baseRouterState[1];
|
||||
const baseSeedDataChildren = baseData !== null ? baseData[1] : null;
|
||||
const newTreeChildren = {};
|
||||
const newSeedDataChildren = {};
|
||||
for(const parallelRouteKey in baseTreeChildren){
|
||||
const childBaseRouterState = baseTreeChildren[parallelRouteKey];
|
||||
const childBaseSeedData = baseSeedDataChildren !== null ? baseSeedDataChildren[parallelRouteKey] ?? null : null;
|
||||
if (parallelRouteKey === updatedParallelRouteKey) {
|
||||
const result = convertServerPatchToFullTreeImpl(childBaseRouterState, childBaseSeedData, treePatch, dataPatch, segmentPath, renderedSearch, // Advance the index by two and keep cloning until we reach
|
||||
// the end of the segment path.
|
||||
index + 2);
|
||||
newTreeChildren[parallelRouteKey] = result.tree;
|
||||
newSeedDataChildren[parallelRouteKey] = result.data;
|
||||
} else {
|
||||
// This child is not being patched. Copy it over as-is.
|
||||
newTreeChildren[parallelRouteKey] = childBaseRouterState;
|
||||
newSeedDataChildren[parallelRouteKey] = childBaseSeedData;
|
||||
}
|
||||
}
|
||||
let clonedTree;
|
||||
let clonedSeedData;
|
||||
// Clone all the fields except the children.
|
||||
// Clone the FlightRouterState tree. Based on equivalent logic in
|
||||
// apply-router-state-patch-to-tree, but should confirm whether we need to
|
||||
// copy all of these fields. Not sure the server ever sends, e.g. the
|
||||
// refetch marker.
|
||||
clonedTree = [
|
||||
baseRouterState[0],
|
||||
newTreeChildren
|
||||
];
|
||||
if (2 in baseRouterState) {
|
||||
const compressedRefreshState = baseRouterState[2];
|
||||
if (compressedRefreshState !== undefined && compressedRefreshState !== null) {
|
||||
// Since this part of the tree was patched with new data, any parent
|
||||
// refresh states should be updated to reflect the new rendered search
|
||||
// value. (The refresh state acts like a "context provider".) All pages
|
||||
// within the same server response share the same renderedSearch value,
|
||||
// but the same RouteTree could be composed from multiple different
|
||||
// routes, and multiple responses.
|
||||
clonedTree[2] = [
|
||||
compressedRefreshState[0],
|
||||
renderedSearch
|
||||
];
|
||||
}
|
||||
}
|
||||
if (3 in baseRouterState) {
|
||||
clonedTree[3] = baseRouterState[3];
|
||||
}
|
||||
if (4 in baseRouterState) {
|
||||
clonedTree[4] = baseRouterState[4];
|
||||
}
|
||||
// Clone the CacheNodeSeedData tree.
|
||||
const isEmptySeedDataPartial = true;
|
||||
clonedSeedData = [
|
||||
null,
|
||||
newSeedDataChildren,
|
||||
null,
|
||||
isEmptySeedDataPartial,
|
||||
null
|
||||
];
|
||||
return {
|
||||
tree: clonedTree,
|
||||
data: clonedSeedData
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Instant Navigation Testing API: ensures a prefetch task has been initiated
|
||||
* and completed before proceeding with the navigation. This guarantees that
|
||||
* segment data requests are at least pending, even for routes whose route
|
||||
* tree is already cached.
|
||||
*
|
||||
* After the prefetch completes, delegates to the normal navigation flow.
|
||||
*/ async function ensurePrefetchThenNavigate(state, url, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, nextUrl, freshnessPolicy, scrollBehavior, navigateType) {
|
||||
const link = getLinkForCurrentNavigation();
|
||||
const fetchStrategy = link !== null ? link.fetchStrategy : FetchStrategy.PPR;
|
||||
// Transition the cookie to captured-SPA immediately, before waiting
|
||||
// for the prefetch. This ensures the devtools panel can update its UI
|
||||
// right away, even if the prefetch takes time (e.g. dev compilation).
|
||||
// The "to" tree starts as null and is filled in after the prefetch
|
||||
// resolves and the navigation produces a new router state.
|
||||
const { transitionToCapturedSPA, updateCapturedSPAToTree } = require('./navigation-testing-lock');
|
||||
transitionToCapturedSPA(currentFlightRouterState, null);
|
||||
const cacheKey = createCacheKey(url.href, nextUrl);
|
||||
await new Promise((resolve)=>{
|
||||
schedulePrefetchTask(cacheKey, currentFlightRouterState, fetchStrategy, PrefetchPriority.Default, null, resolve // _onComplete callback
|
||||
);
|
||||
});
|
||||
// Prefetch is complete. Proceed with the normal navigation flow, which
|
||||
// will now find the route in the cache.
|
||||
const result = await navigateImpl(state, url, currentUrl, currentRenderedSearch, currentCacheNode, currentFlightRouterState, nextUrl, freshnessPolicy, scrollBehavior, navigateType);
|
||||
// Update the cookie with the resolved "to" tree so the devtools
|
||||
// panel can display both routes immediately.
|
||||
updateCapturedSPAToTree(currentFlightRouterState, result.tree);
|
||||
return result;
|
||||
}
|
||||
|
||||
//# sourceMappingURL=navigation.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+536
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* 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.
|
||||
*/ import { EntryStatus, writeRouteIntoCache, fulfillRouteCacheEntry, createMetadataRouteTree } from './cache';
|
||||
import { doesStaticSegmentAppearInURL } from '../../route-params';
|
||||
import { appendLayoutVaryPath, finalizeLayoutVaryPath, finalizePageVaryPath, finalizeMetadataVaryPath } from './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();
|
||||
/**
|
||||
* Learns a route pattern from a server response and inserts it into the cache.
|
||||
*
|
||||
* Called after receiving a route tree from the server (initial load, navigation,
|
||||
* or prefetch). 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 regardless of whether the URL matches the route structure.
|
||||
*
|
||||
* When pendingEntry is provided, it's fulfilled and used. When null, an entry
|
||||
* is created and inserted into the route cache map.
|
||||
*
|
||||
* When hasDynamicRewrite is true, the route entry is marked as having a
|
||||
* dynamic rewrite, which prevents it from being used as a template for future
|
||||
* predictions. This is set when we detect a mismatch between what we predicted
|
||||
* and what the server returned.
|
||||
*
|
||||
* Returns the fulfilled route cache entry.
|
||||
*/ export 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 = 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 = 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 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 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 = writeRouteIntoCache(now, pathname, nextUrl, fullTree, metadataVaryPath, couldBeIntercepted, canonicalUrl, supportsPerSegmentPrefetching);
|
||||
}
|
||||
if (hasDynamicRewrite) {
|
||||
entry.hasDynamicRewrite = true;
|
||||
}
|
||||
// Store as pattern
|
||||
knownRoutePart.pattern = entry;
|
||||
return entry;
|
||||
}
|
||||
/**
|
||||
* Attempts to match a URL against learned route patterns.
|
||||
*
|
||||
* Returns a synthetic FulfilledRouteCacheEntry if the URL matches a known
|
||||
* pattern, or null if no match is found (fall back to server resolution).
|
||||
*/ export 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 = 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: 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 = 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 = finalizePageVaryPath(pattern.requestKey, search, partialVaryPath);
|
||||
// Collect metadata vary path (first page wins, same as original algorithm)
|
||||
if (acc.metadataVaryPath === null) {
|
||||
acc.metadataVaryPath = 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 = finalizeLayoutVaryPath(pattern.requestKey, partialVaryPath);
|
||||
return {
|
||||
requestKey: pattern.requestKey,
|
||||
segment: newSegment,
|
||||
refreshState: pattern.refreshState,
|
||||
slots: newSlots,
|
||||
prefetchHints: pattern.prefetchHints,
|
||||
isPage: false,
|
||||
varyPath: newVaryPath
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resets the known route tree. Called during development when routes may
|
||||
* change due to hot reloading.
|
||||
*/ export function resetKnownRoutes() {
|
||||
knownRouteTreeRoot = createEmptyPart();
|
||||
}
|
||||
|
||||
//# sourceMappingURL=optimistic-routes.js.map
|
||||
Generated
Vendored
+1
File diff suppressed because one or more lines are too long
+34
@@ -0,0 +1,34 @@
|
||||
import { createPrefetchURL } from '../app-router-utils';
|
||||
import { createCacheKey } from './cache-key';
|
||||
import { schedulePrefetchTask } from './scheduler';
|
||||
import { PrefetchPriority } from './types';
|
||||
/**
|
||||
* Entrypoint for prefetching a URL into the Segment Cache.
|
||||
* @param href - The URL to prefetch. Typically this will come from a <Link>,
|
||||
* or router.prefetch. It must be validated before we attempt to prefetch it.
|
||||
* @param nextUrl - A special header used by the server for interception routes.
|
||||
* Roughly corresponds to the current URL.
|
||||
* @param treeAtTimeOfPrefetch - The FlightRouterState at the time the prefetch
|
||||
* was requested. This is only used when PPR is disabled.
|
||||
* @param fetchStrategy - Whether to prefetch dynamic data, in addition to
|
||||
* static data. This is used by `<Link prefetch={true}>`.
|
||||
* @param onInvalidate - A callback that will be called when the prefetch cache
|
||||
* When called, it signals to the listener that the data associated with the
|
||||
* prefetch may have been invalidated from the cache. This is not a live
|
||||
* subscription — it's called at most once per `prefetch` call. The only
|
||||
* supported use case is to trigger a new prefetch inside the listener, if
|
||||
* desired. It also may be called even in cases where the associated data is
|
||||
* still cached. Prefetching is a poll-based (pull) operation, not an event-
|
||||
* based (push) one. Rather than subscribe to specific cache entries, you
|
||||
* occasionally poll the prefetch cache to check if anything is missing.
|
||||
*/ export function prefetch(href, nextUrl, treeAtTimeOfPrefetch, fetchStrategy, onInvalidate) {
|
||||
const url = createPrefetchURL(href);
|
||||
if (url === null) {
|
||||
// This href should not be prefetched.
|
||||
return;
|
||||
}
|
||||
const cacheKey = createCacheKey(url.href, nextUrl);
|
||||
schedulePrefetchTask(cacheKey, treeAtTimeOfPrefetch, fetchStrategy, PrefetchPriority.Default, onInvalidate);
|
||||
}
|
||||
|
||||
//# sourceMappingURL=prefetch.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../../../src/client/components/segment-cache/prefetch.ts"],"sourcesContent":["import type { FlightRouterState } from '../../../shared/lib/app-router-types'\nimport { createPrefetchURL } from '../app-router-utils'\nimport { createCacheKey } from './cache-key'\nimport { schedulePrefetchTask } from './scheduler'\nimport { PrefetchPriority, type PrefetchTaskFetchStrategy } from './types'\n\n/**\n * Entrypoint for prefetching a URL into the Segment Cache.\n * @param href - The URL to prefetch. Typically this will come from a <Link>,\n * or router.prefetch. It must be validated before we attempt to prefetch it.\n * @param nextUrl - A special header used by the server for interception routes.\n * Roughly corresponds to the current URL.\n * @param treeAtTimeOfPrefetch - The FlightRouterState at the time the prefetch\n * was requested. This is only used when PPR is disabled.\n * @param fetchStrategy - Whether to prefetch dynamic data, in addition to\n * static data. This is used by `<Link prefetch={true}>`.\n * @param onInvalidate - A callback that will be called when the prefetch cache\n * When called, it signals to the listener that the data associated with the\n * prefetch may have been invalidated from the cache. This is not a live\n * subscription — it's called at most once per `prefetch` call. The only\n * supported use case is to trigger a new prefetch inside the listener, if\n * desired. It also may be called even in cases where the associated data is\n * still cached. Prefetching is a poll-based (pull) operation, not an event-\n * based (push) one. Rather than subscribe to specific cache entries, you\n * occasionally poll the prefetch cache to check if anything is missing.\n */\nexport function prefetch(\n href: string,\n nextUrl: string | null,\n treeAtTimeOfPrefetch: FlightRouterState,\n fetchStrategy: PrefetchTaskFetchStrategy,\n onInvalidate: null | (() => void)\n) {\n const url = createPrefetchURL(href)\n if (url === null) {\n // This href should not be prefetched.\n return\n }\n const cacheKey = createCacheKey(url.href, nextUrl)\n schedulePrefetchTask(\n cacheKey,\n treeAtTimeOfPrefetch,\n fetchStrategy,\n PrefetchPriority.Default,\n onInvalidate\n )\n}\n"],"names":["createPrefetchURL","createCacheKey","schedulePrefetchTask","PrefetchPriority","prefetch","href","nextUrl","treeAtTimeOfPrefetch","fetchStrategy","onInvalidate","url","cacheKey","Default"],"mappings":"AACA,SAASA,iBAAiB,QAAQ,sBAAqB;AACvD,SAASC,cAAc,QAAQ,cAAa;AAC5C,SAASC,oBAAoB,QAAQ,cAAa;AAClD,SAASC,gBAAgB,QAAwC,UAAS;AAE1E;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,SACdC,IAAY,EACZC,OAAsB,EACtBC,oBAAuC,EACvCC,aAAwC,EACxCC,YAAiC;IAEjC,MAAMC,MAAMV,kBAAkBK;IAC9B,IAAIK,QAAQ,MAAM;QAChB,sCAAsC;QACtC;IACF;IACA,MAAMC,WAAWV,eAAeS,IAAIL,IAAI,EAAEC;IAC1CJ,qBACES,UACAJ,sBACAC,eACAL,iBAAiBS,OAAO,EACxBH;AAEJ","ignoreList":[0]}
|
||||
+1221
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Shared types and constants for the Segment Cache.
|
||||
*/ export var NavigationResultTag = /*#__PURE__*/ function(NavigationResultTag) {
|
||||
NavigationResultTag[NavigationResultTag["MPA"] = 0] = "MPA";
|
||||
NavigationResultTag[NavigationResultTag["Success"] = 1] = "Success";
|
||||
NavigationResultTag[NavigationResultTag["NoOp"] = 2] = "NoOp";
|
||||
NavigationResultTag[NavigationResultTag["Async"] = 3] = "Async";
|
||||
return NavigationResultTag;
|
||||
}({});
|
||||
/**
|
||||
* The priority of the prefetch task. Higher numbers are higher priority.
|
||||
*/ export var PrefetchPriority = /*#__PURE__*/ function(PrefetchPriority) {
|
||||
/**
|
||||
* Assigned to the most recently hovered/touched link. Special network
|
||||
* bandwidth is reserved for this task only. There's only ever one Intent-
|
||||
* priority task at a time; when a new Intent task is scheduled, the previous
|
||||
* one is bumped down to Default.
|
||||
*/ PrefetchPriority[PrefetchPriority["Intent"] = 2] = "Intent";
|
||||
/**
|
||||
* The default priority for prefetch tasks.
|
||||
*/ PrefetchPriority[PrefetchPriority["Default"] = 1] = "Default";
|
||||
/**
|
||||
* Assigned to tasks when they spawn non-blocking background work, like
|
||||
* revalidating a partially cached entry to see if more data is available.
|
||||
*/ PrefetchPriority[PrefetchPriority["Background"] = 0] = "Background";
|
||||
return PrefetchPriority;
|
||||
}({});
|
||||
export var FetchStrategy = /*#__PURE__*/ function(FetchStrategy) {
|
||||
// Deliberately ordered so we can easily compare two segments
|
||||
// and determine if one segment is "more specific" than another
|
||||
// (i.e. if it's likely that it contains more data)
|
||||
FetchStrategy[FetchStrategy["LoadingBoundary"] = 0] = "LoadingBoundary";
|
||||
FetchStrategy[FetchStrategy["PPR"] = 1] = "PPR";
|
||||
FetchStrategy[FetchStrategy["PPRRuntime"] = 2] = "PPRRuntime";
|
||||
FetchStrategy[FetchStrategy["Full"] = 3] = "Full";
|
||||
return FetchStrategy;
|
||||
}({});
|
||||
|
||||
//# sourceMappingURL=types.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../../../src/client/components/segment-cache/types.ts"],"sourcesContent":["/**\n * Shared types and constants for the Segment Cache.\n */\n\nexport const enum NavigationResultTag {\n MPA,\n Success,\n NoOp,\n Async,\n}\n\n/**\n * The priority of the prefetch task. Higher numbers are higher priority.\n */\nexport const enum PrefetchPriority {\n /**\n * Assigned to the most recently hovered/touched link. Special network\n * bandwidth is reserved for this task only. There's only ever one Intent-\n * priority task at a time; when a new Intent task is scheduled, the previous\n * one is bumped down to Default.\n */\n Intent = 2,\n /**\n * The default priority for prefetch tasks.\n */\n Default = 1,\n /**\n * Assigned to tasks when they spawn non-blocking background work, like\n * revalidating a partially cached entry to see if more data is available.\n */\n Background = 0,\n}\n\nexport const enum FetchStrategy {\n // Deliberately ordered so we can easily compare two segments\n // and determine if one segment is \"more specific\" than another\n // (i.e. if it's likely that it contains more data)\n LoadingBoundary = 0,\n PPR = 1,\n PPRRuntime = 2,\n Full = 3,\n}\n\n/**\n * A subset of fetch strategies used for prefetch tasks.\n * A prefetch task can't know if it should use `PPR` or `LoadingBoundary`\n * until we complete the initial tree prefetch request, so we use `PPR` to signal both cases\n * and adjust it based on the route when actually fetching.\n * */\nexport type PrefetchTaskFetchStrategy =\n | FetchStrategy.PPR\n | FetchStrategy.PPRRuntime\n | FetchStrategy.Full\n"],"names":["NavigationResultTag","PrefetchPriority","FetchStrategy"],"mappings":"AAAA;;CAEC,GAED,OAAO,IAAA,AAAWA,6CAAAA;;;;;WAAAA;MAKjB;AAED;;CAEC,GACD,OAAO,IAAA,AAAWC,0CAAAA;IAChB;;;;;GAKC;IAED;;GAEC;IAED;;;GAGC;WAfeA;MAiBjB;AAED,OAAO,IAAA,AAAWC,uCAAAA;IAChB,6DAA6D;IAC7D,+DAA+D;IAC/D,mDAAmD;;;;;WAHnCA;MAQjB","ignoreList":[0]}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import { FetchStrategy } from './types';
|
||||
import { Fallback } from './cache-map';
|
||||
import { HEAD_REQUEST_KEY } from '../../../shared/lib/segment-cache/segment-value-encoding';
|
||||
export function getRouteVaryPath(pathname, search, nextUrl) {
|
||||
// requestKey -> searchParams -> nextUrl
|
||||
const varyPath = {
|
||||
id: null,
|
||||
value: pathname,
|
||||
parent: {
|
||||
id: '?',
|
||||
value: search,
|
||||
parent: {
|
||||
id: null,
|
||||
value: nextUrl,
|
||||
parent: null
|
||||
}
|
||||
}
|
||||
};
|
||||
return varyPath;
|
||||
}
|
||||
export function getFulfilledRouteVaryPath(pathname, search, nextUrl, couldBeIntercepted) {
|
||||
// This is called when a route's data is fulfilled. The cache entry will be
|
||||
// re-keyed based on which inputs the response varies by.
|
||||
// requestKey -> searchParams -> nextUrl
|
||||
const varyPath = {
|
||||
id: null,
|
||||
value: pathname,
|
||||
parent: {
|
||||
id: '?',
|
||||
value: search,
|
||||
parent: {
|
||||
id: null,
|
||||
value: couldBeIntercepted ? nextUrl : Fallback,
|
||||
parent: null
|
||||
}
|
||||
}
|
||||
};
|
||||
return varyPath;
|
||||
}
|
||||
export function appendLayoutVaryPath(parentPath, cacheKey, paramName) {
|
||||
const varyPathPart = {
|
||||
id: paramName,
|
||||
value: cacheKey,
|
||||
parent: parentPath
|
||||
};
|
||||
return varyPathPart;
|
||||
}
|
||||
export function finalizeLayoutVaryPath(requestKey, varyPath) {
|
||||
const layoutVaryPath = {
|
||||
id: null,
|
||||
value: requestKey,
|
||||
parent: varyPath
|
||||
};
|
||||
return layoutVaryPath;
|
||||
}
|
||||
export function getPartialLayoutVaryPath(finalizedVaryPath) {
|
||||
// This is the inverse of finalizeLayoutVaryPath.
|
||||
return finalizedVaryPath.parent;
|
||||
}
|
||||
export function finalizePageVaryPath(requestKey, renderedSearch, varyPath) {
|
||||
// Unlike layouts, a page segment's vary path also includes the search string.
|
||||
// requestKey -> searchParams -> pathParams
|
||||
const pageVaryPath = {
|
||||
id: null,
|
||||
value: requestKey,
|
||||
parent: {
|
||||
id: '?',
|
||||
value: renderedSearch,
|
||||
parent: varyPath
|
||||
}
|
||||
};
|
||||
return pageVaryPath;
|
||||
}
|
||||
export function getPartialPageVaryPath(finalizedVaryPath) {
|
||||
// This is the inverse of finalizePageVaryPath.
|
||||
return finalizedVaryPath.parent.parent;
|
||||
}
|
||||
export function finalizeMetadataVaryPath(pageRequestKey, renderedSearch, varyPath) {
|
||||
// The metadata "segment" is not a real segment because it doesn't exist in
|
||||
// the normal structure of the route tree, but in terms of caching, it
|
||||
// behaves like a page segment because it varies by all the same params as
|
||||
// a page.
|
||||
//
|
||||
// To keep the protocol for querying the server simple, the request key for
|
||||
// the metadata does not include any path information. It's unnecessary from
|
||||
// the server's perspective, because unlike page segments, there's only one
|
||||
// metadata response per URL, i.e. there's no need to distinguish multiple
|
||||
// parallel pages.
|
||||
//
|
||||
// However, this means the metadata request key is insufficient for
|
||||
// caching the the metadata in the client cache, because on the client we
|
||||
// use the request key to distinguish the metadata entry from all other
|
||||
// page's metadata entries.
|
||||
//
|
||||
// So instead we create a simulated request key based on the page segment.
|
||||
// Conceptually this is equivalent to the request key the server would have
|
||||
// assigned the metadata segment if it treated it as part of the actual
|
||||
// route structure.
|
||||
// If there are multiple parallel pages, we use whichever is the first one.
|
||||
// This is fine because the only difference between request keys for
|
||||
// different parallel pages are things like route groups and parallel
|
||||
// route slots. As long as it's always the same one, it doesn't matter.
|
||||
const pageVaryPath = {
|
||||
id: null,
|
||||
// Append the actual metadata request key to the page request key. Note
|
||||
// that we're not using a separate vary path part; it's unnecessary because
|
||||
// these are not conceptually separate inputs.
|
||||
value: pageRequestKey + HEAD_REQUEST_KEY,
|
||||
parent: {
|
||||
id: '?',
|
||||
value: renderedSearch,
|
||||
parent: varyPath
|
||||
}
|
||||
};
|
||||
return pageVaryPath;
|
||||
}
|
||||
export function getSegmentVaryPathForRequest(fetchStrategy, tree) {
|
||||
// This is used for storing pending requests in the cache. We want to choose
|
||||
// the most generic vary path based on the strategy used to fetch it, i.e.
|
||||
// static/PPR versus runtime prefetching, so that it can be reused as much
|
||||
// as possible.
|
||||
//
|
||||
// We may be able to re-key the response to something even more generic once
|
||||
// we receive it — for example, if the server tells us that the response
|
||||
// doesn't vary on a particular param — but even before we send the request,
|
||||
// we know some params are reusable based on the fetch strategy alone. For
|
||||
// example, a static prefetch will never vary on search params.
|
||||
//
|
||||
// The original vary path with all the params filled in is stored on the
|
||||
// route tree object. We will clone this one to create a new vary path
|
||||
// where certain params are replaced with Fallback.
|
||||
//
|
||||
// This result of this function is not stored anywhere. It's only used to
|
||||
// access the cache a single time.
|
||||
//
|
||||
// TODO: Rather than create a new list object just to access the cache, the
|
||||
// plan is to add the concept of a "vary mask". This will represent all the
|
||||
// params that can be treated as Fallback. (Or perhaps the inverse.)
|
||||
const originalVaryPath = tree.varyPath;
|
||||
// Only page segments (and the special "metadata" segment, which is treated
|
||||
// like a page segment for the purposes of caching) may contain search
|
||||
// params. There's no reason to include them in the vary path otherwise.
|
||||
if (tree.isPage) {
|
||||
// Only a runtime prefetch will include search params in the vary path.
|
||||
// Static prefetches never include search params, so they can be reused
|
||||
// across all possible search param values.
|
||||
const doesVaryOnSearchParams = fetchStrategy === FetchStrategy.Full || fetchStrategy === FetchStrategy.PPRRuntime;
|
||||
if (!doesVaryOnSearchParams) {
|
||||
// The response from the the server will not vary on search params. Clone
|
||||
// the end of the original vary path to replace the search params
|
||||
// with Fallback.
|
||||
//
|
||||
// requestKey -> searchParams -> pathParams
|
||||
// ^ This part gets replaced with Fallback
|
||||
const searchParamsVaryPath = originalVaryPath.parent;
|
||||
const pathParamsVaryPath = searchParamsVaryPath.parent;
|
||||
const patchedVaryPath = {
|
||||
id: null,
|
||||
value: originalVaryPath.value,
|
||||
parent: {
|
||||
id: '?',
|
||||
value: Fallback,
|
||||
parent: pathParamsVaryPath
|
||||
}
|
||||
};
|
||||
return patchedVaryPath;
|
||||
}
|
||||
}
|
||||
// The request does vary on search params. We don't need to modify anything.
|
||||
return originalVaryPath;
|
||||
}
|
||||
export function clonePageVaryPathWithNewSearchParams(originalVaryPath, newSearch) {
|
||||
// requestKey -> searchParams -> pathParams
|
||||
// ^ This part gets replaced with newSearch
|
||||
const searchParamsVaryPath = originalVaryPath.parent;
|
||||
const clonedVaryPath = {
|
||||
id: null,
|
||||
value: originalVaryPath.value,
|
||||
parent: {
|
||||
id: '?',
|
||||
value: newSearch,
|
||||
parent: searchParamsVaryPath.parent
|
||||
}
|
||||
};
|
||||
return clonedVaryPath;
|
||||
}
|
||||
export function getRenderedSearchFromVaryPath(varyPath) {
|
||||
const searchParams = varyPath.parent.value;
|
||||
return typeof searchParams === 'string' ? searchParams : null;
|
||||
}
|
||||
export function getFulfilledSegmentVaryPath(original, varyParams) {
|
||||
// Re-keys a segment's vary path based on which params the segment actually
|
||||
// depends on. Params that are NOT in the varyParams set are replaced with
|
||||
// Fallback, allowing the cache entry to be reused across different values of
|
||||
// those params.
|
||||
// This is called when a segment is fulfilled with data from the server. The
|
||||
// varyParams set comes from the server and indicates which params were
|
||||
// accessed during rendering.
|
||||
const clone = {
|
||||
id: original.id,
|
||||
// If the id is null, this node is not a param (e.g., it's a request key).
|
||||
// If the id is in the varyParams set, keep the original value.
|
||||
// Otherwise, replace with Fallback to make it reusable.
|
||||
value: original.id === null || varyParams.has(original.id) ? original.value : Fallback,
|
||||
parent: original.parent === null ? null : getFulfilledSegmentVaryPath(original.parent, varyParams)
|
||||
};
|
||||
return clone;
|
||||
}
|
||||
|
||||
//# sourceMappingURL=vary-path.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user