INDIE UNPKG

15.7 kBJavaScriptView Raw
1/**
2 * react-router v8.5.0
3 *
4 * Copyright (c) Remix Software Inc.
5 *
6 * This source code is licensed under the MIT license found in the
7 * LICENSE.md file in the root directory of this source tree.
8 *
9 * @license MIT
10 */
11import { createPath, invariant } from "../../router/history.js";
12import { DiscoveryCancelledError, getRoutePattern, prependBasename } from "../../router/utils.js";
13import { createClientRoutes } from "./routes.js";
14import { getManifestPath, getPathsWithAncestors, handleClientVersionMismatch, isFogOfWarEnabled } from "./fog-of-war.js";
15import { useFrameworkContext } from "./components.js";
16import * as React$1 from "react";
17//#region lib/dom/ssr/route-discovery.ts
18var RouteDiscoveryRuntime = class {
19 ssrInfo;
20 enabled;
21 discoveredPaths = /* @__PURE__ */ new Set();
22 nextPaths = /* @__PURE__ */ new Set();
23 state;
24 ready = false;
25 registrations = /* @__PURE__ */ new Set();
26 listeners = /* @__PURE__ */ new Set();
27 fullLoadErrorListeners = /* @__PURE__ */ 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 /* @vite-ignore */
52 /* webpackIgnore: true */
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 = /* @__PURE__ */ 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};
255function 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}
263const serverLoad = () => Promise.resolve();
264const serverDiscover = async () => ({ type: "success" });
265/**
266* Control lazy route discovery in Framework Mode with `future.unstable_customRouteDiscovery`
267* enabled. This allows you to imperatively discover routes as well as add custom behavior
268* when a manifest version mismatch is detected. Calling `loadAllRoutes` will load the entire
269* route manifest for the current (potentially stale) client version - this requires that prior
270* client artifacts are still available on your CDN _and_ you have not made breaking changes to
271* the API contracts of your loaders and actions.
272*
273* See the [Route Discovery guide](../../how-to/route-discovery) for more information and examples.
274*
275* @example
276* let { state, discoverRoutes, loadAllRoutes } = unstable_useRouteDiscovery({
277* onManifestMismatch: async (event) => {
278* // Eagerly load the complete route tree for this client without requiring a hard reload
279* await event.loadAllRoutes();
280* },
281* });
282*
283* // Eagerly discover specific routes
284* discoverRoutes(["/a", "/b"]);
285*
286* @name unstable_useRouteDiscovery
287* @public
288* @category Hooks
289* @mode framework
290* @param options An awaited manifest-mismatch handler.
291* @returns The client version, full-manifest loading state, and discovery operations.
292*/
293function 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}
314function 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}
321function 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}
394function 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 = /* @__PURE__ */ 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}
407function debounce(callback, wait) {
408 let timeoutId;
409 return (...args) => {
410 window.clearTimeout(timeoutId);
411 timeoutId = window.setTimeout(() => callback(...args), wait);
412 };
413}
414//#endregion
415export { RouteDiscoveryRuntime, getCustomPatchRoutesOnNavigationFunction, useCustomRouteDiscovery, useRouteDiscovery };