| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 |
|
| 11 | import { createPath, invariant } from "../../router/history.js";
|
| 12 | import { DiscoveryCancelledError, getRoutePattern, prependBasename } from "../../router/utils.js";
|
| 13 | import { createClientRoutes } from "./routes.js";
|
| 14 | import { getManifestPath, getPathsWithAncestors, handleClientVersionMismatch, isFogOfWarEnabled } from "./fog-of-war.js";
|
| 15 | import { useFrameworkContext } from "./components.js";
|
| 16 | import * as React$1 from "react";
|
| 17 |
|
| 18 | var RouteDiscoveryRuntime = class {
|
| 19 | ssrInfo;
|
| 20 | enabled;
|
| 21 | discoveredPaths = new Set();
|
| 22 | nextPaths = new Set();
|
| 23 | state;
|
| 24 | ready = false;
|
| 25 | registrations = new Set();
|
| 26 | listeners = new Set();
|
| 27 | fullLoadErrorListeners = new Set();
|
| 28 | fullLoad;
|
| 29 | loadedManifest;
|
| 30 | mismatch;
|
| 31 | fetchImplementation;
|
| 32 | loadManifest;
|
| 33 | onError;
|
| 34 | constructor(ssrInfo, { fetch: fetchImplementation = (request) => fetch(request), loadManifest } = {}) {
|
| 35 | this.ssrInfo = ssrInfo;
|
| 36 | this.fetchImplementation = fetchImplementation;
|
| 37 | this.loadManifest = loadManifest ?? (() => this.loadManifestAsset());
|
| 38 | let { manifest, context } = ssrInfo;
|
| 39 | this.enabled = isFogOfWarEnabled(context.routeDiscovery, context.ssr);
|
| 40 | this.state = this.enabled && !manifest.hmr ? "partial" : "complete";
|
| 41 | }
|
| 42 | get router() {
|
| 43 | invariant(this.ssrInfo.router, "Router unavailable for route discovery");
|
| 44 | return this.ssrInfo.router;
|
| 45 | }
|
| 46 | async loadManifestAsset() {
|
| 47 | let { manifest } = this.ssrInfo;
|
| 48 | if (this.loadedManifest) return this.loadedManifest;
|
| 49 | try {
|
| 50 | await import(
|
| 51 |
|
| 52 |
|
| 53 | manifest.url
|
| 54 | );
|
| 55 | invariant(window.__reactRouterManifest, "Unable to load the client manifest");
|
| 56 | invariant(window.__reactRouterManifest !== manifest, "The full route manifest asset did not provide a manifest.");
|
| 57 | this.loadedManifest = window.__reactRouterManifest;
|
| 58 | return this.loadedManifest;
|
| 59 | } finally {
|
| 60 | window.__reactRouterManifest = manifest;
|
| 61 | }
|
| 62 | }
|
| 63 | subscribe = (listener) => {
|
| 64 | this.listeners.add(listener);
|
| 65 | return () => {
|
| 66 | this.listeners.delete(listener);
|
| 67 | };
|
| 68 | };
|
| 69 | update(state, ready = this.ready) {
|
| 70 | this.state = state;
|
| 71 | this.ready = ready;
|
| 72 | this.listeners.forEach((listener) => listener());
|
| 73 | }
|
| 74 | markReady = () => {
|
| 75 | if (!this.ready) this.update(this.state, true);
|
| 76 | };
|
| 77 | register(registration) {
|
| 78 | this.registrations.add(registration);
|
| 79 | return () => {
|
| 80 | this.registrations.delete(registration);
|
| 81 | };
|
| 82 | }
|
| 83 | isComplete() {
|
| 84 | return this.state === "complete";
|
| 85 | }
|
| 86 | loadAllRoutes = () => {
|
| 87 | if (this.isComplete()) return Promise.resolve();
|
| 88 | if (this.fullLoad) return this.fullLoad;
|
| 89 | let { manifest, routeModules, context } = this.ssrInfo;
|
| 90 | this.fullLoad = Promise.resolve().then(async () => {
|
| 91 | try {
|
| 92 | let fullManifest = await this.loadManifest();
|
| 93 | invariant(fullManifest.version === manifest.version, "The full route manifest does not match the running client version.");
|
| 94 | applyManifestPatches(fullManifest.routes, manifest, routeModules, context.ssr, context.isSpaMode, this.router.patchRoutes, context.unstable_apiOnly === true);
|
| 95 | this.nextPaths.clear();
|
| 96 | this.update("complete");
|
| 97 | } catch (error) {
|
| 98 | this.fullLoadErrorListeners.forEach((listener) => listener(error));
|
| 99 | this.update("partial");
|
| 100 | throw error;
|
| 101 | } finally {
|
| 102 | this.fullLoad = void 0;
|
| 103 | }
|
| 104 | });
|
| 105 | this.update("loading");
|
| 106 | return this.fullLoad;
|
| 107 | };
|
| 108 | reportError(error) {
|
| 109 | if (this.onError) {
|
| 110 | let { location, matches } = this.router.state;
|
| 111 | this.onError(error, {
|
| 112 | location,
|
| 113 | params: matches[0]?.params || {},
|
| 114 | pattern: getRoutePattern(matches)
|
| 115 | });
|
| 116 | } else console.error(error);
|
| 117 | }
|
| 118 | async dispatchMismatch(source, reloadUrl) {
|
| 119 | let { version } = this.ssrInfo.manifest;
|
| 120 | let stopped = false;
|
| 121 | let defaultBehavior;
|
| 122 | let event = {
|
| 123 | source,
|
| 124 | reloadUrl,
|
| 125 | version,
|
| 126 | stopPropagation() {
|
| 127 | stopped = true;
|
| 128 | },
|
| 129 | loadAllRoutes: this.loadAllRoutes,
|
| 130 | defaultBehavior: () => {
|
| 131 | defaultBehavior ??= Promise.resolve().then(async () => {
|
| 132 | if (source !== "imperative" && !this.isComplete()) await handleClientVersionMismatch(true, version, reloadUrl);
|
| 133 | });
|
| 134 | return defaultBehavior;
|
| 135 | }
|
| 136 | };
|
| 137 | let registrations = [...this.registrations].reverse().filter((registration) => registration.current.onManifestMismatch);
|
| 138 | if (registrations.length === 0) return event.defaultBehavior();
|
| 139 | let loadErrors = new Set();
|
| 140 | let onLoadError = (error) => loadErrors.add(error);
|
| 141 | this.fullLoadErrorListeners.add(onLoadError);
|
| 142 | try {
|
| 143 | for (let registration of registrations) {
|
| 144 | if (!this.registrations.has(registration)) continue;
|
| 145 | try {
|
| 146 | await registration.current.onManifestMismatch?.(event);
|
| 147 | } catch (error) {
|
| 148 | if (!loadErrors.has(error)) throw error;
|
| 149 | this.reportError(error);
|
| 150 | break;
|
| 151 | }
|
| 152 | if (stopped) break;
|
| 153 | }
|
| 154 | } finally {
|
| 155 | this.fullLoadErrorListeners.delete(onLoadError);
|
| 156 | }
|
| 157 | }
|
| 158 | requestUrl(paths) {
|
| 159 | let { manifest, context } = this.ssrInfo;
|
| 160 | let params = new URLSearchParams();
|
| 161 | params.set("paths", paths.slice().sort().join(","));
|
| 162 | params.set("version", manifest.version);
|
| 163 | let url = new URL(getManifestPath(context.routeDiscovery.manifestPath, context.basename), context.unstable_apiServerOrigin ?? window.location.origin);
|
| 164 | url.search = params.toString();
|
| 165 | return url;
|
| 166 | }
|
| 167 | discoverRoutes = async (paths, options = {}) => {
|
| 168 | if (options.signal?.aborted) return { type: "aborted" };
|
| 169 | if (this.isComplete()) return { type: "success" };
|
| 170 | let pathnames = paths.map((path) => {
|
| 171 | invariant(path.startsWith("/") && !path.startsWith("//") && !path.includes("\\"), "discoverRoutes expects application-root paths beginning with a single '/'.");
|
| 172 | return prependBasename({
|
| 173 | pathname: new URL(path, window.location.origin).pathname,
|
| 174 | basename: this.ssrInfo.context.basename || "/"
|
| 175 | });
|
| 176 | });
|
| 177 | return this.discover(pathnames, "imperative", null, options.signal);
|
| 178 | };
|
| 179 | async discover(paths, source, reloadUrl, signal, patch = this.router.patchRoutes) {
|
| 180 | let { manifest, routeModules, context } = this.ssrInfo;
|
| 181 | if (signal?.aborted) return { type: "aborted" };
|
| 182 | if (this.isComplete()) return { type: "success" };
|
| 183 | let pending = getPathsWithAncestors(paths.filter((path) => !this.discoveredPaths.has(path)));
|
| 184 | if (!pending.length) return { type: "success" };
|
| 185 | let batches = [];
|
| 186 | if (source !== "imperative") {
|
| 187 | if (this.requestUrl(pending).href.length > 7680) {
|
| 188 | this.nextPaths.clear();
|
| 189 | return { type: "success" };
|
| 190 | }
|
| 191 | batches.push(pending);
|
| 192 | } else {
|
| 193 | let batch = [];
|
| 194 | for (let path of pending) {
|
| 195 | if (this.discoveredPaths.has(path)) continue;
|
| 196 | if (this.requestUrl([path]).href.length > 7680) {
|
| 197 | console.warn(`Unable to discover path exceeding the manifest request URL limit: ${path}`);
|
| 198 | continue;
|
| 199 | }
|
| 200 | if (this.requestUrl([...batch, path]).href.length > 7680) {
|
| 201 | batches.push(batch);
|
| 202 | batch = [];
|
| 203 | }
|
| 204 | batch.push(path);
|
| 205 | }
|
| 206 | if (batch.length) batches.push(batch);
|
| 207 | }
|
| 208 | for (let batch of batches) try {
|
| 209 | if (signal?.aborted) return { type: "aborted" };
|
| 210 | if (this.isComplete()) return { type: "success" };
|
| 211 | let response;
|
| 212 | try {
|
| 213 | response = await this.fetchImplementation(new Request(this.requestUrl(batch), { signal }), { type: "manifest" });
|
| 214 | } catch (error) {
|
| 215 | if (signal?.aborted) return { type: "aborted" };
|
| 216 | if (this.isComplete()) return { type: "success" };
|
| 217 | throw error;
|
| 218 | }
|
| 219 | if (signal?.aborted) return { type: "aborted" };
|
| 220 | if (this.isComplete()) return { type: "success" };
|
| 221 | if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
| 222 | if (response.status === 204 && response.headers.has("X-Remix-Reload-Document")) {
|
| 223 | if (this.mismatch || [...this.registrations].some((r) => r.current.onManifestMismatch)) {
|
| 224 | if (!this.mismatch) this.mismatch = Promise.resolve().then(() => this.dispatchMismatch(source, reloadUrl)).finally(() => {
|
| 225 | this.mismatch = void 0;
|
| 226 | });
|
| 227 | await waitForDiscovery(this.mismatch, signal);
|
| 228 | } else await waitForDiscovery(this.dispatchMismatch(source, reloadUrl), signal);
|
| 229 | if (signal?.aborted) return { type: "aborted" };
|
| 230 | return { type: this.isComplete() ? "success" : "version-mismatch" };
|
| 231 | }
|
| 232 | await handleClientVersionMismatch(false, manifest.version, reloadUrl);
|
| 233 | let patches;
|
| 234 | try {
|
| 235 | patches = await response.json();
|
| 236 | } catch (error) {
|
| 237 | if (signal?.aborted) return { type: "aborted" };
|
| 238 | if (this.isComplete()) return { type: "success" };
|
| 239 | throw error;
|
| 240 | }
|
| 241 | if (signal?.aborted) return { type: "aborted" };
|
| 242 | if (this.isComplete()) return { type: "success" };
|
| 243 | applyManifestPatches(patches, manifest, routeModules, context.ssr, context.isSpaMode, patch, context.unstable_apiOnly === true);
|
| 244 | batch.forEach((path) => {
|
| 245 | if (this.discoveredPaths.size >= 1e3) this.discoveredPaths.delete(this.discoveredPaths.values().next().value);
|
| 246 | this.discoveredPaths.add(path);
|
| 247 | });
|
| 248 | } catch (error) {
|
| 249 | if (signal?.aborted) return { type: "aborted" };
|
| 250 | throw error;
|
| 251 | }
|
| 252 | return { type: "success" };
|
| 253 | }
|
| 254 | };
|
| 255 | function waitForDiscovery(promise, signal) {
|
| 256 | return new Promise((resolve, reject) => {
|
| 257 | let abort = () => resolve();
|
| 258 | signal?.addEventListener("abort", abort, { once: true });
|
| 259 | if (signal?.aborted) abort();
|
| 260 | promise.then(() => resolve(), reject).finally(() => signal?.removeEventListener("abort", abort));
|
| 261 | });
|
| 262 | }
|
| 263 | const serverLoad = () => Promise.resolve();
|
| 264 | const serverDiscover = async () => ({ type: "success" });
|
| 265 | |
| 266 | |
| 267 | |
| 268 | |
| 269 | |
| 270 | |
| 271 | |
| 272 | |
| 273 | |
| 274 | |
| 275 | |
| 276 | |
| 277 | |
| 278 | |
| 279 | |
| 280 | |
| 281 | |
| 282 | |
| 283 | |
| 284 | |
| 285 | |
| 286 | |
| 287 | |
| 288 | |
| 289 | |
| 290 | |
| 291 | |
| 292 |
|
| 293 | function useRouteDiscovery(options = {}) {
|
| 294 | let context = useFrameworkContext();
|
| 295 | invariant(context.future.unstable_customRouteDiscovery, "unstable_useRouteDiscovery requires future.unstable_customRouteDiscovery to be enabled in react-router.config.ts.");
|
| 296 | invariant(context.manifest.url, "unstable_useRouteDiscovery is not supported in RSC mode.");
|
| 297 | let runtime = context.routeDiscoveryRuntime;
|
| 298 | let registration = React$1.useRef({ current: options });
|
| 299 | let hasHandlers = Boolean(options.onManifestMismatch);
|
| 300 | React$1.useLayoutEffect(() => {
|
| 301 | registration.current.current = options;
|
| 302 | });
|
| 303 | React$1.useLayoutEffect(() => {
|
| 304 | if (hasHandlers) return runtime?.register(registration.current);
|
| 305 | }, [runtime, hasHandlers]);
|
| 306 | let state = context.routeDiscoveryState ?? (isFogOfWarEnabled(context.routeDiscovery, context.ssr) && !context.manifest.hmr ? "partial" : "complete");
|
| 307 | return {
|
| 308 | version: context.manifest.version,
|
| 309 | state,
|
| 310 | loadAllRoutes: runtime?.loadAllRoutes || serverLoad,
|
| 311 | discoverRoutes: runtime?.discoverRoutes || serverDiscover
|
| 312 | };
|
| 313 | }
|
| 314 | function getCustomPatchRoutesOnNavigationFunction(getRouter, runtime) {
|
| 315 | if (!runtime.enabled) return;
|
| 316 | return async ({ path, patch, signal, fetcherKey }) => {
|
| 317 | let { state } = getRouter();
|
| 318 | 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();
|
| 319 | };
|
| 320 | }
|
| 321 | function useCustomRouteDiscovery(runtime, onError) {
|
| 322 | React$1.useLayoutEffect(() => {
|
| 323 | runtime.onError = onError;
|
| 324 | }, [runtime, onError]);
|
| 325 | let [state, setState] = React$1.useState(runtime.state);
|
| 326 | React$1.useLayoutEffect(() => {
|
| 327 | let updateState = () => {
|
| 328 | let nextState = runtime.state;
|
| 329 | React$1.startTransition(() => setState(nextState));
|
| 330 | };
|
| 331 | let unsubscribe = runtime.subscribe(updateState);
|
| 332 | updateState();
|
| 333 | return unsubscribe;
|
| 334 | }, [runtime]);
|
| 335 | React$1.useEffect(() => {
|
| 336 | if (!runtime.enabled || window.navigator?.connection?.saveData === true) return;
|
| 337 | let { discoveredPaths, nextPaths } = runtime;
|
| 338 | function registerElement(el) {
|
| 339 | let path = el.tagName === "FORM" ? el.getAttribute("action") : el.getAttribute("href");
|
| 340 | if (!path) return;
|
| 341 | let pathname = el.tagName === "A" ? el.pathname : new URL(path, window.location.origin).pathname;
|
| 342 | if (!discoveredPaths.has(pathname)) nextPaths.add(pathname);
|
| 343 | }
|
| 344 | let active = false;
|
| 345 | async function fetchPatches() {
|
| 346 | if (!active || runtime.state === "complete") return;
|
| 347 | document.querySelectorAll("a[data-discover], form[data-discover]").forEach(registerElement);
|
| 348 | let lazyPaths = Array.from(nextPaths.keys()).filter((path) => {
|
| 349 | if (discoveredPaths.has(path)) {
|
| 350 | nextPaths.delete(path);
|
| 351 | return false;
|
| 352 | }
|
| 353 | return true;
|
| 354 | });
|
| 355 | if (lazyPaths.length === 0) return;
|
| 356 | try {
|
| 357 | await runtime.discover(lazyPaths, "eager", null);
|
| 358 | } catch (e) {
|
| 359 | runtime.reportError(e);
|
| 360 | }
|
| 361 | }
|
| 362 | let debouncedFetchPatches = debounce(fetchPatches, 100);
|
| 363 | let observer = new MutationObserver(() => debouncedFetchPatches());
|
| 364 | function updateDiscovery() {
|
| 365 | let { ready, state } = runtime;
|
| 366 | if (!ready || state === "complete") {
|
| 367 | active = false;
|
| 368 | observer.disconnect();
|
| 369 | } else if (!active) {
|
| 370 | active = true;
|
| 371 | observer.observe(document.documentElement, {
|
| 372 | subtree: true,
|
| 373 | childList: true,
|
| 374 | attributes: true,
|
| 375 | attributeFilter: [
|
| 376 | "data-discover",
|
| 377 | "href",
|
| 378 | "action"
|
| 379 | ]
|
| 380 | });
|
| 381 | fetchPatches();
|
| 382 | }
|
| 383 | }
|
| 384 | let unsubscribe = runtime.subscribe(updateDiscovery);
|
| 385 | updateDiscovery();
|
| 386 | return () => {
|
| 387 | active = false;
|
| 388 | unsubscribe();
|
| 389 | observer.disconnect();
|
| 390 | };
|
| 391 | }, [runtime]);
|
| 392 | return state;
|
| 393 | }
|
| 394 | function applyManifestPatches(serverPatches, manifest, routeModules, ssr, isSpaMode, patchRoutes, isApiOnly) {
|
| 395 | let knownRoutes = new Set(Object.keys(manifest.routes));
|
| 396 | let patches = Object.values(serverPatches).reduce((acc, route) => {
|
| 397 | if (route && !knownRoutes.has(route.id)) acc[route.id] = route;
|
| 398 | return acc;
|
| 399 | }, {});
|
| 400 | let parentIds = new Set();
|
| 401 | Object.values(patches).forEach((patch) => {
|
| 402 | if (patch && (!patch.parentId || !patches[patch.parentId])) parentIds.add(patch.parentId);
|
| 403 | });
|
| 404 | parentIds.forEach((parentId) => patchRoutes(parentId || null, createClientRoutes(patches, routeModules, null, ssr, isSpaMode, parentId, void 0, void 0, isApiOnly)));
|
| 405 | Object.assign(manifest.routes, patches);
|
| 406 | }
|
| 407 | function debounce(callback, wait) {
|
| 408 | let timeoutId;
|
| 409 | return (...args) => {
|
| 410 | window.clearTimeout(timeoutId);
|
| 411 | timeoutId = window.setTimeout(() => callback(...args), wait);
|
| 412 | };
|
| 413 | }
|
| 414 |
|
| 415 | export { RouteDiscoveryRuntime, getCustomPatchRoutesOnNavigationFunction, useCustomRouteDiscovery, useRouteDiscovery };
|