source: frontend/node_modules/@remix-run/router/router.ts

Last change on this file was 9af201e, checked in by MBK <marija.karapandzova@…>, 13 days ago

Fix frontend appearance

  • Property mode set to 100644
File size: 180.9 KB
Line 
1import type { History, Location, Path, To } from "./history";
2import {
3 Action as HistoryAction,
4 createLocation,
5 createPath,
6 invariant,
7 parsePath,
8 warning,
9} from "./history";
10import type {
11 AgnosticDataRouteMatch,
12 AgnosticDataRouteObject,
13 DataStrategyMatch,
14 AgnosticRouteObject,
15 DataResult,
16 DataStrategyFunction,
17 DataStrategyFunctionArgs,
18 DeferredData,
19 DeferredResult,
20 DetectErrorBoundaryFunction,
21 ErrorResult,
22 FormEncType,
23 FormMethod,
24 HTMLFormMethod,
25 DataStrategyResult,
26 ImmutableRouteKey,
27 MapRoutePropertiesFunction,
28 MutationFormMethod,
29 RedirectResult,
30 RouteData,
31 RouteManifest,
32 ShouldRevalidateFunctionArgs,
33 Submission,
34 SuccessResult,
35 UIMatch,
36 V7_FormMethod,
37 V7_MutationFormMethod,
38 AgnosticPatchRoutesOnNavigationFunction,
39 DataWithResponseInit,
40} from "./utils";
41import {
42 ErrorResponseImpl,
43 ResultType,
44 convertRouteMatchToUiMatch,
45 convertRoutesToDataRoutes,
46 getPathContributingMatches,
47 getResolveToMatches,
48 immutableRouteKeys,
49 isRouteErrorResponse,
50 joinPaths,
51 matchRoutes,
52 matchRoutesImpl,
53 resolveTo,
54 stripBasename,
55} from "./utils";
56
57////////////////////////////////////////////////////////////////////////////////
58//#region Types and Constants
59////////////////////////////////////////////////////////////////////////////////
60
61/**
62 * A Router instance manages all navigation and data loading/mutations
63 */
64export interface Router {
65 /**
66 * @internal
67 * PRIVATE - DO NOT USE
68 *
69 * Return the basename for the router
70 */
71 get basename(): RouterInit["basename"];
72
73 /**
74 * @internal
75 * PRIVATE - DO NOT USE
76 *
77 * Return the future config for the router
78 */
79 get future(): FutureConfig;
80
81 /**
82 * @internal
83 * PRIVATE - DO NOT USE
84 *
85 * Return the current state of the router
86 */
87 get state(): RouterState;
88
89 /**
90 * @internal
91 * PRIVATE - DO NOT USE
92 *
93 * Return the routes for this router instance
94 */
95 get routes(): AgnosticDataRouteObject[];
96
97 /**
98 * @internal
99 * PRIVATE - DO NOT USE
100 *
101 * Return the window associated with the router
102 */
103 get window(): RouterInit["window"];
104
105 /**
106 * @internal
107 * PRIVATE - DO NOT USE
108 *
109 * Initialize the router, including adding history listeners and kicking off
110 * initial data fetches. Returns a function to cleanup listeners and abort
111 * any in-progress loads
112 */
113 initialize(): Router;
114
115 /**
116 * @internal
117 * PRIVATE - DO NOT USE
118 *
119 * Subscribe to router.state updates
120 *
121 * @param fn function to call with the new state
122 */
123 subscribe(fn: RouterSubscriber): () => void;
124
125 /**
126 * @internal
127 * PRIVATE - DO NOT USE
128 *
129 * Enable scroll restoration behavior in the router
130 *
131 * @param savedScrollPositions Object that will manage positions, in case
132 * it's being restored from sessionStorage
133 * @param getScrollPosition Function to get the active Y scroll position
134 * @param getKey Function to get the key to use for restoration
135 */
136 enableScrollRestoration(
137 savedScrollPositions: Record<string, number>,
138 getScrollPosition: GetScrollPositionFunction,
139 getKey?: GetScrollRestorationKeyFunction
140 ): () => void;
141
142 /**
143 * @internal
144 * PRIVATE - DO NOT USE
145 *
146 * Navigate forward/backward in the history stack
147 * @param to Delta to move in the history stack
148 */
149 navigate(to: number): Promise<void>;
150
151 /**
152 * Navigate to the given path
153 * @param to Path to navigate to
154 * @param opts Navigation options (method, submission, etc.)
155 */
156 navigate(to: To | null, opts?: RouterNavigateOptions): Promise<void>;
157
158 /**
159 * @internal
160 * PRIVATE - DO NOT USE
161 *
162 * Trigger a fetcher load/submission
163 *
164 * @param key Fetcher key
165 * @param routeId Route that owns the fetcher
166 * @param href href to fetch
167 * @param opts Fetcher options, (method, submission, etc.)
168 */
169 fetch(
170 key: string,
171 routeId: string,
172 href: string | null,
173 opts?: RouterFetchOptions
174 ): void;
175
176 /**
177 * @internal
178 * PRIVATE - DO NOT USE
179 *
180 * Trigger a revalidation of all current route loaders and fetcher loads
181 */
182 revalidate(): void;
183
184 /**
185 * @internal
186 * PRIVATE - DO NOT USE
187 *
188 * Utility function to create an href for the given location
189 * @param location
190 */
191 createHref(location: Location | URL): string;
192
193 /**
194 * @internal
195 * PRIVATE - DO NOT USE
196 *
197 * Utility function to URL encode a destination path according to the internal
198 * history implementation
199 * @param to
200 */
201 encodeLocation(to: To): Path;
202
203 /**
204 * @internal
205 * PRIVATE - DO NOT USE
206 *
207 * Get/create a fetcher for the given key
208 * @param key
209 */
210 getFetcher<TData = any>(key: string): Fetcher<TData>;
211
212 /**
213 * @internal
214 * PRIVATE - DO NOT USE
215 *
216 * Delete the fetcher for a given key
217 * @param key
218 */
219 deleteFetcher(key: string): void;
220
221 /**
222 * @internal
223 * PRIVATE - DO NOT USE
224 *
225 * Cleanup listeners and abort any in-progress loads
226 */
227 dispose(): void;
228
229 /**
230 * @internal
231 * PRIVATE - DO NOT USE
232 *
233 * Get a navigation blocker
234 * @param key The identifier for the blocker
235 * @param fn The blocker function implementation
236 */
237 getBlocker(key: string, fn: BlockerFunction): Blocker;
238
239 /**
240 * @internal
241 * PRIVATE - DO NOT USE
242 *
243 * Delete a navigation blocker
244 * @param key The identifier for the blocker
245 */
246 deleteBlocker(key: string): void;
247
248 /**
249 * @internal
250 * PRIVATE DO NOT USE
251 *
252 * Patch additional children routes into an existing parent route
253 * @param routeId The parent route id or a callback function accepting `patch`
254 * to perform batch patching
255 * @param children The additional children routes
256 */
257 patchRoutes(routeId: string | null, children: AgnosticRouteObject[]): void;
258
259 /**
260 * @internal
261 * PRIVATE - DO NOT USE
262 *
263 * HMR needs to pass in-flight route updates to React Router
264 * TODO: Replace this with granular route update APIs (addRoute, updateRoute, deleteRoute)
265 */
266 _internalSetRoutes(routes: AgnosticRouteObject[]): void;
267
268 /**
269 * @internal
270 * PRIVATE - DO NOT USE
271 *
272 * Internal fetch AbortControllers accessed by unit tests
273 */
274 _internalFetchControllers: Map<string, AbortController>;
275
276 /**
277 * @internal
278 * PRIVATE - DO NOT USE
279 *
280 * Internal pending DeferredData instances accessed by unit tests
281 */
282 _internalActiveDeferreds: Map<string, DeferredData>;
283}
284
285/**
286 * State maintained internally by the router. During a navigation, all states
287 * reflect the the "old" location unless otherwise noted.
288 */
289export interface RouterState {
290 /**
291 * The action of the most recent navigation
292 */
293 historyAction: HistoryAction;
294
295 /**
296 * The current location reflected by the router
297 */
298 location: Location;
299
300 /**
301 * The current set of route matches
302 */
303 matches: AgnosticDataRouteMatch[];
304
305 /**
306 * Tracks whether we've completed our initial data load
307 */
308 initialized: boolean;
309
310 /**
311 * Current scroll position we should start at for a new view
312 * - number -> scroll position to restore to
313 * - false -> do not restore scroll at all (used during submissions)
314 * - null -> don't have a saved position, scroll to hash or top of page
315 */
316 restoreScrollPosition: number | false | null;
317
318 /**
319 * Indicate whether this navigation should skip resetting the scroll position
320 * if we are unable to restore the scroll position
321 */
322 preventScrollReset: boolean;
323
324 /**
325 * Tracks the state of the current navigation
326 */
327 navigation: Navigation;
328
329 /**
330 * Tracks any in-progress revalidations
331 */
332 revalidation: RevalidationState;
333
334 /**
335 * Data from the loaders for the current matches
336 */
337 loaderData: RouteData;
338
339 /**
340 * Data from the action for the current matches
341 */
342 actionData: RouteData | null;
343
344 /**
345 * Errors caught from loaders for the current matches
346 */
347 errors: RouteData | null;
348
349 /**
350 * Map of current fetchers
351 */
352 fetchers: Map<string, Fetcher>;
353
354 /**
355 * Map of current blockers
356 */
357 blockers: Map<string, Blocker>;
358}
359
360/**
361 * Data that can be passed into hydrate a Router from SSR
362 */
363export type HydrationState = Partial<
364 Pick<RouterState, "loaderData" | "actionData" | "errors">
365>;
366
367/**
368 * Future flags to toggle new feature behavior
369 */
370export interface FutureConfig {
371 v7_fetcherPersist: boolean;
372 v7_normalizeFormMethod: boolean;
373 v7_partialHydration: boolean;
374 v7_prependBasename: boolean;
375 v7_relativeSplatPath: boolean;
376 v7_skipActionErrorRevalidation: boolean;
377}
378
379/**
380 * Initialization options for createRouter
381 */
382export interface RouterInit {
383 routes: AgnosticRouteObject[];
384 history: History;
385 basename?: string;
386 /**
387 * @deprecated Use `mapRouteProperties` instead
388 */
389 detectErrorBoundary?: DetectErrorBoundaryFunction;
390 mapRouteProperties?: MapRoutePropertiesFunction;
391 future?: Partial<FutureConfig>;
392 hydrationData?: HydrationState;
393 window?: Window;
394 dataStrategy?: DataStrategyFunction;
395 patchRoutesOnNavigation?: AgnosticPatchRoutesOnNavigationFunction;
396}
397
398/**
399 * State returned from a server-side query() call
400 */
401export interface StaticHandlerContext {
402 basename: Router["basename"];
403 location: RouterState["location"];
404 matches: RouterState["matches"];
405 loaderData: RouterState["loaderData"];
406 actionData: RouterState["actionData"];
407 errors: RouterState["errors"];
408 statusCode: number;
409 loaderHeaders: Record<string, Headers>;
410 actionHeaders: Record<string, Headers>;
411 activeDeferreds: Record<string, DeferredData> | null;
412 _deepestRenderedBoundaryId?: string | null;
413}
414
415/**
416 * A StaticHandler instance manages a singular SSR navigation/fetch event
417 */
418export interface StaticHandler {
419 dataRoutes: AgnosticDataRouteObject[];
420 query(
421 request: Request,
422 opts?: {
423 requestContext?: unknown;
424 skipLoaderErrorBubbling?: boolean;
425 dataStrategy?: DataStrategyFunction;
426 }
427 ): Promise<StaticHandlerContext | Response>;
428 queryRoute(
429 request: Request,
430 opts?: {
431 routeId?: string;
432 requestContext?: unknown;
433 dataStrategy?: DataStrategyFunction;
434 }
435 ): Promise<any>;
436}
437
438type ViewTransitionOpts = {
439 currentLocation: Location;
440 nextLocation: Location;
441};
442
443/**
444 * Subscriber function signature for changes to router state
445 */
446export interface RouterSubscriber {
447 (
448 state: RouterState,
449 opts: {
450 deletedFetchers: string[];
451 viewTransitionOpts?: ViewTransitionOpts;
452 flushSync: boolean;
453 }
454 ): void;
455}
456
457/**
458 * Function signature for determining the key to be used in scroll restoration
459 * for a given location
460 */
461export interface GetScrollRestorationKeyFunction {
462 (location: Location, matches: UIMatch[]): string | null;
463}
464
465/**
466 * Function signature for determining the current scroll position
467 */
468export interface GetScrollPositionFunction {
469 (): number;
470}
471
472export type RelativeRoutingType = "route" | "path";
473
474// Allowed for any navigation or fetch
475type BaseNavigateOrFetchOptions = {
476 preventScrollReset?: boolean;
477 relative?: RelativeRoutingType;
478 flushSync?: boolean;
479};
480
481// Only allowed for navigations
482type BaseNavigateOptions = BaseNavigateOrFetchOptions & {
483 replace?: boolean;
484 state?: any;
485 fromRouteId?: string;
486 viewTransition?: boolean;
487};
488
489// Only allowed for submission navigations
490type BaseSubmissionOptions = {
491 formMethod?: HTMLFormMethod;
492 formEncType?: FormEncType;
493} & (
494 | { formData: FormData; body?: undefined }
495 | { formData?: undefined; body: any }
496);
497
498/**
499 * Options for a navigate() call for a normal (non-submission) navigation
500 */
501type LinkNavigateOptions = BaseNavigateOptions;
502
503/**
504 * Options for a navigate() call for a submission navigation
505 */
506type SubmissionNavigateOptions = BaseNavigateOptions & BaseSubmissionOptions;
507
508/**
509 * Options to pass to navigate() for a navigation
510 */
511export type RouterNavigateOptions =
512 | LinkNavigateOptions
513 | SubmissionNavigateOptions;
514
515/**
516 * Options for a fetch() load
517 */
518type LoadFetchOptions = BaseNavigateOrFetchOptions;
519
520/**
521 * Options for a fetch() submission
522 */
523type SubmitFetchOptions = BaseNavigateOrFetchOptions & BaseSubmissionOptions;
524
525/**
526 * Options to pass to fetch()
527 */
528export type RouterFetchOptions = LoadFetchOptions | SubmitFetchOptions;
529
530/**
531 * Potential states for state.navigation
532 */
533export type NavigationStates = {
534 Idle: {
535 state: "idle";
536 location: undefined;
537 formMethod: undefined;
538 formAction: undefined;
539 formEncType: undefined;
540 formData: undefined;
541 json: undefined;
542 text: undefined;
543 };
544 Loading: {
545 state: "loading";
546 location: Location;
547 formMethod: Submission["formMethod"] | undefined;
548 formAction: Submission["formAction"] | undefined;
549 formEncType: Submission["formEncType"] | undefined;
550 formData: Submission["formData"] | undefined;
551 json: Submission["json"] | undefined;
552 text: Submission["text"] | undefined;
553 };
554 Submitting: {
555 state: "submitting";
556 location: Location;
557 formMethod: Submission["formMethod"];
558 formAction: Submission["formAction"];
559 formEncType: Submission["formEncType"];
560 formData: Submission["formData"];
561 json: Submission["json"];
562 text: Submission["text"];
563 };
564};
565
566export type Navigation = NavigationStates[keyof NavigationStates];
567
568export type RevalidationState = "idle" | "loading";
569
570/**
571 * Potential states for fetchers
572 */
573type FetcherStates<TData = any> = {
574 Idle: {
575 state: "idle";
576 formMethod: undefined;
577 formAction: undefined;
578 formEncType: undefined;
579 text: undefined;
580 formData: undefined;
581 json: undefined;
582 data: TData | undefined;
583 };
584 Loading: {
585 state: "loading";
586 formMethod: Submission["formMethod"] | undefined;
587 formAction: Submission["formAction"] | undefined;
588 formEncType: Submission["formEncType"] | undefined;
589 text: Submission["text"] | undefined;
590 formData: Submission["formData"] | undefined;
591 json: Submission["json"] | undefined;
592 data: TData | undefined;
593 };
594 Submitting: {
595 state: "submitting";
596 formMethod: Submission["formMethod"];
597 formAction: Submission["formAction"];
598 formEncType: Submission["formEncType"];
599 text: Submission["text"];
600 formData: Submission["formData"];
601 json: Submission["json"];
602 data: TData | undefined;
603 };
604};
605
606export type Fetcher<TData = any> =
607 FetcherStates<TData>[keyof FetcherStates<TData>];
608
609interface BlockerBlocked {
610 state: "blocked";
611 reset(): void;
612 proceed(): void;
613 location: Location;
614}
615
616interface BlockerUnblocked {
617 state: "unblocked";
618 reset: undefined;
619 proceed: undefined;
620 location: undefined;
621}
622
623interface BlockerProceeding {
624 state: "proceeding";
625 reset: undefined;
626 proceed: undefined;
627 location: Location;
628}
629
630export type Blocker = BlockerUnblocked | BlockerBlocked | BlockerProceeding;
631
632export type BlockerFunction = (args: {
633 currentLocation: Location;
634 nextLocation: Location;
635 historyAction: HistoryAction;
636}) => boolean;
637
638interface ShortCircuitable {
639 /**
640 * startNavigation does not need to complete the navigation because we
641 * redirected or got interrupted
642 */
643 shortCircuited?: boolean;
644}
645
646type PendingActionResult = [string, SuccessResult | ErrorResult];
647
648interface HandleActionResult extends ShortCircuitable {
649 /**
650 * Route matches which may have been updated from fog of war discovery
651 */
652 matches?: RouterState["matches"];
653 /**
654 * Tuple for the returned or thrown value from the current action. The routeId
655 * is the action route for success and the bubbled boundary route for errors.
656 */
657 pendingActionResult?: PendingActionResult;
658}
659
660interface HandleLoadersResult extends ShortCircuitable {
661 /**
662 * Route matches which may have been updated from fog of war discovery
663 */
664 matches?: RouterState["matches"];
665 /**
666 * loaderData returned from the current set of loaders
667 */
668 loaderData?: RouterState["loaderData"];
669 /**
670 * errors thrown from the current set of loaders
671 */
672 errors?: RouterState["errors"];
673}
674
675/**
676 * Cached info for active fetcher.load() instances so they can participate
677 * in revalidation
678 */
679interface FetchLoadMatch {
680 routeId: string;
681 path: string;
682}
683
684/**
685 * Identified fetcher.load() calls that need to be revalidated
686 */
687interface RevalidatingFetcher extends FetchLoadMatch {
688 key: string;
689 match: AgnosticDataRouteMatch | null;
690 matches: AgnosticDataRouteMatch[] | null;
691 controller: AbortController | null;
692}
693
694const validMutationMethodsArr: MutationFormMethod[] = [
695 "post",
696 "put",
697 "patch",
698 "delete",
699];
700const validMutationMethods = new Set<MutationFormMethod>(
701 validMutationMethodsArr
702);
703
704const validRequestMethodsArr: FormMethod[] = [
705 "get",
706 ...validMutationMethodsArr,
707];
708const validRequestMethods = new Set<FormMethod>(validRequestMethodsArr);
709
710const redirectStatusCodes = new Set([301, 302, 303, 307, 308]);
711const redirectPreserveMethodStatusCodes = new Set([307, 308]);
712
713export const IDLE_NAVIGATION: NavigationStates["Idle"] = {
714 state: "idle",
715 location: undefined,
716 formMethod: undefined,
717 formAction: undefined,
718 formEncType: undefined,
719 formData: undefined,
720 json: undefined,
721 text: undefined,
722};
723
724export const IDLE_FETCHER: FetcherStates["Idle"] = {
725 state: "idle",
726 data: undefined,
727 formMethod: undefined,
728 formAction: undefined,
729 formEncType: undefined,
730 formData: undefined,
731 json: undefined,
732 text: undefined,
733};
734
735export const IDLE_BLOCKER: BlockerUnblocked = {
736 state: "unblocked",
737 proceed: undefined,
738 reset: undefined,
739 location: undefined,
740};
741
742const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
743
744const defaultMapRouteProperties: MapRoutePropertiesFunction = (route) => ({
745 hasErrorBoundary: Boolean(route.hasErrorBoundary),
746});
747
748const TRANSITIONS_STORAGE_KEY = "remix-router-transitions";
749
750//#endregion
751
752////////////////////////////////////////////////////////////////////////////////
753//#region createRouter
754////////////////////////////////////////////////////////////////////////////////
755
756/**
757 * Create a router and listen to history POP navigations
758 */
759export function createRouter(init: RouterInit): Router {
760 const routerWindow = init.window
761 ? init.window
762 : typeof window !== "undefined"
763 ? window
764 : undefined;
765 const isBrowser =
766 typeof routerWindow !== "undefined" &&
767 typeof routerWindow.document !== "undefined" &&
768 typeof routerWindow.document.createElement !== "undefined";
769 const isServer = !isBrowser;
770
771 invariant(
772 init.routes.length > 0,
773 "You must provide a non-empty routes array to createRouter"
774 );
775
776 let mapRouteProperties: MapRoutePropertiesFunction;
777 if (init.mapRouteProperties) {
778 mapRouteProperties = init.mapRouteProperties;
779 } else if (init.detectErrorBoundary) {
780 // If they are still using the deprecated version, wrap it with the new API
781 let detectErrorBoundary = init.detectErrorBoundary;
782 mapRouteProperties = (route) => ({
783 hasErrorBoundary: detectErrorBoundary(route),
784 });
785 } else {
786 mapRouteProperties = defaultMapRouteProperties;
787 }
788
789 // Routes keyed by ID
790 let manifest: RouteManifest = {};
791 // Routes in tree format for matching
792 let dataRoutes = convertRoutesToDataRoutes(
793 init.routes,
794 mapRouteProperties,
795 undefined,
796 manifest
797 );
798 let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined;
799 let basename = init.basename || "/";
800 let dataStrategyImpl = init.dataStrategy || defaultDataStrategy;
801 let patchRoutesOnNavigationImpl = init.patchRoutesOnNavigation;
802
803 // Config driven behavior flags
804 let future: FutureConfig = {
805 v7_fetcherPersist: false,
806 v7_normalizeFormMethod: false,
807 v7_partialHydration: false,
808 v7_prependBasename: false,
809 v7_relativeSplatPath: false,
810 v7_skipActionErrorRevalidation: false,
811 ...init.future,
812 };
813 // Cleanup function for history
814 let unlistenHistory: (() => void) | null = null;
815 // Externally-provided functions to call on all state changes
816 let subscribers = new Set<RouterSubscriber>();
817 // Externally-provided object to hold scroll restoration locations during routing
818 let savedScrollPositions: Record<string, number> | null = null;
819 // Externally-provided function to get scroll restoration keys
820 let getScrollRestorationKey: GetScrollRestorationKeyFunction | null = null;
821 // Externally-provided function to get current scroll position
822 let getScrollPosition: GetScrollPositionFunction | null = null;
823 // One-time flag to control the initial hydration scroll restoration. Because
824 // we don't get the saved positions from <ScrollRestoration /> until _after_
825 // the initial render, we need to manually trigger a separate updateState to
826 // send along the restoreScrollPosition
827 // Set to true if we have `hydrationData` since we assume we were SSR'd and that
828 // SSR did the initial scroll restoration.
829 let initialScrollRestored = init.hydrationData != null;
830
831 let initialMatches = matchRoutes(dataRoutes, init.history.location, basename);
832 let initialMatchesIsFOW = false;
833 let initialErrors: RouteData | null = null;
834
835 if (initialMatches == null && !patchRoutesOnNavigationImpl) {
836 // If we do not match a user-provided-route, fall back to the root
837 // to allow the error boundary to take over
838 let error = getInternalRouterError(404, {
839 pathname: init.history.location.pathname,
840 });
841 let { matches, route } = getShortCircuitMatches(dataRoutes);
842 initialMatches = matches;
843 initialErrors = { [route.id]: error };
844 }
845
846 // In SPA apps, if the user provided a patchRoutesOnNavigation implementation and
847 // our initial match is a splat route, clear them out so we run through lazy
848 // discovery on hydration in case there's a more accurate lazy route match.
849 // In SSR apps (with `hydrationData`), we expect that the server will send
850 // up the proper matched routes so we don't want to run lazy discovery on
851 // initial hydration and want to hydrate into the splat route.
852 if (initialMatches && !init.hydrationData) {
853 let fogOfWar = checkFogOfWar(
854 initialMatches,
855 dataRoutes,
856 init.history.location.pathname
857 );
858 if (fogOfWar.active) {
859 initialMatches = null;
860 }
861 }
862
863 let initialized: boolean;
864 if (!initialMatches) {
865 initialized = false;
866 initialMatches = [];
867
868 // If partial hydration and fog of war is enabled, we will be running
869 // `patchRoutesOnNavigation` during hydration so include any partial matches as
870 // the initial matches so we can properly render `HydrateFallback`'s
871 if (future.v7_partialHydration) {
872 let fogOfWar = checkFogOfWar(
873 null,
874 dataRoutes,
875 init.history.location.pathname
876 );
877 if (fogOfWar.active && fogOfWar.matches) {
878 initialMatchesIsFOW = true;
879 initialMatches = fogOfWar.matches;
880 }
881 }
882 } else if (initialMatches.some((m) => m.route.lazy)) {
883 // All initialMatches need to be loaded before we're ready. If we have lazy
884 // functions around still then we'll need to run them in initialize()
885 initialized = false;
886 } else if (!initialMatches.some((m) => m.route.loader)) {
887 // If we've got no loaders to run, then we're good to go
888 initialized = true;
889 } else if (future.v7_partialHydration) {
890 // If partial hydration is enabled, we're initialized so long as we were
891 // provided with hydrationData for every route with a loader, and no loaders
892 // were marked for explicit hydration
893 let loaderData = init.hydrationData ? init.hydrationData.loaderData : null;
894 let errors = init.hydrationData ? init.hydrationData.errors : null;
895 // If errors exist, don't consider routes below the boundary
896 if (errors) {
897 let idx = initialMatches.findIndex(
898 (m) => errors![m.route.id] !== undefined
899 );
900 initialized = initialMatches
901 .slice(0, idx + 1)
902 .every((m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors));
903 } else {
904 initialized = initialMatches.every(
905 (m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors)
906 );
907 }
908 } else {
909 // Without partial hydration - we're initialized if we were provided any
910 // hydrationData - which is expected to be complete
911 initialized = init.hydrationData != null;
912 }
913
914 let router: Router;
915 let state: RouterState = {
916 historyAction: init.history.action,
917 location: init.history.location,
918 matches: initialMatches,
919 initialized,
920 navigation: IDLE_NAVIGATION,
921 // Don't restore on initial updateState() if we were SSR'd
922 restoreScrollPosition: init.hydrationData != null ? false : null,
923 preventScrollReset: false,
924 revalidation: "idle",
925 loaderData: (init.hydrationData && init.hydrationData.loaderData) || {},
926 actionData: (init.hydrationData && init.hydrationData.actionData) || null,
927 errors: (init.hydrationData && init.hydrationData.errors) || initialErrors,
928 fetchers: new Map(),
929 blockers: new Map(),
930 };
931
932 // -- Stateful internal variables to manage navigations --
933 // Current navigation in progress (to be committed in completeNavigation)
934 let pendingAction: HistoryAction = HistoryAction.Pop;
935
936 // Should the current navigation prevent the scroll reset if scroll cannot
937 // be restored?
938 let pendingPreventScrollReset = false;
939
940 // AbortController for the active navigation
941 let pendingNavigationController: AbortController | null;
942
943 // Should the current navigation enable document.startViewTransition?
944 let pendingViewTransitionEnabled = false;
945
946 // Store applied view transitions so we can apply them on POP
947 let appliedViewTransitions: Map<string, Set<string>> = new Map<
948 string,
949 Set<string>
950 >();
951
952 // Cleanup function for persisting applied transitions to sessionStorage
953 let removePageHideEventListener: (() => void) | null = null;
954
955 // We use this to avoid touching history in completeNavigation if a
956 // revalidation is entirely uninterrupted
957 let isUninterruptedRevalidation = false;
958
959 // Use this internal flag to force revalidation of all loaders:
960 // - submissions (completed or interrupted)
961 // - useRevalidator()
962 // - X-Remix-Revalidate (from redirect)
963 let isRevalidationRequired = false;
964
965 // Use this internal array to capture routes that require revalidation due
966 // to a cancelled deferred on action submission
967 let cancelledDeferredRoutes: string[] = [];
968
969 // Use this internal array to capture fetcher loads that were cancelled by an
970 // action navigation and require revalidation
971 let cancelledFetcherLoads: Set<string> = new Set();
972
973 // AbortControllers for any in-flight fetchers
974 let fetchControllers = new Map<string, AbortController>();
975
976 // Track loads based on the order in which they started
977 let incrementingLoadId = 0;
978
979 // Track the outstanding pending navigation data load to be compared against
980 // the globally incrementing load when a fetcher load lands after a completed
981 // navigation
982 let pendingNavigationLoadId = -1;
983
984 // Fetchers that triggered data reloads as a result of their actions
985 let fetchReloadIds = new Map<string, number>();
986
987 // Fetchers that triggered redirect navigations
988 let fetchRedirectIds = new Set<string>();
989
990 // Most recent href/match for fetcher.load calls for fetchers
991 let fetchLoadMatches = new Map<string, FetchLoadMatch>();
992
993 // Ref-count mounted fetchers so we know when it's ok to clean them up
994 let activeFetchers = new Map<string, number>();
995
996 // Fetchers that have requested a delete when using v7_fetcherPersist,
997 // they'll be officially removed after they return to idle
998 let deletedFetchers = new Set<string>();
999
1000 // Store DeferredData instances for active route matches. When a
1001 // route loader returns defer() we stick one in here. Then, when a nested
1002 // promise resolves we update loaderData. If a new navigation starts we
1003 // cancel active deferreds for eliminated routes.
1004 let activeDeferreds = new Map<string, DeferredData>();
1005
1006 // Store blocker functions in a separate Map outside of router state since
1007 // we don't need to update UI state if they change
1008 let blockerFunctions = new Map<string, BlockerFunction>();
1009
1010 // Map of pending patchRoutesOnNavigation() promises (keyed by path/matches) so
1011 // that we only kick them off once for a given combo
1012 let pendingPatchRoutes = new Map<
1013 string,
1014 ReturnType<AgnosticPatchRoutesOnNavigationFunction>
1015 >();
1016
1017 // Flag to ignore the next history update, so we can revert the URL change on
1018 // a POP navigation that was blocked by the user without touching router state
1019 let unblockBlockerHistoryUpdate: (() => void) | undefined = undefined;
1020
1021 // Initialize the router, all side effects should be kicked off from here.
1022 // Implemented as a Fluent API for ease of:
1023 // let router = createRouter(init).initialize();
1024 function initialize() {
1025 // If history informs us of a POP navigation, start the navigation but do not update
1026 // state. We'll update our own state once the navigation completes
1027 unlistenHistory = init.history.listen(
1028 ({ action: historyAction, location, delta }) => {
1029 // Ignore this event if it was just us resetting the URL from a
1030 // blocked POP navigation
1031 if (unblockBlockerHistoryUpdate) {
1032 unblockBlockerHistoryUpdate();
1033 unblockBlockerHistoryUpdate = undefined;
1034 return;
1035 }
1036
1037 warning(
1038 blockerFunctions.size === 0 || delta != null,
1039 "You are trying to use a blocker on a POP navigation to a location " +
1040 "that was not created by @remix-run/router. This will fail silently in " +
1041 "production. This can happen if you are navigating outside the router " +
1042 "via `window.history.pushState`/`window.location.hash` instead of using " +
1043 "router navigation APIs. This can also happen if you are using " +
1044 "createHashRouter and the user manually changes the URL."
1045 );
1046
1047 let blockerKey = shouldBlockNavigation({
1048 currentLocation: state.location,
1049 nextLocation: location,
1050 historyAction,
1051 });
1052
1053 if (blockerKey && delta != null) {
1054 // Restore the URL to match the current UI, but don't update router state
1055 let nextHistoryUpdatePromise = new Promise<void>((resolve) => {
1056 unblockBlockerHistoryUpdate = resolve;
1057 });
1058 init.history.go(delta * -1);
1059
1060 // Put the blocker into a blocked state
1061 updateBlocker(blockerKey, {
1062 state: "blocked",
1063 location,
1064 proceed() {
1065 updateBlocker(blockerKey!, {
1066 state: "proceeding",
1067 proceed: undefined,
1068 reset: undefined,
1069 location,
1070 });
1071 // Re-do the same POP navigation we just blocked, after the url
1072 // restoration is also complete. See:
1073 // https://github.com/remix-run/react-router/issues/11613
1074 nextHistoryUpdatePromise.then(() => init.history.go(delta));
1075 },
1076 reset() {
1077 let blockers = new Map(state.blockers);
1078 blockers.set(blockerKey!, IDLE_BLOCKER);
1079 updateState({ blockers });
1080 },
1081 });
1082 return;
1083 }
1084
1085 return startNavigation(historyAction, location);
1086 }
1087 );
1088
1089 if (isBrowser) {
1090 // FIXME: This feels gross. How can we cleanup the lines between
1091 // scrollRestoration/appliedTransitions persistance?
1092 restoreAppliedTransitions(routerWindow, appliedViewTransitions);
1093 let _saveAppliedTransitions = () =>
1094 persistAppliedTransitions(routerWindow, appliedViewTransitions);
1095 routerWindow.addEventListener("pagehide", _saveAppliedTransitions);
1096 removePageHideEventListener = () =>
1097 routerWindow.removeEventListener("pagehide", _saveAppliedTransitions);
1098 }
1099
1100 // Kick off initial data load if needed. Use Pop to avoid modifying history
1101 // Note we don't do any handling of lazy here. For SPA's it'll get handled
1102 // in the normal navigation flow. For SSR it's expected that lazy modules are
1103 // resolved prior to router creation since we can't go into a fallbackElement
1104 // UI for SSR'd apps
1105 if (!state.initialized) {
1106 startNavigation(HistoryAction.Pop, state.location, {
1107 initialHydration: true,
1108 });
1109 }
1110
1111 return router;
1112 }
1113
1114 // Clean up a router and it's side effects
1115 function dispose() {
1116 if (unlistenHistory) {
1117 unlistenHistory();
1118 }
1119 if (removePageHideEventListener) {
1120 removePageHideEventListener();
1121 }
1122 subscribers.clear();
1123 pendingNavigationController && pendingNavigationController.abort();
1124 state.fetchers.forEach((_, key) => deleteFetcher(key));
1125 state.blockers.forEach((_, key) => deleteBlocker(key));
1126 }
1127
1128 // Subscribe to state updates for the router
1129 function subscribe(fn: RouterSubscriber) {
1130 subscribers.add(fn);
1131 return () => subscribers.delete(fn);
1132 }
1133
1134 // Update our state and notify the calling context of the change
1135 function updateState(
1136 newState: Partial<RouterState>,
1137 opts: {
1138 flushSync?: boolean;
1139 viewTransitionOpts?: ViewTransitionOpts;
1140 } = {}
1141 ): void {
1142 state = {
1143 ...state,
1144 ...newState,
1145 };
1146
1147 // Prep fetcher cleanup so we can tell the UI which fetcher data entries
1148 // can be removed
1149 let completedFetchers: string[] = [];
1150 let deletedFetchersKeys: string[] = [];
1151
1152 if (future.v7_fetcherPersist) {
1153 state.fetchers.forEach((fetcher, key) => {
1154 if (fetcher.state === "idle") {
1155 if (deletedFetchers.has(key)) {
1156 // Unmounted from the UI and can be totally removed
1157 deletedFetchersKeys.push(key);
1158 } else {
1159 // Returned to idle but still mounted in the UI, so semi-remains for
1160 // revalidations and such
1161 completedFetchers.push(key);
1162 }
1163 }
1164 });
1165 }
1166
1167 // Remove any lingering deleted fetchers that have already been removed
1168 // from state.fetchers
1169 deletedFetchers.forEach((key) => {
1170 if (!state.fetchers.has(key) && !fetchControllers.has(key)) {
1171 deletedFetchersKeys.push(key);
1172 }
1173 });
1174
1175 // Iterate over a local copy so that if flushSync is used and we end up
1176 // removing and adding a new subscriber due to the useCallback dependencies,
1177 // we don't get ourselves into a loop calling the new subscriber immediately
1178 [...subscribers].forEach((subscriber) =>
1179 subscriber(state, {
1180 deletedFetchers: deletedFetchersKeys,
1181 viewTransitionOpts: opts.viewTransitionOpts,
1182 flushSync: opts.flushSync === true,
1183 })
1184 );
1185
1186 // Remove idle fetchers from state since we only care about in-flight fetchers.
1187 if (future.v7_fetcherPersist) {
1188 completedFetchers.forEach((key) => state.fetchers.delete(key));
1189 deletedFetchersKeys.forEach((key) => deleteFetcher(key));
1190 } else {
1191 // We already called deleteFetcher() on these, can remove them from this
1192 // Set now that we've handed the keys off to the data layer
1193 deletedFetchersKeys.forEach((key) => deletedFetchers.delete(key));
1194 }
1195 }
1196
1197 // Complete a navigation returning the state.navigation back to the IDLE_NAVIGATION
1198 // and setting state.[historyAction/location/matches] to the new route.
1199 // - Location is a required param
1200 // - Navigation will always be set to IDLE_NAVIGATION
1201 // - Can pass any other state in newState
1202 function completeNavigation(
1203 location: Location,
1204 newState: Partial<Omit<RouterState, "action" | "location" | "navigation">>,
1205 { flushSync }: { flushSync?: boolean } = {}
1206 ): void {
1207 // Deduce if we're in a loading/actionReload state:
1208 // - We have committed actionData in the store
1209 // - The current navigation was a mutation submission
1210 // - We're past the submitting state and into the loading state
1211 // - The location being loaded is not the result of a redirect
1212 let isActionReload =
1213 state.actionData != null &&
1214 state.navigation.formMethod != null &&
1215 isMutationMethod(state.navigation.formMethod) &&
1216 state.navigation.state === "loading" &&
1217 location.state?._isRedirect !== true;
1218
1219 let actionData: RouteData | null;
1220 if (newState.actionData) {
1221 if (Object.keys(newState.actionData).length > 0) {
1222 actionData = newState.actionData;
1223 } else {
1224 // Empty actionData -> clear prior actionData due to an action error
1225 actionData = null;
1226 }
1227 } else if (isActionReload) {
1228 // Keep the current data if we're wrapping up the action reload
1229 actionData = state.actionData;
1230 } else {
1231 // Clear actionData on any other completed navigations
1232 actionData = null;
1233 }
1234
1235 // Always preserve any existing loaderData from re-used routes
1236 let loaderData = newState.loaderData
1237 ? mergeLoaderData(
1238 state.loaderData,
1239 newState.loaderData,
1240 newState.matches || [],
1241 newState.errors
1242 )
1243 : state.loaderData;
1244
1245 // On a successful navigation we can assume we got through all blockers
1246 // so we can start fresh
1247 let blockers = state.blockers;
1248 if (blockers.size > 0) {
1249 blockers = new Map(blockers);
1250 blockers.forEach((_, k) => blockers.set(k, IDLE_BLOCKER));
1251 }
1252
1253 // Always respect the user flag. Otherwise don't reset on mutation
1254 // submission navigations unless they redirect
1255 let preventScrollReset =
1256 pendingPreventScrollReset === true ||
1257 (state.navigation.formMethod != null &&
1258 isMutationMethod(state.navigation.formMethod) &&
1259 location.state?._isRedirect !== true);
1260
1261 // Commit any in-flight routes at the end of the HMR revalidation "navigation"
1262 if (inFlightDataRoutes) {
1263 dataRoutes = inFlightDataRoutes;
1264 inFlightDataRoutes = undefined;
1265 }
1266
1267 if (isUninterruptedRevalidation) {
1268 // If this was an uninterrupted revalidation then do not touch history
1269 } else if (pendingAction === HistoryAction.Pop) {
1270 // Do nothing for POP - URL has already been updated
1271 } else if (pendingAction === HistoryAction.Push) {
1272 init.history.push(location, location.state);
1273 } else if (pendingAction === HistoryAction.Replace) {
1274 init.history.replace(location, location.state);
1275 }
1276
1277 let viewTransitionOpts: ViewTransitionOpts | undefined;
1278
1279 // On POP, enable transitions if they were enabled on the original navigation
1280 if (pendingAction === HistoryAction.Pop) {
1281 // Forward takes precedence so they behave like the original navigation
1282 let priorPaths = appliedViewTransitions.get(state.location.pathname);
1283 if (priorPaths && priorPaths.has(location.pathname)) {
1284 viewTransitionOpts = {
1285 currentLocation: state.location,
1286 nextLocation: location,
1287 };
1288 } else if (appliedViewTransitions.has(location.pathname)) {
1289 // If we don't have a previous forward nav, assume we're popping back to
1290 // the new location and enable if that location previously enabled
1291 viewTransitionOpts = {
1292 currentLocation: location,
1293 nextLocation: state.location,
1294 };
1295 }
1296 } else if (pendingViewTransitionEnabled) {
1297 // Store the applied transition on PUSH/REPLACE
1298 let toPaths = appliedViewTransitions.get(state.location.pathname);
1299 if (toPaths) {
1300 toPaths.add(location.pathname);
1301 } else {
1302 toPaths = new Set<string>([location.pathname]);
1303 appliedViewTransitions.set(state.location.pathname, toPaths);
1304 }
1305 viewTransitionOpts = {
1306 currentLocation: state.location,
1307 nextLocation: location,
1308 };
1309 }
1310
1311 updateState(
1312 {
1313 ...newState, // matches, errors, fetchers go through as-is
1314 actionData,
1315 loaderData,
1316 historyAction: pendingAction,
1317 location,
1318 initialized: true,
1319 navigation: IDLE_NAVIGATION,
1320 revalidation: "idle",
1321 restoreScrollPosition: getSavedScrollPosition(
1322 location,
1323 newState.matches || state.matches
1324 ),
1325 preventScrollReset,
1326 blockers,
1327 },
1328 {
1329 viewTransitionOpts,
1330 flushSync: flushSync === true,
1331 }
1332 );
1333
1334 // Reset stateful navigation vars
1335 pendingAction = HistoryAction.Pop;
1336 pendingPreventScrollReset = false;
1337 pendingViewTransitionEnabled = false;
1338 isUninterruptedRevalidation = false;
1339 isRevalidationRequired = false;
1340 cancelledDeferredRoutes = [];
1341 }
1342
1343 // Trigger a navigation event, which can either be a numerical POP or a PUSH
1344 // replace with an optional submission
1345 async function navigate(
1346 to: number | To | null,
1347 opts?: RouterNavigateOptions
1348 ): Promise<void> {
1349 if (typeof to === "number") {
1350 init.history.go(to);
1351 return;
1352 }
1353
1354 let normalizedPath = normalizeTo(
1355 state.location,
1356 state.matches,
1357 basename,
1358 future.v7_prependBasename,
1359 to,
1360 future.v7_relativeSplatPath,
1361 opts?.fromRouteId,
1362 opts?.relative
1363 );
1364 let { path, submission, error } = normalizeNavigateOptions(
1365 future.v7_normalizeFormMethod,
1366 false,
1367 normalizedPath,
1368 opts
1369 );
1370
1371 let currentLocation = state.location;
1372 let nextLocation = createLocation(state.location, path, opts && opts.state);
1373
1374 // When using navigate as a PUSH/REPLACE we aren't reading an already-encoded
1375 // URL from window.location, so we need to encode it here so the behavior
1376 // remains the same as POP and non-data-router usages. new URL() does all
1377 // the same encoding we'd get from a history.pushState/window.location read
1378 // without having to touch history
1379 nextLocation = {
1380 ...nextLocation,
1381 ...init.history.encodeLocation(nextLocation),
1382 };
1383
1384 let userReplace = opts && opts.replace != null ? opts.replace : undefined;
1385
1386 let historyAction = HistoryAction.Push;
1387
1388 if (userReplace === true) {
1389 historyAction = HistoryAction.Replace;
1390 } else if (userReplace === false) {
1391 // no-op
1392 } else if (
1393 submission != null &&
1394 isMutationMethod(submission.formMethod) &&
1395 submission.formAction === state.location.pathname + state.location.search
1396 ) {
1397 // By default on submissions to the current location we REPLACE so that
1398 // users don't have to double-click the back button to get to the prior
1399 // location. If the user redirects to a different location from the
1400 // action/loader this will be ignored and the redirect will be a PUSH
1401 historyAction = HistoryAction.Replace;
1402 }
1403
1404 let preventScrollReset =
1405 opts && "preventScrollReset" in opts
1406 ? opts.preventScrollReset === true
1407 : undefined;
1408
1409 let flushSync = (opts && opts.flushSync) === true;
1410
1411 let blockerKey = shouldBlockNavigation({
1412 currentLocation,
1413 nextLocation,
1414 historyAction,
1415 });
1416
1417 if (blockerKey) {
1418 // Put the blocker into a blocked state
1419 updateBlocker(blockerKey, {
1420 state: "blocked",
1421 location: nextLocation,
1422 proceed() {
1423 updateBlocker(blockerKey!, {
1424 state: "proceeding",
1425 proceed: undefined,
1426 reset: undefined,
1427 location: nextLocation,
1428 });
1429 // Send the same navigation through
1430 navigate(to, opts);
1431 },
1432 reset() {
1433 let blockers = new Map(state.blockers);
1434 blockers.set(blockerKey!, IDLE_BLOCKER);
1435 updateState({ blockers });
1436 },
1437 });
1438 return;
1439 }
1440
1441 return await startNavigation(historyAction, nextLocation, {
1442 submission,
1443 // Send through the formData serialization error if we have one so we can
1444 // render at the right error boundary after we match routes
1445 pendingError: error,
1446 preventScrollReset,
1447 replace: opts && opts.replace,
1448 enableViewTransition: opts && opts.viewTransition,
1449 flushSync,
1450 });
1451 }
1452
1453 // Revalidate all current loaders. If a navigation is in progress or if this
1454 // is interrupted by a navigation, allow this to "succeed" by calling all
1455 // loaders during the next loader round
1456 function revalidate() {
1457 interruptActiveLoads();
1458 updateState({ revalidation: "loading" });
1459
1460 // If we're currently submitting an action, we don't need to start a new
1461 // navigation, we'll just let the follow up loader execution call all loaders
1462 if (state.navigation.state === "submitting") {
1463 return;
1464 }
1465
1466 // If we're currently in an idle state, start a new navigation for the current
1467 // action/location and mark it as uninterrupted, which will skip the history
1468 // update in completeNavigation
1469 if (state.navigation.state === "idle") {
1470 startNavigation(state.historyAction, state.location, {
1471 startUninterruptedRevalidation: true,
1472 });
1473 return;
1474 }
1475
1476 // Otherwise, if we're currently in a loading state, just start a new
1477 // navigation to the navigation.location but do not trigger an uninterrupted
1478 // revalidation so that history correctly updates once the navigation completes
1479 startNavigation(
1480 pendingAction || state.historyAction,
1481 state.navigation.location,
1482 {
1483 overrideNavigation: state.navigation,
1484 // Proxy through any rending view transition
1485 enableViewTransition: pendingViewTransitionEnabled === true,
1486 }
1487 );
1488 }
1489
1490 // Start a navigation to the given action/location. Can optionally provide a
1491 // overrideNavigation which will override the normalLoad in the case of a redirect
1492 // navigation
1493 async function startNavigation(
1494 historyAction: HistoryAction,
1495 location: Location,
1496 opts?: {
1497 initialHydration?: boolean;
1498 submission?: Submission;
1499 fetcherSubmission?: Submission;
1500 overrideNavigation?: Navigation;
1501 pendingError?: ErrorResponseImpl;
1502 startUninterruptedRevalidation?: boolean;
1503 preventScrollReset?: boolean;
1504 replace?: boolean;
1505 enableViewTransition?: boolean;
1506 flushSync?: boolean;
1507 }
1508 ): Promise<void> {
1509 // Abort any in-progress navigations and start a new one. Unset any ongoing
1510 // uninterrupted revalidations unless told otherwise, since we want this
1511 // new navigation to update history normally
1512 pendingNavigationController && pendingNavigationController.abort();
1513 pendingNavigationController = null;
1514 pendingAction = historyAction;
1515 isUninterruptedRevalidation =
1516 (opts && opts.startUninterruptedRevalidation) === true;
1517
1518 // Save the current scroll position every time we start a new navigation,
1519 // and track whether we should reset scroll on completion
1520 saveScrollPosition(state.location, state.matches);
1521 pendingPreventScrollReset = (opts && opts.preventScrollReset) === true;
1522
1523 pendingViewTransitionEnabled = (opts && opts.enableViewTransition) === true;
1524
1525 let routesToUse = inFlightDataRoutes || dataRoutes;
1526 let loadingNavigation = opts && opts.overrideNavigation;
1527 let matches =
1528 opts?.initialHydration &&
1529 state.matches &&
1530 state.matches.length > 0 &&
1531 !initialMatchesIsFOW
1532 ? // `matchRoutes()` has already been called if we're in here via `router.initialize()`
1533 state.matches
1534 : matchRoutes(routesToUse, location, basename);
1535 let flushSync = (opts && opts.flushSync) === true;
1536
1537 // Short circuit if it's only a hash change and not a revalidation or
1538 // mutation submission.
1539 //
1540 // Ignore on initial page loads because since the initial hydration will always
1541 // be "same hash". For example, on /page#hash and submit a <Form method="post">
1542 // which will default to a navigation to /page
1543 if (
1544 matches &&
1545 state.initialized &&
1546 !isRevalidationRequired &&
1547 isHashChangeOnly(state.location, location) &&
1548 !(opts && opts.submission && isMutationMethod(opts.submission.formMethod))
1549 ) {
1550 completeNavigation(location, { matches }, { flushSync });
1551 return;
1552 }
1553
1554 let fogOfWar = checkFogOfWar(matches, routesToUse, location.pathname);
1555 if (fogOfWar.active && fogOfWar.matches) {
1556 matches = fogOfWar.matches;
1557 }
1558
1559 // Short circuit with a 404 on the root error boundary if we match nothing
1560 if (!matches) {
1561 let { error, notFoundMatches, route } = handleNavigational404(
1562 location.pathname
1563 );
1564 completeNavigation(
1565 location,
1566 {
1567 matches: notFoundMatches,
1568 loaderData: {},
1569 errors: {
1570 [route.id]: error,
1571 },
1572 },
1573 { flushSync }
1574 );
1575 return;
1576 }
1577
1578 // Create a controller/Request for this navigation
1579 pendingNavigationController = new AbortController();
1580 let request = createClientSideRequest(
1581 init.history,
1582 location,
1583 pendingNavigationController.signal,
1584 opts && opts.submission
1585 );
1586 let pendingActionResult: PendingActionResult | undefined;
1587
1588 if (opts && opts.pendingError) {
1589 // If we have a pendingError, it means the user attempted a GET submission
1590 // with binary FormData so assign here and skip to handleLoaders. That
1591 // way we handle calling loaders above the boundary etc. It's not really
1592 // different from an actionError in that sense.
1593 pendingActionResult = [
1594 findNearestBoundary(matches).route.id,
1595 { type: ResultType.error, error: opts.pendingError },
1596 ];
1597 } else if (
1598 opts &&
1599 opts.submission &&
1600 isMutationMethod(opts.submission.formMethod)
1601 ) {
1602 // Call action if we received an action submission
1603 let actionResult = await handleAction(
1604 request,
1605 location,
1606 opts.submission,
1607 matches,
1608 fogOfWar.active,
1609 { replace: opts.replace, flushSync }
1610 );
1611
1612 if (actionResult.shortCircuited) {
1613 return;
1614 }
1615
1616 // If we received a 404 from handleAction, it's because we couldn't lazily
1617 // discover the destination route so we don't want to call loaders
1618 if (actionResult.pendingActionResult) {
1619 let [routeId, result] = actionResult.pendingActionResult;
1620 if (
1621 isErrorResult(result) &&
1622 isRouteErrorResponse(result.error) &&
1623 result.error.status === 404
1624 ) {
1625 pendingNavigationController = null;
1626
1627 completeNavigation(location, {
1628 matches: actionResult.matches,
1629 loaderData: {},
1630 errors: {
1631 [routeId]: result.error,
1632 },
1633 });
1634 return;
1635 }
1636 }
1637
1638 matches = actionResult.matches || matches;
1639 pendingActionResult = actionResult.pendingActionResult;
1640 loadingNavigation = getLoadingNavigation(location, opts.submission);
1641 flushSync = false;
1642 // No need to do fog of war matching again on loader execution
1643 fogOfWar.active = false;
1644
1645 // Create a GET request for the loaders
1646 request = createClientSideRequest(
1647 init.history,
1648 request.url,
1649 request.signal
1650 );
1651 }
1652
1653 // Call loaders
1654 let {
1655 shortCircuited,
1656 matches: updatedMatches,
1657 loaderData,
1658 errors,
1659 } = await handleLoaders(
1660 request,
1661 location,
1662 matches,
1663 fogOfWar.active,
1664 loadingNavigation,
1665 opts && opts.submission,
1666 opts && opts.fetcherSubmission,
1667 opts && opts.replace,
1668 opts && opts.initialHydration === true,
1669 flushSync,
1670 pendingActionResult
1671 );
1672
1673 if (shortCircuited) {
1674 return;
1675 }
1676
1677 // Clean up now that the action/loaders have completed. Don't clean up if
1678 // we short circuited because pendingNavigationController will have already
1679 // been assigned to a new controller for the next navigation
1680 pendingNavigationController = null;
1681
1682 completeNavigation(location, {
1683 matches: updatedMatches || matches,
1684 ...getActionDataForCommit(pendingActionResult),
1685 loaderData,
1686 errors,
1687 });
1688 }
1689
1690 // Call the action matched by the leaf route for this navigation and handle
1691 // redirects/errors
1692 async function handleAction(
1693 request: Request,
1694 location: Location,
1695 submission: Submission,
1696 matches: AgnosticDataRouteMatch[],
1697 isFogOfWar: boolean,
1698 opts: { replace?: boolean; flushSync?: boolean } = {}
1699 ): Promise<HandleActionResult> {
1700 interruptActiveLoads();
1701
1702 // Put us in a submitting state
1703 let navigation = getSubmittingNavigation(location, submission);
1704 updateState({ navigation }, { flushSync: opts.flushSync === true });
1705
1706 if (isFogOfWar) {
1707 let discoverResult = await discoverRoutes(
1708 matches,
1709 location.pathname,
1710 request.signal
1711 );
1712 if (discoverResult.type === "aborted") {
1713 return { shortCircuited: true };
1714 } else if (discoverResult.type === "error") {
1715 let boundaryId = findNearestBoundary(discoverResult.partialMatches)
1716 .route.id;
1717 return {
1718 matches: discoverResult.partialMatches,
1719 pendingActionResult: [
1720 boundaryId,
1721 {
1722 type: ResultType.error,
1723 error: discoverResult.error,
1724 },
1725 ],
1726 };
1727 } else if (!discoverResult.matches) {
1728 let { notFoundMatches, error, route } = handleNavigational404(
1729 location.pathname
1730 );
1731 return {
1732 matches: notFoundMatches,
1733 pendingActionResult: [
1734 route.id,
1735 {
1736 type: ResultType.error,
1737 error,
1738 },
1739 ],
1740 };
1741 } else {
1742 matches = discoverResult.matches;
1743 }
1744 }
1745
1746 // Call our action and get the result
1747 let result: DataResult;
1748 let actionMatch = getTargetMatch(matches, location);
1749
1750 if (!actionMatch.route.action && !actionMatch.route.lazy) {
1751 result = {
1752 type: ResultType.error,
1753 error: getInternalRouterError(405, {
1754 method: request.method,
1755 pathname: location.pathname,
1756 routeId: actionMatch.route.id,
1757 }),
1758 };
1759 } else {
1760 let results = await callDataStrategy(
1761 "action",
1762 state,
1763 request,
1764 [actionMatch],
1765 matches,
1766 null
1767 );
1768 result = results[actionMatch.route.id];
1769
1770 if (request.signal.aborted) {
1771 return { shortCircuited: true };
1772 }
1773 }
1774
1775 if (isRedirectResult(result)) {
1776 let replace: boolean;
1777 if (opts && opts.replace != null) {
1778 replace = opts.replace;
1779 } else {
1780 // If the user didn't explicity indicate replace behavior, replace if
1781 // we redirected to the exact same location we're currently at to avoid
1782 // double back-buttons
1783 let location = normalizeRedirectLocation(
1784 result.response.headers.get("Location")!,
1785 new URL(request.url),
1786 basename,
1787 init.history,
1788 );
1789 replace = location === state.location.pathname + state.location.search;
1790 }
1791 await startRedirectNavigation(request, result, true, {
1792 submission,
1793 replace,
1794 });
1795 return { shortCircuited: true };
1796 }
1797
1798 if (isDeferredResult(result)) {
1799 throw getInternalRouterError(400, { type: "defer-action" });
1800 }
1801
1802 if (isErrorResult(result)) {
1803 // Store off the pending error - we use it to determine which loaders
1804 // to call and will commit it when we complete the navigation
1805 let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id);
1806
1807 // By default, all submissions to the current location are REPLACE
1808 // navigations, but if the action threw an error that'll be rendered in
1809 // an errorElement, we fall back to PUSH so that the user can use the
1810 // back button to get back to the pre-submission form location to try
1811 // again
1812 if ((opts && opts.replace) !== true) {
1813 pendingAction = HistoryAction.Push;
1814 }
1815
1816 return {
1817 matches,
1818 pendingActionResult: [boundaryMatch.route.id, result],
1819 };
1820 }
1821
1822 return {
1823 matches,
1824 pendingActionResult: [actionMatch.route.id, result],
1825 };
1826 }
1827
1828 // Call all applicable loaders for the given matches, handling redirects,
1829 // errors, etc.
1830 async function handleLoaders(
1831 request: Request,
1832 location: Location,
1833 matches: AgnosticDataRouteMatch[],
1834 isFogOfWar: boolean,
1835 overrideNavigation?: Navigation,
1836 submission?: Submission,
1837 fetcherSubmission?: Submission,
1838 replace?: boolean,
1839 initialHydration?: boolean,
1840 flushSync?: boolean,
1841 pendingActionResult?: PendingActionResult
1842 ): Promise<HandleLoadersResult> {
1843 // Figure out the right navigation we want to use for data loading
1844 let loadingNavigation =
1845 overrideNavigation || getLoadingNavigation(location, submission);
1846
1847 // If this was a redirect from an action we don't have a "submission" but
1848 // we have it on the loading navigation so use that if available
1849 let activeSubmission =
1850 submission ||
1851 fetcherSubmission ||
1852 getSubmissionFromNavigation(loadingNavigation);
1853
1854 // If this is an uninterrupted revalidation, we remain in our current idle
1855 // state. If not, we need to switch to our loading state and load data,
1856 // preserving any new action data or existing action data (in the case of
1857 // a revalidation interrupting an actionReload)
1858 // If we have partialHydration enabled, then don't update the state for the
1859 // initial data load since it's not a "navigation"
1860 let shouldUpdateNavigationState =
1861 !isUninterruptedRevalidation &&
1862 (!future.v7_partialHydration || !initialHydration);
1863
1864 // When fog of war is enabled, we enter our `loading` state earlier so we
1865 // can discover new routes during the `loading` state. We skip this if
1866 // we've already run actions since we would have done our matching already.
1867 // If the children() function threw then, we want to proceed with the
1868 // partial matches it discovered.
1869 if (isFogOfWar) {
1870 if (shouldUpdateNavigationState) {
1871 let actionData = getUpdatedActionData(pendingActionResult);
1872 updateState(
1873 {
1874 navigation: loadingNavigation,
1875 ...(actionData !== undefined ? { actionData } : {}),
1876 },
1877 {
1878 flushSync,
1879 }
1880 );
1881 }
1882
1883 let discoverResult = await discoverRoutes(
1884 matches,
1885 location.pathname,
1886 request.signal
1887 );
1888
1889 if (discoverResult.type === "aborted") {
1890 return { shortCircuited: true };
1891 } else if (discoverResult.type === "error") {
1892 let boundaryId = findNearestBoundary(discoverResult.partialMatches)
1893 .route.id;
1894 return {
1895 matches: discoverResult.partialMatches,
1896 loaderData: {},
1897 errors: {
1898 [boundaryId]: discoverResult.error,
1899 },
1900 };
1901 } else if (!discoverResult.matches) {
1902 let { error, notFoundMatches, route } = handleNavigational404(
1903 location.pathname
1904 );
1905 return {
1906 matches: notFoundMatches,
1907 loaderData: {},
1908 errors: {
1909 [route.id]: error,
1910 },
1911 };
1912 } else {
1913 matches = discoverResult.matches;
1914 }
1915 }
1916
1917 let routesToUse = inFlightDataRoutes || dataRoutes;
1918 let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad(
1919 init.history,
1920 state,
1921 matches,
1922 activeSubmission,
1923 location,
1924 future.v7_partialHydration && initialHydration === true,
1925 future.v7_skipActionErrorRevalidation,
1926 isRevalidationRequired,
1927 cancelledDeferredRoutes,
1928 cancelledFetcherLoads,
1929 deletedFetchers,
1930 fetchLoadMatches,
1931 fetchRedirectIds,
1932 routesToUse,
1933 basename,
1934 pendingActionResult
1935 );
1936
1937 // Cancel pending deferreds for no-longer-matched routes or routes we're
1938 // about to reload. Note that if this is an action reload we would have
1939 // already cancelled all pending deferreds so this would be a no-op
1940 cancelActiveDeferreds(
1941 (routeId) =>
1942 !(matches && matches.some((m) => m.route.id === routeId)) ||
1943 (matchesToLoad && matchesToLoad.some((m) => m.route.id === routeId))
1944 );
1945
1946 pendingNavigationLoadId = ++incrementingLoadId;
1947
1948 // Short circuit if we have no loaders to run
1949 if (matchesToLoad.length === 0 && revalidatingFetchers.length === 0) {
1950 let updatedFetchers = markFetchRedirectsDone();
1951 completeNavigation(
1952 location,
1953 {
1954 matches,
1955 loaderData: {},
1956 // Commit pending error if we're short circuiting
1957 errors:
1958 pendingActionResult && isErrorResult(pendingActionResult[1])
1959 ? { [pendingActionResult[0]]: pendingActionResult[1].error }
1960 : null,
1961 ...getActionDataForCommit(pendingActionResult),
1962 ...(updatedFetchers ? { fetchers: new Map(state.fetchers) } : {}),
1963 },
1964 { flushSync }
1965 );
1966 return { shortCircuited: true };
1967 }
1968
1969 if (shouldUpdateNavigationState) {
1970 let updates: Partial<RouterState> = {};
1971 if (!isFogOfWar) {
1972 // Only update navigation/actionNData if we didn't already do it above
1973 updates.navigation = loadingNavigation;
1974 let actionData = getUpdatedActionData(pendingActionResult);
1975 if (actionData !== undefined) {
1976 updates.actionData = actionData;
1977 }
1978 }
1979 if (revalidatingFetchers.length > 0) {
1980 updates.fetchers = getUpdatedRevalidatingFetchers(revalidatingFetchers);
1981 }
1982 updateState(updates, { flushSync });
1983 }
1984
1985 revalidatingFetchers.forEach((rf) => {
1986 abortFetcher(rf.key);
1987 if (rf.controller) {
1988 // Fetchers use an independent AbortController so that aborting a fetcher
1989 // (via deleteFetcher) does not abort the triggering navigation that
1990 // triggered the revalidation
1991 fetchControllers.set(rf.key, rf.controller);
1992 }
1993 });
1994
1995 // Proxy navigation abort through to revalidation fetchers
1996 let abortPendingFetchRevalidations = () =>
1997 revalidatingFetchers.forEach((f) => abortFetcher(f.key));
1998 if (pendingNavigationController) {
1999 pendingNavigationController.signal.addEventListener(
2000 "abort",
2001 abortPendingFetchRevalidations
2002 );
2003 }
2004
2005 let { loaderResults, fetcherResults } =
2006 await callLoadersAndMaybeResolveData(
2007 state,
2008 matches,
2009 matchesToLoad,
2010 revalidatingFetchers,
2011 request
2012 );
2013
2014 if (request.signal.aborted) {
2015 return { shortCircuited: true };
2016 }
2017
2018 // Clean up _after_ loaders have completed. Don't clean up if we short
2019 // circuited because fetchControllers would have been aborted and
2020 // reassigned to new controllers for the next navigation
2021 if (pendingNavigationController) {
2022 pendingNavigationController.signal.removeEventListener(
2023 "abort",
2024 abortPendingFetchRevalidations
2025 );
2026 }
2027
2028 revalidatingFetchers.forEach((rf) => fetchControllers.delete(rf.key));
2029
2030 // If any loaders returned a redirect Response, start a new REPLACE navigation
2031 let redirect = findRedirect(loaderResults);
2032 if (redirect) {
2033 await startRedirectNavigation(request, redirect.result, true, {
2034 replace,
2035 });
2036 return { shortCircuited: true };
2037 }
2038
2039 redirect = findRedirect(fetcherResults);
2040 if (redirect) {
2041 // If this redirect came from a fetcher make sure we mark it in
2042 // fetchRedirectIds so it doesn't get revalidated on the next set of
2043 // loader executions
2044 fetchRedirectIds.add(redirect.key);
2045 await startRedirectNavigation(request, redirect.result, true, {
2046 replace,
2047 });
2048 return { shortCircuited: true };
2049 }
2050
2051 // Process and commit output from loaders
2052 let { loaderData, errors } = processLoaderData(
2053 state,
2054 matches,
2055 loaderResults,
2056 pendingActionResult,
2057 revalidatingFetchers,
2058 fetcherResults,
2059 activeDeferreds
2060 );
2061
2062 // Wire up subscribers to update loaderData as promises settle
2063 activeDeferreds.forEach((deferredData, routeId) => {
2064 deferredData.subscribe((aborted) => {
2065 // Note: No need to updateState here since the TrackedPromise on
2066 // loaderData is stable across resolve/reject
2067 // Remove this instance if we were aborted or if promises have settled
2068 if (aborted || deferredData.done) {
2069 activeDeferreds.delete(routeId);
2070 }
2071 });
2072 });
2073
2074 // Preserve SSR errors during partial hydration
2075 if (future.v7_partialHydration && initialHydration && state.errors) {
2076 errors = { ...state.errors, ...errors };
2077 }
2078
2079 let updatedFetchers = markFetchRedirectsDone();
2080 let didAbortFetchLoads = abortStaleFetchLoads(pendingNavigationLoadId);
2081 let shouldUpdateFetchers =
2082 updatedFetchers || didAbortFetchLoads || revalidatingFetchers.length > 0;
2083
2084 return {
2085 matches,
2086 loaderData,
2087 errors,
2088 ...(shouldUpdateFetchers ? { fetchers: new Map(state.fetchers) } : {}),
2089 };
2090 }
2091
2092 function getUpdatedActionData(
2093 pendingActionResult: PendingActionResult | undefined
2094 ): Record<string, RouteData> | null | undefined {
2095 if (pendingActionResult && !isErrorResult(pendingActionResult[1])) {
2096 // This is cast to `any` currently because `RouteData`uses any and it
2097 // would be a breaking change to use any.
2098 // TODO: v7 - change `RouteData` to use `unknown` instead of `any`
2099 return {
2100 [pendingActionResult[0]]: pendingActionResult[1].data as any,
2101 };
2102 } else if (state.actionData) {
2103 if (Object.keys(state.actionData).length === 0) {
2104 return null;
2105 } else {
2106 return state.actionData;
2107 }
2108 }
2109 }
2110
2111 function getUpdatedRevalidatingFetchers(
2112 revalidatingFetchers: RevalidatingFetcher[]
2113 ) {
2114 revalidatingFetchers.forEach((rf) => {
2115 let fetcher = state.fetchers.get(rf.key);
2116 let revalidatingFetcher = getLoadingFetcher(
2117 undefined,
2118 fetcher ? fetcher.data : undefined
2119 );
2120 state.fetchers.set(rf.key, revalidatingFetcher);
2121 });
2122 return new Map(state.fetchers);
2123 }
2124
2125 // Trigger a fetcher load/submit for the given fetcher key
2126 function fetch(
2127 key: string,
2128 routeId: string,
2129 href: string | null,
2130 opts?: RouterFetchOptions
2131 ) {
2132 if (isServer) {
2133 throw new Error(
2134 "router.fetch() was called during the server render, but it shouldn't be. " +
2135 "You are likely calling a useFetcher() method in the body of your component. " +
2136 "Try moving it to a useEffect or a callback."
2137 );
2138 }
2139
2140 abortFetcher(key);
2141
2142 let flushSync = (opts && opts.flushSync) === true;
2143
2144 let routesToUse = inFlightDataRoutes || dataRoutes;
2145 let normalizedPath = normalizeTo(
2146 state.location,
2147 state.matches,
2148 basename,
2149 future.v7_prependBasename,
2150 href,
2151 future.v7_relativeSplatPath,
2152 routeId,
2153 opts?.relative
2154 );
2155 let matches = matchRoutes(routesToUse, normalizedPath, basename);
2156
2157 let fogOfWar = checkFogOfWar(matches, routesToUse, normalizedPath);
2158 if (fogOfWar.active && fogOfWar.matches) {
2159 matches = fogOfWar.matches;
2160 }
2161
2162 if (!matches) {
2163 setFetcherError(
2164 key,
2165 routeId,
2166 getInternalRouterError(404, { pathname: normalizedPath }),
2167 { flushSync }
2168 );
2169 return;
2170 }
2171
2172 let { path, submission, error } = normalizeNavigateOptions(
2173 future.v7_normalizeFormMethod,
2174 true,
2175 normalizedPath,
2176 opts
2177 );
2178
2179 if (error) {
2180 setFetcherError(key, routeId, error, { flushSync });
2181 return;
2182 }
2183
2184 let match = getTargetMatch(matches, path);
2185
2186 let preventScrollReset = (opts && opts.preventScrollReset) === true;
2187
2188 if (submission && isMutationMethod(submission.formMethod)) {
2189 handleFetcherAction(
2190 key,
2191 routeId,
2192 path,
2193 match,
2194 matches,
2195 fogOfWar.active,
2196 flushSync,
2197 preventScrollReset,
2198 submission
2199 );
2200 return;
2201 }
2202
2203 // Store off the match so we can call it's shouldRevalidate on subsequent
2204 // revalidations
2205 fetchLoadMatches.set(key, { routeId, path });
2206 handleFetcherLoader(
2207 key,
2208 routeId,
2209 path,
2210 match,
2211 matches,
2212 fogOfWar.active,
2213 flushSync,
2214 preventScrollReset,
2215 submission
2216 );
2217 }
2218
2219 // Call the action for the matched fetcher.submit(), and then handle redirects,
2220 // errors, and revalidation
2221 async function handleFetcherAction(
2222 key: string,
2223 routeId: string,
2224 path: string,
2225 match: AgnosticDataRouteMatch,
2226 requestMatches: AgnosticDataRouteMatch[],
2227 isFogOfWar: boolean,
2228 flushSync: boolean,
2229 preventScrollReset: boolean,
2230 submission: Submission
2231 ) {
2232 interruptActiveLoads();
2233 fetchLoadMatches.delete(key);
2234
2235 function detectAndHandle405Error(m: AgnosticDataRouteMatch) {
2236 if (!m.route.action && !m.route.lazy) {
2237 let error = getInternalRouterError(405, {
2238 method: submission.formMethod,
2239 pathname: path,
2240 routeId: routeId,
2241 });
2242 setFetcherError(key, routeId, error, { flushSync });
2243 return true;
2244 }
2245 return false;
2246 }
2247
2248 if (!isFogOfWar && detectAndHandle405Error(match)) {
2249 return;
2250 }
2251
2252 // Put this fetcher into it's submitting state
2253 let existingFetcher = state.fetchers.get(key);
2254 updateFetcherState(key, getSubmittingFetcher(submission, existingFetcher), {
2255 flushSync,
2256 });
2257
2258 let abortController = new AbortController();
2259 let fetchRequest = createClientSideRequest(
2260 init.history,
2261 path,
2262 abortController.signal,
2263 submission
2264 );
2265
2266 if (isFogOfWar) {
2267 let discoverResult = await discoverRoutes(
2268 requestMatches,
2269 new URL(fetchRequest.url).pathname,
2270 fetchRequest.signal,
2271 key
2272 );
2273
2274 if (discoverResult.type === "aborted") {
2275 return;
2276 } else if (discoverResult.type === "error") {
2277 setFetcherError(key, routeId, discoverResult.error, { flushSync });
2278 return;
2279 } else if (!discoverResult.matches) {
2280 setFetcherError(
2281 key,
2282 routeId,
2283 getInternalRouterError(404, { pathname: path }),
2284 { flushSync }
2285 );
2286 return;
2287 } else {
2288 requestMatches = discoverResult.matches;
2289 match = getTargetMatch(requestMatches, path);
2290
2291 if (detectAndHandle405Error(match)) {
2292 return;
2293 }
2294 }
2295 }
2296
2297 // Call the action for the fetcher
2298 fetchControllers.set(key, abortController);
2299
2300 let originatingLoadId = incrementingLoadId;
2301 let actionResults = await callDataStrategy(
2302 "action",
2303 state,
2304 fetchRequest,
2305 [match],
2306 requestMatches,
2307 key
2308 );
2309 let actionResult = actionResults[match.route.id];
2310
2311 if (fetchRequest.signal.aborted) {
2312 // We can delete this so long as we weren't aborted by our own fetcher
2313 // re-submit which would have put _new_ controller is in fetchControllers
2314 if (fetchControllers.get(key) === abortController) {
2315 fetchControllers.delete(key);
2316 }
2317 return;
2318 }
2319
2320 // When using v7_fetcherPersist, we don't want errors bubbling up to the UI
2321 // or redirects processed for unmounted fetchers so we just revert them to
2322 // idle
2323 if (future.v7_fetcherPersist && deletedFetchers.has(key)) {
2324 if (isRedirectResult(actionResult) || isErrorResult(actionResult)) {
2325 updateFetcherState(key, getDoneFetcher(undefined));
2326 return;
2327 }
2328 // Let SuccessResult's fall through for revalidation
2329 } else {
2330 if (isRedirectResult(actionResult)) {
2331 fetchControllers.delete(key);
2332 if (pendingNavigationLoadId > originatingLoadId) {
2333 // A new navigation was kicked off after our action started, so that
2334 // should take precedence over this redirect navigation. We already
2335 // set isRevalidationRequired so all loaders for the new route should
2336 // fire unless opted out via shouldRevalidate
2337 updateFetcherState(key, getDoneFetcher(undefined));
2338 return;
2339 } else {
2340 fetchRedirectIds.add(key);
2341 updateFetcherState(key, getLoadingFetcher(submission));
2342 return startRedirectNavigation(fetchRequest, actionResult, false, {
2343 fetcherSubmission: submission,
2344 preventScrollReset,
2345 });
2346 }
2347 }
2348
2349 // Process any non-redirect errors thrown
2350 if (isErrorResult(actionResult)) {
2351 setFetcherError(key, routeId, actionResult.error);
2352 return;
2353 }
2354 }
2355
2356 if (isDeferredResult(actionResult)) {
2357 throw getInternalRouterError(400, { type: "defer-action" });
2358 }
2359
2360 // Start the data load for current matches, or the next location if we're
2361 // in the middle of a navigation
2362 let nextLocation = state.navigation.location || state.location;
2363 let revalidationRequest = createClientSideRequest(
2364 init.history,
2365 nextLocation,
2366 abortController.signal
2367 );
2368 let routesToUse = inFlightDataRoutes || dataRoutes;
2369 let matches =
2370 state.navigation.state !== "idle"
2371 ? matchRoutes(routesToUse, state.navigation.location, basename)
2372 : state.matches;
2373
2374 invariant(matches, "Didn't find any matches after fetcher action");
2375
2376 let loadId = ++incrementingLoadId;
2377 fetchReloadIds.set(key, loadId);
2378
2379 let loadFetcher = getLoadingFetcher(submission, actionResult.data);
2380 state.fetchers.set(key, loadFetcher);
2381
2382 let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad(
2383 init.history,
2384 state,
2385 matches,
2386 submission,
2387 nextLocation,
2388 false,
2389 future.v7_skipActionErrorRevalidation,
2390 isRevalidationRequired,
2391 cancelledDeferredRoutes,
2392 cancelledFetcherLoads,
2393 deletedFetchers,
2394 fetchLoadMatches,
2395 fetchRedirectIds,
2396 routesToUse,
2397 basename,
2398 [match.route.id, actionResult]
2399 );
2400
2401 // Put all revalidating fetchers into the loading state, except for the
2402 // current fetcher which we want to keep in it's current loading state which
2403 // contains it's action submission info + action data
2404 revalidatingFetchers
2405 .filter((rf) => rf.key !== key)
2406 .forEach((rf) => {
2407 let staleKey = rf.key;
2408 let existingFetcher = state.fetchers.get(staleKey);
2409 let revalidatingFetcher = getLoadingFetcher(
2410 undefined,
2411 existingFetcher ? existingFetcher.data : undefined
2412 );
2413 state.fetchers.set(staleKey, revalidatingFetcher);
2414 abortFetcher(staleKey);
2415 if (rf.controller) {
2416 fetchControllers.set(staleKey, rf.controller);
2417 }
2418 });
2419
2420 updateState({ fetchers: new Map(state.fetchers) });
2421
2422 let abortPendingFetchRevalidations = () =>
2423 revalidatingFetchers.forEach((rf) => abortFetcher(rf.key));
2424
2425 abortController.signal.addEventListener(
2426 "abort",
2427 abortPendingFetchRevalidations
2428 );
2429
2430 let { loaderResults, fetcherResults } =
2431 await callLoadersAndMaybeResolveData(
2432 state,
2433 matches,
2434 matchesToLoad,
2435 revalidatingFetchers,
2436 revalidationRequest
2437 );
2438
2439 if (abortController.signal.aborted) {
2440 return;
2441 }
2442
2443 abortController.signal.removeEventListener(
2444 "abort",
2445 abortPendingFetchRevalidations
2446 );
2447
2448 fetchReloadIds.delete(key);
2449 fetchControllers.delete(key);
2450 revalidatingFetchers.forEach((r) => fetchControllers.delete(r.key));
2451
2452 let redirect = findRedirect(loaderResults);
2453 if (redirect) {
2454 return startRedirectNavigation(
2455 revalidationRequest,
2456 redirect.result,
2457 false,
2458 { preventScrollReset }
2459 );
2460 }
2461
2462 redirect = findRedirect(fetcherResults);
2463 if (redirect) {
2464 // If this redirect came from a fetcher make sure we mark it in
2465 // fetchRedirectIds so it doesn't get revalidated on the next set of
2466 // loader executions
2467 fetchRedirectIds.add(redirect.key);
2468 return startRedirectNavigation(
2469 revalidationRequest,
2470 redirect.result,
2471 false,
2472 { preventScrollReset }
2473 );
2474 }
2475
2476 // Process and commit output from loaders
2477 let { loaderData, errors } = processLoaderData(
2478 state,
2479 matches,
2480 loaderResults,
2481 undefined,
2482 revalidatingFetchers,
2483 fetcherResults,
2484 activeDeferreds
2485 );
2486
2487 // Since we let revalidations complete even if the submitting fetcher was
2488 // deleted, only put it back to idle if it hasn't been deleted
2489 if (state.fetchers.has(key)) {
2490 let doneFetcher = getDoneFetcher(actionResult.data);
2491 state.fetchers.set(key, doneFetcher);
2492 }
2493
2494 abortStaleFetchLoads(loadId);
2495
2496 // If we are currently in a navigation loading state and this fetcher is
2497 // more recent than the navigation, we want the newer data so abort the
2498 // navigation and complete it with the fetcher data
2499 if (
2500 state.navigation.state === "loading" &&
2501 loadId > pendingNavigationLoadId
2502 ) {
2503 invariant(pendingAction, "Expected pending action");
2504 pendingNavigationController && pendingNavigationController.abort();
2505
2506 completeNavigation(state.navigation.location, {
2507 matches,
2508 loaderData,
2509 errors,
2510 fetchers: new Map(state.fetchers),
2511 });
2512 } else {
2513 // otherwise just update with the fetcher data, preserving any existing
2514 // loaderData for loaders that did not need to reload. We have to
2515 // manually merge here since we aren't going through completeNavigation
2516 updateState({
2517 errors,
2518 loaderData: mergeLoaderData(
2519 state.loaderData,
2520 loaderData,
2521 matches,
2522 errors
2523 ),
2524 fetchers: new Map(state.fetchers),
2525 });
2526 isRevalidationRequired = false;
2527 }
2528 }
2529
2530 // Call the matched loader for fetcher.load(), handling redirects, errors, etc.
2531 async function handleFetcherLoader(
2532 key: string,
2533 routeId: string,
2534 path: string,
2535 match: AgnosticDataRouteMatch,
2536 matches: AgnosticDataRouteMatch[],
2537 isFogOfWar: boolean,
2538 flushSync: boolean,
2539 preventScrollReset: boolean,
2540 submission?: Submission
2541 ) {
2542 let existingFetcher = state.fetchers.get(key);
2543 updateFetcherState(
2544 key,
2545 getLoadingFetcher(
2546 submission,
2547 existingFetcher ? existingFetcher.data : undefined
2548 ),
2549 { flushSync }
2550 );
2551
2552 let abortController = new AbortController();
2553 let fetchRequest = createClientSideRequest(
2554 init.history,
2555 path,
2556 abortController.signal
2557 );
2558
2559 if (isFogOfWar) {
2560 let discoverResult = await discoverRoutes(
2561 matches,
2562 new URL(fetchRequest.url).pathname,
2563 fetchRequest.signal,
2564 key
2565 );
2566
2567 if (discoverResult.type === "aborted") {
2568 return;
2569 } else if (discoverResult.type === "error") {
2570 setFetcherError(key, routeId, discoverResult.error, { flushSync });
2571 return;
2572 } else if (!discoverResult.matches) {
2573 setFetcherError(
2574 key,
2575 routeId,
2576 getInternalRouterError(404, { pathname: path }),
2577 { flushSync }
2578 );
2579 return;
2580 } else {
2581 matches = discoverResult.matches;
2582 match = getTargetMatch(matches, path);
2583 }
2584 }
2585
2586 // Call the loader for this fetcher route match
2587 fetchControllers.set(key, abortController);
2588
2589 let originatingLoadId = incrementingLoadId;
2590 let results = await callDataStrategy(
2591 "loader",
2592 state,
2593 fetchRequest,
2594 [match],
2595 matches,
2596 key
2597 );
2598 let result = results[match.route.id];
2599
2600 // Deferred isn't supported for fetcher loads, await everything and treat it
2601 // as a normal load. resolveDeferredData will return undefined if this
2602 // fetcher gets aborted, so we just leave result untouched and short circuit
2603 // below if that happens
2604 if (isDeferredResult(result)) {
2605 result =
2606 (await resolveDeferredData(result, fetchRequest.signal, true)) ||
2607 result;
2608 }
2609
2610 // We can delete this so long as we weren't aborted by our our own fetcher
2611 // re-load which would have put _new_ controller is in fetchControllers
2612 if (fetchControllers.get(key) === abortController) {
2613 fetchControllers.delete(key);
2614 }
2615
2616 if (fetchRequest.signal.aborted) {
2617 return;
2618 }
2619
2620 // We don't want errors bubbling up or redirects followed for unmounted
2621 // fetchers, so short circuit here if it was removed from the UI
2622 if (deletedFetchers.has(key)) {
2623 updateFetcherState(key, getDoneFetcher(undefined));
2624 return;
2625 }
2626
2627 // If the loader threw a redirect Response, start a new REPLACE navigation
2628 if (isRedirectResult(result)) {
2629 if (pendingNavigationLoadId > originatingLoadId) {
2630 // A new navigation was kicked off after our loader started, so that
2631 // should take precedence over this redirect navigation
2632 updateFetcherState(key, getDoneFetcher(undefined));
2633 return;
2634 } else {
2635 fetchRedirectIds.add(key);
2636 await startRedirectNavigation(fetchRequest, result, false, {
2637 preventScrollReset,
2638 });
2639 return;
2640 }
2641 }
2642
2643 // Process any non-redirect errors thrown
2644 if (isErrorResult(result)) {
2645 setFetcherError(key, routeId, result.error);
2646 return;
2647 }
2648
2649 invariant(!isDeferredResult(result), "Unhandled fetcher deferred data");
2650
2651 // Put the fetcher back into an idle state
2652 updateFetcherState(key, getDoneFetcher(result.data));
2653 }
2654
2655 /**
2656 * Utility function to handle redirects returned from an action or loader.
2657 * Normally, a redirect "replaces" the navigation that triggered it. So, for
2658 * example:
2659 *
2660 * - user is on /a
2661 * - user clicks a link to /b
2662 * - loader for /b redirects to /c
2663 *
2664 * In a non-JS app the browser would track the in-flight navigation to /b and
2665 * then replace it with /c when it encountered the redirect response. In
2666 * the end it would only ever update the URL bar with /c.
2667 *
2668 * In client-side routing using pushState/replaceState, we aim to emulate
2669 * this behavior and we also do not update history until the end of the
2670 * navigation (including processed redirects). This means that we never
2671 * actually touch history until we've processed redirects, so we just use
2672 * the history action from the original navigation (PUSH or REPLACE).
2673 */
2674 async function startRedirectNavigation(
2675 request: Request,
2676 redirect: RedirectResult,
2677 isNavigation: boolean,
2678 {
2679 submission,
2680 fetcherSubmission,
2681 preventScrollReset,
2682 replace,
2683 }: {
2684 submission?: Submission;
2685 fetcherSubmission?: Submission;
2686 preventScrollReset?: boolean;
2687 replace?: boolean;
2688 } = {}
2689 ) {
2690 if (redirect.response.headers.has("X-Remix-Revalidate")) {
2691 isRevalidationRequired = true;
2692 }
2693
2694 let location = redirect.response.headers.get("Location");
2695 invariant(location, "Expected a Location header on the redirect Response");
2696 location = normalizeRedirectLocation(
2697 location,
2698 new URL(request.url),
2699 basename,
2700 init.history,
2701 );
2702 let redirectLocation = createLocation(state.location, location, {
2703 _isRedirect: true,
2704 });
2705
2706 if (isBrowser) {
2707 let isDocumentReload = false;
2708
2709 if (redirect.response.headers.has("X-Remix-Reload-Document")) {
2710 // Hard reload if the response contained X-Remix-Reload-Document
2711 isDocumentReload = true;
2712 } else if (ABSOLUTE_URL_REGEX.test(location)) {
2713 const url = init.history.createURL(location);
2714 isDocumentReload =
2715 // Hard reload if it's an absolute URL to a new origin
2716 url.origin !== routerWindow.location.origin ||
2717 // Hard reload if it's an absolute URL that does not match our basename
2718 stripBasename(url.pathname, basename) == null;
2719 }
2720
2721 if (isDocumentReload) {
2722 if (replace) {
2723 routerWindow.location.replace(location);
2724 } else {
2725 routerWindow.location.assign(location);
2726 }
2727 return;
2728 }
2729 }
2730
2731 // There's no need to abort on redirects, since we don't detect the
2732 // redirect until the action/loaders have settled
2733 pendingNavigationController = null;
2734
2735 let redirectHistoryAction =
2736 replace === true || redirect.response.headers.has("X-Remix-Replace")
2737 ? HistoryAction.Replace
2738 : HistoryAction.Push;
2739
2740 // Use the incoming submission if provided, fallback on the active one in
2741 // state.navigation
2742 let { formMethod, formAction, formEncType } = state.navigation;
2743 if (
2744 !submission &&
2745 !fetcherSubmission &&
2746 formMethod &&
2747 formAction &&
2748 formEncType
2749 ) {
2750 submission = getSubmissionFromNavigation(state.navigation);
2751 }
2752
2753 // If this was a 307/308 submission we want to preserve the HTTP method and
2754 // re-submit the GET/POST/PUT/PATCH/DELETE as a submission navigation to the
2755 // redirected location
2756 let activeSubmission = submission || fetcherSubmission;
2757 if (
2758 redirectPreserveMethodStatusCodes.has(redirect.response.status) &&
2759 activeSubmission &&
2760 isMutationMethod(activeSubmission.formMethod)
2761 ) {
2762 await startNavigation(redirectHistoryAction, redirectLocation, {
2763 submission: {
2764 ...activeSubmission,
2765 formAction: location,
2766 },
2767 // Preserve these flags across redirects
2768 preventScrollReset: preventScrollReset || pendingPreventScrollReset,
2769 enableViewTransition: isNavigation
2770 ? pendingViewTransitionEnabled
2771 : undefined,
2772 });
2773 } else {
2774 // If we have a navigation submission, we will preserve it through the
2775 // redirect navigation
2776 let overrideNavigation = getLoadingNavigation(
2777 redirectLocation,
2778 submission
2779 );
2780 await startNavigation(redirectHistoryAction, redirectLocation, {
2781 overrideNavigation,
2782 // Send fetcher submissions through for shouldRevalidate
2783 fetcherSubmission,
2784 // Preserve these flags across redirects
2785 preventScrollReset: preventScrollReset || pendingPreventScrollReset,
2786 enableViewTransition: isNavigation
2787 ? pendingViewTransitionEnabled
2788 : undefined,
2789 });
2790 }
2791 }
2792
2793 // Utility wrapper for calling dataStrategy client-side without having to
2794 // pass around the manifest, mapRouteProperties, etc.
2795 async function callDataStrategy(
2796 type: "loader" | "action",
2797 state: RouterState,
2798 request: Request,
2799 matchesToLoad: AgnosticDataRouteMatch[],
2800 matches: AgnosticDataRouteMatch[],
2801 fetcherKey: string | null
2802 ): Promise<Record<string, DataResult>> {
2803 let results: Record<string, DataStrategyResult>;
2804 let dataResults: Record<string, DataResult> = {};
2805 try {
2806 results = await callDataStrategyImpl(
2807 dataStrategyImpl,
2808 type,
2809 state,
2810 request,
2811 matchesToLoad,
2812 matches,
2813 fetcherKey,
2814 manifest,
2815 mapRouteProperties
2816 );
2817 } catch (e) {
2818 // If the outer dataStrategy method throws, just return the error for all
2819 // matches - and it'll naturally bubble to the root
2820 matchesToLoad.forEach((m) => {
2821 dataResults[m.route.id] = {
2822 type: ResultType.error,
2823 error: e,
2824 };
2825 });
2826 return dataResults;
2827 }
2828
2829 for (let [routeId, result] of Object.entries(results)) {
2830 if (isRedirectDataStrategyResultResult(result)) {
2831 let response = result.result as Response;
2832 dataResults[routeId] = {
2833 type: ResultType.redirect,
2834 response: normalizeRelativeRoutingRedirectResponse(
2835 response,
2836 request,
2837 routeId,
2838 matches,
2839 basename,
2840 future.v7_relativeSplatPath
2841 ),
2842 };
2843 } else {
2844 dataResults[routeId] = await convertDataStrategyResultToDataResult(
2845 result
2846 );
2847 }
2848 }
2849
2850 return dataResults;
2851 }
2852
2853 async function callLoadersAndMaybeResolveData(
2854 state: RouterState,
2855 matches: AgnosticDataRouteMatch[],
2856 matchesToLoad: AgnosticDataRouteMatch[],
2857 fetchersToLoad: RevalidatingFetcher[],
2858 request: Request
2859 ) {
2860 let currentMatches = state.matches;
2861
2862 // Kick off loaders and fetchers in parallel
2863 let loaderResultsPromise = callDataStrategy(
2864 "loader",
2865 state,
2866 request,
2867 matchesToLoad,
2868 matches,
2869 null
2870 );
2871
2872 let fetcherResultsPromise = Promise.all(
2873 fetchersToLoad.map(async (f) => {
2874 if (f.matches && f.match && f.controller) {
2875 let results = await callDataStrategy(
2876 "loader",
2877 state,
2878 createClientSideRequest(init.history, f.path, f.controller.signal),
2879 [f.match],
2880 f.matches,
2881 f.key
2882 );
2883 let result = results[f.match.route.id];
2884 // Fetcher results are keyed by fetcher key from here on out, not routeId
2885 return { [f.key]: result };
2886 } else {
2887 return Promise.resolve({
2888 [f.key]: {
2889 type: ResultType.error,
2890 error: getInternalRouterError(404, {
2891 pathname: f.path,
2892 }),
2893 } as ErrorResult,
2894 });
2895 }
2896 })
2897 );
2898
2899 let loaderResults = await loaderResultsPromise;
2900 let fetcherResults = (await fetcherResultsPromise).reduce(
2901 (acc, r) => Object.assign(acc, r),
2902 {}
2903 );
2904
2905 await Promise.all([
2906 resolveNavigationDeferredResults(
2907 matches,
2908 loaderResults,
2909 request.signal,
2910 currentMatches,
2911 state.loaderData
2912 ),
2913 resolveFetcherDeferredResults(matches, fetcherResults, fetchersToLoad),
2914 ]);
2915
2916 return {
2917 loaderResults,
2918 fetcherResults,
2919 };
2920 }
2921
2922 function interruptActiveLoads() {
2923 // Every interruption triggers a revalidation
2924 isRevalidationRequired = true;
2925
2926 // Cancel pending route-level deferreds and mark cancelled routes for
2927 // revalidation
2928 cancelledDeferredRoutes.push(...cancelActiveDeferreds());
2929
2930 // Abort in-flight fetcher loads
2931 fetchLoadMatches.forEach((_, key) => {
2932 if (fetchControllers.has(key)) {
2933 cancelledFetcherLoads.add(key);
2934 }
2935 abortFetcher(key);
2936 });
2937 }
2938
2939 function updateFetcherState(
2940 key: string,
2941 fetcher: Fetcher,
2942 opts: { flushSync?: boolean } = {}
2943 ) {
2944 state.fetchers.set(key, fetcher);
2945 updateState(
2946 { fetchers: new Map(state.fetchers) },
2947 { flushSync: (opts && opts.flushSync) === true }
2948 );
2949 }
2950
2951 function setFetcherError(
2952 key: string,
2953 routeId: string,
2954 error: any,
2955 opts: { flushSync?: boolean } = {}
2956 ) {
2957 let boundaryMatch = findNearestBoundary(state.matches, routeId);
2958 deleteFetcher(key);
2959 updateState(
2960 {
2961 errors: {
2962 [boundaryMatch.route.id]: error,
2963 },
2964 fetchers: new Map(state.fetchers),
2965 },
2966 { flushSync: (opts && opts.flushSync) === true }
2967 );
2968 }
2969
2970 function getFetcher<TData = any>(key: string): Fetcher<TData> {
2971 activeFetchers.set(key, (activeFetchers.get(key) || 0) + 1);
2972 // If this fetcher was previously marked for deletion, unmark it since we
2973 // have a new instance
2974 if (deletedFetchers.has(key)) {
2975 deletedFetchers.delete(key);
2976 }
2977 return state.fetchers.get(key) || IDLE_FETCHER;
2978 }
2979
2980 function deleteFetcher(key: string): void {
2981 let fetcher = state.fetchers.get(key);
2982 // Don't abort the controller if this is a deletion of a fetcher.submit()
2983 // in it's loading phase since - we don't want to abort the corresponding
2984 // revalidation and want them to complete and land
2985 if (
2986 fetchControllers.has(key) &&
2987 !(fetcher && fetcher.state === "loading" && fetchReloadIds.has(key))
2988 ) {
2989 abortFetcher(key);
2990 }
2991 fetchLoadMatches.delete(key);
2992 fetchReloadIds.delete(key);
2993 fetchRedirectIds.delete(key);
2994
2995 // If we opted into the flag we can clear this now since we're calling
2996 // deleteFetcher() at the end of updateState() and we've already handed the
2997 // deleted fetcher keys off to the data layer.
2998 // If not, we're eagerly calling deleteFetcher() and we need to keep this
2999 // Set populated until the next updateState call, and we'll clear
3000 // `deletedFetchers` then
3001 if (future.v7_fetcherPersist) {
3002 deletedFetchers.delete(key);
3003 }
3004
3005 cancelledFetcherLoads.delete(key);
3006 state.fetchers.delete(key);
3007 }
3008
3009 function deleteFetcherAndUpdateState(key: string): void {
3010 let count = (activeFetchers.get(key) || 0) - 1;
3011 if (count <= 0) {
3012 activeFetchers.delete(key);
3013 deletedFetchers.add(key);
3014 if (!future.v7_fetcherPersist) {
3015 deleteFetcher(key);
3016 }
3017 } else {
3018 activeFetchers.set(key, count);
3019 }
3020
3021 updateState({ fetchers: new Map(state.fetchers) });
3022 }
3023
3024 function abortFetcher(key: string) {
3025 let controller = fetchControllers.get(key);
3026 if (controller) {
3027 controller.abort();
3028 fetchControllers.delete(key);
3029 }
3030 }
3031
3032 function markFetchersDone(keys: string[]) {
3033 for (let key of keys) {
3034 let fetcher = getFetcher(key);
3035 let doneFetcher = getDoneFetcher(fetcher.data);
3036 state.fetchers.set(key, doneFetcher);
3037 }
3038 }
3039
3040 function markFetchRedirectsDone(): boolean {
3041 let doneKeys = [];
3042 let updatedFetchers = false;
3043 for (let key of fetchRedirectIds) {
3044 let fetcher = state.fetchers.get(key);
3045 invariant(fetcher, `Expected fetcher: ${key}`);
3046 if (fetcher.state === "loading") {
3047 fetchRedirectIds.delete(key);
3048 doneKeys.push(key);
3049 updatedFetchers = true;
3050 }
3051 }
3052 markFetchersDone(doneKeys);
3053 return updatedFetchers;
3054 }
3055
3056 function abortStaleFetchLoads(landedId: number): boolean {
3057 let yeetedKeys = [];
3058 for (let [key, id] of fetchReloadIds) {
3059 if (id < landedId) {
3060 let fetcher = state.fetchers.get(key);
3061 invariant(fetcher, `Expected fetcher: ${key}`);
3062 if (fetcher.state === "loading") {
3063 abortFetcher(key);
3064 fetchReloadIds.delete(key);
3065 yeetedKeys.push(key);
3066 }
3067 }
3068 }
3069 markFetchersDone(yeetedKeys);
3070 return yeetedKeys.length > 0;
3071 }
3072
3073 function getBlocker(key: string, fn: BlockerFunction) {
3074 let blocker: Blocker = state.blockers.get(key) || IDLE_BLOCKER;
3075
3076 if (blockerFunctions.get(key) !== fn) {
3077 blockerFunctions.set(key, fn);
3078 }
3079
3080 return blocker;
3081 }
3082
3083 function deleteBlocker(key: string) {
3084 state.blockers.delete(key);
3085 blockerFunctions.delete(key);
3086 }
3087
3088 // Utility function to update blockers, ensuring valid state transitions
3089 function updateBlocker(key: string, newBlocker: Blocker) {
3090 let blocker = state.blockers.get(key) || IDLE_BLOCKER;
3091
3092 // Poor mans state machine :)
3093 // https://mermaid.live/edit#pako:eNqVkc9OwzAMxl8l8nnjAYrEtDIOHEBIgwvKJTReGy3_lDpIqO27k6awMG0XcrLlnz87nwdonESogKXXBuE79rq75XZO3-yHds0RJVuv70YrPlUrCEe2HfrORS3rubqZfuhtpg5C9wk5tZ4VKcRUq88q9Z8RS0-48cE1iHJkL0ugbHuFLus9L6spZy8nX9MP2CNdomVaposqu3fGayT8T8-jJQwhepo_UtpgBQaDEUom04dZhAN1aJBDlUKJBxE1ceB2Smj0Mln-IBW5AFU2dwUiktt_2Qaq2dBfaKdEup85UV7Yd-dKjlnkabl2Pvr0DTkTreM
3094 invariant(
3095 (blocker.state === "unblocked" && newBlocker.state === "blocked") ||
3096 (blocker.state === "blocked" && newBlocker.state === "blocked") ||
3097 (blocker.state === "blocked" && newBlocker.state === "proceeding") ||
3098 (blocker.state === "blocked" && newBlocker.state === "unblocked") ||
3099 (blocker.state === "proceeding" && newBlocker.state === "unblocked"),
3100 `Invalid blocker state transition: ${blocker.state} -> ${newBlocker.state}`
3101 );
3102
3103 let blockers = new Map(state.blockers);
3104 blockers.set(key, newBlocker);
3105 updateState({ blockers });
3106 }
3107
3108 function shouldBlockNavigation({
3109 currentLocation,
3110 nextLocation,
3111 historyAction,
3112 }: {
3113 currentLocation: Location;
3114 nextLocation: Location;
3115 historyAction: HistoryAction;
3116 }): string | undefined {
3117 if (blockerFunctions.size === 0) {
3118 return;
3119 }
3120
3121 // We ony support a single active blocker at the moment since we don't have
3122 // any compelling use cases for multi-blocker yet
3123 if (blockerFunctions.size > 1) {
3124 warning(false, "A router only supports one blocker at a time");
3125 }
3126
3127 let entries = Array.from(blockerFunctions.entries());
3128 let [blockerKey, blockerFunction] = entries[entries.length - 1];
3129 let blocker = state.blockers.get(blockerKey);
3130
3131 if (blocker && blocker.state === "proceeding") {
3132 // If the blocker is currently proceeding, we don't need to re-check
3133 // it and can let this navigation continue
3134 return;
3135 }
3136
3137 // At this point, we know we're unblocked/blocked so we need to check the
3138 // user-provided blocker function
3139 if (blockerFunction({ currentLocation, nextLocation, historyAction })) {
3140 return blockerKey;
3141 }
3142 }
3143
3144 function handleNavigational404(pathname: string) {
3145 let error = getInternalRouterError(404, { pathname });
3146 let routesToUse = inFlightDataRoutes || dataRoutes;
3147 let { matches, route } = getShortCircuitMatches(routesToUse);
3148
3149 // Cancel all pending deferred on 404s since we don't keep any routes
3150 cancelActiveDeferreds();
3151
3152 return { notFoundMatches: matches, route, error };
3153 }
3154
3155 function cancelActiveDeferreds(
3156 predicate?: (routeId: string) => boolean
3157 ): string[] {
3158 let cancelledRouteIds: string[] = [];
3159 activeDeferreds.forEach((dfd, routeId) => {
3160 if (!predicate || predicate(routeId)) {
3161 // Cancel the deferred - but do not remove from activeDeferreds here -
3162 // we rely on the subscribers to do that so our tests can assert proper
3163 // cleanup via _internalActiveDeferreds
3164 dfd.cancel();
3165 cancelledRouteIds.push(routeId);
3166 activeDeferreds.delete(routeId);
3167 }
3168 });
3169 return cancelledRouteIds;
3170 }
3171
3172 // Opt in to capturing and reporting scroll positions during navigations,
3173 // used by the <ScrollRestoration> component
3174 function enableScrollRestoration(
3175 positions: Record<string, number>,
3176 getPosition: GetScrollPositionFunction,
3177 getKey?: GetScrollRestorationKeyFunction
3178 ) {
3179 savedScrollPositions = positions;
3180 getScrollPosition = getPosition;
3181 getScrollRestorationKey = getKey || null;
3182
3183 // Perform initial hydration scroll restoration, since we miss the boat on
3184 // the initial updateState() because we've not yet rendered <ScrollRestoration/>
3185 // and therefore have no savedScrollPositions available
3186 if (!initialScrollRestored && state.navigation === IDLE_NAVIGATION) {
3187 initialScrollRestored = true;
3188 let y = getSavedScrollPosition(state.location, state.matches);
3189 if (y != null) {
3190 updateState({ restoreScrollPosition: y });
3191 }
3192 }
3193
3194 return () => {
3195 savedScrollPositions = null;
3196 getScrollPosition = null;
3197 getScrollRestorationKey = null;
3198 };
3199 }
3200
3201 function getScrollKey(location: Location, matches: AgnosticDataRouteMatch[]) {
3202 if (getScrollRestorationKey) {
3203 let key = getScrollRestorationKey(
3204 location,
3205 matches.map((m) => convertRouteMatchToUiMatch(m, state.loaderData))
3206 );
3207 return key || location.key;
3208 }
3209 return location.key;
3210 }
3211
3212 function saveScrollPosition(
3213 location: Location,
3214 matches: AgnosticDataRouteMatch[]
3215 ): void {
3216 if (savedScrollPositions && getScrollPosition) {
3217 let key = getScrollKey(location, matches);
3218 savedScrollPositions[key] = getScrollPosition();
3219 }
3220 }
3221
3222 function getSavedScrollPosition(
3223 location: Location,
3224 matches: AgnosticDataRouteMatch[]
3225 ): number | null {
3226 if (savedScrollPositions) {
3227 let key = getScrollKey(location, matches);
3228 let y = savedScrollPositions[key];
3229 if (typeof y === "number") {
3230 return y;
3231 }
3232 }
3233 return null;
3234 }
3235
3236 function checkFogOfWar(
3237 matches: AgnosticDataRouteMatch[] | null,
3238 routesToUse: AgnosticDataRouteObject[],
3239 pathname: string
3240 ): { active: boolean; matches: AgnosticDataRouteMatch[] | null } {
3241 if (patchRoutesOnNavigationImpl) {
3242 if (!matches) {
3243 let fogMatches = matchRoutesImpl<AgnosticDataRouteObject>(
3244 routesToUse,
3245 pathname,
3246 basename,
3247 true
3248 );
3249
3250 return { active: true, matches: fogMatches || [] };
3251 } else {
3252 if (Object.keys(matches[0].params).length > 0) {
3253 // If we matched a dynamic param or a splat, it might only be because
3254 // we haven't yet discovered other routes that would match with a
3255 // higher score. Call patchRoutesOnNavigation just to be sure
3256 let partialMatches = matchRoutesImpl<AgnosticDataRouteObject>(
3257 routesToUse,
3258 pathname,
3259 basename,
3260 true
3261 );
3262 return { active: true, matches: partialMatches };
3263 }
3264 }
3265 }
3266
3267 return { active: false, matches: null };
3268 }
3269
3270 type DiscoverRoutesSuccessResult = {
3271 type: "success";
3272 matches: AgnosticDataRouteMatch[] | null;
3273 };
3274 type DiscoverRoutesErrorResult = {
3275 type: "error";
3276 error: any;
3277 partialMatches: AgnosticDataRouteMatch[];
3278 };
3279 type DiscoverRoutesAbortedResult = { type: "aborted" };
3280 type DiscoverRoutesResult =
3281 | DiscoverRoutesSuccessResult
3282 | DiscoverRoutesErrorResult
3283 | DiscoverRoutesAbortedResult;
3284
3285 async function discoverRoutes(
3286 matches: AgnosticDataRouteMatch[],
3287 pathname: string,
3288 signal: AbortSignal,
3289 fetcherKey?: string
3290 ): Promise<DiscoverRoutesResult> {
3291 if (!patchRoutesOnNavigationImpl) {
3292 return { type: "success", matches };
3293 }
3294
3295 let partialMatches: AgnosticDataRouteMatch[] | null = matches;
3296 while (true) {
3297 let isNonHMR = inFlightDataRoutes == null;
3298 let routesToUse = inFlightDataRoutes || dataRoutes;
3299 let localManifest = manifest;
3300 try {
3301 await patchRoutesOnNavigationImpl({
3302 signal,
3303 path: pathname,
3304 matches: partialMatches,
3305 fetcherKey,
3306 patch: (routeId, children) => {
3307 if (signal.aborted) return;
3308 patchRoutesImpl(
3309 routeId,
3310 children,
3311 routesToUse,
3312 localManifest,
3313 mapRouteProperties
3314 );
3315 },
3316 });
3317 } catch (e) {
3318 return { type: "error", error: e, partialMatches };
3319 } finally {
3320 // If we are not in the middle of an HMR revalidation and we changed the
3321 // routes, provide a new identity so when we `updateState` at the end of
3322 // this navigation/fetch `router.routes` will be a new identity and
3323 // trigger a re-run of memoized `router.routes` dependencies.
3324 // HMR will already update the identity and reflow when it lands
3325 // `inFlightDataRoutes` in `completeNavigation`
3326 if (isNonHMR && !signal.aborted) {
3327 dataRoutes = [...dataRoutes];
3328 }
3329 }
3330
3331 if (signal.aborted) {
3332 return { type: "aborted" };
3333 }
3334
3335 let newMatches = matchRoutes(routesToUse, pathname, basename);
3336 if (newMatches) {
3337 return { type: "success", matches: newMatches };
3338 }
3339
3340 let newPartialMatches = matchRoutesImpl<AgnosticDataRouteObject>(
3341 routesToUse,
3342 pathname,
3343 basename,
3344 true
3345 );
3346
3347 // Avoid loops if the second pass results in the same partial matches
3348 if (
3349 !newPartialMatches ||
3350 (partialMatches.length === newPartialMatches.length &&
3351 partialMatches.every(
3352 (m, i) => m.route.id === newPartialMatches![i].route.id
3353 ))
3354 ) {
3355 return { type: "success", matches: null };
3356 }
3357
3358 partialMatches = newPartialMatches;
3359 }
3360 }
3361
3362 function _internalSetRoutes(newRoutes: AgnosticDataRouteObject[]) {
3363 manifest = {};
3364 inFlightDataRoutes = convertRoutesToDataRoutes(
3365 newRoutes,
3366 mapRouteProperties,
3367 undefined,
3368 manifest
3369 );
3370 }
3371
3372 function patchRoutes(
3373 routeId: string | null,
3374 children: AgnosticRouteObject[]
3375 ): void {
3376 let isNonHMR = inFlightDataRoutes == null;
3377 let routesToUse = inFlightDataRoutes || dataRoutes;
3378 patchRoutesImpl(
3379 routeId,
3380 children,
3381 routesToUse,
3382 manifest,
3383 mapRouteProperties
3384 );
3385
3386 // If we are not in the middle of an HMR revalidation and we changed the
3387 // routes, provide a new identity and trigger a reflow via `updateState`
3388 // to re-run memoized `router.routes` dependencies.
3389 // HMR will already update the identity and reflow when it lands
3390 // `inFlightDataRoutes` in `completeNavigation`
3391 if (isNonHMR) {
3392 dataRoutes = [...dataRoutes];
3393 updateState({});
3394 }
3395 }
3396
3397 router = {
3398 get basename() {
3399 return basename;
3400 },
3401 get future() {
3402 return future;
3403 },
3404 get state() {
3405 return state;
3406 },
3407 get routes() {
3408 return dataRoutes;
3409 },
3410 get window() {
3411 return routerWindow;
3412 },
3413 initialize,
3414 subscribe,
3415 enableScrollRestoration,
3416 navigate,
3417 fetch,
3418 revalidate,
3419 // Passthrough to history-aware createHref used by useHref so we get proper
3420 // hash-aware URLs in DOM paths
3421 createHref: (to: To) => init.history.createHref(to),
3422 encodeLocation: (to: To) => init.history.encodeLocation(to),
3423 getFetcher,
3424 deleteFetcher: deleteFetcherAndUpdateState,
3425 dispose,
3426 getBlocker,
3427 deleteBlocker,
3428 patchRoutes,
3429 _internalFetchControllers: fetchControllers,
3430 _internalActiveDeferreds: activeDeferreds,
3431 // TODO: Remove setRoutes, it's temporary to avoid dealing with
3432 // updating the tree while validating the update algorithm.
3433 _internalSetRoutes,
3434 };
3435
3436 return router;
3437}
3438//#endregion
3439
3440////////////////////////////////////////////////////////////////////////////////
3441//#region createStaticHandler
3442////////////////////////////////////////////////////////////////////////////////
3443
3444export const UNSAFE_DEFERRED_SYMBOL = Symbol("deferred");
3445
3446/**
3447 * Future flags to toggle new feature behavior
3448 */
3449export interface StaticHandlerFutureConfig {
3450 v7_relativeSplatPath: boolean;
3451 v7_throwAbortReason: boolean;
3452}
3453
3454export interface CreateStaticHandlerOptions {
3455 basename?: string;
3456 /**
3457 * @deprecated Use `mapRouteProperties` instead
3458 */
3459 detectErrorBoundary?: DetectErrorBoundaryFunction;
3460 mapRouteProperties?: MapRoutePropertiesFunction;
3461 future?: Partial<StaticHandlerFutureConfig>;
3462}
3463
3464export function createStaticHandler(
3465 routes: AgnosticRouteObject[],
3466 opts?: CreateStaticHandlerOptions
3467): StaticHandler {
3468 invariant(
3469 routes.length > 0,
3470 "You must provide a non-empty routes array to createStaticHandler"
3471 );
3472
3473 let manifest: RouteManifest = {};
3474 let basename = (opts ? opts.basename : null) || "/";
3475 let mapRouteProperties: MapRoutePropertiesFunction;
3476 if (opts?.mapRouteProperties) {
3477 mapRouteProperties = opts.mapRouteProperties;
3478 } else if (opts?.detectErrorBoundary) {
3479 // If they are still using the deprecated version, wrap it with the new API
3480 let detectErrorBoundary = opts.detectErrorBoundary;
3481 mapRouteProperties = (route) => ({
3482 hasErrorBoundary: detectErrorBoundary(route),
3483 });
3484 } else {
3485 mapRouteProperties = defaultMapRouteProperties;
3486 }
3487 // Config driven behavior flags
3488 let future: StaticHandlerFutureConfig = {
3489 v7_relativeSplatPath: false,
3490 v7_throwAbortReason: false,
3491 ...(opts ? opts.future : null),
3492 };
3493
3494 let dataRoutes = convertRoutesToDataRoutes(
3495 routes,
3496 mapRouteProperties,
3497 undefined,
3498 manifest
3499 );
3500
3501 /**
3502 * The query() method is intended for document requests, in which we want to
3503 * call an optional action and potentially multiple loaders for all nested
3504 * routes. It returns a StaticHandlerContext object, which is very similar
3505 * to the router state (location, loaderData, actionData, errors, etc.) and
3506 * also adds SSR-specific information such as the statusCode and headers
3507 * from action/loaders Responses.
3508 *
3509 * It _should_ never throw and should report all errors through the
3510 * returned context.errors object, properly associating errors to their error
3511 * boundary. Additionally, it tracks _deepestRenderedBoundaryId which can be
3512 * used to emulate React error boundaries during SSr by performing a second
3513 * pass only down to the boundaryId.
3514 *
3515 * The one exception where we do not return a StaticHandlerContext is when a
3516 * redirect response is returned or thrown from any action/loader. We
3517 * propagate that out and return the raw Response so the HTTP server can
3518 * return it directly.
3519 *
3520 * - `opts.requestContext` is an optional server context that will be passed
3521 * to actions/loaders in the `context` parameter
3522 * - `opts.skipLoaderErrorBubbling` is an optional parameter that will prevent
3523 * the bubbling of errors which allows single-fetch-type implementations
3524 * where the client will handle the bubbling and we may need to return data
3525 * for the handling route
3526 */
3527 async function query(
3528 request: Request,
3529 {
3530 requestContext,
3531 skipLoaderErrorBubbling,
3532 dataStrategy,
3533 }: {
3534 requestContext?: unknown;
3535 skipLoaderErrorBubbling?: boolean;
3536 dataStrategy?: DataStrategyFunction;
3537 } = {}
3538 ): Promise<StaticHandlerContext | Response> {
3539 let url = new URL(request.url);
3540 let method = request.method;
3541 let location = createLocation("", createPath(url), null, "default");
3542 let matches = matchRoutes(dataRoutes, location, basename);
3543
3544 // SSR supports HEAD requests while SPA doesn't
3545 if (!isValidMethod(method) && method !== "HEAD") {
3546 let error = getInternalRouterError(405, { method });
3547 let { matches: methodNotAllowedMatches, route } =
3548 getShortCircuitMatches(dataRoutes);
3549 return {
3550 basename,
3551 location,
3552 matches: methodNotAllowedMatches,
3553 loaderData: {},
3554 actionData: null,
3555 errors: {
3556 [route.id]: error,
3557 },
3558 statusCode: error.status,
3559 loaderHeaders: {},
3560 actionHeaders: {},
3561 activeDeferreds: null,
3562 };
3563 } else if (!matches) {
3564 let error = getInternalRouterError(404, { pathname: location.pathname });
3565 let { matches: notFoundMatches, route } =
3566 getShortCircuitMatches(dataRoutes);
3567 return {
3568 basename,
3569 location,
3570 matches: notFoundMatches,
3571 loaderData: {},
3572 actionData: null,
3573 errors: {
3574 [route.id]: error,
3575 },
3576 statusCode: error.status,
3577 loaderHeaders: {},
3578 actionHeaders: {},
3579 activeDeferreds: null,
3580 };
3581 }
3582
3583 let result = await queryImpl(
3584 request,
3585 location,
3586 matches,
3587 requestContext,
3588 dataStrategy || null,
3589 skipLoaderErrorBubbling === true,
3590 null
3591 );
3592 if (isResponse(result)) {
3593 return result;
3594 }
3595
3596 // When returning StaticHandlerContext, we patch back in the location here
3597 // since we need it for React Context. But this helps keep our submit and
3598 // loadRouteData operating on a Request instead of a Location
3599 return { location, basename, ...result };
3600 }
3601
3602 /**
3603 * The queryRoute() method is intended for targeted route requests, either
3604 * for fetch ?_data requests or resource route requests. In this case, we
3605 * are only ever calling a single action or loader, and we are returning the
3606 * returned value directly. In most cases, this will be a Response returned
3607 * from the action/loader, but it may be a primitive or other value as well -
3608 * and in such cases the calling context should handle that accordingly.
3609 *
3610 * We do respect the throw/return differentiation, so if an action/loader
3611 * throws, then this method will throw the value. This is important so we
3612 * can do proper boundary identification in Remix where a thrown Response
3613 * must go to the Catch Boundary but a returned Response is happy-path.
3614 *
3615 * One thing to note is that any Router-initiated Errors that make sense
3616 * to associate with a status code will be thrown as an ErrorResponse
3617 * instance which include the raw Error, such that the calling context can
3618 * serialize the error as they see fit while including the proper response
3619 * code. Examples here are 404 and 405 errors that occur prior to reaching
3620 * any user-defined loaders.
3621 *
3622 * - `opts.routeId` allows you to specify the specific route handler to call.
3623 * If not provided the handler will determine the proper route by matching
3624 * against `request.url`
3625 * - `opts.requestContext` is an optional server context that will be passed
3626 * to actions/loaders in the `context` parameter
3627 */
3628 async function queryRoute(
3629 request: Request,
3630 {
3631 routeId,
3632 requestContext,
3633 dataStrategy,
3634 }: {
3635 requestContext?: unknown;
3636 routeId?: string;
3637 dataStrategy?: DataStrategyFunction;
3638 } = {}
3639 ): Promise<any> {
3640 let url = new URL(request.url);
3641 let method = request.method;
3642 let location = createLocation("", createPath(url), null, "default");
3643 let matches = matchRoutes(dataRoutes, location, basename);
3644
3645 // SSR supports HEAD requests while SPA doesn't
3646 if (!isValidMethod(method) && method !== "HEAD" && method !== "OPTIONS") {
3647 throw getInternalRouterError(405, { method });
3648 } else if (!matches) {
3649 throw getInternalRouterError(404, { pathname: location.pathname });
3650 }
3651
3652 let match = routeId
3653 ? matches.find((m) => m.route.id === routeId)
3654 : getTargetMatch(matches, location);
3655
3656 if (routeId && !match) {
3657 throw getInternalRouterError(403, {
3658 pathname: location.pathname,
3659 routeId,
3660 });
3661 } else if (!match) {
3662 // This should never hit I don't think?
3663 throw getInternalRouterError(404, { pathname: location.pathname });
3664 }
3665
3666 let result = await queryImpl(
3667 request,
3668 location,
3669 matches,
3670 requestContext,
3671 dataStrategy || null,
3672 false,
3673 match
3674 );
3675
3676 if (isResponse(result)) {
3677 return result;
3678 }
3679
3680 let error = result.errors ? Object.values(result.errors)[0] : undefined;
3681 if (error !== undefined) {
3682 // If we got back result.errors, that means the loader/action threw
3683 // _something_ that wasn't a Response, but it's not guaranteed/required
3684 // to be an `instanceof Error` either, so we have to use throw here to
3685 // preserve the "error" state outside of queryImpl.
3686 throw error;
3687 }
3688
3689 // Pick off the right state value to return
3690 if (result.actionData) {
3691 return Object.values(result.actionData)[0];
3692 }
3693
3694 if (result.loaderData) {
3695 let data = Object.values(result.loaderData)[0];
3696 if (result.activeDeferreds?.[match.route.id]) {
3697 data[UNSAFE_DEFERRED_SYMBOL] = result.activeDeferreds[match.route.id];
3698 }
3699 return data;
3700 }
3701
3702 return undefined;
3703 }
3704
3705 async function queryImpl(
3706 request: Request,
3707 location: Location,
3708 matches: AgnosticDataRouteMatch[],
3709 requestContext: unknown,
3710 dataStrategy: DataStrategyFunction | null,
3711 skipLoaderErrorBubbling: boolean,
3712 routeMatch: AgnosticDataRouteMatch | null
3713 ): Promise<Omit<StaticHandlerContext, "location" | "basename"> | Response> {
3714 invariant(
3715 request.signal,
3716 "query()/queryRoute() requests must contain an AbortController signal"
3717 );
3718
3719 try {
3720 if (isMutationMethod(request.method.toLowerCase())) {
3721 let result = await submit(
3722 request,
3723 matches,
3724 routeMatch || getTargetMatch(matches, location),
3725 requestContext,
3726 dataStrategy,
3727 skipLoaderErrorBubbling,
3728 routeMatch != null
3729 );
3730 return result;
3731 }
3732
3733 let result = await loadRouteData(
3734 request,
3735 matches,
3736 requestContext,
3737 dataStrategy,
3738 skipLoaderErrorBubbling,
3739 routeMatch
3740 );
3741 return isResponse(result)
3742 ? result
3743 : {
3744 ...result,
3745 actionData: null,
3746 actionHeaders: {},
3747 };
3748 } catch (e) {
3749 // If the user threw/returned a Response in callLoaderOrAction for a
3750 // `queryRoute` call, we throw the `DataStrategyResult` to bail out early
3751 // and then return or throw the raw Response here accordingly
3752 if (isDataStrategyResult(e) && isResponse(e.result)) {
3753 if (e.type === ResultType.error) {
3754 throw e.result;
3755 }
3756 return e.result;
3757 }
3758 // Redirects are always returned since they don't propagate to catch
3759 // boundaries
3760 if (isRedirectResponse(e)) {
3761 return e;
3762 }
3763 throw e;
3764 }
3765 }
3766
3767 async function submit(
3768 request: Request,
3769 matches: AgnosticDataRouteMatch[],
3770 actionMatch: AgnosticDataRouteMatch,
3771 requestContext: unknown,
3772 dataStrategy: DataStrategyFunction | null,
3773 skipLoaderErrorBubbling: boolean,
3774 isRouteRequest: boolean
3775 ): Promise<Omit<StaticHandlerContext, "location" | "basename"> | Response> {
3776 let result: DataResult;
3777
3778 if (!actionMatch.route.action && !actionMatch.route.lazy) {
3779 let error = getInternalRouterError(405, {
3780 method: request.method,
3781 pathname: new URL(request.url).pathname,
3782 routeId: actionMatch.route.id,
3783 });
3784 if (isRouteRequest) {
3785 throw error;
3786 }
3787 result = {
3788 type: ResultType.error,
3789 error,
3790 };
3791 } else {
3792 let results = await callDataStrategy(
3793 "action",
3794 request,
3795 [actionMatch],
3796 matches,
3797 isRouteRequest,
3798 requestContext,
3799 dataStrategy
3800 );
3801 result = results[actionMatch.route.id];
3802
3803 if (request.signal.aborted) {
3804 throwStaticHandlerAbortedError(request, isRouteRequest, future);
3805 }
3806 }
3807
3808 if (isRedirectResult(result)) {
3809 // Uhhhh - this should never happen, we should always throw these from
3810 // callLoaderOrAction, but the type narrowing here keeps TS happy and we
3811 // can get back on the "throw all redirect responses" train here should
3812 // this ever happen :/
3813 throw new Response(null, {
3814 status: result.response.status,
3815 headers: {
3816 Location: result.response.headers.get("Location")!,
3817 },
3818 });
3819 }
3820
3821 if (isDeferredResult(result)) {
3822 let error = getInternalRouterError(400, { type: "defer-action" });
3823 if (isRouteRequest) {
3824 throw error;
3825 }
3826 result = {
3827 type: ResultType.error,
3828 error,
3829 };
3830 }
3831
3832 if (isRouteRequest) {
3833 // Note: This should only be non-Response values if we get here, since
3834 // isRouteRequest should throw any Response received in callLoaderOrAction
3835 if (isErrorResult(result)) {
3836 throw result.error;
3837 }
3838
3839 return {
3840 matches: [actionMatch],
3841 loaderData: {},
3842 actionData: { [actionMatch.route.id]: result.data },
3843 errors: null,
3844 // Note: statusCode + headers are unused here since queryRoute will
3845 // return the raw Response or value
3846 statusCode: 200,
3847 loaderHeaders: {},
3848 actionHeaders: {},
3849 activeDeferreds: null,
3850 };
3851 }
3852
3853 // Create a GET request for the loaders
3854 let loaderRequest = new Request(request.url, {
3855 headers: request.headers,
3856 redirect: request.redirect,
3857 signal: request.signal,
3858 });
3859
3860 if (isErrorResult(result)) {
3861 // Store off the pending error - we use it to determine which loaders
3862 // to call and will commit it when we complete the navigation
3863 let boundaryMatch = skipLoaderErrorBubbling
3864 ? actionMatch
3865 : findNearestBoundary(matches, actionMatch.route.id);
3866
3867 let context = await loadRouteData(
3868 loaderRequest,
3869 matches,
3870 requestContext,
3871 dataStrategy,
3872 skipLoaderErrorBubbling,
3873 null,
3874 [boundaryMatch.route.id, result]
3875 );
3876
3877 // action status codes take precedence over loader status codes
3878 return {
3879 ...context,
3880 statusCode: isRouteErrorResponse(result.error)
3881 ? result.error.status
3882 : result.statusCode != null
3883 ? result.statusCode
3884 : 500,
3885 actionData: null,
3886 actionHeaders: {
3887 ...(result.headers ? { [actionMatch.route.id]: result.headers } : {}),
3888 },
3889 };
3890 }
3891
3892 let context = await loadRouteData(
3893 loaderRequest,
3894 matches,
3895 requestContext,
3896 dataStrategy,
3897 skipLoaderErrorBubbling,
3898 null
3899 );
3900
3901 return {
3902 ...context,
3903 actionData: {
3904 [actionMatch.route.id]: result.data,
3905 },
3906 // action status codes take precedence over loader status codes
3907 ...(result.statusCode ? { statusCode: result.statusCode } : {}),
3908 actionHeaders: result.headers
3909 ? { [actionMatch.route.id]: result.headers }
3910 : {},
3911 };
3912 }
3913
3914 async function loadRouteData(
3915 request: Request,
3916 matches: AgnosticDataRouteMatch[],
3917 requestContext: unknown,
3918 dataStrategy: DataStrategyFunction | null,
3919 skipLoaderErrorBubbling: boolean,
3920 routeMatch: AgnosticDataRouteMatch | null,
3921 pendingActionResult?: PendingActionResult
3922 ): Promise<
3923 | Omit<
3924 StaticHandlerContext,
3925 "location" | "basename" | "actionData" | "actionHeaders"
3926 >
3927 | Response
3928 > {
3929 let isRouteRequest = routeMatch != null;
3930
3931 // Short circuit if we have no loaders to run (queryRoute())
3932 if (
3933 isRouteRequest &&
3934 !routeMatch?.route.loader &&
3935 !routeMatch?.route.lazy
3936 ) {
3937 throw getInternalRouterError(400, {
3938 method: request.method,
3939 pathname: new URL(request.url).pathname,
3940 routeId: routeMatch?.route.id,
3941 });
3942 }
3943
3944 let requestMatches = routeMatch
3945 ? [routeMatch]
3946 : pendingActionResult && isErrorResult(pendingActionResult[1])
3947 ? getLoaderMatchesUntilBoundary(matches, pendingActionResult[0])
3948 : matches;
3949 let matchesToLoad = requestMatches.filter(
3950 (m) => m.route.loader || m.route.lazy
3951 );
3952
3953 // Short circuit if we have no loaders to run (query())
3954 if (matchesToLoad.length === 0) {
3955 return {
3956 matches,
3957 // Add a null for all matched routes for proper revalidation on the client
3958 loaderData: matches.reduce(
3959 (acc, m) => Object.assign(acc, { [m.route.id]: null }),
3960 {}
3961 ),
3962 errors:
3963 pendingActionResult && isErrorResult(pendingActionResult[1])
3964 ? {
3965 [pendingActionResult[0]]: pendingActionResult[1].error,
3966 }
3967 : null,
3968 statusCode: 200,
3969 loaderHeaders: {},
3970 activeDeferreds: null,
3971 };
3972 }
3973
3974 let results = await callDataStrategy(
3975 "loader",
3976 request,
3977 matchesToLoad,
3978 matches,
3979 isRouteRequest,
3980 requestContext,
3981 dataStrategy
3982 );
3983
3984 if (request.signal.aborted) {
3985 throwStaticHandlerAbortedError(request, isRouteRequest, future);
3986 }
3987
3988 // Process and commit output from loaders
3989 let activeDeferreds = new Map<string, DeferredData>();
3990 let context = processRouteLoaderData(
3991 matches,
3992 results,
3993 pendingActionResult,
3994 activeDeferreds,
3995 skipLoaderErrorBubbling
3996 );
3997
3998 // Add a null for any non-loader matches for proper revalidation on the client
3999 let executedLoaders = new Set<string>(
4000 matchesToLoad.map((match) => match.route.id)
4001 );
4002 matches.forEach((match) => {
4003 if (!executedLoaders.has(match.route.id)) {
4004 context.loaderData[match.route.id] = null;
4005 }
4006 });
4007
4008 return {
4009 ...context,
4010 matches,
4011 activeDeferreds:
4012 activeDeferreds.size > 0
4013 ? Object.fromEntries(activeDeferreds.entries())
4014 : null,
4015 };
4016 }
4017
4018 // Utility wrapper for calling dataStrategy server-side without having to
4019 // pass around the manifest, mapRouteProperties, etc.
4020 async function callDataStrategy(
4021 type: "loader" | "action",
4022 request: Request,
4023 matchesToLoad: AgnosticDataRouteMatch[],
4024 matches: AgnosticDataRouteMatch[],
4025 isRouteRequest: boolean,
4026 requestContext: unknown,
4027 dataStrategy: DataStrategyFunction | null
4028 ): Promise<Record<string, DataResult>> {
4029 let results = await callDataStrategyImpl(
4030 dataStrategy || defaultDataStrategy,
4031 type,
4032 null,
4033 request,
4034 matchesToLoad,
4035 matches,
4036 null,
4037 manifest,
4038 mapRouteProperties,
4039 requestContext
4040 );
4041
4042 let dataResults: Record<string, DataResult> = {};
4043 await Promise.all(
4044 matches.map(async (match) => {
4045 if (!(match.route.id in results)) {
4046 return;
4047 }
4048 let result = results[match.route.id];
4049 if (isRedirectDataStrategyResultResult(result)) {
4050 let response = result.result as Response;
4051 // Throw redirects and let the server handle them with an HTTP redirect
4052 throw normalizeRelativeRoutingRedirectResponse(
4053 response,
4054 request,
4055 match.route.id,
4056 matches,
4057 basename,
4058 future.v7_relativeSplatPath
4059 );
4060 }
4061 if (isResponse(result.result) && isRouteRequest) {
4062 // For SSR single-route requests, we want to hand Responses back
4063 // directly without unwrapping
4064 throw result;
4065 }
4066
4067 dataResults[match.route.id] =
4068 await convertDataStrategyResultToDataResult(result);
4069 })
4070 );
4071 return dataResults;
4072 }
4073
4074 return {
4075 dataRoutes,
4076 query,
4077 queryRoute,
4078 };
4079}
4080
4081//#endregion
4082
4083////////////////////////////////////////////////////////////////////////////////
4084//#region Helpers
4085////////////////////////////////////////////////////////////////////////////////
4086
4087/**
4088 * Given an existing StaticHandlerContext and an error thrown at render time,
4089 * provide an updated StaticHandlerContext suitable for a second SSR render
4090 */
4091export function getStaticContextFromError(
4092 routes: AgnosticDataRouteObject[],
4093 context: StaticHandlerContext,
4094 error: any
4095) {
4096 let newContext: StaticHandlerContext = {
4097 ...context,
4098 statusCode: isRouteErrorResponse(error) ? error.status : 500,
4099 errors: {
4100 [context._deepestRenderedBoundaryId || routes[0].id]: error,
4101 },
4102 };
4103 return newContext;
4104}
4105
4106function throwStaticHandlerAbortedError(
4107 request: Request,
4108 isRouteRequest: boolean,
4109 future: StaticHandlerFutureConfig
4110) {
4111 if (future.v7_throwAbortReason && request.signal.reason !== undefined) {
4112 throw request.signal.reason;
4113 }
4114
4115 let method = isRouteRequest ? "queryRoute" : "query";
4116 throw new Error(`${method}() call aborted: ${request.method} ${request.url}`);
4117}
4118
4119function isSubmissionNavigation(
4120 opts: BaseNavigateOrFetchOptions
4121): opts is SubmissionNavigateOptions {
4122 return (
4123 opts != null &&
4124 (("formData" in opts && opts.formData != null) ||
4125 ("body" in opts && opts.body !== undefined))
4126 );
4127}
4128
4129function normalizeTo(
4130 location: Path,
4131 matches: AgnosticDataRouteMatch[],
4132 basename: string,
4133 prependBasename: boolean,
4134 to: To | null,
4135 v7_relativeSplatPath: boolean,
4136 fromRouteId?: string,
4137 relative?: RelativeRoutingType
4138) {
4139 let contextualMatches: AgnosticDataRouteMatch[];
4140 let activeRouteMatch: AgnosticDataRouteMatch | undefined;
4141 if (fromRouteId) {
4142 // Grab matches up to the calling route so our route-relative logic is
4143 // relative to the correct source route
4144 contextualMatches = [];
4145 for (let match of matches) {
4146 contextualMatches.push(match);
4147 if (match.route.id === fromRouteId) {
4148 activeRouteMatch = match;
4149 break;
4150 }
4151 }
4152 } else {
4153 contextualMatches = matches;
4154 activeRouteMatch = matches[matches.length - 1];
4155 }
4156
4157 // Resolve the relative path
4158 let path = resolveTo(
4159 to ? to : ".",
4160 getResolveToMatches(contextualMatches, v7_relativeSplatPath),
4161 stripBasename(location.pathname, basename) || location.pathname,
4162 relative === "path"
4163 );
4164
4165 // When `to` is not specified we inherit search/hash from the current
4166 // location, unlike when to="." and we just inherit the path.
4167 // See https://github.com/remix-run/remix/issues/927
4168 if (to == null) {
4169 path.search = location.search;
4170 path.hash = location.hash;
4171 }
4172
4173 // Account for `?index` params when routing to the current location
4174 if ((to == null || to === "" || to === ".") && activeRouteMatch) {
4175 let nakedIndex = hasNakedIndexQuery(path.search);
4176 if (activeRouteMatch.route.index && !nakedIndex) {
4177 // Add one when we're targeting an index route
4178 path.search = path.search
4179 ? path.search.replace(/^\?/, "?index&")
4180 : "?index";
4181 } else if (!activeRouteMatch.route.index && nakedIndex) {
4182 // Remove existing ones when we're not
4183 let params = new URLSearchParams(path.search);
4184 let indexValues = params.getAll("index");
4185 params.delete("index");
4186 indexValues.filter((v) => v).forEach((v) => params.append("index", v));
4187 let qs = params.toString();
4188 path.search = qs ? `?${qs}` : "";
4189 }
4190 }
4191
4192 // If we're operating within a basename, prepend it to the pathname. If
4193 // this is a root navigation, then just use the raw basename which allows
4194 // the basename to have full control over the presence of a trailing slash
4195 // on root actions
4196 if (prependBasename && basename !== "/") {
4197 path.pathname =
4198 path.pathname === "/" ? basename : joinPaths([basename, path.pathname]);
4199 }
4200
4201 return createPath(path);
4202}
4203
4204// Normalize navigation options by converting formMethod=GET formData objects to
4205// URLSearchParams so they behave identically to links with query params
4206function normalizeNavigateOptions(
4207 normalizeFormMethod: boolean,
4208 isFetcher: boolean,
4209 path: string,
4210 opts?: BaseNavigateOrFetchOptions
4211): {
4212 path: string;
4213 submission?: Submission;
4214 error?: ErrorResponseImpl;
4215} {
4216 // Return location verbatim on non-submission navigations
4217 if (!opts || !isSubmissionNavigation(opts)) {
4218 return { path };
4219 }
4220
4221 if (opts.formMethod && !isValidMethod(opts.formMethod)) {
4222 return {
4223 path,
4224 error: getInternalRouterError(405, { method: opts.formMethod }),
4225 };
4226 }
4227
4228 let getInvalidBodyError = () => ({
4229 path,
4230 error: getInternalRouterError(400, { type: "invalid-body" }),
4231 });
4232
4233 // Create a Submission on non-GET navigations
4234 let rawFormMethod = opts.formMethod || "get";
4235 let formMethod = normalizeFormMethod
4236 ? (rawFormMethod.toUpperCase() as V7_FormMethod)
4237 : (rawFormMethod.toLowerCase() as FormMethod);
4238 let formAction = stripHashFromPath(path);
4239
4240 if (opts.body !== undefined) {
4241 if (opts.formEncType === "text/plain") {
4242 // text only support POST/PUT/PATCH/DELETE submissions
4243 if (!isMutationMethod(formMethod)) {
4244 return getInvalidBodyError();
4245 }
4246
4247 let text =
4248 typeof opts.body === "string"
4249 ? opts.body
4250 : opts.body instanceof FormData ||
4251 opts.body instanceof URLSearchParams
4252 ? // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#plain-text-form-data
4253 Array.from(opts.body.entries()).reduce(
4254 (acc, [name, value]) => `${acc}${name}=${value}\n`,
4255 ""
4256 )
4257 : String(opts.body);
4258
4259 return {
4260 path,
4261 submission: {
4262 formMethod,
4263 formAction,
4264 formEncType: opts.formEncType,
4265 formData: undefined,
4266 json: undefined,
4267 text,
4268 },
4269 };
4270 } else if (opts.formEncType === "application/json") {
4271 // json only supports POST/PUT/PATCH/DELETE submissions
4272 if (!isMutationMethod(formMethod)) {
4273 return getInvalidBodyError();
4274 }
4275
4276 try {
4277 let json =
4278 typeof opts.body === "string" ? JSON.parse(opts.body) : opts.body;
4279
4280 return {
4281 path,
4282 submission: {
4283 formMethod,
4284 formAction,
4285 formEncType: opts.formEncType,
4286 formData: undefined,
4287 json,
4288 text: undefined,
4289 },
4290 };
4291 } catch (e) {
4292 return getInvalidBodyError();
4293 }
4294 }
4295 }
4296
4297 invariant(
4298 typeof FormData === "function",
4299 "FormData is not available in this environment"
4300 );
4301
4302 let searchParams: URLSearchParams;
4303 let formData: FormData;
4304
4305 if (opts.formData) {
4306 searchParams = convertFormDataToSearchParams(opts.formData);
4307 formData = opts.formData;
4308 } else if (opts.body instanceof FormData) {
4309 searchParams = convertFormDataToSearchParams(opts.body);
4310 formData = opts.body;
4311 } else if (opts.body instanceof URLSearchParams) {
4312 searchParams = opts.body;
4313 formData = convertSearchParamsToFormData(searchParams);
4314 } else if (opts.body == null) {
4315 searchParams = new URLSearchParams();
4316 formData = new FormData();
4317 } else {
4318 try {
4319 searchParams = new URLSearchParams(opts.body);
4320 formData = convertSearchParamsToFormData(searchParams);
4321 } catch (e) {
4322 return getInvalidBodyError();
4323 }
4324 }
4325
4326 let submission: Submission = {
4327 formMethod,
4328 formAction,
4329 formEncType:
4330 (opts && opts.formEncType) || "application/x-www-form-urlencoded",
4331 formData,
4332 json: undefined,
4333 text: undefined,
4334 };
4335
4336 if (isMutationMethod(submission.formMethod)) {
4337 return { path, submission };
4338 }
4339
4340 // Flatten submission onto URLSearchParams for GET submissions
4341 let parsedPath = parsePath(path);
4342 // On GET navigation submissions we can drop the ?index param from the
4343 // resulting location since all loaders will run. But fetcher GET submissions
4344 // only run a single loader so we need to preserve any incoming ?index params
4345 if (isFetcher && parsedPath.search && hasNakedIndexQuery(parsedPath.search)) {
4346 searchParams.append("index", "");
4347 }
4348 parsedPath.search = `?${searchParams}`;
4349
4350 return { path: createPath(parsedPath), submission };
4351}
4352
4353// Filter out all routes at/below any caught error as they aren't going to
4354// render so we don't need to load them
4355function getLoaderMatchesUntilBoundary(
4356 matches: AgnosticDataRouteMatch[],
4357 boundaryId: string,
4358 includeBoundary = false
4359) {
4360 let index = matches.findIndex((m) => m.route.id === boundaryId);
4361 if (index >= 0) {
4362 return matches.slice(0, includeBoundary ? index + 1 : index);
4363 }
4364 return matches;
4365}
4366
4367function getMatchesToLoad(
4368 history: History,
4369 state: RouterState,
4370 matches: AgnosticDataRouteMatch[],
4371 submission: Submission | undefined,
4372 location: Location,
4373 initialHydration: boolean,
4374 skipActionErrorRevalidation: boolean,
4375 isRevalidationRequired: boolean,
4376 cancelledDeferredRoutes: string[],
4377 cancelledFetcherLoads: Set<string>,
4378 deletedFetchers: Set<string>,
4379 fetchLoadMatches: Map<string, FetchLoadMatch>,
4380 fetchRedirectIds: Set<string>,
4381 routesToUse: AgnosticDataRouteObject[],
4382 basename: string | undefined,
4383 pendingActionResult?: PendingActionResult
4384): [AgnosticDataRouteMatch[], RevalidatingFetcher[]] {
4385 let actionResult = pendingActionResult
4386 ? isErrorResult(pendingActionResult[1])
4387 ? pendingActionResult[1].error
4388 : pendingActionResult[1].data
4389 : undefined;
4390 let currentUrl = history.createURL(state.location);
4391 let nextUrl = history.createURL(location);
4392
4393 // Pick navigation matches that are net-new or qualify for revalidation
4394 let boundaryMatches = matches;
4395 if (initialHydration && state.errors) {
4396 // On initial hydration, only consider matches up to _and including_ the boundary.
4397 // This is inclusive to handle cases where a server loader ran successfully,
4398 // a child server loader bubbled up to this route, but this route has
4399 // `clientLoader.hydrate` so we want to still run the `clientLoader` so that
4400 // we have a complete version of `loaderData`
4401 boundaryMatches = getLoaderMatchesUntilBoundary(
4402 matches,
4403 Object.keys(state.errors)[0],
4404 true
4405 );
4406 } else if (pendingActionResult && isErrorResult(pendingActionResult[1])) {
4407 // If an action threw an error, we call loaders up to, but not including the
4408 // boundary
4409 boundaryMatches = getLoaderMatchesUntilBoundary(
4410 matches,
4411 pendingActionResult[0]
4412 );
4413 }
4414
4415 // Don't revalidate loaders by default after action 4xx/5xx responses
4416 // when the flag is enabled. They can still opt-into revalidation via
4417 // `shouldRevalidate` via `actionResult`
4418 let actionStatus = pendingActionResult
4419 ? pendingActionResult[1].statusCode
4420 : undefined;
4421 let shouldSkipRevalidation =
4422 skipActionErrorRevalidation && actionStatus && actionStatus >= 400;
4423
4424 let navigationMatches = boundaryMatches.filter((match, index) => {
4425 let { route } = match;
4426 if (route.lazy) {
4427 // We haven't loaded this route yet so we don't know if it's got a loader!
4428 return true;
4429 }
4430
4431 if (route.loader == null) {
4432 return false;
4433 }
4434
4435 if (initialHydration) {
4436 return shouldLoadRouteOnHydration(route, state.loaderData, state.errors);
4437 }
4438
4439 // Always call the loader on new route instances and pending defer cancellations
4440 if (
4441 isNewLoader(state.loaderData, state.matches[index], match) ||
4442 cancelledDeferredRoutes.some((id) => id === match.route.id)
4443 ) {
4444 return true;
4445 }
4446
4447 // This is the default implementation for when we revalidate. If the route
4448 // provides it's own implementation, then we give them full control but
4449 // provide this value so they can leverage it if needed after they check
4450 // their own specific use cases
4451 let currentRouteMatch = state.matches[index];
4452 let nextRouteMatch = match;
4453
4454 return shouldRevalidateLoader(match, {
4455 currentUrl,
4456 currentParams: currentRouteMatch.params,
4457 nextUrl,
4458 nextParams: nextRouteMatch.params,
4459 ...submission,
4460 actionResult,
4461 actionStatus,
4462 defaultShouldRevalidate: shouldSkipRevalidation
4463 ? false
4464 : // Forced revalidation due to submission, useRevalidator, or X-Remix-Revalidate
4465 isRevalidationRequired ||
4466 currentUrl.pathname + currentUrl.search ===
4467 nextUrl.pathname + nextUrl.search ||
4468 // Search params affect all loaders
4469 currentUrl.search !== nextUrl.search ||
4470 isNewRouteInstance(currentRouteMatch, nextRouteMatch),
4471 });
4472 });
4473
4474 // Pick fetcher.loads that need to be revalidated
4475 let revalidatingFetchers: RevalidatingFetcher[] = [];
4476 fetchLoadMatches.forEach((f, key) => {
4477 // Don't revalidate:
4478 // - on initial hydration (shouldn't be any fetchers then anyway)
4479 // - if fetcher won't be present in the subsequent render
4480 // - no longer matches the URL (v7_fetcherPersist=false)
4481 // - was unmounted but persisted due to v7_fetcherPersist=true
4482 if (
4483 initialHydration ||
4484 !matches.some((m) => m.route.id === f.routeId) ||
4485 deletedFetchers.has(key)
4486 ) {
4487 return;
4488 }
4489
4490 let fetcherMatches = matchRoutes(routesToUse, f.path, basename);
4491
4492 // If the fetcher path no longer matches, push it in with null matches so
4493 // we can trigger a 404 in callLoadersAndMaybeResolveData. Note this is
4494 // currently only a use-case for Remix HMR where the route tree can change
4495 // at runtime and remove a route previously loaded via a fetcher
4496 if (!fetcherMatches) {
4497 revalidatingFetchers.push({
4498 key,
4499 routeId: f.routeId,
4500 path: f.path,
4501 matches: null,
4502 match: null,
4503 controller: null,
4504 });
4505 return;
4506 }
4507
4508 // Revalidating fetchers are decoupled from the route matches since they
4509 // load from a static href. They revalidate based on explicit revalidation
4510 // (submission, useRevalidator, or X-Remix-Revalidate)
4511 let fetcher = state.fetchers.get(key);
4512 let fetcherMatch = getTargetMatch(fetcherMatches, f.path);
4513
4514 let shouldRevalidate = false;
4515 if (fetchRedirectIds.has(key)) {
4516 // Never trigger a revalidation of an actively redirecting fetcher
4517 shouldRevalidate = false;
4518 } else if (cancelledFetcherLoads.has(key)) {
4519 // Always mark for revalidation if the fetcher was cancelled
4520 cancelledFetcherLoads.delete(key);
4521 shouldRevalidate = true;
4522 } else if (
4523 fetcher &&
4524 fetcher.state !== "idle" &&
4525 fetcher.data === undefined
4526 ) {
4527 // If the fetcher hasn't ever completed loading yet, then this isn't a
4528 // revalidation, it would just be a brand new load if an explicit
4529 // revalidation is required
4530 shouldRevalidate = isRevalidationRequired;
4531 } else {
4532 // Otherwise fall back on any user-defined shouldRevalidate, defaulting
4533 // to explicit revalidations only
4534 shouldRevalidate = shouldRevalidateLoader(fetcherMatch, {
4535 currentUrl,
4536 currentParams: state.matches[state.matches.length - 1].params,
4537 nextUrl,
4538 nextParams: matches[matches.length - 1].params,
4539 ...submission,
4540 actionResult,
4541 actionStatus,
4542 defaultShouldRevalidate: shouldSkipRevalidation
4543 ? false
4544 : isRevalidationRequired,
4545 });
4546 }
4547
4548 if (shouldRevalidate) {
4549 revalidatingFetchers.push({
4550 key,
4551 routeId: f.routeId,
4552 path: f.path,
4553 matches: fetcherMatches,
4554 match: fetcherMatch,
4555 controller: new AbortController(),
4556 });
4557 }
4558 });
4559
4560 return [navigationMatches, revalidatingFetchers];
4561}
4562
4563function shouldLoadRouteOnHydration(
4564 route: AgnosticDataRouteObject,
4565 loaderData: RouteData | null | undefined,
4566 errors: RouteData | null | undefined
4567) {
4568 // We dunno if we have a loader - gotta find out!
4569 if (route.lazy) {
4570 return true;
4571 }
4572
4573 // No loader, nothing to initialize
4574 if (!route.loader) {
4575 return false;
4576 }
4577
4578 let hasData = loaderData != null && loaderData[route.id] !== undefined;
4579 let hasError = errors != null && errors[route.id] !== undefined;
4580
4581 // Don't run if we error'd during SSR
4582 if (!hasData && hasError) {
4583 return false;
4584 }
4585
4586 // Explicitly opting-in to running on hydration
4587 if (typeof route.loader === "function" && route.loader.hydrate === true) {
4588 return true;
4589 }
4590
4591 // Otherwise, run if we're not yet initialized with anything
4592 return !hasData && !hasError;
4593}
4594
4595function isNewLoader(
4596 currentLoaderData: RouteData,
4597 currentMatch: AgnosticDataRouteMatch,
4598 match: AgnosticDataRouteMatch
4599) {
4600 let isNew =
4601 // [a] -> [a, b]
4602 !currentMatch ||
4603 // [a, b] -> [a, c]
4604 match.route.id !== currentMatch.route.id;
4605
4606 // Handle the case that we don't have data for a re-used route, potentially
4607 // from a prior error or from a cancelled pending deferred
4608 let isMissingData = currentLoaderData[match.route.id] === undefined;
4609
4610 // Always load if this is a net-new route or we don't yet have data
4611 return isNew || isMissingData;
4612}
4613
4614function isNewRouteInstance(
4615 currentMatch: AgnosticDataRouteMatch,
4616 match: AgnosticDataRouteMatch
4617) {
4618 let currentPath = currentMatch.route.path;
4619 return (
4620 // param change for this match, /users/123 -> /users/456
4621 currentMatch.pathname !== match.pathname ||
4622 // splat param changed, which is not present in match.path
4623 // e.g. /files/images/avatar.jpg -> files/finances.xls
4624 (currentPath != null &&
4625 currentPath.endsWith("*") &&
4626 currentMatch.params["*"] !== match.params["*"])
4627 );
4628}
4629
4630function shouldRevalidateLoader(
4631 loaderMatch: AgnosticDataRouteMatch,
4632 arg: ShouldRevalidateFunctionArgs
4633) {
4634 if (loaderMatch.route.shouldRevalidate) {
4635 let routeChoice = loaderMatch.route.shouldRevalidate(arg);
4636 if (typeof routeChoice === "boolean") {
4637 return routeChoice;
4638 }
4639 }
4640
4641 return arg.defaultShouldRevalidate;
4642}
4643
4644function patchRoutesImpl(
4645 routeId: string | null,
4646 children: AgnosticRouteObject[],
4647 routesToUse: AgnosticDataRouteObject[],
4648 manifest: RouteManifest,
4649 mapRouteProperties: MapRoutePropertiesFunction
4650) {
4651 let childrenToPatch: AgnosticDataRouteObject[];
4652 if (routeId) {
4653 let route = manifest[routeId];
4654 invariant(
4655 route,
4656 `No route found to patch children into: routeId = ${routeId}`
4657 );
4658 if (!route.children) {
4659 route.children = [];
4660 }
4661 childrenToPatch = route.children;
4662 } else {
4663 childrenToPatch = routesToUse;
4664 }
4665
4666 // Don't patch in routes we already know about so that `patch` is idempotent
4667 // to simplify user-land code. This is useful because we re-call the
4668 // `patchRoutesOnNavigation` function for matched routes with params.
4669 let uniqueChildren = children.filter(
4670 (newRoute) =>
4671 !childrenToPatch.some((existingRoute) =>
4672 isSameRoute(newRoute, existingRoute)
4673 )
4674 );
4675
4676 let newRoutes = convertRoutesToDataRoutes(
4677 uniqueChildren,
4678 mapRouteProperties,
4679 [routeId || "_", "patch", String(childrenToPatch?.length || "0")],
4680 manifest
4681 );
4682
4683 childrenToPatch.push(...newRoutes);
4684}
4685
4686function isSameRoute(
4687 newRoute: AgnosticRouteObject,
4688 existingRoute: AgnosticRouteObject
4689): boolean {
4690 // Most optimal check is by id
4691 if (
4692 "id" in newRoute &&
4693 "id" in existingRoute &&
4694 newRoute.id === existingRoute.id
4695 ) {
4696 return true;
4697 }
4698
4699 // Second is by pathing differences
4700 if (
4701 !(
4702 newRoute.index === existingRoute.index &&
4703 newRoute.path === existingRoute.path &&
4704 newRoute.caseSensitive === existingRoute.caseSensitive
4705 )
4706 ) {
4707 return false;
4708 }
4709
4710 // Pathless layout routes are trickier since we need to check children.
4711 // If they have no children then they're the same as far as we can tell
4712 if (
4713 (!newRoute.children || newRoute.children.length === 0) &&
4714 (!existingRoute.children || existingRoute.children.length === 0)
4715 ) {
4716 return true;
4717 }
4718
4719 // Otherwise, we look to see if every child in the new route is already
4720 // represented in the existing route's children
4721 return newRoute.children!.every((aChild, i) =>
4722 existingRoute.children?.some((bChild) => isSameRoute(aChild, bChild))
4723 );
4724}
4725
4726/**
4727 * Execute route.lazy() methods to lazily load route modules (loader, action,
4728 * shouldRevalidate) and update the routeManifest in place which shares objects
4729 * with dataRoutes so those get updated as well.
4730 */
4731async function loadLazyRouteModule(
4732 route: AgnosticDataRouteObject,
4733 mapRouteProperties: MapRoutePropertiesFunction,
4734 manifest: RouteManifest
4735) {
4736 if (!route.lazy) {
4737 return;
4738 }
4739
4740 let lazyRoute = await route.lazy();
4741
4742 // If the lazy route function was executed and removed by another parallel
4743 // call then we can return - first lazy() to finish wins because the return
4744 // value of lazy is expected to be static
4745 if (!route.lazy) {
4746 return;
4747 }
4748
4749 let routeToUpdate = manifest[route.id];
4750 invariant(routeToUpdate, "No route found in manifest");
4751
4752 // Update the route in place. This should be safe because there's no way
4753 // we could yet be sitting on this route as we can't get there without
4754 // resolving lazy() first.
4755 //
4756 // This is different than the HMR "update" use-case where we may actively be
4757 // on the route being updated. The main concern boils down to "does this
4758 // mutation affect any ongoing navigations or any current state.matches
4759 // values?". If not, it should be safe to update in place.
4760 let routeUpdates: Record<string, any> = {};
4761 for (let lazyRouteProperty in lazyRoute) {
4762 let staticRouteValue =
4763 routeToUpdate[lazyRouteProperty as keyof typeof routeToUpdate];
4764
4765 let isPropertyStaticallyDefined =
4766 staticRouteValue !== undefined &&
4767 // This property isn't static since it should always be updated based
4768 // on the route updates
4769 lazyRouteProperty !== "hasErrorBoundary";
4770
4771 warning(
4772 !isPropertyStaticallyDefined,
4773 `Route "${routeToUpdate.id}" has a static property "${lazyRouteProperty}" ` +
4774 `defined but its lazy function is also returning a value for this property. ` +
4775 `The lazy route property "${lazyRouteProperty}" will be ignored.`
4776 );
4777
4778 if (
4779 !isPropertyStaticallyDefined &&
4780 !immutableRouteKeys.has(lazyRouteProperty as ImmutableRouteKey)
4781 ) {
4782 routeUpdates[lazyRouteProperty] =
4783 lazyRoute[lazyRouteProperty as keyof typeof lazyRoute];
4784 }
4785 }
4786
4787 // Mutate the route with the provided updates. Do this first so we pass
4788 // the updated version to mapRouteProperties
4789 Object.assign(routeToUpdate, routeUpdates);
4790
4791 // Mutate the `hasErrorBoundary` property on the route based on the route
4792 // updates and remove the `lazy` function so we don't resolve the lazy
4793 // route again.
4794 Object.assign(routeToUpdate, {
4795 // To keep things framework agnostic, we use the provided
4796 // `mapRouteProperties` (or wrapped `detectErrorBoundary`) function to
4797 // set the framework-aware properties (`element`/`hasErrorBoundary`) since
4798 // the logic will differ between frameworks.
4799 ...mapRouteProperties(routeToUpdate),
4800 lazy: undefined,
4801 });
4802}
4803
4804// Default implementation of `dataStrategy` which fetches all loaders in parallel
4805async function defaultDataStrategy({
4806 matches,
4807}: DataStrategyFunctionArgs): ReturnType<DataStrategyFunction> {
4808 let matchesToLoad = matches.filter((m) => m.shouldLoad);
4809 let results = await Promise.all(matchesToLoad.map((m) => m.resolve()));
4810 return results.reduce(
4811 (acc, result, i) =>
4812 Object.assign(acc, { [matchesToLoad[i].route.id]: result }),
4813 {}
4814 );
4815}
4816
4817async function callDataStrategyImpl(
4818 dataStrategyImpl: DataStrategyFunction,
4819 type: "loader" | "action",
4820 state: RouterState | null,
4821 request: Request,
4822 matchesToLoad: AgnosticDataRouteMatch[],
4823 matches: AgnosticDataRouteMatch[],
4824 fetcherKey: string | null,
4825 manifest: RouteManifest,
4826 mapRouteProperties: MapRoutePropertiesFunction,
4827 requestContext?: unknown
4828): Promise<Record<string, DataStrategyResult>> {
4829 let loadRouteDefinitionsPromises = matches.map((m) =>
4830 m.route.lazy
4831 ? loadLazyRouteModule(m.route, mapRouteProperties, manifest)
4832 : undefined
4833 );
4834
4835 let dsMatches = matches.map((match, i) => {
4836 let loadRoutePromise = loadRouteDefinitionsPromises[i];
4837 let shouldLoad = matchesToLoad.some((m) => m.route.id === match.route.id);
4838 // `resolve` encapsulates route.lazy(), executing the loader/action,
4839 // and mapping return values/thrown errors to a `DataStrategyResult`. Users
4840 // can pass a callback to take fine-grained control over the execution
4841 // of the loader/action
4842 let resolve: DataStrategyMatch["resolve"] = async (handlerOverride) => {
4843 if (
4844 handlerOverride &&
4845 request.method === "GET" &&
4846 (match.route.lazy || match.route.loader)
4847 ) {
4848 shouldLoad = true;
4849 }
4850 return shouldLoad
4851 ? callLoaderOrAction(
4852 type,
4853 request,
4854 match,
4855 loadRoutePromise,
4856 handlerOverride,
4857 requestContext
4858 )
4859 : Promise.resolve({ type: ResultType.data, result: undefined });
4860 };
4861
4862 return {
4863 ...match,
4864 shouldLoad,
4865 resolve,
4866 };
4867 });
4868
4869 // Send all matches here to allow for a middleware-type implementation.
4870 // handler will be a no-op for unneeded routes and we filter those results
4871 // back out below.
4872 let results = await dataStrategyImpl({
4873 matches: dsMatches,
4874 request,
4875 params: matches[0].params,
4876 fetcherKey,
4877 context: requestContext,
4878 });
4879
4880 // Wait for all routes to load here but 'swallow the error since we want
4881 // it to bubble up from the `await loadRoutePromise` in `callLoaderOrAction` -
4882 // called from `match.resolve()`
4883 try {
4884 await Promise.all(loadRouteDefinitionsPromises);
4885 } catch (e) {
4886 // No-op
4887 }
4888
4889 return results;
4890}
4891
4892// Default logic for calling a loader/action is the user has no specified a dataStrategy
4893async function callLoaderOrAction(
4894 type: "loader" | "action",
4895 request: Request,
4896 match: AgnosticDataRouteMatch,
4897 loadRoutePromise: Promise<void> | undefined,
4898 handlerOverride: Parameters<DataStrategyMatch["resolve"]>[0],
4899 staticContext?: unknown
4900): Promise<DataStrategyResult> {
4901 let result: DataStrategyResult;
4902 let onReject: (() => void) | undefined;
4903
4904 let runHandler = (
4905 handler: AgnosticRouteObject["loader"] | AgnosticRouteObject["action"]
4906 ): Promise<DataStrategyResult> => {
4907 // Setup a promise we can race against so that abort signals short circuit
4908 let reject: () => void;
4909 // This will never resolve so safe to type it as Promise<DataStrategyResult> to
4910 // satisfy the function return value
4911 let abortPromise = new Promise<DataStrategyResult>((_, r) => (reject = r));
4912 onReject = () => reject();
4913 request.signal.addEventListener("abort", onReject);
4914
4915 let actualHandler = (ctx?: unknown) => {
4916 if (typeof handler !== "function") {
4917 return Promise.reject(
4918 new Error(
4919 `You cannot call the handler for a route which defines a boolean ` +
4920 `"${type}" [routeId: ${match.route.id}]`
4921 )
4922 );
4923 }
4924 return handler(
4925 {
4926 request,
4927 params: match.params,
4928 context: staticContext,
4929 },
4930 ...(ctx !== undefined ? [ctx] : [])
4931 );
4932 };
4933
4934 let handlerPromise: Promise<DataStrategyResult> = (async () => {
4935 try {
4936 let val = await (handlerOverride
4937 ? handlerOverride((ctx: unknown) => actualHandler(ctx))
4938 : actualHandler());
4939 return { type: "data", result: val };
4940 } catch (e) {
4941 return { type: "error", result: e };
4942 }
4943 })();
4944
4945 return Promise.race([handlerPromise, abortPromise]);
4946 };
4947
4948 try {
4949 let handler = match.route[type];
4950
4951 // If we have a route.lazy promise, await that first
4952 if (loadRoutePromise) {
4953 if (handler) {
4954 // Run statically defined handler in parallel with lazy()
4955 let handlerError;
4956 let [value] = await Promise.all([
4957 // If the handler throws, don't let it immediately bubble out,
4958 // since we need to let the lazy() execution finish so we know if this
4959 // route has a boundary that can handle the error
4960 runHandler(handler).catch((e) => {
4961 handlerError = e;
4962 }),
4963 loadRoutePromise,
4964 ]);
4965 if (handlerError !== undefined) {
4966 throw handlerError;
4967 }
4968 result = value!;
4969 } else {
4970 // Load lazy route module, then run any returned handler
4971 await loadRoutePromise;
4972
4973 handler = match.route[type];
4974 if (handler) {
4975 // Handler still runs even if we got interrupted to maintain consistency
4976 // with un-abortable behavior of handler execution on non-lazy or
4977 // previously-lazy-loaded routes
4978 result = await runHandler(handler);
4979 } else if (type === "action") {
4980 let url = new URL(request.url);
4981 let pathname = url.pathname + url.search;
4982 throw getInternalRouterError(405, {
4983 method: request.method,
4984 pathname,
4985 routeId: match.route.id,
4986 });
4987 } else {
4988 // lazy() route has no loader to run. Short circuit here so we don't
4989 // hit the invariant below that errors on returning undefined.
4990 return { type: ResultType.data, result: undefined };
4991 }
4992 }
4993 } else if (!handler) {
4994 let url = new URL(request.url);
4995 let pathname = url.pathname + url.search;
4996 throw getInternalRouterError(404, {
4997 pathname,
4998 });
4999 } else {
5000 result = await runHandler(handler);
5001 }
5002
5003 invariant(
5004 result.result !== undefined,
5005 `You defined ${type === "action" ? "an action" : "a loader"} for route ` +
5006 `"${match.route.id}" but didn't return anything from your \`${type}\` ` +
5007 `function. Please return a value or \`null\`.`
5008 );
5009 } catch (e) {
5010 // We should already be catching and converting normal handler executions to
5011 // DataStrategyResults and returning them, so anything that throws here is an
5012 // unexpected error we still need to wrap
5013 return { type: ResultType.error, result: e };
5014 } finally {
5015 if (onReject) {
5016 request.signal.removeEventListener("abort", onReject);
5017 }
5018 }
5019
5020 return result;
5021}
5022
5023async function convertDataStrategyResultToDataResult(
5024 dataStrategyResult: DataStrategyResult
5025): Promise<DataResult> {
5026 let { result, type } = dataStrategyResult;
5027
5028 if (isResponse(result)) {
5029 let data: any;
5030
5031 try {
5032 let contentType = result.headers.get("Content-Type");
5033 // Check between word boundaries instead of startsWith() due to the last
5034 // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type
5035 if (contentType && /\bapplication\/json\b/.test(contentType)) {
5036 if (result.body == null) {
5037 data = null;
5038 } else {
5039 data = await result.json();
5040 }
5041 } else {
5042 data = await result.text();
5043 }
5044 } catch (e) {
5045 return { type: ResultType.error, error: e };
5046 }
5047
5048 if (type === ResultType.error) {
5049 return {
5050 type: ResultType.error,
5051 error: new ErrorResponseImpl(result.status, result.statusText, data),
5052 statusCode: result.status,
5053 headers: result.headers,
5054 };
5055 }
5056
5057 return {
5058 type: ResultType.data,
5059 data,
5060 statusCode: result.status,
5061 headers: result.headers,
5062 };
5063 }
5064
5065 if (type === ResultType.error) {
5066 if (isDataWithResponseInit(result)) {
5067 if (result.data instanceof Error) {
5068 return {
5069 type: ResultType.error,
5070 error: result.data,
5071 statusCode: result.init?.status,
5072 headers: result.init?.headers
5073 ? new Headers(result.init.headers)
5074 : undefined,
5075 };
5076 }
5077
5078 // Convert thrown data() to ErrorResponse instances
5079 return {
5080 type: ResultType.error,
5081 error: new ErrorResponseImpl(
5082 result.init?.status || 500,
5083 undefined,
5084 result.data
5085 ),
5086 statusCode: isRouteErrorResponse(result) ? result.status : undefined,
5087 headers: result.init?.headers
5088 ? new Headers(result.init.headers)
5089 : undefined,
5090 };
5091 }
5092 return {
5093 type: ResultType.error,
5094 error: result,
5095 statusCode: isRouteErrorResponse(result) ? result.status : undefined,
5096 };
5097 }
5098
5099 if (isDeferredData(result)) {
5100 return {
5101 type: ResultType.deferred,
5102 deferredData: result,
5103 statusCode: result.init?.status,
5104 headers: result.init?.headers && new Headers(result.init.headers),
5105 };
5106 }
5107
5108 if (isDataWithResponseInit(result)) {
5109 return {
5110 type: ResultType.data,
5111 data: result.data,
5112 statusCode: result.init?.status,
5113 headers: result.init?.headers
5114 ? new Headers(result.init.headers)
5115 : undefined,
5116 };
5117 }
5118
5119 return { type: ResultType.data, data: result };
5120}
5121
5122// Support relative routing in internal redirects
5123function normalizeRelativeRoutingRedirectResponse(
5124 response: Response,
5125 request: Request,
5126 routeId: string,
5127 matches: AgnosticDataRouteMatch[],
5128 basename: string,
5129 v7_relativeSplatPath: boolean
5130) {
5131 let location = response.headers.get("Location");
5132 invariant(
5133 location,
5134 "Redirects returned/thrown from loaders/actions must have a Location header"
5135 );
5136
5137 if (!ABSOLUTE_URL_REGEX.test(location)) {
5138 let trimmedMatches = matches.slice(
5139 0,
5140 matches.findIndex((m) => m.route.id === routeId) + 1
5141 );
5142 location = normalizeTo(
5143 new URL(request.url),
5144 trimmedMatches,
5145 basename,
5146 true,
5147 location,
5148 v7_relativeSplatPath
5149 );
5150 response.headers.set("Location", location);
5151 }
5152
5153 return response;
5154}
5155
5156function normalizeRedirectLocation(
5157 location: string,
5158 currentUrl: URL,
5159 basename: string,
5160 historyInstance: History,
5161): string {
5162 // Match Chrome's behavior:
5163 // https://github.com/chromium/chromium/blob/216dbeb61db0c667e62082e5f5400a32d6983df3/content/public/common/url_utils.cc#L82
5164 let invalidProtocols = [
5165 "about:",
5166 "blob:",
5167 "chrome:",
5168 "chrome-untrusted:",
5169 "content:",
5170 "data:",
5171 "devtools:",
5172 "file:",
5173 "filesystem:",
5174 // eslint-disable-next-line no-script-url
5175 "javascript:",
5176 ];
5177
5178 if (ABSOLUTE_URL_REGEX.test(location)) {
5179 // Strip off the protocol+origin for same-origin + same-basename absolute redirects
5180 let normalizedLocation = location;
5181 let url = normalizedLocation.startsWith("//")
5182 ? new URL(currentUrl.protocol + normalizedLocation)
5183 : new URL(normalizedLocation);
5184 if (invalidProtocols.includes(url.protocol)) {
5185 throw new Error("Invalid redirect location");
5186 }
5187 let isSameBasename = stripBasename(url.pathname, basename) != null;
5188 if (url.origin === currentUrl.origin && isSameBasename) {
5189 return url.pathname + url.search + url.hash;
5190 }
5191 }
5192
5193 try {
5194 let url = historyInstance.createURL(location);
5195 if (invalidProtocols.includes(url.protocol)) {
5196 throw new Error("Invalid redirect location");
5197 }
5198 } catch (e) {}
5199
5200 return location;
5201}
5202
5203// Utility method for creating the Request instances for loaders/actions during
5204// client-side navigations and fetches. During SSR we will always have a
5205// Request instance from the static handler (query/queryRoute)
5206function createClientSideRequest(
5207 history: History,
5208 location: string | Location,
5209 signal: AbortSignal,
5210 submission?: Submission
5211): Request {
5212 let url = history.createURL(stripHashFromPath(location)).toString();
5213 let init: RequestInit = { signal };
5214
5215 if (submission && isMutationMethod(submission.formMethod)) {
5216 let { formMethod, formEncType } = submission;
5217 // Didn't think we needed this but it turns out unlike other methods, patch
5218 // won't be properly normalized to uppercase and results in a 405 error.
5219 // See: https://fetch.spec.whatwg.org/#concept-method
5220 init.method = formMethod.toUpperCase();
5221
5222 if (formEncType === "application/json") {
5223 init.headers = new Headers({ "Content-Type": formEncType });
5224 init.body = JSON.stringify(submission.json);
5225 } else if (formEncType === "text/plain") {
5226 // Content-Type is inferred (https://fetch.spec.whatwg.org/#dom-request)
5227 init.body = submission.text;
5228 } else if (
5229 formEncType === "application/x-www-form-urlencoded" &&
5230 submission.formData
5231 ) {
5232 // Content-Type is inferred (https://fetch.spec.whatwg.org/#dom-request)
5233 init.body = convertFormDataToSearchParams(submission.formData);
5234 } else {
5235 // Content-Type is inferred (https://fetch.spec.whatwg.org/#dom-request)
5236 init.body = submission.formData;
5237 }
5238 }
5239
5240 return new Request(url, init);
5241}
5242
5243function convertFormDataToSearchParams(formData: FormData): URLSearchParams {
5244 let searchParams = new URLSearchParams();
5245
5246 for (let [key, value] of formData.entries()) {
5247 // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs
5248 searchParams.append(key, typeof value === "string" ? value : value.name);
5249 }
5250
5251 return searchParams;
5252}
5253
5254function convertSearchParamsToFormData(
5255 searchParams: URLSearchParams
5256): FormData {
5257 let formData = new FormData();
5258 for (let [key, value] of searchParams.entries()) {
5259 formData.append(key, value);
5260 }
5261 return formData;
5262}
5263
5264function processRouteLoaderData(
5265 matches: AgnosticDataRouteMatch[],
5266 results: Record<string, DataResult>,
5267 pendingActionResult: PendingActionResult | undefined,
5268 activeDeferreds: Map<string, DeferredData>,
5269 skipLoaderErrorBubbling: boolean
5270): {
5271 loaderData: RouterState["loaderData"];
5272 errors: RouterState["errors"] | null;
5273 statusCode: number;
5274 loaderHeaders: Record<string, Headers>;
5275} {
5276 // Fill in loaderData/errors from our loaders
5277 let loaderData: RouterState["loaderData"] = {};
5278 let errors: RouterState["errors"] | null = null;
5279 let statusCode: number | undefined;
5280 let foundError = false;
5281 let loaderHeaders: Record<string, Headers> = {};
5282 let pendingError =
5283 pendingActionResult && isErrorResult(pendingActionResult[1])
5284 ? pendingActionResult[1].error
5285 : undefined;
5286
5287 // Process loader results into state.loaderData/state.errors
5288 matches.forEach((match) => {
5289 if (!(match.route.id in results)) {
5290 return;
5291 }
5292 let id = match.route.id;
5293 let result = results[id];
5294 invariant(
5295 !isRedirectResult(result),
5296 "Cannot handle redirect results in processLoaderData"
5297 );
5298 if (isErrorResult(result)) {
5299 let error = result.error;
5300 // If we have a pending action error, we report it at the highest-route
5301 // that throws a loader error, and then clear it out to indicate that
5302 // it was consumed
5303 if (pendingError !== undefined) {
5304 error = pendingError;
5305 pendingError = undefined;
5306 }
5307
5308 errors = errors || {};
5309
5310 if (skipLoaderErrorBubbling) {
5311 errors[id] = error;
5312 } else {
5313 // Look upwards from the matched route for the closest ancestor error
5314 // boundary, defaulting to the root match. Prefer higher error values
5315 // if lower errors bubble to the same boundary
5316 let boundaryMatch = findNearestBoundary(matches, id);
5317 if (errors[boundaryMatch.route.id] == null) {
5318 errors[boundaryMatch.route.id] = error;
5319 }
5320 }
5321
5322 // Clear our any prior loaderData for the throwing route
5323 loaderData[id] = undefined;
5324
5325 // Once we find our first (highest) error, we set the status code and
5326 // prevent deeper status codes from overriding
5327 if (!foundError) {
5328 foundError = true;
5329 statusCode = isRouteErrorResponse(result.error)
5330 ? result.error.status
5331 : 500;
5332 }
5333 if (result.headers) {
5334 loaderHeaders[id] = result.headers;
5335 }
5336 } else {
5337 if (isDeferredResult(result)) {
5338 activeDeferreds.set(id, result.deferredData);
5339 loaderData[id] = result.deferredData.data;
5340 // Error status codes always override success status codes, but if all
5341 // loaders are successful we take the deepest status code.
5342 if (
5343 result.statusCode != null &&
5344 result.statusCode !== 200 &&
5345 !foundError
5346 ) {
5347 statusCode = result.statusCode;
5348 }
5349 if (result.headers) {
5350 loaderHeaders[id] = result.headers;
5351 }
5352 } else {
5353 loaderData[id] = result.data;
5354 // Error status codes always override success status codes, but if all
5355 // loaders are successful we take the deepest status code.
5356 if (result.statusCode && result.statusCode !== 200 && !foundError) {
5357 statusCode = result.statusCode;
5358 }
5359 if (result.headers) {
5360 loaderHeaders[id] = result.headers;
5361 }
5362 }
5363 }
5364 });
5365
5366 // If we didn't consume the pending action error (i.e., all loaders
5367 // resolved), then consume it here. Also clear out any loaderData for the
5368 // throwing route
5369 if (pendingError !== undefined && pendingActionResult) {
5370 errors = { [pendingActionResult[0]]: pendingError };
5371 loaderData[pendingActionResult[0]] = undefined;
5372 }
5373
5374 return {
5375 loaderData,
5376 errors,
5377 statusCode: statusCode || 200,
5378 loaderHeaders,
5379 };
5380}
5381
5382function processLoaderData(
5383 state: RouterState,
5384 matches: AgnosticDataRouteMatch[],
5385 results: Record<string, DataResult>,
5386 pendingActionResult: PendingActionResult | undefined,
5387 revalidatingFetchers: RevalidatingFetcher[],
5388 fetcherResults: Record<string, DataResult>,
5389 activeDeferreds: Map<string, DeferredData>
5390): {
5391 loaderData: RouterState["loaderData"];
5392 errors?: RouterState["errors"];
5393} {
5394 let { loaderData, errors } = processRouteLoaderData(
5395 matches,
5396 results,
5397 pendingActionResult,
5398 activeDeferreds,
5399 false // This method is only called client side so we always want to bubble
5400 );
5401
5402 // Process results from our revalidating fetchers
5403 revalidatingFetchers.forEach((rf) => {
5404 let { key, match, controller } = rf;
5405 let result = fetcherResults[key];
5406 invariant(result, "Did not find corresponding fetcher result");
5407
5408 // Process fetcher non-redirect errors
5409 if (controller && controller.signal.aborted) {
5410 // Nothing to do for aborted fetchers
5411 return;
5412 } else if (isErrorResult(result)) {
5413 let boundaryMatch = findNearestBoundary(state.matches, match?.route.id);
5414 if (!(errors && errors[boundaryMatch.route.id])) {
5415 errors = {
5416 ...errors,
5417 [boundaryMatch.route.id]: result.error,
5418 };
5419 }
5420 state.fetchers.delete(key);
5421 } else if (isRedirectResult(result)) {
5422 // Should never get here, redirects should get processed above, but we
5423 // keep this to type narrow to a success result in the else
5424 invariant(false, "Unhandled fetcher revalidation redirect");
5425 } else if (isDeferredResult(result)) {
5426 // Should never get here, deferred data should be awaited for fetchers
5427 // in resolveDeferredResults
5428 invariant(false, "Unhandled fetcher deferred data");
5429 } else {
5430 let doneFetcher = getDoneFetcher(result.data);
5431 state.fetchers.set(key, doneFetcher);
5432 }
5433 });
5434
5435 return { loaderData, errors };
5436}
5437
5438function mergeLoaderData(
5439 loaderData: RouteData,
5440 newLoaderData: RouteData,
5441 matches: AgnosticDataRouteMatch[],
5442 errors: RouteData | null | undefined
5443): RouteData {
5444 let mergedLoaderData = { ...newLoaderData };
5445 for (let match of matches) {
5446 let id = match.route.id;
5447 if (newLoaderData.hasOwnProperty(id)) {
5448 if (newLoaderData[id] !== undefined) {
5449 mergedLoaderData[id] = newLoaderData[id];
5450 } else {
5451 // No-op - this is so we ignore existing data if we have a key in the
5452 // incoming object with an undefined value, which is how we unset a prior
5453 // loaderData if we encounter a loader error
5454 }
5455 } else if (loaderData[id] !== undefined && match.route.loader) {
5456 // Preserve existing keys not included in newLoaderData and where a loader
5457 // wasn't removed by HMR
5458 mergedLoaderData[id] = loaderData[id];
5459 }
5460
5461 if (errors && errors.hasOwnProperty(id)) {
5462 // Don't keep any loader data below the boundary
5463 break;
5464 }
5465 }
5466 return mergedLoaderData;
5467}
5468
5469function getActionDataForCommit(
5470 pendingActionResult: PendingActionResult | undefined
5471) {
5472 if (!pendingActionResult) {
5473 return {};
5474 }
5475 return isErrorResult(pendingActionResult[1])
5476 ? {
5477 // Clear out prior actionData on errors
5478 actionData: {},
5479 }
5480 : {
5481 actionData: {
5482 [pendingActionResult[0]]: pendingActionResult[1].data,
5483 },
5484 };
5485}
5486
5487// Find the nearest error boundary, looking upwards from the leaf route (or the
5488// route specified by routeId) for the closest ancestor error boundary,
5489// defaulting to the root match
5490function findNearestBoundary(
5491 matches: AgnosticDataRouteMatch[],
5492 routeId?: string
5493): AgnosticDataRouteMatch {
5494 let eligibleMatches = routeId
5495 ? matches.slice(0, matches.findIndex((m) => m.route.id === routeId) + 1)
5496 : [...matches];
5497 return (
5498 eligibleMatches.reverse().find((m) => m.route.hasErrorBoundary === true) ||
5499 matches[0]
5500 );
5501}
5502
5503function getShortCircuitMatches(routes: AgnosticDataRouteObject[]): {
5504 matches: AgnosticDataRouteMatch[];
5505 route: AgnosticDataRouteObject;
5506} {
5507 // Prefer a root layout route if present, otherwise shim in a route object
5508 let route =
5509 routes.length === 1
5510 ? routes[0]
5511 : routes.find((r) => r.index || !r.path || r.path === "/") || {
5512 id: `__shim-error-route__`,
5513 };
5514
5515 return {
5516 matches: [
5517 {
5518 params: {},
5519 pathname: "",
5520 pathnameBase: "",
5521 route,
5522 },
5523 ],
5524 route,
5525 };
5526}
5527
5528function getInternalRouterError(
5529 status: number,
5530 {
5531 pathname,
5532 routeId,
5533 method,
5534 type,
5535 message,
5536 }: {
5537 pathname?: string;
5538 routeId?: string;
5539 method?: string;
5540 type?: "defer-action" | "invalid-body";
5541 message?: string;
5542 } = {}
5543) {
5544 let statusText = "Unknown Server Error";
5545 let errorMessage = "Unknown @remix-run/router error";
5546
5547 if (status === 400) {
5548 statusText = "Bad Request";
5549 if (method && pathname && routeId) {
5550 errorMessage =
5551 `You made a ${method} request to "${pathname}" but ` +
5552 `did not provide a \`loader\` for route "${routeId}", ` +
5553 `so there is no way to handle the request.`;
5554 } else if (type === "defer-action") {
5555 errorMessage = "defer() is not supported in actions";
5556 } else if (type === "invalid-body") {
5557 errorMessage = "Unable to encode submission body";
5558 }
5559 } else if (status === 403) {
5560 statusText = "Forbidden";
5561 errorMessage = `Route "${routeId}" does not match URL "${pathname}"`;
5562 } else if (status === 404) {
5563 statusText = "Not Found";
5564 errorMessage = `No route matches URL "${pathname}"`;
5565 } else if (status === 405) {
5566 statusText = "Method Not Allowed";
5567 if (method && pathname && routeId) {
5568 errorMessage =
5569 `You made a ${method.toUpperCase()} request to "${pathname}" but ` +
5570 `did not provide an \`action\` for route "${routeId}", ` +
5571 `so there is no way to handle the request.`;
5572 } else if (method) {
5573 errorMessage = `Invalid request method "${method.toUpperCase()}"`;
5574 }
5575 }
5576
5577 return new ErrorResponseImpl(
5578 status || 500,
5579 statusText,
5580 new Error(errorMessage),
5581 true
5582 );
5583}
5584
5585// Find any returned redirect errors, starting from the lowest match
5586function findRedirect(
5587 results: Record<string, DataResult>
5588): { key: string; result: RedirectResult } | undefined {
5589 let entries = Object.entries(results);
5590 for (let i = entries.length - 1; i >= 0; i--) {
5591 let [key, result] = entries[i];
5592 if (isRedirectResult(result)) {
5593 return { key, result };
5594 }
5595 }
5596}
5597
5598function stripHashFromPath(path: To) {
5599 let parsedPath = typeof path === "string" ? parsePath(path) : path;
5600 return createPath({ ...parsedPath, hash: "" });
5601}
5602
5603function isHashChangeOnly(a: Location, b: Location): boolean {
5604 if (a.pathname !== b.pathname || a.search !== b.search) {
5605 return false;
5606 }
5607
5608 if (a.hash === "") {
5609 // /page -> /page#hash
5610 return b.hash !== "";
5611 } else if (a.hash === b.hash) {
5612 // /page#hash -> /page#hash
5613 return true;
5614 } else if (b.hash !== "") {
5615 // /page#hash -> /page#other
5616 return true;
5617 }
5618
5619 // If the hash is removed the browser will re-perform a request to the server
5620 // /page#hash -> /page
5621 return false;
5622}
5623
5624function isPromise<T = unknown>(val: unknown): val is Promise<T> {
5625 return typeof val === "object" && val != null && "then" in val;
5626}
5627
5628function isDataStrategyResult(result: unknown): result is DataStrategyResult {
5629 return (
5630 result != null &&
5631 typeof result === "object" &&
5632 "type" in result &&
5633 "result" in result &&
5634 (result.type === ResultType.data || result.type === ResultType.error)
5635 );
5636}
5637
5638function isRedirectDataStrategyResultResult(result: DataStrategyResult) {
5639 return (
5640 isResponse(result.result) && redirectStatusCodes.has(result.result.status)
5641 );
5642}
5643
5644function isDeferredResult(result: DataResult): result is DeferredResult {
5645 return result.type === ResultType.deferred;
5646}
5647
5648function isErrorResult(result: DataResult): result is ErrorResult {
5649 return result.type === ResultType.error;
5650}
5651
5652function isRedirectResult(result?: DataResult): result is RedirectResult {
5653 return (result && result.type) === ResultType.redirect;
5654}
5655
5656export function isDataWithResponseInit(
5657 value: any
5658): value is DataWithResponseInit<unknown> {
5659 return (
5660 typeof value === "object" &&
5661 value != null &&
5662 "type" in value &&
5663 "data" in value &&
5664 "init" in value &&
5665 value.type === "DataWithResponseInit"
5666 );
5667}
5668
5669export function isDeferredData(value: any): value is DeferredData {
5670 let deferred: DeferredData = value;
5671 return (
5672 deferred &&
5673 typeof deferred === "object" &&
5674 typeof deferred.data === "object" &&
5675 typeof deferred.subscribe === "function" &&
5676 typeof deferred.cancel === "function" &&
5677 typeof deferred.resolveData === "function"
5678 );
5679}
5680
5681function isResponse(value: any): value is Response {
5682 return (
5683 value != null &&
5684 typeof value.status === "number" &&
5685 typeof value.statusText === "string" &&
5686 typeof value.headers === "object" &&
5687 typeof value.body !== "undefined"
5688 );
5689}
5690
5691function isRedirectResponse(result: any): result is Response {
5692 if (!isResponse(result)) {
5693 return false;
5694 }
5695
5696 let status = result.status;
5697 let location = result.headers.get("Location");
5698 return status >= 300 && status <= 399 && location != null;
5699}
5700
5701function isValidMethod(method: string): method is FormMethod | V7_FormMethod {
5702 return validRequestMethods.has(method.toLowerCase() as FormMethod);
5703}
5704
5705function isMutationMethod(
5706 method: string
5707): method is MutationFormMethod | V7_MutationFormMethod {
5708 return validMutationMethods.has(method.toLowerCase() as MutationFormMethod);
5709}
5710
5711async function resolveNavigationDeferredResults(
5712 matches: (AgnosticDataRouteMatch | null)[],
5713 results: Record<string, DataResult>,
5714 signal: AbortSignal,
5715 currentMatches: AgnosticDataRouteMatch[],
5716 currentLoaderData: RouteData
5717) {
5718 let entries = Object.entries(results);
5719 for (let index = 0; index < entries.length; index++) {
5720 let [routeId, result] = entries[index];
5721 let match = matches.find((m) => m?.route.id === routeId);
5722 // If we don't have a match, then we can have a deferred result to do
5723 // anything with. This is for revalidating fetchers where the route was
5724 // removed during HMR
5725 if (!match) {
5726 continue;
5727 }
5728
5729 let currentMatch = currentMatches.find(
5730 (m) => m.route.id === match!.route.id
5731 );
5732 let isRevalidatingLoader =
5733 currentMatch != null &&
5734 !isNewRouteInstance(currentMatch, match) &&
5735 (currentLoaderData && currentLoaderData[match.route.id]) !== undefined;
5736
5737 if (isDeferredResult(result) && isRevalidatingLoader) {
5738 // Note: we do not have to touch activeDeferreds here since we race them
5739 // against the signal in resolveDeferredData and they'll get aborted
5740 // there if needed
5741 await resolveDeferredData(result, signal, false).then((result) => {
5742 if (result) {
5743 results[routeId] = result;
5744 }
5745 });
5746 }
5747 }
5748}
5749
5750async function resolveFetcherDeferredResults(
5751 matches: (AgnosticDataRouteMatch | null)[],
5752 results: Record<string, DataResult>,
5753 revalidatingFetchers: RevalidatingFetcher[]
5754) {
5755 for (let index = 0; index < revalidatingFetchers.length; index++) {
5756 let { key, routeId, controller } = revalidatingFetchers[index];
5757 let result = results[key];
5758 let match = matches.find((m) => m?.route.id === routeId);
5759 // If we don't have a match, then we can have a deferred result to do
5760 // anything with. This is for revalidating fetchers where the route was
5761 // removed during HMR
5762 if (!match) {
5763 continue;
5764 }
5765
5766 if (isDeferredResult(result)) {
5767 // Note: we do not have to touch activeDeferreds here since we race them
5768 // against the signal in resolveDeferredData and they'll get aborted
5769 // there if needed
5770 invariant(
5771 controller,
5772 "Expected an AbortController for revalidating fetcher deferred result"
5773 );
5774 await resolveDeferredData(result, controller.signal, true).then(
5775 (result) => {
5776 if (result) {
5777 results[key] = result;
5778 }
5779 }
5780 );
5781 }
5782 }
5783}
5784
5785async function resolveDeferredData(
5786 result: DeferredResult,
5787 signal: AbortSignal,
5788 unwrap = false
5789): Promise<SuccessResult | ErrorResult | undefined> {
5790 let aborted = await result.deferredData.resolveData(signal);
5791 if (aborted) {
5792 return;
5793 }
5794
5795 if (unwrap) {
5796 try {
5797 return {
5798 type: ResultType.data,
5799 data: result.deferredData.unwrappedData,
5800 };
5801 } catch (e) {
5802 // Handle any TrackedPromise._error values encountered while unwrapping
5803 return {
5804 type: ResultType.error,
5805 error: e,
5806 };
5807 }
5808 }
5809
5810 return {
5811 type: ResultType.data,
5812 data: result.deferredData.data,
5813 };
5814}
5815
5816function hasNakedIndexQuery(search: string): boolean {
5817 return new URLSearchParams(search).getAll("index").some((v) => v === "");
5818}
5819
5820function getTargetMatch(
5821 matches: AgnosticDataRouteMatch[],
5822 location: Location | string
5823) {
5824 let search =
5825 typeof location === "string" ? parsePath(location).search : location.search;
5826 if (
5827 matches[matches.length - 1].route.index &&
5828 hasNakedIndexQuery(search || "")
5829 ) {
5830 // Return the leaf index route when index is present
5831 return matches[matches.length - 1];
5832 }
5833 // Otherwise grab the deepest "path contributing" match (ignoring index and
5834 // pathless layout routes)
5835 let pathMatches = getPathContributingMatches(matches);
5836 return pathMatches[pathMatches.length - 1];
5837}
5838
5839function getSubmissionFromNavigation(
5840 navigation: Navigation
5841): Submission | undefined {
5842 let { formMethod, formAction, formEncType, text, formData, json } =
5843 navigation;
5844 if (!formMethod || !formAction || !formEncType) {
5845 return;
5846 }
5847
5848 if (text != null) {
5849 return {
5850 formMethod,
5851 formAction,
5852 formEncType,
5853 formData: undefined,
5854 json: undefined,
5855 text,
5856 };
5857 } else if (formData != null) {
5858 return {
5859 formMethod,
5860 formAction,
5861 formEncType,
5862 formData,
5863 json: undefined,
5864 text: undefined,
5865 };
5866 } else if (json !== undefined) {
5867 return {
5868 formMethod,
5869 formAction,
5870 formEncType,
5871 formData: undefined,
5872 json,
5873 text: undefined,
5874 };
5875 }
5876}
5877
5878function getLoadingNavigation(
5879 location: Location,
5880 submission?: Submission
5881): NavigationStates["Loading"] {
5882 if (submission) {
5883 let navigation: NavigationStates["Loading"] = {
5884 state: "loading",
5885 location,
5886 formMethod: submission.formMethod,
5887 formAction: submission.formAction,
5888 formEncType: submission.formEncType,
5889 formData: submission.formData,
5890 json: submission.json,
5891 text: submission.text,
5892 };
5893 return navigation;
5894 } else {
5895 let navigation: NavigationStates["Loading"] = {
5896 state: "loading",
5897 location,
5898 formMethod: undefined,
5899 formAction: undefined,
5900 formEncType: undefined,
5901 formData: undefined,
5902 json: undefined,
5903 text: undefined,
5904 };
5905 return navigation;
5906 }
5907}
5908
5909function getSubmittingNavigation(
5910 location: Location,
5911 submission: Submission
5912): NavigationStates["Submitting"] {
5913 let navigation: NavigationStates["Submitting"] = {
5914 state: "submitting",
5915 location,
5916 formMethod: submission.formMethod,
5917 formAction: submission.formAction,
5918 formEncType: submission.formEncType,
5919 formData: submission.formData,
5920 json: submission.json,
5921 text: submission.text,
5922 };
5923 return navigation;
5924}
5925
5926function getLoadingFetcher(
5927 submission?: Submission,
5928 data?: Fetcher["data"]
5929): FetcherStates["Loading"] {
5930 if (submission) {
5931 let fetcher: FetcherStates["Loading"] = {
5932 state: "loading",
5933 formMethod: submission.formMethod,
5934 formAction: submission.formAction,
5935 formEncType: submission.formEncType,
5936 formData: submission.formData,
5937 json: submission.json,
5938 text: submission.text,
5939 data,
5940 };
5941 return fetcher;
5942 } else {
5943 let fetcher: FetcherStates["Loading"] = {
5944 state: "loading",
5945 formMethod: undefined,
5946 formAction: undefined,
5947 formEncType: undefined,
5948 formData: undefined,
5949 json: undefined,
5950 text: undefined,
5951 data,
5952 };
5953 return fetcher;
5954 }
5955}
5956
5957function getSubmittingFetcher(
5958 submission: Submission,
5959 existingFetcher?: Fetcher
5960): FetcherStates["Submitting"] {
5961 let fetcher: FetcherStates["Submitting"] = {
5962 state: "submitting",
5963 formMethod: submission.formMethod,
5964 formAction: submission.formAction,
5965 formEncType: submission.formEncType,
5966 formData: submission.formData,
5967 json: submission.json,
5968 text: submission.text,
5969 data: existingFetcher ? existingFetcher.data : undefined,
5970 };
5971 return fetcher;
5972}
5973
5974function getDoneFetcher(data: Fetcher["data"]): FetcherStates["Idle"] {
5975 let fetcher: FetcherStates["Idle"] = {
5976 state: "idle",
5977 formMethod: undefined,
5978 formAction: undefined,
5979 formEncType: undefined,
5980 formData: undefined,
5981 json: undefined,
5982 text: undefined,
5983 data,
5984 };
5985 return fetcher;
5986}
5987
5988function restoreAppliedTransitions(
5989 _window: Window,
5990 transitions: Map<string, Set<string>>
5991) {
5992 try {
5993 let sessionPositions = _window.sessionStorage.getItem(
5994 TRANSITIONS_STORAGE_KEY
5995 );
5996 if (sessionPositions) {
5997 let json = JSON.parse(sessionPositions);
5998 for (let [k, v] of Object.entries(json || {})) {
5999 if (v && Array.isArray(v)) {
6000 transitions.set(k, new Set(v || []));
6001 }
6002 }
6003 }
6004 } catch (e) {
6005 // no-op, use default empty object
6006 }
6007}
6008
6009function persistAppliedTransitions(
6010 _window: Window,
6011 transitions: Map<string, Set<string>>
6012) {
6013 if (transitions.size > 0) {
6014 let json: Record<string, string[]> = {};
6015 for (let [k, v] of transitions) {
6016 json[k] = [...v];
6017 }
6018 try {
6019 _window.sessionStorage.setItem(
6020 TRANSITIONS_STORAGE_KEY,
6021 JSON.stringify(json)
6022 );
6023 } catch (error) {
6024 warning(
6025 false,
6026 `Failed to save applied view transitions in sessionStorage (${error}).`
6027 );
6028 }
6029 }
6030}
6031//#endregion
Note: See TracBrowser for help on using the repository browser.