1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682 |
- import type { History, Location, Path, To } from "./history";
- import {
- Action as HistoryAction,
- createLocation,
- createPath,
- invariant,
- parsePath,
- } from "./history";
- import type {
- DataResult,
- AgnosticDataRouteMatch,
- AgnosticDataRouteObject,
- DeferredResult,
- ErrorResult,
- FormEncType,
- FormMethod,
- RedirectResult,
- RouteData,
- AgnosticRouteObject,
- Submission,
- SuccessResult,
- AgnosticRouteMatch,
- MutationFormMethod,
- ShouldRevalidateFunction,
- } from "./utils";
- import {
- DeferredData,
- ErrorResponse,
- ResultType,
- convertRoutesToDataRoutes,
- getPathContributingMatches,
- isRouteErrorResponse,
- joinPaths,
- matchRoutes,
- resolveTo,
- warning,
- } from "./utils";
- ////////////////////////////////////////////////////////////////////////////////
- //#region Types and Constants
- ////////////////////////////////////////////////////////////////////////////////
- /**
- * A Router instance manages all navigation and data loading/mutations
- */
- export interface Router {
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Return the basename for the router
- */
- get basename(): RouterInit["basename"];
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Return the current state of the router
- */
- get state(): RouterState;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Return the routes for this router instance
- */
- get routes(): AgnosticDataRouteObject[];
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Initialize the router, including adding history listeners and kicking off
- * initial data fetches. Returns a function to cleanup listeners and abort
- * any in-progress loads
- */
- initialize(): Router;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Subscribe to router.state updates
- *
- * @param fn function to call with the new state
- */
- subscribe(fn: RouterSubscriber): () => void;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Enable scroll restoration behavior in the router
- *
- * @param savedScrollPositions Object that will manage positions, in case
- * it's being restored from sessionStorage
- * @param getScrollPosition Function to get the active Y scroll position
- * @param getKey Function to get the key to use for restoration
- */
- enableScrollRestoration(
- savedScrollPositions: Record<string, number>,
- getScrollPosition: GetScrollPositionFunction,
- getKey?: GetScrollRestorationKeyFunction
- ): () => void;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Navigate forward/backward in the history stack
- * @param to Delta to move in the history stack
- */
- navigate(to: number): Promise<void>;
- /**
- * Navigate to the given path
- * @param to Path to navigate to
- * @param opts Navigation options (method, submission, etc.)
- */
- navigate(to: To, opts?: RouterNavigateOptions): Promise<void>;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Trigger a fetcher load/submission
- *
- * @param key Fetcher key
- * @param routeId Route that owns the fetcher
- * @param href href to fetch
- * @param opts Fetcher options, (method, submission, etc.)
- */
- fetch(
- key: string,
- routeId: string,
- href: string,
- opts?: RouterNavigateOptions
- ): void;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Trigger a revalidation of all current route loaders and fetcher loads
- */
- revalidate(): void;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Utility function to create an href for the given location
- * @param location
- */
- createHref(location: Location | URL): string;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Utility function to URL encode a destination path according to the internal
- * history implementation
- * @param to
- */
- encodeLocation(to: To): Path;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Get/create a fetcher for the given key
- * @param key
- */
- getFetcher<TData = any>(key?: string): Fetcher<TData>;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Delete the fetcher for a given key
- * @param key
- */
- deleteFetcher(key?: string): void;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Cleanup listeners and abort any in-progress loads
- */
- dispose(): void;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Get a navigation blocker
- * @param key The identifier for the blocker
- * @param fn The blocker function implementation
- */
- getBlocker(key: string, fn: BlockerFunction): Blocker;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Delete a navigation blocker
- * @param key The identifier for the blocker
- */
- deleteBlocker(key: string): void;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Internal fetch AbortControllers accessed by unit tests
- */
- _internalFetchControllers: Map<string, AbortController>;
- /**
- * @internal
- * PRIVATE - DO NOT USE
- *
- * Internal pending DeferredData instances accessed by unit tests
- */
- _internalActiveDeferreds: Map<string, DeferredData>;
- }
- /**
- * State maintained internally by the router. During a navigation, all states
- * reflect the the "old" location unless otherwise noted.
- */
- export interface RouterState {
- /**
- * The action of the most recent navigation
- */
- historyAction: HistoryAction;
- /**
- * The current location reflected by the router
- */
- location: Location;
- /**
- * The current set of route matches
- */
- matches: AgnosticDataRouteMatch[];
- /**
- * Tracks whether we've completed our initial data load
- */
- initialized: boolean;
- /**
- * Current scroll position we should start at for a new view
- * - number -> scroll position to restore to
- * - false -> do not restore scroll at all (used during submissions)
- * - null -> don't have a saved position, scroll to hash or top of page
- */
- restoreScrollPosition: number | false | null;
- /**
- * Indicate whether this navigation should skip resetting the scroll position
- * if we are unable to restore the scroll position
- */
- preventScrollReset: boolean;
- /**
- * Tracks the state of the current navigation
- */
- navigation: Navigation;
- /**
- * Tracks any in-progress revalidations
- */
- revalidation: RevalidationState;
- /**
- * Data from the loaders for the current matches
- */
- loaderData: RouteData;
- /**
- * Data from the action for the current matches
- */
- actionData: RouteData | null;
- /**
- * Errors caught from loaders for the current matches
- */
- errors: RouteData | null;
- /**
- * Map of current fetchers
- */
- fetchers: Map<string, Fetcher>;
- /**
- * Map of current blockers
- */
- blockers: Map<string, Blocker>;
- }
- /**
- * Data that can be passed into hydrate a Router from SSR
- */
- export type HydrationState = Partial<
- Pick<RouterState, "loaderData" | "actionData" | "errors">
- >;
- /**
- * Initialization options for createRouter
- */
- export interface RouterInit {
- basename?: string;
- routes: AgnosticRouteObject[];
- history: History;
- hydrationData?: HydrationState;
- }
- /**
- * State returned from a server-side query() call
- */
- export interface StaticHandlerContext {
- basename: Router["basename"];
- location: RouterState["location"];
- matches: RouterState["matches"];
- loaderData: RouterState["loaderData"];
- actionData: RouterState["actionData"];
- errors: RouterState["errors"];
- statusCode: number;
- loaderHeaders: Record<string, Headers>;
- actionHeaders: Record<string, Headers>;
- activeDeferreds: Record<string, DeferredData> | null;
- _deepestRenderedBoundaryId?: string | null;
- }
- /**
- * A StaticHandler instance manages a singular SSR navigation/fetch event
- */
- export interface StaticHandler {
- dataRoutes: AgnosticDataRouteObject[];
- query(
- request: Request,
- opts?: { requestContext?: unknown }
- ): Promise<StaticHandlerContext | Response>;
- queryRoute(
- request: Request,
- opts?: { routeId?: string; requestContext?: unknown }
- ): Promise<any>;
- }
- /**
- * Subscriber function signature for changes to router state
- */
- export interface RouterSubscriber {
- (state: RouterState): void;
- }
- interface UseMatchesMatch {
- id: string;
- pathname: string;
- params: AgnosticRouteMatch["params"];
- data: unknown;
- handle: unknown;
- }
- /**
- * Function signature for determining the key to be used in scroll restoration
- * for a given location
- */
- export interface GetScrollRestorationKeyFunction {
- (location: Location, matches: UseMatchesMatch[]): string | null;
- }
- /**
- * Function signature for determining the current scroll position
- */
- export interface GetScrollPositionFunction {
- (): number;
- }
- /**
- * Options for a navigate() call for a Link navigation
- */
- type LinkNavigateOptions = {
- replace?: boolean;
- state?: any;
- preventScrollReset?: boolean;
- };
- /**
- * Options for a navigate() call for a Form navigation
- */
- type SubmissionNavigateOptions = {
- replace?: boolean;
- state?: any;
- preventScrollReset?: boolean;
- formMethod?: FormMethod;
- formEncType?: FormEncType;
- formData: FormData;
- };
- /**
- * Options to pass to navigate() for either a Link or Form navigation
- */
- export type RouterNavigateOptions =
- | LinkNavigateOptions
- | SubmissionNavigateOptions;
- /**
- * Options to pass to fetch()
- */
- export type RouterFetchOptions =
- | Omit<LinkNavigateOptions, "replace">
- | Omit<SubmissionNavigateOptions, "replace">;
- /**
- * Potential states for state.navigation
- */
- export type NavigationStates = {
- Idle: {
- state: "idle";
- location: undefined;
- formMethod: undefined;
- formAction: undefined;
- formEncType: undefined;
- formData: undefined;
- };
- Loading: {
- state: "loading";
- location: Location;
- formMethod: FormMethod | undefined;
- formAction: string | undefined;
- formEncType: FormEncType | undefined;
- formData: FormData | undefined;
- };
- Submitting: {
- state: "submitting";
- location: Location;
- formMethod: FormMethod;
- formAction: string;
- formEncType: FormEncType;
- formData: FormData;
- };
- };
- export type Navigation = NavigationStates[keyof NavigationStates];
- export type RevalidationState = "idle" | "loading";
- /**
- * Potential states for fetchers
- */
- type FetcherStates<TData = any> = {
- Idle: {
- state: "idle";
- formMethod: undefined;
- formAction: undefined;
- formEncType: undefined;
- formData: undefined;
- data: TData | undefined;
- " _hasFetcherDoneAnything "?: boolean;
- };
- Loading: {
- state: "loading";
- formMethod: FormMethod | undefined;
- formAction: string | undefined;
- formEncType: FormEncType | undefined;
- formData: FormData | undefined;
- data: TData | undefined;
- " _hasFetcherDoneAnything "?: boolean;
- };
- Submitting: {
- state: "submitting";
- formMethod: FormMethod;
- formAction: string;
- formEncType: FormEncType;
- formData: FormData;
- data: TData | undefined;
- " _hasFetcherDoneAnything "?: boolean;
- };
- };
- export type Fetcher<TData = any> =
- FetcherStates<TData>[keyof FetcherStates<TData>];
- interface BlockerBlocked {
- state: "blocked";
- reset(): void;
- proceed(): void;
- location: Location;
- }
- interface BlockerUnblocked {
- state: "unblocked";
- reset: undefined;
- proceed: undefined;
- location: undefined;
- }
- interface BlockerProceeding {
- state: "proceeding";
- reset: undefined;
- proceed: undefined;
- location: Location;
- }
- export type Blocker = BlockerUnblocked | BlockerBlocked | BlockerProceeding;
- export type BlockerFunction = (args: {
- currentLocation: Location;
- nextLocation: Location;
- historyAction: HistoryAction;
- }) => boolean;
- interface ShortCircuitable {
- /**
- * startNavigation does not need to complete the navigation because we
- * redirected or got interrupted
- */
- shortCircuited?: boolean;
- }
- interface HandleActionResult extends ShortCircuitable {
- /**
- * Error thrown from the current action, keyed by the route containing the
- * error boundary to render the error. To be committed to the state after
- * loaders have completed
- */
- pendingActionError?: RouteData;
- /**
- * Data returned from the current action, keyed by the route owning the action.
- * To be committed to the state after loaders have completed
- */
- pendingActionData?: RouteData;
- }
- interface HandleLoadersResult extends ShortCircuitable {
- /**
- * loaderData returned from the current set of loaders
- */
- loaderData?: RouterState["loaderData"];
- /**
- * errors thrown from the current set of loaders
- */
- errors?: RouterState["errors"];
- }
- /**
- * Cached info for active fetcher.load() instances so they can participate
- * in revalidation
- */
- interface FetchLoadMatch {
- routeId: string;
- path: string;
- match: AgnosticDataRouteMatch;
- matches: AgnosticDataRouteMatch[];
- }
- /**
- * Identified fetcher.load() calls that need to be revalidated
- */
- interface RevalidatingFetcher extends FetchLoadMatch {
- key: string;
- }
- /**
- * Wrapper object to allow us to throw any response out from callLoaderOrAction
- * for queryRouter while preserving whether or not it was thrown or returned
- * from the loader/action
- */
- interface QueryRouteResponse {
- type: ResultType.data | ResultType.error;
- response: Response;
- }
- const validMutationMethodsArr: MutationFormMethod[] = [
- "post",
- "put",
- "patch",
- "delete",
- ];
- const validMutationMethods = new Set<MutationFormMethod>(
- validMutationMethodsArr
- );
- const validRequestMethodsArr: FormMethod[] = [
- "get",
- ...validMutationMethodsArr,
- ];
- const validRequestMethods = new Set<FormMethod>(validRequestMethodsArr);
- const redirectStatusCodes = new Set([301, 302, 303, 307, 308]);
- const redirectPreserveMethodStatusCodes = new Set([307, 308]);
- export const IDLE_NAVIGATION: NavigationStates["Idle"] = {
- state: "idle",
- location: undefined,
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- };
- export const IDLE_FETCHER: FetcherStates["Idle"] = {
- state: "idle",
- data: undefined,
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- };
- export const IDLE_BLOCKER: BlockerUnblocked = {
- state: "unblocked",
- proceed: undefined,
- reset: undefined,
- location: undefined,
- };
- const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
- const isBrowser =
- typeof window !== "undefined" &&
- typeof window.document !== "undefined" &&
- typeof window.document.createElement !== "undefined";
- const isServer = !isBrowser;
- //#endregion
- ////////////////////////////////////////////////////////////////////////////////
- //#region createRouter
- ////////////////////////////////////////////////////////////////////////////////
- /**
- * Create a router and listen to history POP navigations
- */
- export function createRouter(init: RouterInit): Router {
- invariant(
- init.routes.length > 0,
- "You must provide a non-empty routes array to createRouter"
- );
- let dataRoutes = convertRoutesToDataRoutes(init.routes);
- // Cleanup function for history
- let unlistenHistory: (() => void) | null = null;
- // Externally-provided functions to call on all state changes
- let subscribers = new Set<RouterSubscriber>();
- // Externally-provided object to hold scroll restoration locations during routing
- let savedScrollPositions: Record<string, number> | null = null;
- // Externally-provided function to get scroll restoration keys
- let getScrollRestorationKey: GetScrollRestorationKeyFunction | null = null;
- // Externally-provided function to get current scroll position
- let getScrollPosition: GetScrollPositionFunction | null = null;
- // One-time flag to control the initial hydration scroll restoration. Because
- // we don't get the saved positions from <ScrollRestoration /> until _after_
- // the initial render, we need to manually trigger a separate updateState to
- // send along the restoreScrollPosition
- // Set to true if we have `hydrationData` since we assume we were SSR'd and that
- // SSR did the initial scroll restoration.
- let initialScrollRestored = init.hydrationData != null;
- let initialMatches = matchRoutes(
- dataRoutes,
- init.history.location,
- init.basename
- );
- let initialErrors: RouteData | null = null;
- if (initialMatches == null) {
- // If we do not match a user-provided-route, fall back to the root
- // to allow the error boundary to take over
- let error = getInternalRouterError(404, {
- pathname: init.history.location.pathname,
- });
- let { matches, route } = getShortCircuitMatches(dataRoutes);
- initialMatches = matches;
- initialErrors = { [route.id]: error };
- }
- let initialized =
- !initialMatches.some((m) => m.route.loader) || init.hydrationData != null;
- let router: Router;
- let state: RouterState = {
- historyAction: init.history.action,
- location: init.history.location,
- matches: initialMatches,
- initialized,
- navigation: IDLE_NAVIGATION,
- // Don't restore on initial updateState() if we were SSR'd
- restoreScrollPosition: init.hydrationData != null ? false : null,
- preventScrollReset: false,
- revalidation: "idle",
- loaderData: (init.hydrationData && init.hydrationData.loaderData) || {},
- actionData: (init.hydrationData && init.hydrationData.actionData) || null,
- errors: (init.hydrationData && init.hydrationData.errors) || initialErrors,
- fetchers: new Map(),
- blockers: new Map(),
- };
- // -- Stateful internal variables to manage navigations --
- // Current navigation in progress (to be committed in completeNavigation)
- let pendingAction: HistoryAction = HistoryAction.Pop;
- // Should the current navigation prevent the scroll reset if scroll cannot
- // be restored?
- let pendingPreventScrollReset = false;
- // AbortController for the active navigation
- let pendingNavigationController: AbortController | null;
- // We use this to avoid touching history in completeNavigation if a
- // revalidation is entirely uninterrupted
- let isUninterruptedRevalidation = false;
- // Use this internal flag to force revalidation of all loaders:
- // - submissions (completed or interrupted)
- // - useRevalidate()
- // - X-Remix-Revalidate (from redirect)
- let isRevalidationRequired = false;
- // Use this internal array to capture routes that require revalidation due
- // to a cancelled deferred on action submission
- let cancelledDeferredRoutes: string[] = [];
- // Use this internal array to capture fetcher loads that were cancelled by an
- // action navigation and require revalidation
- let cancelledFetcherLoads: string[] = [];
- // AbortControllers for any in-flight fetchers
- let fetchControllers = new Map<string, AbortController>();
- // Track loads based on the order in which they started
- let incrementingLoadId = 0;
- // Track the outstanding pending navigation data load to be compared against
- // the globally incrementing load when a fetcher load lands after a completed
- // navigation
- let pendingNavigationLoadId = -1;
- // Fetchers that triggered data reloads as a result of their actions
- let fetchReloadIds = new Map<string, number>();
- // Fetchers that triggered redirect navigations from their actions
- let fetchRedirectIds = new Set<string>();
- // Most recent href/match for fetcher.load calls for fetchers
- let fetchLoadMatches = new Map<string, FetchLoadMatch>();
- // Store DeferredData instances for active route matches. When a
- // route loader returns defer() we stick one in here. Then, when a nested
- // promise resolves we update loaderData. If a new navigation starts we
- // cancel active deferreds for eliminated routes.
- let activeDeferreds = new Map<string, DeferredData>();
- // Store blocker functions in a separate Map outside of router state since
- // we don't need to update UI state if they change
- let blockerFunctions = new Map<string, BlockerFunction>();
- // Flag to ignore the next history update, so we can revert the URL change on
- // a POP navigation that was blocked by the user without touching router state
- let ignoreNextHistoryUpdate = false;
- // Initialize the router, all side effects should be kicked off from here.
- // Implemented as a Fluent API for ease of:
- // let router = createRouter(init).initialize();
- function initialize() {
- // If history informs us of a POP navigation, start the navigation but do not update
- // state. We'll update our own state once the navigation completes
- unlistenHistory = init.history.listen(
- ({ action: historyAction, location, delta }) => {
- // Ignore this event if it was just us resetting the URL from a
- // blocked POP navigation
- if (ignoreNextHistoryUpdate) {
- ignoreNextHistoryUpdate = false;
- return;
- }
- warning(
- blockerFunctions.size === 0 || delta != null,
- "You are trying to use a blocker on a POP navigation to a location " +
- "that was not created by @remix-run/router. This will fail silently in " +
- "production. This can happen if you are navigating outside the router " +
- "via `window.history.pushState`/`window.location.hash` instead of using " +
- "router navigation APIs. This can also happen if you are using " +
- "createHashRouter and the user manually changes the URL."
- );
- let blockerKey = shouldBlockNavigation({
- currentLocation: state.location,
- nextLocation: location,
- historyAction,
- });
- if (blockerKey && delta != null) {
- // Restore the URL to match the current UI, but don't update router state
- ignoreNextHistoryUpdate = true;
- init.history.go(delta * -1);
- // Put the blocker into a blocked state
- updateBlocker(blockerKey, {
- state: "blocked",
- location,
- proceed() {
- updateBlocker(blockerKey!, {
- state: "proceeding",
- proceed: undefined,
- reset: undefined,
- location,
- });
- // Re-do the same POP navigation we just blocked
- init.history.go(delta);
- },
- reset() {
- deleteBlocker(blockerKey!);
- updateState({ blockers: new Map(router.state.blockers) });
- },
- });
- return;
- }
- return startNavigation(historyAction, location);
- }
- );
- // Kick off initial data load if needed. Use Pop to avoid modifying history
- if (!state.initialized) {
- startNavigation(HistoryAction.Pop, state.location);
- }
- return router;
- }
- // Clean up a router and it's side effects
- function dispose() {
- if (unlistenHistory) {
- unlistenHistory();
- }
- subscribers.clear();
- pendingNavigationController && pendingNavigationController.abort();
- state.fetchers.forEach((_, key) => deleteFetcher(key));
- state.blockers.forEach((_, key) => deleteBlocker(key));
- }
- // Subscribe to state updates for the router
- function subscribe(fn: RouterSubscriber) {
- subscribers.add(fn);
- return () => subscribers.delete(fn);
- }
- // Update our state and notify the calling context of the change
- function updateState(newState: Partial<RouterState>): void {
- state = {
- ...state,
- ...newState,
- };
- subscribers.forEach((subscriber) => subscriber(state));
- }
- // Complete a navigation returning the state.navigation back to the IDLE_NAVIGATION
- // and setting state.[historyAction/location/matches] to the new route.
- // - Location is a required param
- // - Navigation will always be set to IDLE_NAVIGATION
- // - Can pass any other state in newState
- function completeNavigation(
- location: Location,
- newState: Partial<Omit<RouterState, "action" | "location" | "navigation">>
- ): void {
- // Deduce if we're in a loading/actionReload state:
- // - We have committed actionData in the store
- // - The current navigation was a mutation submission
- // - We're past the submitting state and into the loading state
- // - The location being loaded is not the result of a redirect
- let isActionReload =
- state.actionData != null &&
- state.navigation.formMethod != null &&
- isMutationMethod(state.navigation.formMethod) &&
- state.navigation.state === "loading" &&
- location.state?._isRedirect !== true;
- let actionData: RouteData | null;
- if (newState.actionData) {
- if (Object.keys(newState.actionData).length > 0) {
- actionData = newState.actionData;
- } else {
- // Empty actionData -> clear prior actionData due to an action error
- actionData = null;
- }
- } else if (isActionReload) {
- // Keep the current data if we're wrapping up the action reload
- actionData = state.actionData;
- } else {
- // Clear actionData on any other completed navigations
- actionData = null;
- }
- // Always preserve any existing loaderData from re-used routes
- let loaderData = newState.loaderData
- ? mergeLoaderData(
- state.loaderData,
- newState.loaderData,
- newState.matches || [],
- newState.errors
- )
- : state.loaderData;
- // On a successful navigation we can assume we got through all blockers
- // so we can start fresh
- for (let [key] of blockerFunctions) {
- deleteBlocker(key);
- }
- // Always respect the user flag. Otherwise don't reset on mutation
- // submission navigations unless they redirect
- let preventScrollReset =
- pendingPreventScrollReset === true ||
- (state.navigation.formMethod != null &&
- isMutationMethod(state.navigation.formMethod) &&
- location.state?._isRedirect !== true);
- updateState({
- ...newState, // matches, errors, fetchers go through as-is
- actionData,
- loaderData,
- historyAction: pendingAction,
- location,
- initialized: true,
- navigation: IDLE_NAVIGATION,
- revalidation: "idle",
- restoreScrollPosition: getSavedScrollPosition(
- location,
- newState.matches || state.matches
- ),
- preventScrollReset,
- blockers: new Map(state.blockers),
- });
- if (isUninterruptedRevalidation) {
- // If this was an uninterrupted revalidation then do not touch history
- } else if (pendingAction === HistoryAction.Pop) {
- // Do nothing for POP - URL has already been updated
- } else if (pendingAction === HistoryAction.Push) {
- init.history.push(location, location.state);
- } else if (pendingAction === HistoryAction.Replace) {
- init.history.replace(location, location.state);
- }
- // Reset stateful navigation vars
- pendingAction = HistoryAction.Pop;
- pendingPreventScrollReset = false;
- isUninterruptedRevalidation = false;
- isRevalidationRequired = false;
- cancelledDeferredRoutes = [];
- cancelledFetcherLoads = [];
- }
- // Trigger a navigation event, which can either be a numerical POP or a PUSH
- // replace with an optional submission
- async function navigate(
- to: number | To,
- opts?: RouterNavigateOptions
- ): Promise<void> {
- if (typeof to === "number") {
- init.history.go(to);
- return;
- }
- let { path, submission, error } = normalizeNavigateOptions(to, opts);
- let currentLocation = state.location;
- let nextLocation = createLocation(state.location, path, opts && opts.state);
- // When using navigate as a PUSH/REPLACE we aren't reading an already-encoded
- // URL from window.location, so we need to encode it here so the behavior
- // remains the same as POP and non-data-router usages. new URL() does all
- // the same encoding we'd get from a history.pushState/window.location read
- // without having to touch history
- nextLocation = {
- ...nextLocation,
- ...init.history.encodeLocation(nextLocation),
- };
- let userReplace = opts && opts.replace != null ? opts.replace : undefined;
- let historyAction = HistoryAction.Push;
- if (userReplace === true) {
- historyAction = HistoryAction.Replace;
- } else if (userReplace === false) {
- // no-op
- } else if (
- submission != null &&
- isMutationMethod(submission.formMethod) &&
- submission.formAction === state.location.pathname + state.location.search
- ) {
- // By default on submissions to the current location we REPLACE so that
- // users don't have to double-click the back button to get to the prior
- // location. If the user redirects to a different location from the
- // action/loader this will be ignored and the redirect will be a PUSH
- historyAction = HistoryAction.Replace;
- }
- let preventScrollReset =
- opts && "preventScrollReset" in opts
- ? opts.preventScrollReset === true
- : undefined;
- let blockerKey = shouldBlockNavigation({
- currentLocation,
- nextLocation,
- historyAction,
- });
- if (blockerKey) {
- // Put the blocker into a blocked state
- updateBlocker(blockerKey, {
- state: "blocked",
- location: nextLocation,
- proceed() {
- updateBlocker(blockerKey!, {
- state: "proceeding",
- proceed: undefined,
- reset: undefined,
- location: nextLocation,
- });
- // Send the same navigation through
- navigate(to, opts);
- },
- reset() {
- deleteBlocker(blockerKey!);
- updateState({ blockers: new Map(state.blockers) });
- },
- });
- return;
- }
- return await startNavigation(historyAction, nextLocation, {
- submission,
- // Send through the formData serialization error if we have one so we can
- // render at the right error boundary after we match routes
- pendingError: error,
- preventScrollReset,
- replace: opts && opts.replace,
- });
- }
- // Revalidate all current loaders. If a navigation is in progress or if this
- // is interrupted by a navigation, allow this to "succeed" by calling all
- // loaders during the next loader round
- function revalidate() {
- interruptActiveLoads();
- updateState({ revalidation: "loading" });
- // If we're currently submitting an action, we don't need to start a new
- // navigation, we'll just let the follow up loader execution call all loaders
- if (state.navigation.state === "submitting") {
- return;
- }
- // If we're currently in an idle state, start a new navigation for the current
- // action/location and mark it as uninterrupted, which will skip the history
- // update in completeNavigation
- if (state.navigation.state === "idle") {
- startNavigation(state.historyAction, state.location, {
- startUninterruptedRevalidation: true,
- });
- return;
- }
- // Otherwise, if we're currently in a loading state, just start a new
- // navigation to the navigation.location but do not trigger an uninterrupted
- // revalidation so that history correctly updates once the navigation completes
- startNavigation(
- pendingAction || state.historyAction,
- state.navigation.location,
- { overrideNavigation: state.navigation }
- );
- }
- // Start a navigation to the given action/location. Can optionally provide a
- // overrideNavigation which will override the normalLoad in the case of a redirect
- // navigation
- async function startNavigation(
- historyAction: HistoryAction,
- location: Location,
- opts?: {
- submission?: Submission;
- overrideNavigation?: Navigation;
- pendingError?: ErrorResponse;
- startUninterruptedRevalidation?: boolean;
- preventScrollReset?: boolean;
- replace?: boolean;
- }
- ): Promise<void> {
- // Abort any in-progress navigations and start a new one. Unset any ongoing
- // uninterrupted revalidations unless told otherwise, since we want this
- // new navigation to update history normally
- pendingNavigationController && pendingNavigationController.abort();
- pendingNavigationController = null;
- pendingAction = historyAction;
- isUninterruptedRevalidation =
- (opts && opts.startUninterruptedRevalidation) === true;
- // Save the current scroll position every time we start a new navigation,
- // and track whether we should reset scroll on completion
- saveScrollPosition(state.location, state.matches);
- pendingPreventScrollReset = (opts && opts.preventScrollReset) === true;
- let loadingNavigation = opts && opts.overrideNavigation;
- let matches = matchRoutes(dataRoutes, location, init.basename);
- // Short circuit with a 404 on the root error boundary if we match nothing
- if (!matches) {
- let error = getInternalRouterError(404, { pathname: location.pathname });
- let { matches: notFoundMatches, route } =
- getShortCircuitMatches(dataRoutes);
- // Cancel all pending deferred on 404s since we don't keep any routes
- cancelActiveDeferreds();
- completeNavigation(location, {
- matches: notFoundMatches,
- loaderData: {},
- errors: {
- [route.id]: error,
- },
- });
- return;
- }
- // Short circuit if it's only a hash change and not a mutation submission
- // For example, on /page#hash and submit a <Form method="post"> which will
- // default to a navigation to /page
- if (
- isHashChangeOnly(state.location, location) &&
- !(opts && opts.submission && isMutationMethod(opts.submission.formMethod))
- ) {
- completeNavigation(location, { matches });
- return;
- }
- // Create a controller/Request for this navigation
- pendingNavigationController = new AbortController();
- let request = createClientSideRequest(
- init.history,
- location,
- pendingNavigationController.signal,
- opts && opts.submission
- );
- let pendingActionData: RouteData | undefined;
- let pendingError: RouteData | undefined;
- if (opts && opts.pendingError) {
- // If we have a pendingError, it means the user attempted a GET submission
- // with binary FormData so assign here and skip to handleLoaders. That
- // way we handle calling loaders above the boundary etc. It's not really
- // different from an actionError in that sense.
- pendingError = {
- [findNearestBoundary(matches).route.id]: opts.pendingError,
- };
- } else if (
- opts &&
- opts.submission &&
- isMutationMethod(opts.submission.formMethod)
- ) {
- // Call action if we received an action submission
- let actionOutput = await handleAction(
- request,
- location,
- opts.submission,
- matches,
- { replace: opts.replace }
- );
- if (actionOutput.shortCircuited) {
- return;
- }
- pendingActionData = actionOutput.pendingActionData;
- pendingError = actionOutput.pendingActionError;
- let navigation: NavigationStates["Loading"] = {
- state: "loading",
- location,
- ...opts.submission,
- };
- loadingNavigation = navigation;
- // Create a GET request for the loaders
- request = new Request(request.url, { signal: request.signal });
- }
- // Call loaders
- let { shortCircuited, loaderData, errors } = await handleLoaders(
- request,
- location,
- matches,
- loadingNavigation,
- opts && opts.submission,
- opts && opts.replace,
- pendingActionData,
- pendingError
- );
- if (shortCircuited) {
- return;
- }
- // Clean up now that the action/loaders have completed. Don't clean up if
- // we short circuited because pendingNavigationController will have already
- // been assigned to a new controller for the next navigation
- pendingNavigationController = null;
- completeNavigation(location, {
- matches,
- ...(pendingActionData ? { actionData: pendingActionData } : {}),
- loaderData,
- errors,
- });
- }
- // Call the action matched by the leaf route for this navigation and handle
- // redirects/errors
- async function handleAction(
- request: Request,
- location: Location,
- submission: Submission,
- matches: AgnosticDataRouteMatch[],
- opts?: { replace?: boolean }
- ): Promise<HandleActionResult> {
- interruptActiveLoads();
- // Put us in a submitting state
- let navigation: NavigationStates["Submitting"] = {
- state: "submitting",
- location,
- ...submission,
- };
- updateState({ navigation });
- // Call our action and get the result
- let result: DataResult;
- let actionMatch = getTargetMatch(matches, location);
- if (!actionMatch.route.action) {
- result = {
- type: ResultType.error,
- error: getInternalRouterError(405, {
- method: request.method,
- pathname: location.pathname,
- routeId: actionMatch.route.id,
- }),
- };
- } else {
- result = await callLoaderOrAction(
- "action",
- request,
- actionMatch,
- matches,
- router.basename
- );
- if (request.signal.aborted) {
- return { shortCircuited: true };
- }
- }
- if (isRedirectResult(result)) {
- let replace: boolean;
- if (opts && opts.replace != null) {
- replace = opts.replace;
- } else {
- // If the user didn't explicity indicate replace behavior, replace if
- // we redirected to the exact same location we're currently at to avoid
- // double back-buttons
- replace =
- result.location === state.location.pathname + state.location.search;
- }
- await startRedirectNavigation(state, result, { submission, replace });
- return { shortCircuited: true };
- }
- if (isErrorResult(result)) {
- // Store off the pending error - we use it to determine which loaders
- // to call and will commit it when we complete the navigation
- let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id);
- // By default, all submissions are REPLACE navigations, but if the
- // action threw an error that'll be rendered in an errorElement, we fall
- // back to PUSH so that the user can use the back button to get back to
- // the pre-submission form location to try again
- if ((opts && opts.replace) !== true) {
- pendingAction = HistoryAction.Push;
- }
- return {
- // Send back an empty object we can use to clear out any prior actionData
- pendingActionData: {},
- pendingActionError: { [boundaryMatch.route.id]: result.error },
- };
- }
- if (isDeferredResult(result)) {
- throw getInternalRouterError(400, { type: "defer-action" });
- }
- return {
- pendingActionData: { [actionMatch.route.id]: result.data },
- };
- }
- // Call all applicable loaders for the given matches, handling redirects,
- // errors, etc.
- async function handleLoaders(
- request: Request,
- location: Location,
- matches: AgnosticDataRouteMatch[],
- overrideNavigation?: Navigation,
- submission?: Submission,
- replace?: boolean,
- pendingActionData?: RouteData,
- pendingError?: RouteData
- ): Promise<HandleLoadersResult> {
- // Figure out the right navigation we want to use for data loading
- let loadingNavigation = overrideNavigation;
- if (!loadingNavigation) {
- let navigation: NavigationStates["Loading"] = {
- state: "loading",
- location,
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- ...submission,
- };
- loadingNavigation = navigation;
- }
- // If this was a redirect from an action we don't have a "submission" but
- // we have it on the loading navigation so use that if available
- let activeSubmission = submission
- ? submission
- : loadingNavigation.formMethod &&
- loadingNavigation.formAction &&
- loadingNavigation.formData &&
- loadingNavigation.formEncType
- ? {
- formMethod: loadingNavigation.formMethod,
- formAction: loadingNavigation.formAction,
- formData: loadingNavigation.formData,
- formEncType: loadingNavigation.formEncType,
- }
- : undefined;
- let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad(
- init.history,
- state,
- matches,
- activeSubmission,
- location,
- isRevalidationRequired,
- cancelledDeferredRoutes,
- cancelledFetcherLoads,
- pendingActionData,
- pendingError,
- fetchLoadMatches
- );
- // Cancel pending deferreds for no-longer-matched routes or routes we're
- // about to reload. Note that if this is an action reload we would have
- // already cancelled all pending deferreds so this would be a no-op
- cancelActiveDeferreds(
- (routeId) =>
- !(matches && matches.some((m) => m.route.id === routeId)) ||
- (matchesToLoad && matchesToLoad.some((m) => m.route.id === routeId))
- );
- // Short circuit if we have no loaders to run
- if (matchesToLoad.length === 0 && revalidatingFetchers.length === 0) {
- completeNavigation(location, {
- matches,
- loaderData: {},
- // Commit pending error if we're short circuiting
- errors: pendingError || null,
- ...(pendingActionData ? { actionData: pendingActionData } : {}),
- });
- return { shortCircuited: true };
- }
- // If this is an uninterrupted revalidation, we remain in our current idle
- // state. If not, we need to switch to our loading state and load data,
- // preserving any new action data or existing action data (in the case of
- // a revalidation interrupting an actionReload)
- if (!isUninterruptedRevalidation) {
- revalidatingFetchers.forEach((rf) => {
- let fetcher = state.fetchers.get(rf.key);
- let revalidatingFetcher: FetcherStates["Loading"] = {
- state: "loading",
- data: fetcher && fetcher.data,
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(rf.key, revalidatingFetcher);
- });
- let actionData = pendingActionData || state.actionData;
- updateState({
- navigation: loadingNavigation,
- ...(actionData
- ? Object.keys(actionData).length === 0
- ? { actionData: null }
- : { actionData }
- : {}),
- ...(revalidatingFetchers.length > 0
- ? { fetchers: new Map(state.fetchers) }
- : {}),
- });
- }
- pendingNavigationLoadId = ++incrementingLoadId;
- revalidatingFetchers.forEach((rf) =>
- fetchControllers.set(rf.key, pendingNavigationController!)
- );
- let { results, loaderResults, fetcherResults } =
- await callLoadersAndMaybeResolveData(
- state.matches,
- matches,
- matchesToLoad,
- revalidatingFetchers,
- request
- );
- if (request.signal.aborted) {
- return { shortCircuited: true };
- }
- // Clean up _after_ loaders have completed. Don't clean up if we short
- // circuited because fetchControllers would have been aborted and
- // reassigned to new controllers for the next navigation
- revalidatingFetchers.forEach((rf) => fetchControllers.delete(rf.key));
- // If any loaders returned a redirect Response, start a new REPLACE navigation
- let redirect = findRedirect(results);
- if (redirect) {
- await startRedirectNavigation(state, redirect, { replace });
- return { shortCircuited: true };
- }
- // Process and commit output from loaders
- let { loaderData, errors } = processLoaderData(
- state,
- matches,
- matchesToLoad,
- loaderResults,
- pendingError,
- revalidatingFetchers,
- fetcherResults,
- activeDeferreds
- );
- // Wire up subscribers to update loaderData as promises settle
- activeDeferreds.forEach((deferredData, routeId) => {
- deferredData.subscribe((aborted) => {
- // Note: No need to updateState here since the TrackedPromise on
- // loaderData is stable across resolve/reject
- // Remove this instance if we were aborted or if promises have settled
- if (aborted || deferredData.done) {
- activeDeferreds.delete(routeId);
- }
- });
- });
- markFetchRedirectsDone();
- let didAbortFetchLoads = abortStaleFetchLoads(pendingNavigationLoadId);
- return {
- loaderData,
- errors,
- ...(didAbortFetchLoads || revalidatingFetchers.length > 0
- ? { fetchers: new Map(state.fetchers) }
- : {}),
- };
- }
- function getFetcher<TData = any>(key: string): Fetcher<TData> {
- return state.fetchers.get(key) || IDLE_FETCHER;
- }
- // Trigger a fetcher load/submit for the given fetcher key
- function fetch(
- key: string,
- routeId: string,
- href: string,
- opts?: RouterFetchOptions
- ) {
- if (isServer) {
- throw new Error(
- "router.fetch() was called during the server render, but it shouldn't be. " +
- "You are likely calling a useFetcher() method in the body of your component. " +
- "Try moving it to a useEffect or a callback."
- );
- }
- if (fetchControllers.has(key)) abortFetcher(key);
- let matches = matchRoutes(dataRoutes, href, init.basename);
- if (!matches) {
- setFetcherError(
- key,
- routeId,
- getInternalRouterError(404, { pathname: href })
- );
- return;
- }
- let { path, submission } = normalizeNavigateOptions(href, opts, true);
- let match = getTargetMatch(matches, path);
- pendingPreventScrollReset = (opts && opts.preventScrollReset) === true;
- if (submission && isMutationMethod(submission.formMethod)) {
- handleFetcherAction(key, routeId, path, match, matches, submission);
- return;
- }
- // Store off the match so we can call it's shouldRevalidate on subsequent
- // revalidations
- fetchLoadMatches.set(key, { routeId, path, match, matches });
- handleFetcherLoader(key, routeId, path, match, matches, submission);
- }
- // Call the action for the matched fetcher.submit(), and then handle redirects,
- // errors, and revalidation
- async function handleFetcherAction(
- key: string,
- routeId: string,
- path: string,
- match: AgnosticDataRouteMatch,
- requestMatches: AgnosticDataRouteMatch[],
- submission: Submission
- ) {
- interruptActiveLoads();
- fetchLoadMatches.delete(key);
- if (!match.route.action) {
- let error = getInternalRouterError(405, {
- method: submission.formMethod,
- pathname: path,
- routeId: routeId,
- });
- setFetcherError(key, routeId, error);
- return;
- }
- // Put this fetcher into it's submitting state
- let existingFetcher = state.fetchers.get(key);
- let fetcher: FetcherStates["Submitting"] = {
- state: "submitting",
- ...submission,
- data: existingFetcher && existingFetcher.data,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(key, fetcher);
- updateState({ fetchers: new Map(state.fetchers) });
- // Call the action for the fetcher
- let abortController = new AbortController();
- let fetchRequest = createClientSideRequest(
- init.history,
- path,
- abortController.signal,
- submission
- );
- fetchControllers.set(key, abortController);
- let actionResult = await callLoaderOrAction(
- "action",
- fetchRequest,
- match,
- requestMatches,
- router.basename
- );
- if (fetchRequest.signal.aborted) {
- // We can delete this so long as we weren't aborted by ou our own fetcher
- // re-submit which would have put _new_ controller is in fetchControllers
- if (fetchControllers.get(key) === abortController) {
- fetchControllers.delete(key);
- }
- return;
- }
- if (isRedirectResult(actionResult)) {
- fetchControllers.delete(key);
- fetchRedirectIds.add(key);
- let loadingFetcher: FetcherStates["Loading"] = {
- state: "loading",
- ...submission,
- data: undefined,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(key, loadingFetcher);
- updateState({ fetchers: new Map(state.fetchers) });
- return startRedirectNavigation(state, actionResult, {
- isFetchActionRedirect: true,
- });
- }
- // Process any non-redirect errors thrown
- if (isErrorResult(actionResult)) {
- setFetcherError(key, routeId, actionResult.error);
- return;
- }
- if (isDeferredResult(actionResult)) {
- throw getInternalRouterError(400, { type: "defer-action" });
- }
- // Start the data load for current matches, or the next location if we're
- // in the middle of a navigation
- let nextLocation = state.navigation.location || state.location;
- let revalidationRequest = createClientSideRequest(
- init.history,
- nextLocation,
- abortController.signal
- );
- let matches =
- state.navigation.state !== "idle"
- ? matchRoutes(dataRoutes, state.navigation.location, init.basename)
- : state.matches;
- invariant(matches, "Didn't find any matches after fetcher action");
- let loadId = ++incrementingLoadId;
- fetchReloadIds.set(key, loadId);
- let loadFetcher: FetcherStates["Loading"] = {
- state: "loading",
- data: actionResult.data,
- ...submission,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(key, loadFetcher);
- let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad(
- init.history,
- state,
- matches,
- submission,
- nextLocation,
- isRevalidationRequired,
- cancelledDeferredRoutes,
- cancelledFetcherLoads,
- { [match.route.id]: actionResult.data },
- undefined, // No need to send through errors since we short circuit above
- fetchLoadMatches
- );
- // Put all revalidating fetchers into the loading state, except for the
- // current fetcher which we want to keep in it's current loading state which
- // contains it's action submission info + action data
- revalidatingFetchers
- .filter((rf) => rf.key !== key)
- .forEach((rf) => {
- let staleKey = rf.key;
- let existingFetcher = state.fetchers.get(staleKey);
- let revalidatingFetcher: FetcherStates["Loading"] = {
- state: "loading",
- data: existingFetcher && existingFetcher.data,
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(staleKey, revalidatingFetcher);
- fetchControllers.set(staleKey, abortController);
- });
- updateState({ fetchers: new Map(state.fetchers) });
- let { results, loaderResults, fetcherResults } =
- await callLoadersAndMaybeResolveData(
- state.matches,
- matches,
- matchesToLoad,
- revalidatingFetchers,
- revalidationRequest
- );
- if (abortController.signal.aborted) {
- return;
- }
- fetchReloadIds.delete(key);
- fetchControllers.delete(key);
- revalidatingFetchers.forEach((r) => fetchControllers.delete(r.key));
- let redirect = findRedirect(results);
- if (redirect) {
- return startRedirectNavigation(state, redirect);
- }
- // Process and commit output from loaders
- let { loaderData, errors } = processLoaderData(
- state,
- state.matches,
- matchesToLoad,
- loaderResults,
- undefined,
- revalidatingFetchers,
- fetcherResults,
- activeDeferreds
- );
- let doneFetcher: FetcherStates["Idle"] = {
- state: "idle",
- data: actionResult.data,
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(key, doneFetcher);
- let didAbortFetchLoads = abortStaleFetchLoads(loadId);
- // If we are currently in a navigation loading state and this fetcher is
- // more recent than the navigation, we want the newer data so abort the
- // navigation and complete it with the fetcher data
- if (
- state.navigation.state === "loading" &&
- loadId > pendingNavigationLoadId
- ) {
- invariant(pendingAction, "Expected pending action");
- pendingNavigationController && pendingNavigationController.abort();
- completeNavigation(state.navigation.location, {
- matches,
- loaderData,
- errors,
- fetchers: new Map(state.fetchers),
- });
- } else {
- // otherwise just update with the fetcher data, preserving any existing
- // loaderData for loaders that did not need to reload. We have to
- // manually merge here since we aren't going through completeNavigation
- updateState({
- errors,
- loaderData: mergeLoaderData(
- state.loaderData,
- loaderData,
- matches,
- errors
- ),
- ...(didAbortFetchLoads ? { fetchers: new Map(state.fetchers) } : {}),
- });
- isRevalidationRequired = false;
- }
- }
- // Call the matched loader for fetcher.load(), handling redirects, errors, etc.
- async function handleFetcherLoader(
- key: string,
- routeId: string,
- path: string,
- match: AgnosticDataRouteMatch,
- matches: AgnosticDataRouteMatch[],
- submission?: Submission
- ) {
- let existingFetcher = state.fetchers.get(key);
- // Put this fetcher into it's loading state
- let loadingFetcher: FetcherStates["Loading"] = {
- state: "loading",
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- ...submission,
- data: existingFetcher && existingFetcher.data,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(key, loadingFetcher);
- updateState({ fetchers: new Map(state.fetchers) });
- // Call the loader for this fetcher route match
- let abortController = new AbortController();
- let fetchRequest = createClientSideRequest(
- init.history,
- path,
- abortController.signal
- );
- fetchControllers.set(key, abortController);
- let result: DataResult = await callLoaderOrAction(
- "loader",
- fetchRequest,
- match,
- matches,
- router.basename
- );
- // Deferred isn't supported for fetcher loads, await everything and treat it
- // as a normal load. resolveDeferredData will return undefined if this
- // fetcher gets aborted, so we just leave result untouched and short circuit
- // below if that happens
- if (isDeferredResult(result)) {
- result =
- (await resolveDeferredData(result, fetchRequest.signal, true)) ||
- result;
- }
- // We can delete this so long as we weren't aborted by ou our own fetcher
- // re-load which would have put _new_ controller is in fetchControllers
- if (fetchControllers.get(key) === abortController) {
- fetchControllers.delete(key);
- }
- if (fetchRequest.signal.aborted) {
- return;
- }
- // If the loader threw a redirect Response, start a new REPLACE navigation
- if (isRedirectResult(result)) {
- await startRedirectNavigation(state, result);
- return;
- }
- // Process any non-redirect errors thrown
- if (isErrorResult(result)) {
- let boundaryMatch = findNearestBoundary(state.matches, routeId);
- state.fetchers.delete(key);
- // TODO: In remix, this would reset to IDLE_NAVIGATION if it was a catch -
- // do we need to behave any differently with our non-redirect errors?
- // What if it was a non-redirect Response?
- updateState({
- fetchers: new Map(state.fetchers),
- errors: {
- [boundaryMatch.route.id]: result.error,
- },
- });
- return;
- }
- invariant(!isDeferredResult(result), "Unhandled fetcher deferred data");
- // Put the fetcher back into an idle state
- let doneFetcher: FetcherStates["Idle"] = {
- state: "idle",
- data: result.data,
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(key, doneFetcher);
- updateState({ fetchers: new Map(state.fetchers) });
- }
- /**
- * Utility function to handle redirects returned from an action or loader.
- * Normally, a redirect "replaces" the navigation that triggered it. So, for
- * example:
- *
- * - user is on /a
- * - user clicks a link to /b
- * - loader for /b redirects to /c
- *
- * In a non-JS app the browser would track the in-flight navigation to /b and
- * then replace it with /c when it encountered the redirect response. In
- * the end it would only ever update the URL bar with /c.
- *
- * In client-side routing using pushState/replaceState, we aim to emulate
- * this behavior and we also do not update history until the end of the
- * navigation (including processed redirects). This means that we never
- * actually touch history until we've processed redirects, so we just use
- * the history action from the original navigation (PUSH or REPLACE).
- */
- async function startRedirectNavigation(
- state: RouterState,
- redirect: RedirectResult,
- {
- submission,
- replace,
- isFetchActionRedirect,
- }: {
- submission?: Submission;
- replace?: boolean;
- isFetchActionRedirect?: boolean;
- } = {}
- ) {
- if (redirect.revalidate) {
- isRevalidationRequired = true;
- }
- let redirectLocation = createLocation(
- state.location,
- redirect.location,
- // TODO: This can be removed once we get rid of useTransition in Remix v2
- {
- _isRedirect: true,
- ...(isFetchActionRedirect ? { _isFetchActionRedirect: true } : {}),
- }
- );
- invariant(
- redirectLocation,
- "Expected a location on the redirect navigation"
- );
- // Check if this an absolute external redirect that goes to a new origin
- if (
- ABSOLUTE_URL_REGEX.test(redirect.location) &&
- isBrowser &&
- typeof window?.location !== "undefined"
- ) {
- let newOrigin = init.history.createURL(redirect.location).origin;
- if (window.location.origin !== newOrigin) {
- if (replace) {
- window.location.replace(redirect.location);
- } else {
- window.location.assign(redirect.location);
- }
- return;
- }
- }
- // There's no need to abort on redirects, since we don't detect the
- // redirect until the action/loaders have settled
- pendingNavigationController = null;
- let redirectHistoryAction =
- replace === true ? HistoryAction.Replace : HistoryAction.Push;
- // Use the incoming submission if provided, fallback on the active one in
- // state.navigation
- let { formMethod, formAction, formEncType, formData } = state.navigation;
- if (!submission && formMethod && formAction && formData && formEncType) {
- submission = {
- formMethod,
- formAction,
- formEncType,
- formData,
- };
- }
- // If this was a 307/308 submission we want to preserve the HTTP method and
- // re-submit the GET/POST/PUT/PATCH/DELETE as a submission navigation to the
- // redirected location
- if (
- redirectPreserveMethodStatusCodes.has(redirect.status) &&
- submission &&
- isMutationMethod(submission.formMethod)
- ) {
- await startNavigation(redirectHistoryAction, redirectLocation, {
- submission: {
- ...submission,
- formAction: redirect.location,
- },
- // Preserve this flag across redirects
- preventScrollReset: pendingPreventScrollReset,
- });
- } else {
- // Otherwise, we kick off a new loading navigation, preserving the
- // submission info for the duration of this navigation
- await startNavigation(redirectHistoryAction, redirectLocation, {
- overrideNavigation: {
- state: "loading",
- location: redirectLocation,
- formMethod: submission ? submission.formMethod : undefined,
- formAction: submission ? submission.formAction : undefined,
- formEncType: submission ? submission.formEncType : undefined,
- formData: submission ? submission.formData : undefined,
- },
- // Preserve this flag across redirects
- preventScrollReset: pendingPreventScrollReset,
- });
- }
- }
- async function callLoadersAndMaybeResolveData(
- currentMatches: AgnosticDataRouteMatch[],
- matches: AgnosticDataRouteMatch[],
- matchesToLoad: AgnosticDataRouteMatch[],
- fetchersToLoad: RevalidatingFetcher[],
- request: Request
- ) {
- // Call all navigation loaders and revalidating fetcher loaders in parallel,
- // then slice off the results into separate arrays so we can handle them
- // accordingly
- let results = await Promise.all([
- ...matchesToLoad.map((match) =>
- callLoaderOrAction("loader", request, match, matches, router.basename)
- ),
- ...fetchersToLoad.map((f) =>
- callLoaderOrAction(
- "loader",
- createClientSideRequest(init.history, f.path, request.signal),
- f.match,
- f.matches,
- router.basename
- )
- ),
- ]);
- let loaderResults = results.slice(0, matchesToLoad.length);
- let fetcherResults = results.slice(matchesToLoad.length);
- await Promise.all([
- resolveDeferredResults(
- currentMatches,
- matchesToLoad,
- loaderResults,
- request.signal,
- false,
- state.loaderData
- ),
- resolveDeferredResults(
- currentMatches,
- fetchersToLoad.map((f) => f.match),
- fetcherResults,
- request.signal,
- true
- ),
- ]);
- return { results, loaderResults, fetcherResults };
- }
- function interruptActiveLoads() {
- // Every interruption triggers a revalidation
- isRevalidationRequired = true;
- // Cancel pending route-level deferreds and mark cancelled routes for
- // revalidation
- cancelledDeferredRoutes.push(...cancelActiveDeferreds());
- // Abort in-flight fetcher loads
- fetchLoadMatches.forEach((_, key) => {
- if (fetchControllers.has(key)) {
- cancelledFetcherLoads.push(key);
- abortFetcher(key);
- }
- });
- }
- function setFetcherError(key: string, routeId: string, error: any) {
- let boundaryMatch = findNearestBoundary(state.matches, routeId);
- deleteFetcher(key);
- updateState({
- errors: {
- [boundaryMatch.route.id]: error,
- },
- fetchers: new Map(state.fetchers),
- });
- }
- function deleteFetcher(key: string): void {
- if (fetchControllers.has(key)) abortFetcher(key);
- fetchLoadMatches.delete(key);
- fetchReloadIds.delete(key);
- fetchRedirectIds.delete(key);
- state.fetchers.delete(key);
- }
- function abortFetcher(key: string) {
- let controller = fetchControllers.get(key);
- invariant(controller, `Expected fetch controller: ${key}`);
- controller.abort();
- fetchControllers.delete(key);
- }
- function markFetchersDone(keys: string[]) {
- for (let key of keys) {
- let fetcher = getFetcher(key);
- let doneFetcher: FetcherStates["Idle"] = {
- state: "idle",
- data: fetcher.data,
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(key, doneFetcher);
- }
- }
- function markFetchRedirectsDone(): void {
- let doneKeys = [];
- for (let key of fetchRedirectIds) {
- let fetcher = state.fetchers.get(key);
- invariant(fetcher, `Expected fetcher: ${key}`);
- if (fetcher.state === "loading") {
- fetchRedirectIds.delete(key);
- doneKeys.push(key);
- }
- }
- markFetchersDone(doneKeys);
- }
- function abortStaleFetchLoads(landedId: number): boolean {
- let yeetedKeys = [];
- for (let [key, id] of fetchReloadIds) {
- if (id < landedId) {
- let fetcher = state.fetchers.get(key);
- invariant(fetcher, `Expected fetcher: ${key}`);
- if (fetcher.state === "loading") {
- abortFetcher(key);
- fetchReloadIds.delete(key);
- yeetedKeys.push(key);
- }
- }
- }
- markFetchersDone(yeetedKeys);
- return yeetedKeys.length > 0;
- }
- function getBlocker(key: string, fn: BlockerFunction) {
- let blocker: Blocker = state.blockers.get(key) || IDLE_BLOCKER;
- if (blockerFunctions.get(key) !== fn) {
- blockerFunctions.set(key, fn);
- }
- return blocker;
- }
- function deleteBlocker(key: string) {
- state.blockers.delete(key);
- blockerFunctions.delete(key);
- }
- // Utility function to update blockers, ensuring valid state transitions
- function updateBlocker(key: string, newBlocker: Blocker) {
- let blocker = state.blockers.get(key) || IDLE_BLOCKER;
- // Poor mans state machine :)
- // https://mermaid.live/edit#pako:eNqVkc9OwzAMxl8l8nnjAYrEtDIOHEBIgwvKJTReGy3_lDpIqO27k6awMG0XcrLlnz87nwdonESogKXXBuE79rq75XZO3-yHds0RJVuv70YrPlUrCEe2HfrORS3rubqZfuhtpg5C9wk5tZ4VKcRUq88q9Z8RS0-48cE1iHJkL0ugbHuFLus9L6spZy8nX9MP2CNdomVaposqu3fGayT8T8-jJQwhepo_UtpgBQaDEUom04dZhAN1aJBDlUKJBxE1ceB2Smj0Mln-IBW5AFU2dwUiktt_2Qaq2dBfaKdEup85UV7Yd-dKjlnkabl2Pvr0DTkTreM
- invariant(
- (blocker.state === "unblocked" && newBlocker.state === "blocked") ||
- (blocker.state === "blocked" && newBlocker.state === "blocked") ||
- (blocker.state === "blocked" && newBlocker.state === "proceeding") ||
- (blocker.state === "blocked" && newBlocker.state === "unblocked") ||
- (blocker.state === "proceeding" && newBlocker.state === "unblocked"),
- `Invalid blocker state transition: ${blocker.state} -> ${newBlocker.state}`
- );
- state.blockers.set(key, newBlocker);
- updateState({ blockers: new Map(state.blockers) });
- }
- function shouldBlockNavigation({
- currentLocation,
- nextLocation,
- historyAction,
- }: {
- currentLocation: Location;
- nextLocation: Location;
- historyAction: HistoryAction;
- }): string | undefined {
- if (blockerFunctions.size === 0) {
- return;
- }
- // We ony support a single active blocker at the moment since we don't have
- // any compelling use cases for multi-blocker yet
- if (blockerFunctions.size > 1) {
- warning(false, "A router only supports one blocker at a time");
- }
- let entries = Array.from(blockerFunctions.entries());
- let [blockerKey, blockerFunction] = entries[entries.length - 1];
- let blocker = state.blockers.get(blockerKey);
- if (blocker && blocker.state === "proceeding") {
- // If the blocker is currently proceeding, we don't need to re-check
- // it and can let this navigation continue
- return;
- }
- // At this point, we know we're unblocked/blocked so we need to check the
- // user-provided blocker function
- if (blockerFunction({ currentLocation, nextLocation, historyAction })) {
- return blockerKey;
- }
- }
- function cancelActiveDeferreds(
- predicate?: (routeId: string) => boolean
- ): string[] {
- let cancelledRouteIds: string[] = [];
- activeDeferreds.forEach((dfd, routeId) => {
- if (!predicate || predicate(routeId)) {
- // Cancel the deferred - but do not remove from activeDeferreds here -
- // we rely on the subscribers to do that so our tests can assert proper
- // cleanup via _internalActiveDeferreds
- dfd.cancel();
- cancelledRouteIds.push(routeId);
- activeDeferreds.delete(routeId);
- }
- });
- return cancelledRouteIds;
- }
- // Opt in to capturing and reporting scroll positions during navigations,
- // used by the <ScrollRestoration> component
- function enableScrollRestoration(
- positions: Record<string, number>,
- getPosition: GetScrollPositionFunction,
- getKey?: GetScrollRestorationKeyFunction
- ) {
- savedScrollPositions = positions;
- getScrollPosition = getPosition;
- getScrollRestorationKey = getKey || ((location) => location.key);
- // Perform initial hydration scroll restoration, since we miss the boat on
- // the initial updateState() because we've not yet rendered <ScrollRestoration/>
- // and therefore have no savedScrollPositions available
- if (!initialScrollRestored && state.navigation === IDLE_NAVIGATION) {
- initialScrollRestored = true;
- let y = getSavedScrollPosition(state.location, state.matches);
- if (y != null) {
- updateState({ restoreScrollPosition: y });
- }
- }
- return () => {
- savedScrollPositions = null;
- getScrollPosition = null;
- getScrollRestorationKey = null;
- };
- }
- function saveScrollPosition(
- location: Location,
- matches: AgnosticDataRouteMatch[]
- ): void {
- if (savedScrollPositions && getScrollRestorationKey && getScrollPosition) {
- let userMatches = matches.map((m) =>
- createUseMatchesMatch(m, state.loaderData)
- );
- let key = getScrollRestorationKey(location, userMatches) || location.key;
- savedScrollPositions[key] = getScrollPosition();
- }
- }
- function getSavedScrollPosition(
- location: Location,
- matches: AgnosticDataRouteMatch[]
- ): number | null {
- if (savedScrollPositions && getScrollRestorationKey && getScrollPosition) {
- let userMatches = matches.map((m) =>
- createUseMatchesMatch(m, state.loaderData)
- );
- let key = getScrollRestorationKey(location, userMatches) || location.key;
- let y = savedScrollPositions[key];
- if (typeof y === "number") {
- return y;
- }
- }
- return null;
- }
- router = {
- get basename() {
- return init.basename;
- },
- get state() {
- return state;
- },
- get routes() {
- return dataRoutes;
- },
- initialize,
- subscribe,
- enableScrollRestoration,
- navigate,
- fetch,
- revalidate,
- // Passthrough to history-aware createHref used by useHref so we get proper
- // hash-aware URLs in DOM paths
- createHref: (to: To) => init.history.createHref(to),
- encodeLocation: (to: To) => init.history.encodeLocation(to),
- getFetcher,
- deleteFetcher,
- dispose,
- getBlocker,
- deleteBlocker,
- _internalFetchControllers: fetchControllers,
- _internalActiveDeferreds: activeDeferreds,
- };
- return router;
- }
- //#endregion
- ////////////////////////////////////////////////////////////////////////////////
- //#region createStaticHandler
- ////////////////////////////////////////////////////////////////////////////////
- export const UNSAFE_DEFERRED_SYMBOL = Symbol("deferred");
- export function createStaticHandler(
- routes: AgnosticRouteObject[],
- opts?: {
- basename?: string;
- }
- ): StaticHandler {
- invariant(
- routes.length > 0,
- "You must provide a non-empty routes array to createStaticHandler"
- );
- let dataRoutes = convertRoutesToDataRoutes(routes);
- let basename = (opts ? opts.basename : null) || "/";
- /**
- * The query() method is intended for document requests, in which we want to
- * call an optional action and potentially multiple loaders for all nested
- * routes. It returns a StaticHandlerContext object, which is very similar
- * to the router state (location, loaderData, actionData, errors, etc.) and
- * also adds SSR-specific information such as the statusCode and headers
- * from action/loaders Responses.
- *
- * It _should_ never throw and should report all errors through the
- * returned context.errors object, properly associating errors to their error
- * boundary. Additionally, it tracks _deepestRenderedBoundaryId which can be
- * used to emulate React error boundaries during SSr by performing a second
- * pass only down to the boundaryId.
- *
- * The one exception where we do not return a StaticHandlerContext is when a
- * redirect response is returned or thrown from any action/loader. We
- * propagate that out and return the raw Response so the HTTP server can
- * return it directly.
- */
- async function query(
- request: Request,
- { requestContext }: { requestContext?: unknown } = {}
- ): Promise<StaticHandlerContext | Response> {
- let url = new URL(request.url);
- let method = request.method.toLowerCase();
- let location = createLocation("", createPath(url), null, "default");
- let matches = matchRoutes(dataRoutes, location, basename);
- // SSR supports HEAD requests while SPA doesn't
- if (!isValidMethod(method) && method !== "head") {
- let error = getInternalRouterError(405, { method });
- let { matches: methodNotAllowedMatches, route } =
- getShortCircuitMatches(dataRoutes);
- return {
- basename,
- location,
- matches: methodNotAllowedMatches,
- loaderData: {},
- actionData: null,
- errors: {
- [route.id]: error,
- },
- statusCode: error.status,
- loaderHeaders: {},
- actionHeaders: {},
- activeDeferreds: null,
- };
- } else if (!matches) {
- let error = getInternalRouterError(404, { pathname: location.pathname });
- let { matches: notFoundMatches, route } =
- getShortCircuitMatches(dataRoutes);
- return {
- basename,
- location,
- matches: notFoundMatches,
- loaderData: {},
- actionData: null,
- errors: {
- [route.id]: error,
- },
- statusCode: error.status,
- loaderHeaders: {},
- actionHeaders: {},
- activeDeferreds: null,
- };
- }
- let result = await queryImpl(request, location, matches, requestContext);
- if (isResponse(result)) {
- return result;
- }
- // When returning StaticHandlerContext, we patch back in the location here
- // since we need it for React Context. But this helps keep our submit and
- // loadRouteData operating on a Request instead of a Location
- return { location, basename, ...result };
- }
- /**
- * The queryRoute() method is intended for targeted route requests, either
- * for fetch ?_data requests or resource route requests. In this case, we
- * are only ever calling a single action or loader, and we are returning the
- * returned value directly. In most cases, this will be a Response returned
- * from the action/loader, but it may be a primitive or other value as well -
- * and in such cases the calling context should handle that accordingly.
- *
- * We do respect the throw/return differentiation, so if an action/loader
- * throws, then this method will throw the value. This is important so we
- * can do proper boundary identification in Remix where a thrown Response
- * must go to the Catch Boundary but a returned Response is happy-path.
- *
- * One thing to note is that any Router-initiated Errors that make sense
- * to associate with a status code will be thrown as an ErrorResponse
- * instance which include the raw Error, such that the calling context can
- * serialize the error as they see fit while including the proper response
- * code. Examples here are 404 and 405 errors that occur prior to reaching
- * any user-defined loaders.
- */
- async function queryRoute(
- request: Request,
- {
- routeId,
- requestContext,
- }: { requestContext?: unknown; routeId?: string } = {}
- ): Promise<any> {
- let url = new URL(request.url);
- let method = request.method.toLowerCase();
- let location = createLocation("", createPath(url), null, "default");
- let matches = matchRoutes(dataRoutes, location, basename);
- // SSR supports HEAD requests while SPA doesn't
- if (!isValidMethod(method) && method !== "head" && method !== "options") {
- throw getInternalRouterError(405, { method });
- } else if (!matches) {
- throw getInternalRouterError(404, { pathname: location.pathname });
- }
- let match = routeId
- ? matches.find((m) => m.route.id === routeId)
- : getTargetMatch(matches, location);
- if (routeId && !match) {
- throw getInternalRouterError(403, {
- pathname: location.pathname,
- routeId,
- });
- } else if (!match) {
- // This should never hit I don't think?
- throw getInternalRouterError(404, { pathname: location.pathname });
- }
- let result = await queryImpl(
- request,
- location,
- matches,
- requestContext,
- match
- );
- if (isResponse(result)) {
- return result;
- }
- let error = result.errors ? Object.values(result.errors)[0] : undefined;
- if (error !== undefined) {
- // If we got back result.errors, that means the loader/action threw
- // _something_ that wasn't a Response, but it's not guaranteed/required
- // to be an `instanceof Error` either, so we have to use throw here to
- // preserve the "error" state outside of queryImpl.
- throw error;
- }
- // Pick off the right state value to return
- if (result.actionData) {
- return Object.values(result.actionData)[0];
- }
- if (result.loaderData) {
- let data = Object.values(result.loaderData)[0];
- if (result.activeDeferreds?.[match.route.id]) {
- data[UNSAFE_DEFERRED_SYMBOL] = result.activeDeferreds[match.route.id];
- }
- return data;
- }
- return undefined;
- }
- async function queryImpl(
- request: Request,
- location: Location,
- matches: AgnosticDataRouteMatch[],
- requestContext: unknown,
- routeMatch?: AgnosticDataRouteMatch
- ): Promise<Omit<StaticHandlerContext, "location" | "basename"> | Response> {
- invariant(
- request.signal,
- "query()/queryRoute() requests must contain an AbortController signal"
- );
- try {
- if (isMutationMethod(request.method.toLowerCase())) {
- let result = await submit(
- request,
- matches,
- routeMatch || getTargetMatch(matches, location),
- requestContext,
- routeMatch != null
- );
- return result;
- }
- let result = await loadRouteData(
- request,
- matches,
- requestContext,
- routeMatch
- );
- return isResponse(result)
- ? result
- : {
- ...result,
- actionData: null,
- actionHeaders: {},
- };
- } catch (e) {
- // If the user threw/returned a Response in callLoaderOrAction, we throw
- // it to bail out and then return or throw here based on whether the user
- // returned or threw
- if (isQueryRouteResponse(e)) {
- if (e.type === ResultType.error && !isRedirectResponse(e.response)) {
- throw e.response;
- }
- return e.response;
- }
- // Redirects are always returned since they don't propagate to catch
- // boundaries
- if (isRedirectResponse(e)) {
- return e;
- }
- throw e;
- }
- }
- async function submit(
- request: Request,
- matches: AgnosticDataRouteMatch[],
- actionMatch: AgnosticDataRouteMatch,
- requestContext: unknown,
- isRouteRequest: boolean
- ): Promise<Omit<StaticHandlerContext, "location" | "basename"> | Response> {
- let result: DataResult;
- if (!actionMatch.route.action) {
- let error = getInternalRouterError(405, {
- method: request.method,
- pathname: new URL(request.url).pathname,
- routeId: actionMatch.route.id,
- });
- if (isRouteRequest) {
- throw error;
- }
- result = {
- type: ResultType.error,
- error,
- };
- } else {
- result = await callLoaderOrAction(
- "action",
- request,
- actionMatch,
- matches,
- basename,
- true,
- isRouteRequest,
- requestContext
- );
- if (request.signal.aborted) {
- let method = isRouteRequest ? "queryRoute" : "query";
- throw new Error(`${method}() call aborted`);
- }
- }
- if (isRedirectResult(result)) {
- // Uhhhh - this should never happen, we should always throw these from
- // callLoaderOrAction, but the type narrowing here keeps TS happy and we
- // can get back on the "throw all redirect responses" train here should
- // this ever happen :/
- throw new Response(null, {
- status: result.status,
- headers: {
- Location: result.location,
- },
- });
- }
- if (isDeferredResult(result)) {
- let error = getInternalRouterError(400, { type: "defer-action" });
- if (isRouteRequest) {
- throw error;
- }
- result = {
- type: ResultType.error,
- error,
- };
- }
- if (isRouteRequest) {
- // Note: This should only be non-Response values if we get here, since
- // isRouteRequest should throw any Response received in callLoaderOrAction
- if (isErrorResult(result)) {
- throw result.error;
- }
- return {
- matches: [actionMatch],
- loaderData: {},
- actionData: { [actionMatch.route.id]: result.data },
- errors: null,
- // Note: statusCode + headers are unused here since queryRoute will
- // return the raw Response or value
- statusCode: 200,
- loaderHeaders: {},
- actionHeaders: {},
- activeDeferreds: null,
- };
- }
- if (isErrorResult(result)) {
- // Store off the pending error - we use it to determine which loaders
- // to call and will commit it when we complete the navigation
- let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id);
- let context = await loadRouteData(
- request,
- matches,
- requestContext,
- undefined,
- {
- [boundaryMatch.route.id]: result.error,
- }
- );
- // action status codes take precedence over loader status codes
- return {
- ...context,
- statusCode: isRouteErrorResponse(result.error)
- ? result.error.status
- : 500,
- actionData: null,
- actionHeaders: {
- ...(result.headers ? { [actionMatch.route.id]: result.headers } : {}),
- },
- };
- }
- // Create a GET request for the loaders
- let loaderRequest = new Request(request.url, {
- headers: request.headers,
- redirect: request.redirect,
- signal: request.signal,
- });
- let context = await loadRouteData(loaderRequest, matches, requestContext);
- return {
- ...context,
- // action status codes take precedence over loader status codes
- ...(result.statusCode ? { statusCode: result.statusCode } : {}),
- actionData: {
- [actionMatch.route.id]: result.data,
- },
- actionHeaders: {
- ...(result.headers ? { [actionMatch.route.id]: result.headers } : {}),
- },
- };
- }
- async function loadRouteData(
- request: Request,
- matches: AgnosticDataRouteMatch[],
- requestContext: unknown,
- routeMatch?: AgnosticDataRouteMatch,
- pendingActionError?: RouteData
- ): Promise<
- | Omit<
- StaticHandlerContext,
- "location" | "basename" | "actionData" | "actionHeaders"
- >
- | Response
- > {
- let isRouteRequest = routeMatch != null;
- // Short circuit if we have no loaders to run (queryRoute())
- if (isRouteRequest && !routeMatch?.route.loader) {
- throw getInternalRouterError(400, {
- method: request.method,
- pathname: new URL(request.url).pathname,
- routeId: routeMatch?.route.id,
- });
- }
- let requestMatches = routeMatch
- ? [routeMatch]
- : getLoaderMatchesUntilBoundary(
- matches,
- Object.keys(pendingActionError || {})[0]
- );
- let matchesToLoad = requestMatches.filter((m) => m.route.loader);
- // Short circuit if we have no loaders to run (query())
- if (matchesToLoad.length === 0) {
- return {
- matches,
- // Add a null for all matched routes for proper revalidation on the client
- loaderData: matches.reduce(
- (acc, m) => Object.assign(acc, { [m.route.id]: null }),
- {}
- ),
- errors: pendingActionError || null,
- statusCode: 200,
- loaderHeaders: {},
- activeDeferreds: null,
- };
- }
- let results = await Promise.all([
- ...matchesToLoad.map((match) =>
- callLoaderOrAction(
- "loader",
- request,
- match,
- matches,
- basename,
- true,
- isRouteRequest,
- requestContext
- )
- ),
- ]);
- if (request.signal.aborted) {
- let method = isRouteRequest ? "queryRoute" : "query";
- throw new Error(`${method}() call aborted`);
- }
- // Process and commit output from loaders
- let activeDeferreds = new Map<string, DeferredData>();
- let context = processRouteLoaderData(
- matches,
- matchesToLoad,
- results,
- pendingActionError,
- activeDeferreds
- );
- // Add a null for any non-loader matches for proper revalidation on the client
- let executedLoaders = new Set<string>(
- matchesToLoad.map((match) => match.route.id)
- );
- matches.forEach((match) => {
- if (!executedLoaders.has(match.route.id)) {
- context.loaderData[match.route.id] = null;
- }
- });
- return {
- ...context,
- matches,
- activeDeferreds:
- activeDeferreds.size > 0
- ? Object.fromEntries(activeDeferreds.entries())
- : null,
- };
- }
- return {
- dataRoutes,
- query,
- queryRoute,
- };
- }
- //#endregion
- ////////////////////////////////////////////////////////////////////////////////
- //#region Helpers
- ////////////////////////////////////////////////////////////////////////////////
- /**
- * Given an existing StaticHandlerContext and an error thrown at render time,
- * provide an updated StaticHandlerContext suitable for a second SSR render
- */
- export function getStaticContextFromError(
- routes: AgnosticDataRouteObject[],
- context: StaticHandlerContext,
- error: any
- ) {
- let newContext: StaticHandlerContext = {
- ...context,
- statusCode: 500,
- errors: {
- [context._deepestRenderedBoundaryId || routes[0].id]: error,
- },
- };
- return newContext;
- }
- function isSubmissionNavigation(
- opts: RouterNavigateOptions
- ): opts is SubmissionNavigateOptions {
- return opts != null && "formData" in opts;
- }
- // Normalize navigation options by converting formMethod=GET formData objects to
- // URLSearchParams so they behave identically to links with query params
- function normalizeNavigateOptions(
- to: To,
- opts?: RouterNavigateOptions,
- isFetcher = false
- ): {
- path: string;
- submission?: Submission;
- error?: ErrorResponse;
- } {
- let path = typeof to === "string" ? to : createPath(to);
- // Return location verbatim on non-submission navigations
- if (!opts || !isSubmissionNavigation(opts)) {
- return { path };
- }
- if (opts.formMethod && !isValidMethod(opts.formMethod)) {
- return {
- path,
- error: getInternalRouterError(405, { method: opts.formMethod }),
- };
- }
- // Create a Submission on non-GET navigations
- let submission: Submission | undefined;
- if (opts.formData) {
- submission = {
- formMethod: opts.formMethod || "get",
- formAction: stripHashFromPath(path),
- formEncType:
- (opts && opts.formEncType) || "application/x-www-form-urlencoded",
- formData: opts.formData,
- };
- if (isMutationMethod(submission.formMethod)) {
- return { path, submission };
- }
- }
- // Flatten submission onto URLSearchParams for GET submissions
- let parsedPath = parsePath(path);
- let searchParams = convertFormDataToSearchParams(opts.formData);
- // Since fetcher GET submissions only run a single loader (as opposed to
- // navigation GET submissions which run all loaders), we need to preserve
- // any incoming ?index params
- if (isFetcher && parsedPath.search && hasNakedIndexQuery(parsedPath.search)) {
- searchParams.append("index", "");
- }
- parsedPath.search = `?${searchParams}`;
- return { path: createPath(parsedPath), submission };
- }
- // Filter out all routes below any caught error as they aren't going to
- // render so we don't need to load them
- function getLoaderMatchesUntilBoundary(
- matches: AgnosticDataRouteMatch[],
- boundaryId?: string
- ) {
- let boundaryMatches = matches;
- if (boundaryId) {
- let index = matches.findIndex((m) => m.route.id === boundaryId);
- if (index >= 0) {
- boundaryMatches = matches.slice(0, index);
- }
- }
- return boundaryMatches;
- }
- function getMatchesToLoad(
- history: History,
- state: RouterState,
- matches: AgnosticDataRouteMatch[],
- submission: Submission | undefined,
- location: Location,
- isRevalidationRequired: boolean,
- cancelledDeferredRoutes: string[],
- cancelledFetcherLoads: string[],
- pendingActionData?: RouteData,
- pendingError?: RouteData,
- fetchLoadMatches?: Map<string, FetchLoadMatch>
- ): [AgnosticDataRouteMatch[], RevalidatingFetcher[]] {
- let actionResult = pendingError
- ? Object.values(pendingError)[0]
- : pendingActionData
- ? Object.values(pendingActionData)[0]
- : undefined;
- let currentUrl = history.createURL(state.location);
- let nextUrl = history.createURL(location);
- let defaultShouldRevalidate =
- // Forced revalidation due to submission, useRevalidate, or X-Remix-Revalidate
- isRevalidationRequired ||
- // Clicked the same link, resubmitted a GET form
- currentUrl.toString() === nextUrl.toString() ||
- // Search params affect all loaders
- currentUrl.search !== nextUrl.search;
- // Pick navigation matches that are net-new or qualify for revalidation
- let boundaryId = pendingError ? Object.keys(pendingError)[0] : undefined;
- let boundaryMatches = getLoaderMatchesUntilBoundary(matches, boundaryId);
- let navigationMatches = boundaryMatches.filter((match, index) => {
- if (match.route.loader == null) {
- return false;
- }
- // Always call the loader on new route instances and pending defer cancellations
- if (
- isNewLoader(state.loaderData, state.matches[index], match) ||
- cancelledDeferredRoutes.some((id) => id === match.route.id)
- ) {
- return true;
- }
- // This is the default implementation for when we revalidate. If the route
- // provides it's own implementation, then we give them full control but
- // provide this value so they can leverage it if needed after they check
- // their own specific use cases
- let currentRouteMatch = state.matches[index];
- let nextRouteMatch = match;
- return shouldRevalidateLoader(match, {
- currentUrl,
- currentParams: currentRouteMatch.params,
- nextUrl,
- nextParams: nextRouteMatch.params,
- ...submission,
- actionResult,
- defaultShouldRevalidate:
- defaultShouldRevalidate ||
- isNewRouteInstance(currentRouteMatch, nextRouteMatch),
- });
- });
- // Pick fetcher.loads that need to be revalidated
- let revalidatingFetchers: RevalidatingFetcher[] = [];
- fetchLoadMatches &&
- fetchLoadMatches.forEach((f, key) => {
- if (!matches.some((m) => m.route.id === f.routeId)) {
- // This fetcher is not going to be present in the subsequent render so
- // there's no need to revalidate it
- return;
- } else if (cancelledFetcherLoads.includes(key)) {
- // This fetcher was cancelled from a prior action submission - force reload
- revalidatingFetchers.push({ key, ...f });
- } else {
- // Revalidating fetchers are decoupled from the route matches since they
- // hit a static href, so they _always_ check shouldRevalidate and the
- // default is strictly if a revalidation is explicitly required (action
- // submissions, useRevalidator, X-Remix-Revalidate).
- let shouldRevalidate = shouldRevalidateLoader(f.match, {
- currentUrl,
- currentParams: state.matches[state.matches.length - 1].params,
- nextUrl,
- nextParams: matches[matches.length - 1].params,
- ...submission,
- actionResult,
- defaultShouldRevalidate,
- });
- if (shouldRevalidate) {
- revalidatingFetchers.push({ key, ...f });
- }
- }
- });
- return [navigationMatches, revalidatingFetchers];
- }
- function isNewLoader(
- currentLoaderData: RouteData,
- currentMatch: AgnosticDataRouteMatch,
- match: AgnosticDataRouteMatch
- ) {
- let isNew =
- // [a] -> [a, b]
- !currentMatch ||
- // [a, b] -> [a, c]
- match.route.id !== currentMatch.route.id;
- // Handle the case that we don't have data for a re-used route, potentially
- // from a prior error or from a cancelled pending deferred
- let isMissingData = currentLoaderData[match.route.id] === undefined;
- // Always load if this is a net-new route or we don't yet have data
- return isNew || isMissingData;
- }
- function isNewRouteInstance(
- currentMatch: AgnosticDataRouteMatch,
- match: AgnosticDataRouteMatch
- ) {
- let currentPath = currentMatch.route.path;
- return (
- // param change for this match, /users/123 -> /users/456
- currentMatch.pathname !== match.pathname ||
- // splat param changed, which is not present in match.path
- // e.g. /files/images/avatar.jpg -> files/finances.xls
- (currentPath != null &&
- currentPath.endsWith("*") &&
- currentMatch.params["*"] !== match.params["*"])
- );
- }
- function shouldRevalidateLoader(
- loaderMatch: AgnosticDataRouteMatch,
- arg: Parameters<ShouldRevalidateFunction>[0]
- ) {
- if (loaderMatch.route.shouldRevalidate) {
- let routeChoice = loaderMatch.route.shouldRevalidate(arg);
- if (typeof routeChoice === "boolean") {
- return routeChoice;
- }
- }
- return arg.defaultShouldRevalidate;
- }
- async function callLoaderOrAction(
- type: "loader" | "action",
- request: Request,
- match: AgnosticDataRouteMatch,
- matches: AgnosticDataRouteMatch[],
- basename = "/",
- isStaticRequest: boolean = false,
- isRouteRequest: boolean = false,
- requestContext?: unknown
- ): Promise<DataResult> {
- let resultType;
- let result;
- // Setup a promise we can race against so that abort signals short circuit
- let reject: () => void;
- let abortPromise = new Promise((_, r) => (reject = r));
- let onReject = () => reject();
- request.signal.addEventListener("abort", onReject);
- try {
- let handler = match.route[type];
- invariant<Function>(
- handler,
- `Could not find the ${type} to run on the "${match.route.id}" route`
- );
- result = await Promise.race([
- handler({ request, params: match.params, context: requestContext }),
- abortPromise,
- ]);
- invariant(
- result !== undefined,
- `You defined ${type === "action" ? "an action" : "a loader"} for route ` +
- `"${match.route.id}" but didn't return anything from your \`${type}\` ` +
- `function. Please return a value or \`null\`.`
- );
- } catch (e) {
- resultType = ResultType.error;
- result = e;
- } finally {
- request.signal.removeEventListener("abort", onReject);
- }
- if (isResponse(result)) {
- let status = result.status;
- // Process redirects
- if (redirectStatusCodes.has(status)) {
- let location = result.headers.get("Location");
- invariant(
- location,
- "Redirects returned/thrown from loaders/actions must have a Location header"
- );
- // Support relative routing in internal redirects
- if (!ABSOLUTE_URL_REGEX.test(location)) {
- let activeMatches = matches.slice(0, matches.indexOf(match) + 1);
- let routePathnames = getPathContributingMatches(activeMatches).map(
- (match) => match.pathnameBase
- );
- let resolvedLocation = resolveTo(
- location,
- routePathnames,
- new URL(request.url).pathname
- );
- invariant(
- createPath(resolvedLocation),
- `Unable to resolve redirect location: ${location}`
- );
- // Prepend the basename to the redirect location if we have one
- if (basename) {
- let path = resolvedLocation.pathname;
- resolvedLocation.pathname =
- path === "/" ? basename : joinPaths([basename, path]);
- }
- location = createPath(resolvedLocation);
- } else if (!isStaticRequest) {
- // Strip off the protocol+origin for same-origin absolute redirects.
- // If this is a static reques, we can let it go back to the browser
- // as-is
- let currentUrl = new URL(request.url);
- let url = location.startsWith("//")
- ? new URL(currentUrl.protocol + location)
- : new URL(location);
- if (url.origin === currentUrl.origin) {
- location = url.pathname + url.search + url.hash;
- }
- }
- // Don't process redirects in the router during static requests requests.
- // Instead, throw the Response and let the server handle it with an HTTP
- // redirect. We also update the Location header in place in this flow so
- // basename and relative routing is taken into account
- if (isStaticRequest) {
- result.headers.set("Location", location);
- throw result;
- }
- return {
- type: ResultType.redirect,
- status,
- location,
- revalidate: result.headers.get("X-Remix-Revalidate") !== null,
- };
- }
- // For SSR single-route requests, we want to hand Responses back directly
- // without unwrapping. We do this with the QueryRouteResponse wrapper
- // interface so we can know whether it was returned or thrown
- if (isRouteRequest) {
- // eslint-disable-next-line no-throw-literal
- throw {
- type: resultType || ResultType.data,
- response: result,
- };
- }
- let data: any;
- let contentType = result.headers.get("Content-Type");
- // Check between word boundaries instead of startsWith() due to the last
- // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type
- if (contentType && /\bapplication\/json\b/.test(contentType)) {
- data = await result.json();
- } else {
- data = await result.text();
- }
- if (resultType === ResultType.error) {
- return {
- type: resultType,
- error: new ErrorResponse(status, result.statusText, data),
- headers: result.headers,
- };
- }
- return {
- type: ResultType.data,
- data,
- statusCode: result.status,
- headers: result.headers,
- };
- }
- if (resultType === ResultType.error) {
- return { type: resultType, error: result };
- }
- if (result instanceof DeferredData) {
- return { type: ResultType.deferred, deferredData: result };
- }
- return { type: ResultType.data, data: result };
- }
- // Utility method for creating the Request instances for loaders/actions during
- // client-side navigations and fetches. During SSR we will always have a
- // Request instance from the static handler (query/queryRoute)
- function createClientSideRequest(
- history: History,
- location: string | Location,
- signal: AbortSignal,
- submission?: Submission
- ): Request {
- let url = history.createURL(stripHashFromPath(location)).toString();
- let init: RequestInit = { signal };
- if (submission && isMutationMethod(submission.formMethod)) {
- let { formMethod, formEncType, formData } = submission;
- init.method = formMethod.toUpperCase();
- init.body =
- formEncType === "application/x-www-form-urlencoded"
- ? convertFormDataToSearchParams(formData)
- : formData;
- }
- // Content-Type is inferred (https://fetch.spec.whatwg.org/#dom-request)
- return new Request(url, init);
- }
- function convertFormDataToSearchParams(formData: FormData): URLSearchParams {
- let searchParams = new URLSearchParams();
- for (let [key, value] of formData.entries()) {
- // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs
- searchParams.append(key, value instanceof File ? value.name : value);
- }
- return searchParams;
- }
- function processRouteLoaderData(
- matches: AgnosticDataRouteMatch[],
- matchesToLoad: AgnosticDataRouteMatch[],
- results: DataResult[],
- pendingError: RouteData | undefined,
- activeDeferreds: Map<string, DeferredData>
- ): {
- loaderData: RouterState["loaderData"];
- errors: RouterState["errors"] | null;
- statusCode: number;
- loaderHeaders: Record<string, Headers>;
- } {
- // Fill in loaderData/errors from our loaders
- let loaderData: RouterState["loaderData"] = {};
- let errors: RouterState["errors"] | null = null;
- let statusCode: number | undefined;
- let foundError = false;
- let loaderHeaders: Record<string, Headers> = {};
- // Process loader results into state.loaderData/state.errors
- results.forEach((result, index) => {
- let id = matchesToLoad[index].route.id;
- invariant(
- !isRedirectResult(result),
- "Cannot handle redirect results in processLoaderData"
- );
- if (isErrorResult(result)) {
- // Look upwards from the matched route for the closest ancestor
- // error boundary, defaulting to the root match
- let boundaryMatch = findNearestBoundary(matches, id);
- let error = result.error;
- // If we have a pending action error, we report it at the highest-route
- // that throws a loader error, and then clear it out to indicate that
- // it was consumed
- if (pendingError) {
- error = Object.values(pendingError)[0];
- pendingError = undefined;
- }
- errors = errors || {};
- // Prefer higher error values if lower errors bubble to the same boundary
- if (errors[boundaryMatch.route.id] == null) {
- errors[boundaryMatch.route.id] = error;
- }
- // Clear our any prior loaderData for the throwing route
- loaderData[id] = undefined;
- // Once we find our first (highest) error, we set the status code and
- // prevent deeper status codes from overriding
- if (!foundError) {
- foundError = true;
- statusCode = isRouteErrorResponse(result.error)
- ? result.error.status
- : 500;
- }
- if (result.headers) {
- loaderHeaders[id] = result.headers;
- }
- } else {
- if (isDeferredResult(result)) {
- activeDeferreds.set(id, result.deferredData);
- loaderData[id] = result.deferredData.data;
- } else {
- loaderData[id] = result.data;
- }
- // Error status codes always override success status codes, but if all
- // loaders are successful we take the deepest status code.
- if (
- result.statusCode != null &&
- result.statusCode !== 200 &&
- !foundError
- ) {
- statusCode = result.statusCode;
- }
- if (result.headers) {
- loaderHeaders[id] = result.headers;
- }
- }
- });
- // If we didn't consume the pending action error (i.e., all loaders
- // resolved), then consume it here. Also clear out any loaderData for the
- // throwing route
- if (pendingError) {
- errors = pendingError;
- loaderData[Object.keys(pendingError)[0]] = undefined;
- }
- return {
- loaderData,
- errors,
- statusCode: statusCode || 200,
- loaderHeaders,
- };
- }
- function processLoaderData(
- state: RouterState,
- matches: AgnosticDataRouteMatch[],
- matchesToLoad: AgnosticDataRouteMatch[],
- results: DataResult[],
- pendingError: RouteData | undefined,
- revalidatingFetchers: RevalidatingFetcher[],
- fetcherResults: DataResult[],
- activeDeferreds: Map<string, DeferredData>
- ): {
- loaderData: RouterState["loaderData"];
- errors?: RouterState["errors"];
- } {
- let { loaderData, errors } = processRouteLoaderData(
- matches,
- matchesToLoad,
- results,
- pendingError,
- activeDeferreds
- );
- // Process results from our revalidating fetchers
- for (let index = 0; index < revalidatingFetchers.length; index++) {
- let { key, match } = revalidatingFetchers[index];
- invariant(
- fetcherResults !== undefined && fetcherResults[index] !== undefined,
- "Did not find corresponding fetcher result"
- );
- let result = fetcherResults[index];
- // Process fetcher non-redirect errors
- if (isErrorResult(result)) {
- let boundaryMatch = findNearestBoundary(state.matches, match.route.id);
- if (!(errors && errors[boundaryMatch.route.id])) {
- errors = {
- ...errors,
- [boundaryMatch.route.id]: result.error,
- };
- }
- state.fetchers.delete(key);
- } else if (isRedirectResult(result)) {
- // Should never get here, redirects should get processed above, but we
- // keep this to type narrow to a success result in the else
- invariant(false, "Unhandled fetcher revalidation redirect");
- } else if (isDeferredResult(result)) {
- // Should never get here, deferred data should be awaited for fetchers
- // in resolveDeferredResults
- invariant(false, "Unhandled fetcher deferred data");
- } else {
- let doneFetcher: FetcherStates["Idle"] = {
- state: "idle",
- data: result.data,
- formMethod: undefined,
- formAction: undefined,
- formEncType: undefined,
- formData: undefined,
- " _hasFetcherDoneAnything ": true,
- };
- state.fetchers.set(key, doneFetcher);
- }
- }
- return { loaderData, errors };
- }
- function mergeLoaderData(
- loaderData: RouteData,
- newLoaderData: RouteData,
- matches: AgnosticDataRouteMatch[],
- errors: RouteData | null | undefined
- ): RouteData {
- let mergedLoaderData = { ...newLoaderData };
- for (let match of matches) {
- let id = match.route.id;
- if (newLoaderData.hasOwnProperty(id)) {
- if (newLoaderData[id] !== undefined) {
- mergedLoaderData[id] = newLoaderData[id];
- } else {
- // No-op - this is so we ignore existing data if we have a key in the
- // incoming object with an undefined value, which is how we unset a prior
- // loaderData if we encounter a loader error
- }
- } else if (loaderData[id] !== undefined) {
- mergedLoaderData[id] = loaderData[id];
- }
- if (errors && errors.hasOwnProperty(id)) {
- // Don't keep any loader data below the boundary
- break;
- }
- }
- return mergedLoaderData;
- }
- // Find the nearest error boundary, looking upwards from the leaf route (or the
- // route specified by routeId) for the closest ancestor error boundary,
- // defaulting to the root match
- function findNearestBoundary(
- matches: AgnosticDataRouteMatch[],
- routeId?: string
- ): AgnosticDataRouteMatch {
- let eligibleMatches = routeId
- ? matches.slice(0, matches.findIndex((m) => m.route.id === routeId) + 1)
- : [...matches];
- return (
- eligibleMatches.reverse().find((m) => m.route.hasErrorBoundary === true) ||
- matches[0]
- );
- }
- function getShortCircuitMatches(routes: AgnosticDataRouteObject[]): {
- matches: AgnosticDataRouteMatch[];
- route: AgnosticDataRouteObject;
- } {
- // Prefer a root layout route if present, otherwise shim in a route object
- let route = routes.find((r) => r.index || !r.path || r.path === "/") || {
- id: `__shim-error-route__`,
- };
- return {
- matches: [
- {
- params: {},
- pathname: "",
- pathnameBase: "",
- route,
- },
- ],
- route,
- };
- }
- function getInternalRouterError(
- status: number,
- {
- pathname,
- routeId,
- method,
- type,
- }: {
- pathname?: string;
- routeId?: string;
- method?: string;
- type?: "defer-action";
- } = {}
- ) {
- let statusText = "Unknown Server Error";
- let errorMessage = "Unknown @remix-run/router error";
- if (status === 400) {
- statusText = "Bad Request";
- if (method && pathname && routeId) {
- errorMessage =
- `You made a ${method} request to "${pathname}" but ` +
- `did not provide a \`loader\` for route "${routeId}", ` +
- `so there is no way to handle the request.`;
- } else if (type === "defer-action") {
- errorMessage = "defer() is not supported in actions";
- }
- } else if (status === 403) {
- statusText = "Forbidden";
- errorMessage = `Route "${routeId}" does not match URL "${pathname}"`;
- } else if (status === 404) {
- statusText = "Not Found";
- errorMessage = `No route matches URL "${pathname}"`;
- } else if (status === 405) {
- statusText = "Method Not Allowed";
- if (method && pathname && routeId) {
- errorMessage =
- `You made a ${method.toUpperCase()} request to "${pathname}" but ` +
- `did not provide an \`action\` for route "${routeId}", ` +
- `so there is no way to handle the request.`;
- } else if (method) {
- errorMessage = `Invalid request method "${method.toUpperCase()}"`;
- }
- }
- return new ErrorResponse(
- status || 500,
- statusText,
- new Error(errorMessage),
- true
- );
- }
- // Find any returned redirect errors, starting from the lowest match
- function findRedirect(results: DataResult[]): RedirectResult | undefined {
- for (let i = results.length - 1; i >= 0; i--) {
- let result = results[i];
- if (isRedirectResult(result)) {
- return result;
- }
- }
- }
- function stripHashFromPath(path: To) {
- let parsedPath = typeof path === "string" ? parsePath(path) : path;
- return createPath({ ...parsedPath, hash: "" });
- }
- function isHashChangeOnly(a: Location, b: Location): boolean {
- return (
- a.pathname === b.pathname && a.search === b.search && a.hash !== b.hash
- );
- }
- function isDeferredResult(result: DataResult): result is DeferredResult {
- return result.type === ResultType.deferred;
- }
- function isErrorResult(result: DataResult): result is ErrorResult {
- return result.type === ResultType.error;
- }
- function isRedirectResult(result?: DataResult): result is RedirectResult {
- return (result && result.type) === ResultType.redirect;
- }
- function isResponse(value: any): value is Response {
- return (
- value != null &&
- typeof value.status === "number" &&
- typeof value.statusText === "string" &&
- typeof value.headers === "object" &&
- typeof value.body !== "undefined"
- );
- }
- function isRedirectResponse(result: any): result is Response {
- if (!isResponse(result)) {
- return false;
- }
- let status = result.status;
- let location = result.headers.get("Location");
- return status >= 300 && status <= 399 && location != null;
- }
- function isQueryRouteResponse(obj: any): obj is QueryRouteResponse {
- return (
- obj &&
- isResponse(obj.response) &&
- (obj.type === ResultType.data || ResultType.error)
- );
- }
- function isValidMethod(method: string): method is FormMethod {
- return validRequestMethods.has(method as FormMethod);
- }
- function isMutationMethod(method?: string): method is MutationFormMethod {
- return validMutationMethods.has(method as MutationFormMethod);
- }
- async function resolveDeferredResults(
- currentMatches: AgnosticDataRouteMatch[],
- matchesToLoad: AgnosticDataRouteMatch[],
- results: DataResult[],
- signal: AbortSignal,
- isFetcher: boolean,
- currentLoaderData?: RouteData
- ) {
- for (let index = 0; index < results.length; index++) {
- let result = results[index];
- let match = matchesToLoad[index];
- let currentMatch = currentMatches.find(
- (m) => m.route.id === match.route.id
- );
- let isRevalidatingLoader =
- currentMatch != null &&
- !isNewRouteInstance(currentMatch, match) &&
- (currentLoaderData && currentLoaderData[match.route.id]) !== undefined;
- if (isDeferredResult(result) && (isFetcher || isRevalidatingLoader)) {
- // Note: we do not have to touch activeDeferreds here since we race them
- // against the signal in resolveDeferredData and they'll get aborted
- // there if needed
- await resolveDeferredData(result, signal, isFetcher).then((result) => {
- if (result) {
- results[index] = result || results[index];
- }
- });
- }
- }
- }
- async function resolveDeferredData(
- result: DeferredResult,
- signal: AbortSignal,
- unwrap = false
- ): Promise<SuccessResult | ErrorResult | undefined> {
- let aborted = await result.deferredData.resolveData(signal);
- if (aborted) {
- return;
- }
- if (unwrap) {
- try {
- return {
- type: ResultType.data,
- data: result.deferredData.unwrappedData,
- };
- } catch (e) {
- // Handle any TrackedPromise._error values encountered while unwrapping
- return {
- type: ResultType.error,
- error: e,
- };
- }
- }
- return {
- type: ResultType.data,
- data: result.deferredData.data,
- };
- }
- function hasNakedIndexQuery(search: string): boolean {
- return new URLSearchParams(search).getAll("index").some((v) => v === "");
- }
- // Note: This should match the format exported by useMatches, so if you change
- // this please also change that :) Eventually we'll DRY this up
- function createUseMatchesMatch(
- match: AgnosticDataRouteMatch,
- loaderData: RouteData
- ): UseMatchesMatch {
- let { route, pathname, params } = match;
- return {
- id: route.id,
- pathname,
- params,
- data: loaderData[route.id] as unknown,
- handle: route.handle as unknown,
- };
- }
- function getTargetMatch(
- matches: AgnosticDataRouteMatch[],
- location: Location | string
- ) {
- let search =
- typeof location === "string" ? parsePath(location).search : location.search;
- if (
- matches[matches.length - 1].route.index &&
- hasNakedIndexQuery(search || "")
- ) {
- // Return the leaf index route when index is present
- return matches[matches.length - 1];
- }
- // Otherwise grab the deepest "path contributing" match (ignoring index and
- // pathless layout routes)
- let pathMatches = getPathContributingMatches(matches);
- return pathMatches[pathMatches.length - 1];
- }
- //#endregion
|