UNPKG

next

Version:

The React Framework

470 lines (469 loc) 21.1 kB
/** * 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). * * This module assumes the Instant Navigation Testing API is enabled. When it * is disabled, the bundler resolves this module to * `./navigation-testing-lock.disabled` instead (see * `create-compiler-aliases.ts` for webpack and * `crates/next-core/src/next_import_map.rs` for Turbopack), so none of this * code ships in the browser bundle. */ import { PrefetchHint } from '../../../shared/lib/app-router-types'; import { NEXT_INSTANT_TEST_COOKIE } from '../app-router-headers'; import { refreshOnInstantNavigationUnlock } from '../use-action-queue'; import { subtreeHasSpeculativePrefetch } from './scheduler'; import { waitForSegmentCacheEntry } from './cache'; function parseCookieValue(raw) { if (raw === '') { return 'empty'; } try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { if (parsed.length >= 3) { const rawState = parsed[2]; return rawState === null ? 'mpa' : 'spa'; } } } catch {} return 'pending'; } function writeDocumentCookie(value, options) { if (typeof document === 'undefined') { return; } let cookie = `${NEXT_INSTANT_TEST_COOKIE}=${JSON.stringify(value)}; Path=${options.path ?? '/'}`; if (options.domain) { cookie += `; Domain=${options.domain}`; } document.cookie = cookie; } 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. The read goes // through `cookieStore.get` because `document.cookie` exposes only names and // values, not the domain/path we need to preserve. The write goes through // document.cookie because WebKit exposes Cookie Store on localhost but does // not commit cookies written through cookieStore.set() there. // // Capture the current lockState and compare it in the callback so we only // write if the lock we observed at call time is still held. This guards // against two races: (a) the scope ended between get and set (lockState is // now null), and (b) the scope ended and a new one was acquired in the same // gap (lockState is a different object). In either case we must not write — // doing so would leak stale state into the next scope or outlive the current // one. It cannot close one window, though: the callback can run after an // external delete but before the deleted-event handler nulls lockState, so // the guard still passes and we resurrect the cookie. The deleted handler // clears any such entry once the lock is released (see the `event.deleted` // loop below). const lockAtCall = lockState; cookieStore.get(NEXT_INSTANT_TEST_COOKIE).then((existing)=>{ if (existing && lockState === lockAtCall && lockAtCall !== null) { writeDocumentCookie(value, existing); } }); } let lockState = null; export function getPreLockFetch() { return lockState !== null ? lockState.fetch : null; } /** * Creates the "wait for prefetch to fulfill" state for one locked navigation, * registers it on the current lock, and returns it (the caller stores it on the * prefetch task and awaits `.promise`). Returns null if no lock is held. * * `pendingCount` starts at 1, representing the scheduler itself while it is * still spawning requests; that reference is released by * `finishNavigationLockPrefetchSpawning`. Each spawned pending entry adds * another (see `trackNavigationLockPrefetchEntry`). `promise` resolves when the * count drains to 0 — i.e. spawning finished and every entry fulfilled. */ export function beginNavigationLockPrefetch() { if (lockState !== null) { let resolve; const promise = new Promise((r)=>{ resolve = r; }); const prefetch = { promise, resolve: resolve, pendingCount: 1, trackedEntries: new Set() }; lockState.activePrefetches.add(prefetch); return prefetch; } return null; } /** * Records a freshly-created segment entry as owned by the current lock scope, so * navigation reads will match it — and only entries created within the scope * (see `NavigationLockState.ownedEntries`). Called from * `createDetachedSegmentCacheEntry`, the single factory every creation path * funnels through, so re-keyed entries created during response processing (e.g. * a runtime prefetch resolving a concrete param) are owned too. No-op when no * lock is held. */ export function recordNavigationLockOwnedEntry(entry) { if (lockState !== null) { lockState.ownedEntries.add(entry); } } /** * Called by `upgradeToPendingSegment` whenever the locked-navigation prefetch * spawns a pending segment entry. Adds the entry to the prefetch's ref count and * decrements when it fulfills (or rejects — `waitForSegmentCacheEntry` resolves * to null). Deduped so the same entry never double-counts. */ export function trackNavigationLockPrefetchEntry(prefetch, entry) { if (prefetch.trackedEntries.has(entry)) { return; } prefetch.trackedEntries.add(entry); prefetch.pendingCount++; const onSettled = ()=>{ prefetch.pendingCount--; settleNavigationLockPrefetchIfDrained(prefetch); }; // Decrement whether the entry fulfills or its request rejects, so a failed // segment can't leave the navigation waiting forever. waitForSegmentCacheEntry(entry).then(onSettled, onSettled); } /** * Called once the scheduler has finished spawning every request for the * locked-navigation prefetch, releasing the scheduler's reference from the ref * count. The prefetch resolves here if every spawned entry already fulfilled. */ export function finishNavigationLockPrefetchSpawning(prefetch) { prefetch.pendingCount--; settleNavigationLockPrefetchIfDrained(prefetch); } function settleNavigationLockPrefetchIfDrained(prefetch) { if (prefetch.pendingCount === 0) { // Unregister from the lock (if still held) and resolve. Resolving is // idempotent, so it's safe even if the lock already force-resolved this on // release. if (lockState !== null) { lockState.activePrefetches.delete(prefetch); } prefetch.resolve(); } } function acquireLock() { if (lockState !== null) { return; } let resolveReleased; const released = new Promise((r)=>{ resolveReleased = r; }); let resolveCurrentNavigation; const currentNavigation = new Promise((r)=>{ resolveCurrentNavigation = r; }); lockState = { released, resolveReleased: resolveReleased, fetch: window.fetch, activePrefetches: new Set(), ownedEntries: new Set(), currentNavigation, resolveCurrentNavigation: resolveCurrentNavigation }; // Install the fetch blocker. We only intercept `window.fetch` for the // duration of the lock so that — outside of a testing scope — user- // installed overrides of `window.fetch` are untouched. window.fetch = globalFetchOverride; } function releaseLock() { if (lockState === null) { return; } // Restore the pre-lock `window.fetch` before resolving the lock promise // so any fetches queued on the promise see the restored fetch. window.fetch = lockState.fetch; const { resolveReleased, activePrefetches, resolveCurrentNavigation } = lockState; lockState = null; // Force-resolve every prefetch that hasn't finished, so a navigation still // waiting on one doesn't hang now that the scope is ending. for (const prefetch of activePrefetches){ prefetch.resolve(); } // Resolve the current locked navigation's withheld-data gate, so its gated // dynamic write unblocks now that the scope is ending. resolveCurrentNavigation(); // Resolve the release promise so blocked out-of-band fetches dispatch too. resolveReleased(); } /** * Called when a new locked navigation begins (from `navigate` while the lock is * held). Rolls over the lock's withheld-data gate: it resolves the current * `currentNavigation` promise — so the *previous* locked navigation's withheld * dynamic write proceeds and the cache nodes it produced stop holding pending * deferred `rsc` promises that a reused shared segment in this navigation would * otherwise suspend on — then installs a fresh promise for this navigation. * Only the most recent navigation's data stays withheld; a new navigation * always releases the previous one. Returns this navigation's gate — the * immutable promise its dynamic write awaits — or null when no lock is held. * * This is the testing-lock behavior for repeated navigations while paused. It * is not a principled fix for the underlying `useDeferredValue`/reuse-suspend * behavior; it just ensures that, under the lock, a reused segment never * carries a still-pending deferred `rsc` from an earlier navigation. */ export function beginLockedNavigation() { if (lockState === null) { return null; } // Release the previous locked navigation's withheld data, then roll over to a // fresh gate for this navigation — all without ending the scope. lockState.resolveCurrentNavigation(); let resolveCurrentNavigation; const currentNavigation = new Promise((r)=>{ resolveCurrentNavigation = r; }); lockState.currentNavigation = currentNavigation; lockState.resolveCurrentNavigation = resolveCurrentNavigation; return currentNavigation; } /** * Called when the router applies a history traversal (Back/Forward restore) while * the testing lock is active. A traversal is not a capture — the mental model is * that history entries are already cached — so it must not participate in the * current capture. Instead it resets the lock to a fresh pending scope: * * - `releaseLock` flushes every still-withheld write from prior forward * navigations, so the pages you navigated away from finish streaming. * - `acquireLock` immediately re-arms a fresh pending scope (no gap where the * lock or fetch blocker is down). * - the cookie flips from the captured state back to pending. * * The traversal's own dynamic requests are spawned ungated by the caller (see * `restore-reducer`), so they render from cache or fetch normally rather than * being withheld. */ export function resetNavigationLockToPending() { if (lockState === null || typeof document === 'undefined') { return; } releaseLock(); acquireLock(); writeCookieValue([ 0, `c${Math.random()}` ]); } /** * Returns true if the request targets a dev-server endpoint — one of the * hot-reloader middleware routes (error overlay, source maps, launch-editor, * devtools). They all share the `/__nextjs_` path prefix and are always * requested root-relative on the same origin. */ function isDevServerRequest(input) { let url; try { url = new URL(typeof input === 'string' ? input : input instanceof URL ? input : input.url, window.location.href); } catch { return false; } return url.origin === window.location.origin && url.pathname.startsWith('/__nextjs_'); } /** * Global fetch override * * While the navigation lock is active, we install this as `window.fetch` so * out-of-band client-side fetches (e.g. `fetch('/api/data')` inside a * useEffect) are blocked until the lock is released. Next.js internals * bypass the override by importing `fetch` from `./fetch`, which reads the * captured pre-lock fetch via `getPreLockFetch`. * * NOTE: This override only affects environments where the Instant Navigation * Testing API is enabled. It has no impact on live production behavior. */ function globalFetchOverride(input, init) { if (lockState === null) { // Lock is not active. Fall through to the global fetch — we reach this // only if a caller captured a reference to this function during a lock // scope and invoked it after release. return fetch(input, init); } if (process.env.__NEXT_DEV_SERVER && isDevServerRequest(input)) { // Dev-server requests must not be gated on the testing lock — blocking // them would break the error overlay, source maps, and devtools for the // whole scope. Dispatch immediately through the pre-lock fetch. Copy to a // local so the call doesn't bind `this` to the lock state object (native // fetch throws "Illegal invocation" for a foreign receiver). const preLockFetch = lockState.fetch; return preLockFetch(input, init); } // Block user-initiated fetches until the lock is released, then dispatch // through the fetch captured at acquire time. Reading from `lockState` // (rather than `window.fetch`) pins to the capture even if `window.fetch` // is reassigned after release. const currentLock = lockState; return currentLock.released.then(()=>{ const preLockFetch = currentLock.fetch; return preLockFetch(input, init); }); } /** * 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 the server served a 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(); } }); } // Acquire the lock before writing the cookie. writeCookieValue's // guard requires lockState to be non-null at call time (so a stale // write can't outlive its scope). On a fresh page load that scope // is the one we're about to establish, so we have to establish it // first. acquireLock(); writeCookieValue([ 1, `c${Math.random()}`, null ]); } 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') { // External actor starting a new lock scope. if (lockState !== null) { // This can be the delayed CookieStore event for the pending // cookie that was already observed synchronously from // document.cookie. Keep the existing lock identity so work that // captured it keeps waiting on the same promise. return; } acquireLock(); } // Captured value (our own transition) or empty. Ignore. return; } } for (const cookie of event.deleted){ if (cookie.name === NEXT_INSTANT_TEST_COOKIE) { if (lockState === null) { // Either no lock is active, or this is the re-entrant change event // from the defensive clear below (which runs after releaseLock). // Nothing to release either way. return; } releaseLock(); // A captured write from this page's bootstrap can resurrect the // cookie in the narrow gap between the external delete and this // handler: writeCookieValue's guard only rejects the write once the // lock is torn down, which happens here. Now that the lock is // released, no further captured write can re-add the cookie, so clear // any entry that was resurrected in that gap. Otherwise an unlock // that falls back to a hard reload (when the shell has not yet // hydrated) would carry the stale cookie, be served the shell again, // and re-enter instant mode with no scope left to release it. if (typeof document !== 'undefined') { document.cookie = `${NEXT_INSTANT_TEST_COOKIE}=; Path=/; Max-Age=0`; } refreshOnInstantNavigationUnlock(); return; } } }); } /** * Transitions the cookie from pending to captured-SPA once the prefetch resolves * and the navigation is known to be an SPA. */ export function updateCapturedSPAToTree(fromTree, toTree) { writeCookieValue([ 1, `c${Math.random()}`, { from: fromTree, to: toTree } ]); } /** * Returns true if the navigation lock is currently active. */ export function isNavigationLocked() { if (lockState !== null) { return true; } // If `lockState` is null, fall back to reading the test cookie // synchronously from `document.cookie`. This accounts for a small race // between `cookieStore.set(...)` and its corresponding `change` event. // During that gap `lockState` is still null even though the cookie // indicates a new lock scope is starting. if (typeof document === 'undefined') { return false; } const allCookies = document.cookie; if (!allCookies.includes(NEXT_INSTANT_TEST_COOKIE)) { // Fast bail-out: in almost every navigation the test cookie is not // set at all. return false; } const target = NEXT_INSTANT_TEST_COOKIE + '='; for (const segment of allCookies.split(';')){ const trimmed = segment.trim(); if (trimmed.startsWith(target) && parseCookieValue(trimmed.slice(target.length)) === 'pending') { // The cookie was set by an external actor but the change event was not // yet dispatched. Acquire the lock synchronously. acquireLock(); return true; } } return false; } export function getCurrentNavigationLock() { return lockState; } /** * Returns the current locked navigation's withheld-data gate — the same * immutable promise `beginLockedNavigation` handed that navigation — or null * when no lock is held. For router work that spawns a dynamic write without * beginning a navigation of its own (refreshes, server actions, server * patches): it gates behind the navigation that is current when it spawns, so * the next locked navigation (or unlock) releases it. */ export function getCurrentNavigationGate() { return lockState !== null ? lockState.currentNavigation : null; } /** * Decides whether segment reads during a navigation should be restricted to * shell entries (every param substituted with Fallback) rather than matching * entries that vary on concrete route params. * * The testing tools (Navigation Inspector, instant()) simulate what a user * would see with a warm cache. When the lock is held, partial prefetching is * enabled for the target route, and no whole-route ("speculative") prefetch * would have been made, only the shell is prefetched — so that's all a * navigation should be allowed to match. A speculative prefetch happens for a * `<Link prefetch={true}>` or an eagerly-prefetched subtree, in which case the * concrete-param entry is genuinely warm and may be matched. * * Always returns false outside the testing API, via the aliased * `navigation-testing-lock.disabled` module. */ export function shouldRestrictNavigationToShell(rootPrefetchHints, linkFetchStrategy) { return isNavigationLocked() && (rootPrefetchHints & PrefetchHint.SubtreeHasPartialPrefetching) !== 0 && !subtreeHasSpeculativePrefetch(linkFetchStrategy, rootPrefetchHints); } //# sourceMappingURL=navigation-testing-lock.js.map