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

main
Last change on this file was d24f17c, checked in by Aleksandar Panovski <apano77@…>, 15 months ago

Initial commit

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