next
Version:
The React Framework
453 lines (451 loc) • 18.3 kB
JavaScript
/**
* 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.
*/ "use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
0 && (module.exports = {
beginLockedNavigation: null,
beginNavigationLockPrefetch: null,
finishNavigationLockPrefetchSpawning: null,
getCurrentNavigationGate: null,
getCurrentNavigationLock: null,
getPreLockFetch: null,
isNavigationLocked: null,
recordNavigationLockOwnedEntry: null,
resetNavigationLockToPending: null,
shouldRestrictNavigationToShell: null,
startListeningForInstantNavigationCookie: null,
trackNavigationLockPrefetchEntry: null,
updateCapturedSPAToTree: null
});
function _export(target, all) {
for(var name in all)Object.defineProperty(target, name, {
enumerable: true,
get: all[name]
});
}
_export(exports, {
beginLockedNavigation: function() {
return beginLockedNavigation;
},
beginNavigationLockPrefetch: function() {
return beginNavigationLockPrefetch;
},
finishNavigationLockPrefetchSpawning: function() {
return finishNavigationLockPrefetchSpawning;
},
getCurrentNavigationGate: function() {
return getCurrentNavigationGate;
},
getCurrentNavigationLock: function() {
return getCurrentNavigationLock;
},
getPreLockFetch: function() {
return getPreLockFetch;
},
isNavigationLocked: function() {
return isNavigationLocked;
},
recordNavigationLockOwnedEntry: function() {
return recordNavigationLockOwnedEntry;
},
resetNavigationLockToPending: function() {
return resetNavigationLockToPending;
},
shouldRestrictNavigationToShell: function() {
return shouldRestrictNavigationToShell;
},
startListeningForInstantNavigationCookie: function() {
return startListeningForInstantNavigationCookie;
},
trackNavigationLockPrefetchEntry: function() {
return trackNavigationLockPrefetchEntry;
},
updateCapturedSPAToTree: function() {
return updateCapturedSPAToTree;
}
});
const _approutertypes = require("../../../shared/lib/app-router-types");
const _approuterheaders = require("../app-router-headers");
const _useactionqueue = require("../use-action-queue");
const _scheduler = require("./scheduler");
const _cache = require("./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 = `${_approuterheaders.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(_approuterheaders.NEXT_INSTANT_TEST_COOKIE).then((existing)=>{
if (existing && lockState === lockAtCall && lockAtCall !== null) {
writeDocumentCookie(value, existing);
}
});
}
let lockState = null;
function getPreLockFetch() {
return lockState !== null ? lockState.fetch : null;
}
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;
}
function recordNavigationLockOwnedEntry(entry) {
if (lockState !== null) {
lockState.ownedEntries.add(entry);
}
}
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.
(0, _cache.waitForSegmentCacheEntry)(entry).then(onSettled, onSettled);
}
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();
}
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;
}
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);
});
}
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(_approuterheaders.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 === _approuterheaders.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 === _approuterheaders.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 = `${_approuterheaders.NEXT_INSTANT_TEST_COOKIE}=; Path=/; Max-Age=0`;
}
(0, _useactionqueue.refreshOnInstantNavigationUnlock)();
return;
}
}
});
}
function updateCapturedSPAToTree(fromTree, toTree) {
writeCookieValue([
1,
`c${Math.random()}`,
{
from: fromTree,
to: toTree
}
]);
}
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(_approuterheaders.NEXT_INSTANT_TEST_COOKIE)) {
// Fast bail-out: in almost every navigation the test cookie is not
// set at all.
return false;
}
const target = _approuterheaders.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;
}
function getCurrentNavigationLock() {
return lockState;
}
function getCurrentNavigationGate() {
return lockState !== null ? lockState.currentNavigation : null;
}
function shouldRestrictNavigationToShell(rootPrefetchHints, linkFetchStrategy) {
return isNavigationLocked() && (rootPrefetchHints & _approutertypes.PrefetchHint.SubtreeHasPartialPrefetching) !== 0 && !(0, _scheduler.subtreeHasSpeculativePrefetch)(linkFetchStrategy, rootPrefetchHints);
}
if ((typeof exports.default === 'function' || (typeof exports.default === 'object' && exports.default !== null)) && typeof exports.default.__esModule === 'undefined') {
Object.defineProperty(exports.default, '__esModule', { value: true });
Object.assign(exports.default, exports);
module.exports = exports.default;
}
//# sourceMappingURL=navigation-testing-lock.js.map