react-router
Version:
Declarative routing for React
416 lines (415 loc) • 15.7 kB
JavaScript
/**
* react-router v8.5.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
import { createPath, invariant } from "../../router/history.js";
import { DiscoveryCancelledError, getRoutePattern, prependBasename } from "../../router/utils.js";
import { createClientRoutes } from "./routes.js";
import { getManifestPath, getPathsWithAncestors, handleClientVersionMismatch, isFogOfWarEnabled } from "./fog-of-war.js";
import { useFrameworkContext } from "./components.js";
import * as React$1 from "react";
//#region lib/dom/ssr/route-discovery.ts
var RouteDiscoveryRuntime = class {
ssrInfo;
enabled;
discoveredPaths = /* @__PURE__ */ new Set();
nextPaths = /* @__PURE__ */ new Set();
state;
ready = false;
registrations = /* @__PURE__ */ new Set();
listeners = /* @__PURE__ */ new Set();
fullLoadErrorListeners = /* @__PURE__ */ new Set();
fullLoad;
loadedManifest;
mismatch;
fetchImplementation;
loadManifest;
onError;
constructor(ssrInfo, { fetch: fetchImplementation = (request) => fetch(request), loadManifest } = {}) {
this.ssrInfo = ssrInfo;
this.fetchImplementation = fetchImplementation;
this.loadManifest = loadManifest ?? (() => this.loadManifestAsset());
let { manifest, context } = ssrInfo;
this.enabled = isFogOfWarEnabled(context.routeDiscovery, context.ssr);
this.state = this.enabled && !manifest.hmr ? "partial" : "complete";
}
get router() {
invariant(this.ssrInfo.router, "Router unavailable for route discovery");
return this.ssrInfo.router;
}
async loadManifestAsset() {
let { manifest } = this.ssrInfo;
if (this.loadedManifest) return this.loadedManifest;
try {
await import(
/* @vite-ignore */
/* webpackIgnore: true */
manifest.url
);
invariant(window.__reactRouterManifest, "Unable to load the client manifest");
invariant(window.__reactRouterManifest !== manifest, "The full route manifest asset did not provide a manifest.");
this.loadedManifest = window.__reactRouterManifest;
return this.loadedManifest;
} finally {
window.__reactRouterManifest = manifest;
}
}
subscribe = (listener) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
update(state, ready = this.ready) {
this.state = state;
this.ready = ready;
this.listeners.forEach((listener) => listener());
}
markReady = () => {
if (!this.ready) this.update(this.state, true);
};
register(registration) {
this.registrations.add(registration);
return () => {
this.registrations.delete(registration);
};
}
isComplete() {
return this.state === "complete";
}
loadAllRoutes = () => {
if (this.isComplete()) return Promise.resolve();
if (this.fullLoad) return this.fullLoad;
let { manifest, routeModules, context } = this.ssrInfo;
this.fullLoad = Promise.resolve().then(async () => {
try {
let fullManifest = await this.loadManifest();
invariant(fullManifest.version === manifest.version, "The full route manifest does not match the running client version.");
applyManifestPatches(fullManifest.routes, manifest, routeModules, context.ssr, context.isSpaMode, this.router.patchRoutes, context.unstable_apiOnly === true);
this.nextPaths.clear();
this.update("complete");
} catch (error) {
this.fullLoadErrorListeners.forEach((listener) => listener(error));
this.update("partial");
throw error;
} finally {
this.fullLoad = void 0;
}
});
this.update("loading");
return this.fullLoad;
};
reportError(error) {
if (this.onError) {
let { location, matches } = this.router.state;
this.onError(error, {
location,
params: matches[0]?.params || {},
pattern: getRoutePattern(matches)
});
} else console.error(error);
}
async dispatchMismatch(source, reloadUrl) {
let { version } = this.ssrInfo.manifest;
let stopped = false;
let defaultBehavior;
let event = {
source,
reloadUrl,
version,
stopPropagation() {
stopped = true;
},
loadAllRoutes: this.loadAllRoutes,
defaultBehavior: () => {
defaultBehavior ??= Promise.resolve().then(async () => {
if (source !== "imperative" && !this.isComplete()) await handleClientVersionMismatch(true, version, reloadUrl);
});
return defaultBehavior;
}
};
let registrations = [...this.registrations].reverse().filter((registration) => registration.current.onManifestMismatch);
if (registrations.length === 0) return event.defaultBehavior();
let loadErrors = /* @__PURE__ */ new Set();
let onLoadError = (error) => loadErrors.add(error);
this.fullLoadErrorListeners.add(onLoadError);
try {
for (let registration of registrations) {
if (!this.registrations.has(registration)) continue;
try {
await registration.current.onManifestMismatch?.(event);
} catch (error) {
if (!loadErrors.has(error)) throw error;
this.reportError(error);
break;
}
if (stopped) break;
}
} finally {
this.fullLoadErrorListeners.delete(onLoadError);
}
}
requestUrl(paths) {
let { manifest, context } = this.ssrInfo;
let params = new URLSearchParams();
params.set("paths", paths.slice().sort().join(","));
params.set("version", manifest.version);
let url = new URL(getManifestPath(context.routeDiscovery.manifestPath, context.basename), context.unstable_apiServerOrigin ?? window.location.origin);
url.search = params.toString();
return url;
}
discoverRoutes = async (paths, options = {}) => {
if (options.signal?.aborted) return { type: "aborted" };
if (this.isComplete()) return { type: "success" };
let pathnames = paths.map((path) => {
invariant(path.startsWith("/") && !path.startsWith("//") && !path.includes("\\"), "discoverRoutes expects application-root paths beginning with a single '/'.");
return prependBasename({
pathname: new URL(path, window.location.origin).pathname,
basename: this.ssrInfo.context.basename || "/"
});
});
return this.discover(pathnames, "imperative", null, options.signal);
};
async discover(paths, source, reloadUrl, signal, patch = this.router.patchRoutes) {
let { manifest, routeModules, context } = this.ssrInfo;
if (signal?.aborted) return { type: "aborted" };
if (this.isComplete()) return { type: "success" };
let pending = getPathsWithAncestors(paths.filter((path) => !this.discoveredPaths.has(path)));
if (!pending.length) return { type: "success" };
let batches = [];
if (source !== "imperative") {
if (this.requestUrl(pending).href.length > 7680) {
this.nextPaths.clear();
return { type: "success" };
}
batches.push(pending);
} else {
let batch = [];
for (let path of pending) {
if (this.discoveredPaths.has(path)) continue;
if (this.requestUrl([path]).href.length > 7680) {
console.warn(`Unable to discover path exceeding the manifest request URL limit: ${path}`);
continue;
}
if (this.requestUrl([...batch, path]).href.length > 7680) {
batches.push(batch);
batch = [];
}
batch.push(path);
}
if (batch.length) batches.push(batch);
}
for (let batch of batches) try {
if (signal?.aborted) return { type: "aborted" };
if (this.isComplete()) return { type: "success" };
let response;
try {
response = await this.fetchImplementation(new Request(this.requestUrl(batch), { signal }), { type: "manifest" });
} catch (error) {
if (signal?.aborted) return { type: "aborted" };
if (this.isComplete()) return { type: "success" };
throw error;
}
if (signal?.aborted) return { type: "aborted" };
if (this.isComplete()) return { type: "success" };
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
if (response.status === 204 && response.headers.has("X-Remix-Reload-Document")) {
if (this.mismatch || [...this.registrations].some((r) => r.current.onManifestMismatch)) {
if (!this.mismatch) this.mismatch = Promise.resolve().then(() => this.dispatchMismatch(source, reloadUrl)).finally(() => {
this.mismatch = void 0;
});
await waitForDiscovery(this.mismatch, signal);
} else await waitForDiscovery(this.dispatchMismatch(source, reloadUrl), signal);
if (signal?.aborted) return { type: "aborted" };
return { type: this.isComplete() ? "success" : "version-mismatch" };
}
await handleClientVersionMismatch(false, manifest.version, reloadUrl);
let patches;
try {
patches = await response.json();
} catch (error) {
if (signal?.aborted) return { type: "aborted" };
if (this.isComplete()) return { type: "success" };
throw error;
}
if (signal?.aborted) return { type: "aborted" };
if (this.isComplete()) return { type: "success" };
applyManifestPatches(patches, manifest, routeModules, context.ssr, context.isSpaMode, patch, context.unstable_apiOnly === true);
batch.forEach((path) => {
if (this.discoveredPaths.size >= 1e3) this.discoveredPaths.delete(this.discoveredPaths.values().next().value);
this.discoveredPaths.add(path);
});
} catch (error) {
if (signal?.aborted) return { type: "aborted" };
throw error;
}
return { type: "success" };
}
};
function waitForDiscovery(promise, signal) {
return new Promise((resolve, reject) => {
let abort = () => resolve();
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
promise.then(() => resolve(), reject).finally(() => signal?.removeEventListener("abort", abort));
});
}
const serverLoad = () => Promise.resolve();
const serverDiscover = async () => ({ type: "success" });
/**
* Control lazy route discovery in Framework Mode with `future.unstable_customRouteDiscovery`
* enabled. This allows you to imperatively discover routes as well as add custom behavior
* when a manifest version mismatch is detected. Calling `loadAllRoutes` will load the entire
* route manifest for the current (potentially stale) client version - this requires that prior
* client artifacts are still available on your CDN _and_ you have not made breaking changes to
* the API contracts of your loaders and actions.
*
* See the [Route Discovery guide](../../how-to/route-discovery) for more information and examples.
*
* @example
* let { state, discoverRoutes, loadAllRoutes } = unstable_useRouteDiscovery({
* onManifestMismatch: async (event) => {
* // Eagerly load the complete route tree for this client without requiring a hard reload
* await event.loadAllRoutes();
* },
* });
*
* // Eagerly discover specific routes
* discoverRoutes(["/a", "/b"]);
*
* @name unstable_useRouteDiscovery
* @public
* @category Hooks
* @mode framework
* @param options An awaited manifest-mismatch handler.
* @returns The client version, full-manifest loading state, and discovery operations.
*/
function useRouteDiscovery(options = {}) {
let context = useFrameworkContext();
invariant(context.future.unstable_customRouteDiscovery, "unstable_useRouteDiscovery requires future.unstable_customRouteDiscovery to be enabled in react-router.config.ts.");
invariant(context.manifest.url, "unstable_useRouteDiscovery is not supported in RSC mode.");
let runtime = context.routeDiscoveryRuntime;
let registration = React$1.useRef({ current: options });
let hasHandlers = Boolean(options.onManifestMismatch);
React$1.useLayoutEffect(() => {
registration.current.current = options;
});
React$1.useLayoutEffect(() => {
if (hasHandlers) return runtime?.register(registration.current);
}, [runtime, hasHandlers]);
let state = context.routeDiscoveryState ?? (isFogOfWarEnabled(context.routeDiscovery, context.ssr) && !context.manifest.hmr ? "partial" : "complete");
return {
version: context.manifest.version,
state,
loadAllRoutes: runtime?.loadAllRoutes || serverLoad,
discoverRoutes: runtime?.discoverRoutes || serverDiscover
};
}
function getCustomPatchRoutesOnNavigationFunction(getRouter, runtime) {
if (!runtime.enabled) return;
return async ({ path, patch, signal, fetcherKey }) => {
let { state } = getRouter();
if ((await runtime.discover([path], fetcherKey != null ? "fetcher" : "navigation", fetcherKey != null ? window.location.href : createPath(state.navigation.location || state.location), signal, patch)).type === "version-mismatch") throw new DiscoveryCancelledError();
};
}
function useCustomRouteDiscovery(runtime, onError) {
React$1.useLayoutEffect(() => {
runtime.onError = onError;
}, [runtime, onError]);
let [state, setState] = React$1.useState(runtime.state);
React$1.useLayoutEffect(() => {
let updateState = () => {
let nextState = runtime.state;
React$1.startTransition(() => setState(nextState));
};
let unsubscribe = runtime.subscribe(updateState);
updateState();
return unsubscribe;
}, [runtime]);
React$1.useEffect(() => {
if (!runtime.enabled || window.navigator?.connection?.saveData === true) return;
let { discoveredPaths, nextPaths } = runtime;
function registerElement(el) {
let path = el.tagName === "FORM" ? el.getAttribute("action") : el.getAttribute("href");
if (!path) return;
let pathname = el.tagName === "A" ? el.pathname : new URL(path, window.location.origin).pathname;
if (!discoveredPaths.has(pathname)) nextPaths.add(pathname);
}
let active = false;
async function fetchPatches() {
if (!active || runtime.state === "complete") return;
document.querySelectorAll("a[data-discover], form[data-discover]").forEach(registerElement);
let lazyPaths = Array.from(nextPaths.keys()).filter((path) => {
if (discoveredPaths.has(path)) {
nextPaths.delete(path);
return false;
}
return true;
});
if (lazyPaths.length === 0) return;
try {
await runtime.discover(lazyPaths, "eager", null);
} catch (e) {
runtime.reportError(e);
}
}
let debouncedFetchPatches = debounce(fetchPatches, 100);
let observer = new MutationObserver(() => debouncedFetchPatches());
function updateDiscovery() {
let { ready, state } = runtime;
if (!ready || state === "complete") {
active = false;
observer.disconnect();
} else if (!active) {
active = true;
observer.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: [
"data-discover",
"href",
"action"
]
});
fetchPatches();
}
}
let unsubscribe = runtime.subscribe(updateDiscovery);
updateDiscovery();
return () => {
active = false;
unsubscribe();
observer.disconnect();
};
}, [runtime]);
return state;
}
function applyManifestPatches(serverPatches, manifest, routeModules, ssr, isSpaMode, patchRoutes, isApiOnly) {
let knownRoutes = new Set(Object.keys(manifest.routes));
let patches = Object.values(serverPatches).reduce((acc, route) => {
if (route && !knownRoutes.has(route.id)) acc[route.id] = route;
return acc;
}, {});
let parentIds = /* @__PURE__ */ new Set();
Object.values(patches).forEach((patch) => {
if (patch && (!patch.parentId || !patches[patch.parentId])) parentIds.add(patch.parentId);
});
parentIds.forEach((parentId) => patchRoutes(parentId || null, createClientRoutes(patches, routeModules, null, ssr, isSpaMode, parentId, void 0, void 0, isApiOnly)));
Object.assign(manifest.routes, patches);
}
function debounce(callback, wait) {
let timeoutId;
return (...args) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => callback(...args), wait);
};
}
//#endregion
export { RouteDiscoveryRuntime, getCustomPatchRoutesOnNavigationFunction, useCustomRouteDiscovery, useRouteDiscovery };