UNPKG

bmad-agent-init

Version:

Windsurf integration for BMAD-METHOD - automatic initialization of bmad-agent in projects

1,621 lines (1,448 loc) 183 kB
import type { History, Location, Path, To } from "./history"; import { Action as HistoryAction, createLocation, createPath, invariant, parsePath, warning, } from "./history"; import type { AgnosticDataRouteMatch, AgnosticDataRouteObject, DataStrategyMatch, AgnosticRouteObject, DataResult, DataStrategyFunction, DataStrategyFunctionArgs, DeferredData, DeferredResult, DetectErrorBoundaryFunction, ErrorResult, FormEncType, FormMethod, HTMLFormMethod, DataStrategyResult, ImmutableRouteKey, MapRoutePropertiesFunction, MutationFormMethod, RedirectResult, RouteData, RouteManifest, ShouldRevalidateFunctionArgs, Submission, SuccessResult, UIMatch, V7_FormMethod, V7_MutationFormMethod, AgnosticPatchRoutesOnNavigationFunction, DataWithResponseInit, } from "./utils"; import { ErrorResponseImpl, ResultType, convertRouteMatchToUiMatch, convertRoutesToDataRoutes, getPathContributingMatches, getResolveToMatches, immutableRouteKeys, isRouteErrorResponse, joinPaths, matchRoutes, matchRoutesImpl, resolveTo, stripBasename, } 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 future config for the router */ get future(): FutureConfig; /** * @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 * * Return the window associated with the router */ get window(): RouterInit["window"]; /** * @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 | null, 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 | null, opts?: RouterFetchOptions ): 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 * * Patch additional children routes into an existing parent route * @param routeId The parent route id or a callback function accepting `patch` * to perform batch patching * @param children The additional children routes */ patchRoutes(routeId: string | null, children: AgnosticRouteObject[]): void; /** * @internal * PRIVATE - DO NOT USE * * HMR needs to pass in-flight route updates to React Router * TODO: Replace this with granular route update APIs (addRoute, updateRoute, deleteRoute) */ _internalSetRoutes(routes: AgnosticRouteObject[]): 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"> >; /** * Future flags to toggle new feature behavior */ export interface FutureConfig { v7_fetcherPersist: boolean; v7_normalizeFormMethod: boolean; v7_partialHydration: boolean; v7_prependBasename: boolean; v7_relativeSplatPath: boolean; v7_skipActionErrorRevalidation: boolean; } /** * Initialization options for createRouter */ export interface RouterInit { routes: AgnosticRouteObject[]; history: History; basename?: string; /** * @deprecated Use `mapRouteProperties` instead */ detectErrorBoundary?: DetectErrorBoundaryFunction; mapRouteProperties?: MapRoutePropertiesFunction; future?: Partial<FutureConfig>; hydrationData?: HydrationState; window?: Window; dataStrategy?: DataStrategyFunction; patchRoutesOnNavigation?: AgnosticPatchRoutesOnNavigationFunction; } /** * 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; skipLoaderErrorBubbling?: boolean; dataStrategy?: DataStrategyFunction; } ): Promise<StaticHandlerContext | Response>; queryRoute( request: Request, opts?: { routeId?: string; requestContext?: unknown; dataStrategy?: DataStrategyFunction; } ): Promise<any>; } type ViewTransitionOpts = { currentLocation: Location; nextLocation: Location; }; /** * Subscriber function signature for changes to router state */ export interface RouterSubscriber { ( state: RouterState, opts: { deletedFetchers: string[]; viewTransitionOpts?: ViewTransitionOpts; flushSync: boolean; } ): void; } /** * Function signature for determining the key to be used in scroll restoration * for a given location */ export interface GetScrollRestorationKeyFunction { (location: Location, matches: UIMatch[]): string | null; } /** * Function signature for determining the current scroll position */ export interface GetScrollPositionFunction { (): number; } export type RelativeRoutingType = "route" | "path"; // Allowed for any navigation or fetch type BaseNavigateOrFetchOptions = { preventScrollReset?: boolean; relative?: RelativeRoutingType; flushSync?: boolean; }; // Only allowed for navigations type BaseNavigateOptions = BaseNavigateOrFetchOptions & { replace?: boolean; state?: any; fromRouteId?: string; viewTransition?: boolean; }; // Only allowed for submission navigations type BaseSubmissionOptions = { formMethod?: HTMLFormMethod; formEncType?: FormEncType; } & ( | { formData: FormData; body?: undefined } | { formData?: undefined; body: any } ); /** * Options for a navigate() call for a normal (non-submission) navigation */ type LinkNavigateOptions = BaseNavigateOptions; /** * Options for a navigate() call for a submission navigation */ type SubmissionNavigateOptions = BaseNavigateOptions & BaseSubmissionOptions; /** * Options to pass to navigate() for a navigation */ export type RouterNavigateOptions = | LinkNavigateOptions | SubmissionNavigateOptions; /** * Options for a fetch() load */ type LoadFetchOptions = BaseNavigateOrFetchOptions; /** * Options for a fetch() submission */ type SubmitFetchOptions = BaseNavigateOrFetchOptions & BaseSubmissionOptions; /** * Options to pass to fetch() */ export type RouterFetchOptions = LoadFetchOptions | SubmitFetchOptions; /** * Potential states for state.navigation */ export type NavigationStates = { Idle: { state: "idle"; location: undefined; formMethod: undefined; formAction: undefined; formEncType: undefined; formData: undefined; json: undefined; text: undefined; }; Loading: { state: "loading"; location: Location; formMethod: Submission["formMethod"] | undefined; formAction: Submission["formAction"] | undefined; formEncType: Submission["formEncType"] | undefined; formData: Submission["formData"] | undefined; json: Submission["json"] | undefined; text: Submission["text"] | undefined; }; Submitting: { state: "submitting"; location: Location; formMethod: Submission["formMethod"]; formAction: Submission["formAction"]; formEncType: Submission["formEncType"]; formData: Submission["formData"]; json: Submission["json"]; text: Submission["text"]; }; }; 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; text: undefined; formData: undefined; json: undefined; data: TData | undefined; }; Loading: { state: "loading"; formMethod: Submission["formMethod"] | undefined; formAction: Submission["formAction"] | undefined; formEncType: Submission["formEncType"] | undefined; text: Submission["text"] | undefined; formData: Submission["formData"] | undefined; json: Submission["json"] | undefined; data: TData | undefined; }; Submitting: { state: "submitting"; formMethod: Submission["formMethod"]; formAction: Submission["formAction"]; formEncType: Submission["formEncType"]; text: Submission["text"]; formData: Submission["formData"]; json: Submission["json"]; data: TData | undefined; }; }; 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; } type PendingActionResult = [string, SuccessResult | ErrorResult]; interface HandleActionResult extends ShortCircuitable { /** * Route matches which may have been updated from fog of war discovery */ matches?: RouterState["matches"]; /** * Tuple for the returned or thrown value from the current action. The routeId * is the action route for success and the bubbled boundary route for errors. */ pendingActionResult?: PendingActionResult; } interface HandleLoadersResult extends ShortCircuitable { /** * Route matches which may have been updated from fog of war discovery */ matches?: RouterState["matches"]; /** * 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; } /** * Identified fetcher.load() calls that need to be revalidated */ interface RevalidatingFetcher extends FetchLoadMatch { key: string; match: AgnosticDataRouteMatch | null; matches: AgnosticDataRouteMatch[] | null; controller: AbortController | null; } 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, json: undefined, text: undefined, }; export const IDLE_FETCHER: FetcherStates["Idle"] = { state: "idle", data: undefined, formMethod: undefined, formAction: undefined, formEncType: undefined, formData: undefined, json: undefined, text: 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 defaultMapRouteProperties: MapRoutePropertiesFunction = (route) => ({ hasErrorBoundary: Boolean(route.hasErrorBoundary), }); const TRANSITIONS_STORAGE_KEY = "remix-router-transitions"; //#endregion //////////////////////////////////////////////////////////////////////////////// //#region createRouter //////////////////////////////////////////////////////////////////////////////// /** * Create a router and listen to history POP navigations */ export function createRouter(init: RouterInit): Router { const routerWindow = init.window ? init.window : typeof window !== "undefined" ? window : undefined; const isBrowser = typeof routerWindow !== "undefined" && typeof routerWindow.document !== "undefined" && typeof routerWindow.document.createElement !== "undefined"; const isServer = !isBrowser; invariant( init.routes.length > 0, "You must provide a non-empty routes array to createRouter" ); let mapRouteProperties: MapRoutePropertiesFunction; if (init.mapRouteProperties) { mapRouteProperties = init.mapRouteProperties; } else if (init.detectErrorBoundary) { // If they are still using the deprecated version, wrap it with the new API let detectErrorBoundary = init.detectErrorBoundary; mapRouteProperties = (route) => ({ hasErrorBoundary: detectErrorBoundary(route), }); } else { mapRouteProperties = defaultMapRouteProperties; } // Routes keyed by ID let manifest: RouteManifest = {}; // Routes in tree format for matching let dataRoutes = convertRoutesToDataRoutes( init.routes, mapRouteProperties, undefined, manifest ); let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined; let basename = init.basename || "/"; let dataStrategyImpl = init.dataStrategy || defaultDataStrategy; let patchRoutesOnNavigationImpl = init.patchRoutesOnNavigation; // Config driven behavior flags let future: FutureConfig = { v7_fetcherPersist: false, v7_normalizeFormMethod: false, v7_partialHydration: false, v7_prependBasename: false, v7_relativeSplatPath: false, v7_skipActionErrorRevalidation: false, ...init.future, }; // 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, basename); let initialErrors: RouteData | null = null; if (initialMatches == null && !patchRoutesOnNavigationImpl) { // 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 }; } // In SPA apps, if the user provided a patchRoutesOnNavigation implementation and // our initial match is a splat route, clear them out so we run through lazy // discovery on hydration in case there's a more accurate lazy route match. // In SSR apps (with `hydrationData`), we expect that the server will send // up the proper matched routes so we don't want to run lazy discovery on // initial hydration and want to hydrate into the splat route. if (initialMatches && !init.hydrationData) { let fogOfWar = checkFogOfWar( initialMatches, dataRoutes, init.history.location.pathname ); if (fogOfWar.active) { initialMatches = null; } } let initialized: boolean; if (!initialMatches) { initialized = false; initialMatches = []; // If partial hydration and fog of war is enabled, we will be running // `patchRoutesOnNavigation` during hydration so include any partial matches as // the initial matches so we can properly render `HydrateFallback`'s if (future.v7_partialHydration) { let fogOfWar = checkFogOfWar( null, dataRoutes, init.history.location.pathname ); if (fogOfWar.active && fogOfWar.matches) { initialMatches = fogOfWar.matches; } } } else if (initialMatches.some((m) => m.route.lazy)) { // All initialMatches need to be loaded before we're ready. If we have lazy // functions around still then we'll need to run them in initialize() initialized = false; } else if (!initialMatches.some((m) => m.route.loader)) { // If we've got no loaders to run, then we're good to go initialized = true; } else if (future.v7_partialHydration) { // If partial hydration is enabled, we're initialized so long as we were // provided with hydrationData for every route with a loader, and no loaders // were marked for explicit hydration let loaderData = init.hydrationData ? init.hydrationData.loaderData : null; let errors = init.hydrationData ? init.hydrationData.errors : null; // If errors exist, don't consider routes below the boundary if (errors) { let idx = initialMatches.findIndex( (m) => errors![m.route.id] !== undefined ); initialized = initialMatches .slice(0, idx + 1) .every((m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors)); } else { initialized = initialMatches.every( (m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors) ); } } else { // Without partial hydration - we're initialized if we were provided any // hydrationData - which is expected to be complete initialized = 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; // Should the current navigation enable document.startViewTransition? let pendingViewTransitionEnabled = false; // Store applied view transitions so we can apply them on POP let appliedViewTransitions: Map<string, Set<string>> = new Map< string, Set<string> >(); // Cleanup function for persisting applied transitions to sessionStorage let removePageHideEventListener: (() => void) | null = 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) // - useRevalidator() // - 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: Set<string> = new Set(); // 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 let fetchRedirectIds = new Set<string>(); // Most recent href/match for fetcher.load calls for fetchers let fetchLoadMatches = new Map<string, FetchLoadMatch>(); // Ref-count mounted fetchers so we know when it's ok to clean them up let activeFetchers = new Map<string, number>(); // Fetchers that have requested a delete when using v7_fetcherPersist, // they'll be officially removed after they return to idle let deletedFetchers = new Set<string>(); // 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>(); // Map of pending patchRoutesOnNavigation() promises (keyed by path/matches) so // that we only kick them off once for a given combo let pendingPatchRoutes = new Map< string, ReturnType<AgnosticPatchRoutesOnNavigationFunction> >(); // 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 unblockBlockerHistoryUpdate: (() => void) | undefined = undefined; // 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 (unblockBlockerHistoryUpdate) { unblockBlockerHistoryUpdate(); unblockBlockerHistoryUpdate = undefined; 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 let nextHistoryUpdatePromise = new Promise<void>((resolve) => { unblockBlockerHistoryUpdate = resolve; }); 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, after the url // restoration is also complete. See: // https://github.com/remix-run/react-router/issues/11613 nextHistoryUpdatePromise.then(() => init.history.go(delta)); }, reset() { let blockers = new Map(state.blockers); blockers.set(blockerKey!, IDLE_BLOCKER); updateState({ blockers }); }, }); return; } return startNavigation(historyAction, location); } ); if (isBrowser) { // FIXME: This feels gross. How can we cleanup the lines between // scrollRestoration/appliedTransitions persistance? restoreAppliedTransitions(routerWindow, appliedViewTransitions); let _saveAppliedTransitions = () => persistAppliedTransitions(routerWindow, appliedViewTransitions); routerWindow.addEventListener("pagehide", _saveAppliedTransitions); removePageHideEventListener = () => routerWindow.removeEventListener("pagehide", _saveAppliedTransitions); } // Kick off initial data load if needed. Use Pop to avoid modifying history // Note we don't do any handling of lazy here. For SPA's it'll get handled // in the normal navigation flow. For SSR it's expected that lazy modules are // resolved prior to router creation since we can't go into a fallbackElement // UI for SSR'd apps if (!state.initialized) { startNavigation(HistoryAction.Pop, state.location, { initialHydration: true, }); } return router; } // Clean up a router and it's side effects function dispose() { if (unlistenHistory) { unlistenHistory(); } if (removePageHideEventListener) { removePageHideEventListener(); } 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>, opts: { flushSync?: boolean; viewTransitionOpts?: ViewTransitionOpts; } = {} ): void { state = { ...state, ...newState, }; // Prep fetcher cleanup so we can tell the UI which fetcher data entries // can be removed let completedFetchers: string[] = []; let deletedFetchersKeys: string[] = []; if (future.v7_fetcherPersist) { state.fetchers.forEach((fetcher, key) => { if (fetcher.state === "idle") { if (deletedFetchers.has(key)) { // Unmounted from the UI and can be totally removed deletedFetchersKeys.push(key); } else { // Returned to idle but still mounted in the UI, so semi-remains for // revalidations and such completedFetchers.push(key); } } }); } // Iterate over a local copy so that if flushSync is used and we end up // removing and adding a new subscriber due to the useCallback dependencies, // we don't get ourselves into a loop calling the new subscriber immediately [...subscribers].forEach((subscriber) => subscriber(state, { deletedFetchers: deletedFetchersKeys, viewTransitionOpts: opts.viewTransitionOpts, flushSync: opts.flushSync === true, }) ); // Remove idle fetchers from state since we only care about in-flight fetchers. if (future.v7_fetcherPersist) { completedFetchers.forEach((key) => state.fetchers.delete(key)); deletedFetchersKeys.forEach((key) => deleteFetcher(key)); } } // 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">>, { flushSync }: { flushSync?: boolean } = {} ): 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 let blockers = state.blockers; if (blockers.size > 0) { blockers = new Map(blockers); blockers.forEach((_, k) => blockers.set(k, IDLE_BLOCKER)); } // 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); // Commit any in-flight routes at the end of the HMR revalidation "navigation" if (inFlightDataRoutes) { dataRoutes = inFlightDataRoutes; inFlightDataRoutes = undefined; } 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); } let viewTransitionOpts: ViewTransitionOpts | undefined; // On POP, enable transitions if they were enabled on the original navigation if (pendingAction === HistoryAction.Pop) { // Forward takes precedence so they behave like the original navigation let priorPaths = appliedViewTransitions.get(state.location.pathname); if (priorPaths && priorPaths.has(location.pathname)) { viewTransitionOpts = { currentLocation: state.location, nextLocation: location, }; } else if (appliedViewTransitions.has(location.pathname)) { // If we don't have a previous forward nav, assume we're popping back to // the new location and enable if that location previously enabled viewTransitionOpts = { currentLocation: location, nextLocation: state.location, }; } } else if (pendingViewTransitionEnabled) { // Store the applied transition on PUSH/REPLACE let toPaths = appliedViewTransitions.get(state.location.pathname); if (toPaths) { toPaths.add(location.pathname); } else { toPaths = new Set<string>([location.pathname]); appliedViewTransitions.set(state.location.pathname, toPaths); } viewTransitionOpts = { currentLocation: state.location, nextLocation: location, }; } 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, }, { viewTransitionOpts, flushSync: flushSync === true, } ); // Reset stateful navigation vars pendingAction = HistoryAction.Pop; pendingPreventScrollReset = false; pendingViewTransitionEnabled = false; isUninterruptedRevalidation = false; isRevalidationRequired = false; cancelledDeferredRoutes = []; } // 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 | null, opts?: RouterNavigateOptions ): Promise<void> { if (typeof to === "number") { init.history.go(to); return; } let normalizedPath = normalizeTo( state.location, state.matches, basename, future.v7_prependBasename, to, future.v7_relativeSplatPath, opts?.fromRouteId, opts?.relative ); let { path, submission, error } = normalizeNavigateOptions( future.v7_normalizeFormMethod, false, normalizedPath, 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 flushSync = (opts && opts.flushSync) === true; 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() { let blockers = new Map(state.blockers); blockers.set(blockerKey!, IDLE_BLOCKER); updateState({ 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, enableViewTransition: opts && opts.viewTransition, flushSync, }); } // 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, // Proxy through any rending view transition enableViewTransition: pendingViewTransitionEnabled === true, } ); } // 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?: { initialHydration?: boolean; submission?: Submission; fetcherSubmission?: Submission; overrideNavigation?: Navigation; pendingError?: ErrorResponseImpl; startUninterruptedRevalidation?: boolean; preventScrollReset?: boolean; replace?: boolean; enableViewTransition?: boolean; flushSync?: 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; pendingViewTransitionEnabled = (opts && opts.enableViewTransition) === true; let routesToUse = inFlightDataRoutes || dataRoutes; let loadingNavigation = opts && opts.overrideNavigation; let matches = matchRoutes(routesToUse, location, basename); let flushSync = (opts && opts.flushSync) === true; let fogOfWar = checkFogOfWar(matches, routesToUse, location.pathname); if (fogOfWar.active && fogOfWar.matches) { matches = fogOfWar.matches; } // Short circuit with a 404 on the root error boundary if we match nothing if (!matches) { let { error, notFoundMatches, route } = handleNavigational404( location.pathname ); completeNavigation( location, { matches: notFoundMatches, loaderData: {}, errors: { [route.id]: error, }, }, { flushSync } ); return; } // Short circuit if it's only a hash change and not a revalidation or // mutation submission. // // Ignore on initial page loads because since the initial hydration will always // be "same hash". For example, on /page#hash and submit a <Form method="post"> // which will default to a navigation to /page if ( state.initialized && !isRevalidationRequired && isHashChangeOnly(state.location, location) && !(opts && opts.submission && isMutationMethod(opts.submission.formMethod)) ) { completeNavigation(location, { matches }, { flushSync }); return; } // Create a controller/Request for this navigation pendingNavigationController = new AbortController(); let request = createClientSideRequest( init.history, location, pendingNavigationController.signal, opts && opts.submission ); let pendingActionResult: PendingActionResult | 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. pendingActionResult = [ findNearestBoundary(matches).route.id, { type: ResultType.error, error: opts.pendingError }, ]; } else if ( opts && opts.submission && isMutationMethod(opts.submission.formMethod) ) { // Call action if we received an action submission let actionResult = await handleAction( request, location, opts.submission, matches, fogOfWar.active, { replace: opts.replace, flushSync } ); if (actionResult.shortCircuited) { return; } // If we received a 404 from handleAction, it's because we couldn't lazily // discover the destination route so we don't want to call loaders if (actionResult.pendingActionResult) { let [routeId, result] = actionResult.pendingActionResult; if ( isErrorResult(result) && isRouteErrorResponse(result.error) && result.error.status === 404 ) { pendingNavigationController = null; completeNavigation(location, { matches: actionResult.matches, loaderData: {}, errors: { [routeId]: result.error, }, }); return; } } matches = actionResult.matches || matches; pendingActionResult = actionResult.pendingActionResult; loadingNavigation = getLoadingNavigation(location, opts.submission); flushSync = false; // No need to do fog of war matching again on loader execution