diff --git a/.changeset/quick-yaks-join.md b/.changeset/quick-yaks-join.md new file mode 100644 index 0000000000..4147237861 --- /dev/null +++ b/.changeset/quick-yaks-join.md @@ -0,0 +1,5 @@ +--- +"@remix-run/router": patch +--- + +Add internal API for custom HMR implementations diff --git a/package.json b/package.json index d2cf1bd74a..607528bd9b 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "41.5 kB" + "none": "41.6 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "13 kB" diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 6e9c6cd365..cdd66b429f 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -307,6 +307,9 @@ export function createStaticRouter( }, _internalFetchControllers: new Map(), _internalActiveDeferreds: new Map(), + _internalSetRoutes() { + throw msg("_internalSetRoutes"); + }, }; } diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index 6ab973216d..b641de6df1 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -821,7 +821,6 @@ describe("", () => { initialEntries={["/foo"]} hydrationData={{ loaderData: { - layout: null, foo: "FOO", }, }} @@ -861,7 +860,7 @@ describe("", () => { } expect(spy).toHaveBeenCalledWith({ - layout: null, + layout: undefined, foo: "FOO", bar: undefined, child: undefined, @@ -896,7 +895,7 @@ describe("", () => { " `); expect(spy).toHaveBeenCalledWith({ - layout: null, + layout: undefined, foo: undefined, bar: undefined, child: "CHILD", diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 5c288e8735..05181d86d2 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -294,11 +294,12 @@ function setup({ // active navigation loader/action function enhanceRoutes(_routes: TestRouteObject[]) { return _routes.map((r) => { - let enhancedRoute: AgnosticRouteObject = { + let enhancedRoute: AgnosticDataRouteObject = { ...r, loader: undefined, action: undefined, children: undefined, + id: r.id || `route-${guid++}`, }; if (r.loader) { enhancedRoute.loader = (args) => { @@ -468,6 +469,12 @@ function setup({ ); } + let inFlightRoutes: AgnosticDataRouteObject[] | undefined; + function _internalSetRoutes(routes: AgnosticDataRouteObject[]) { + inFlightRoutes = routes; + currentRouter?._internalSetRoutes(routes); + } + function getNavigationHelpers( href: string, navigationId: number @@ -476,7 +483,7 @@ function setup({ currentRouter?.routes, "No currentRouter.routes available in getNavigationHelpers" ); - let matches = matchRoutes(currentRouter.routes, href); + let matches = matchRoutes(inFlightRoutes || currentRouter.routes, href); // Generate helpers for all route matches that contain loaders let loaderHelpers = getHelpers( @@ -515,7 +522,7 @@ function setup({ currentRouter?.routes, "No currentRouter.routes available in getFetcherHelpers" ); - let matches = matchRoutes(currentRouter.routes, href); + let matches = matchRoutes(inFlightRoutes || currentRouter.routes, href); invariant(currentRouter, "No currentRouter available"); let search = parsePath(href).search || ""; let hasNakedIndexQuery = new URLSearchParams(search) @@ -548,7 +555,7 @@ function setup({ if (opts?.formMethod === "post") { if (currentRouter.state.navigation?.location) { let matches = matchRoutes( - currentRouter.routes, + inFlightRoutes || currentRouter.routes, currentRouter.state.navigation.location ); invariant(matches, "No matches found for fetcher"); @@ -754,6 +761,8 @@ function setup({ fetch, revalidate, shimHelper, + enhanceRoutes, + _internalSetRoutes, }; } @@ -854,6 +863,12 @@ const TM_ROUTES: TestRouteObject[] = [ loader: true, action: true, }, + { + path: "/no-loader", + id: "noLoader", + loader: false, + action: true, + }, ], }, ]; @@ -13544,4 +13559,412 @@ describe("a router", () => { }); }); }); + + describe("routes updates", () => { + it("should retain existing routes until revalidation completes on loader removal", async () => { + let t = initializeTmTest(); + let ogRoutes = t.router.routes; + let A = await t.navigate("/foo"); + await A.loaders.foo.resolve("foo"); + expect(t.router.state.loaderData).toMatchObject({ + root: "ROOT", + foo: "foo", + }); + + let newRoutes = t.enhanceRoutes([ + { + path: "", + id: "root", + hasErrorBoundary: true, + loader: true, + children: [ + { + path: "/", + id: "index", + loader: true, + action: true, + }, + { + path: "/foo", + id: "foo", + loader: false, + action: true, + }, + ], + }, + ]); + t._internalSetRoutes(newRoutes); + + // Get a new revalidation helper that should use the updated routes + let R = await t.revalidate(); + expect(t.router.state.revalidation).toBe("loading"); + + // Should still expose be the og routes until revalidation completes + expect(t.router.routes).toBe(ogRoutes); + + // Resolve any loaders that should have ran (foo's loader has been removed) + await R.loaders.root.resolve("ROOT*"); + expect(t.router.state.revalidation).toBe("idle"); + + // Routes should be updated + expect(t.router.routes).not.toBe(ogRoutes); + expect(t.router.routes).toBe(newRoutes); + + // Loader data should be updated and foo removed + expect(t.router.state.loaderData).toEqual({ + root: "ROOT*", + }); + expect(t.router.state.errors).toEqual(null); + }); + + it("should retain existing routes until revalidation completes on loader addition", async () => { + let t = initializeTmTest(); + let ogRoutes = t.router.routes; + await t.navigate("/no-loader"); + expect(t.router.state.loaderData).toMatchObject({ + root: "ROOT", + }); + + let newRoutes = t.enhanceRoutes([ + { + path: "", + id: "root", + hasErrorBoundary: true, + loader: true, + children: [ + { + path: "/no-loader", + id: "noLoader", + loader: true, + action: true, + }, + ], + }, + ]); + t._internalSetRoutes(newRoutes); + // Get a new revalidation helper that should use the updated routes + let R = await t.revalidate(); + expect(t.router.state.revalidation).toBe("loading"); + expect(t.router.routes).toBe(ogRoutes); + + // Should still expose be the og routes until revalidation completes + expect(t.router.routes).toBe(ogRoutes); + + // Resolve any loaders that should have ran + await R.loaders.root.resolve("ROOT*"); + await R.loaders.noLoader.resolve("NO_LOADER*"); + expect(t.router.state.revalidation).toBe("idle"); + + // Routes should be updated + expect(t.router.routes).not.toBe(ogRoutes); + expect(t.router.routes).toBe(newRoutes); + + // Loader data should be updated + expect(t.router.state.loaderData).toEqual({ + root: "ROOT*", + noLoader: "NO_LOADER*", + }); + expect(t.router.state.errors).toEqual(null); + }); + + it("should retain existing routes until interrupting navigation completes", async () => { + let t = initializeTmTest(); + let ogRoutes = t.router.routes; + let A = await t.navigate("/foo"); + await A.loaders.foo.resolve("foo"); + expect(t.router.state.loaderData).toMatchObject({ + root: "ROOT", + foo: "foo", + }); + + let newRoutes = t.enhanceRoutes([ + { + path: "", + id: "root", + hasErrorBoundary: true, + loader: true, + children: [ + { + path: "/", + id: "index", + loader: false, + action: true, + }, + { + path: "/foo", + id: "foo", + loader: false, + action: true, + }, + ], + }, + ]); + t._internalSetRoutes(newRoutes); + + // Revalidate and interrupt with a navigation + let R = await t.revalidate(); + let N = await t.navigate("/?revalidate"); + + expect(t.router.state.navigation.state).toBe("loading"); + expect(t.router.state.revalidation).toBe("loading"); + + // Should still expose be the og routes until navigation completes + expect(t.router.routes).toBe(ogRoutes); + + // Revalidation cancelled so this shouldn't make it through + await R.loaders.root.resolve("ROOT STALE"); + + // Resolve any loaders that should have ran (foo's loader has been removed) + await N.loaders.root.resolve("ROOT*"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.revalidation).toBe("idle"); + + // Routes should be updated + expect(t.router.routes).not.toBe(ogRoutes); + expect(t.router.routes).toBe(newRoutes); + + // Loader data should be updated + expect(t.router.state.loaderData).toEqual({ + root: "ROOT*", + }); + expect(t.router.state.errors).toEqual(null); + }); + + it("should retain existing routes until interrupted navigation completes", async () => { + let t = initializeTmTest(); + let ogRoutes = t.router.routes; + + let N = await t.navigate("/foo"); + + let newRoutes = t.enhanceRoutes([ + { + path: "", + id: "root", + hasErrorBoundary: true, + loader: true, + children: [ + { + path: "/", + id: "index", + loader: false, + action: true, + }, + { + path: "/foo", + id: "foo", + loader: false, + action: true, + }, + ], + }, + ]); + t._internalSetRoutes(newRoutes); + + // Interrupt /foo navigation with a revalidation + let R = await t.revalidate(); + + expect(t.router.state.navigation.state).toBe("loading"); + expect(t.router.state.revalidation).toBe("loading"); + + // Should still expose be the og routes until navigation completes + expect(t.router.routes).toBe(ogRoutes); + + // NAvigation interrupted so this shouldn't make it through + await N.loaders.root.resolve("ROOT STALE"); + + // Resolve any loaders that should have ran (foo's loader has been removed) + await R.loaders.root.resolve("ROOT*"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.revalidation).toBe("idle"); + + // Routes should be updated + expect(t.router.routes).not.toBe(ogRoutes); + expect(t.router.routes).toBe(newRoutes); + + // Loader data should be updated + expect(t.router.state.loaderData).toEqual({ + root: "ROOT*", + }); + expect(t.router.state.errors).toEqual(null); + }); + + it("should retain existing routes until revalidation completes on loader removal (fetch)", async () => { + let rootDfd = createDeferred(); + let fooDfd = createDeferred(); + let ogRoutes: AgnosticDataRouteObject[] = [ + { + path: "/", + id: "root", + hasErrorBoundary: true, + loader: () => rootDfd.promise, + children: [ + { + index: true, + id: "index", + }, + { + path: "foo", + id: "foo", + loader: () => fooDfd.promise, + children: undefined, + }, + ], + }, + ]; + currentRouter = createRouter({ + routes: ogRoutes, + history: createMemoryHistory(), + hydrationData: { + loaderData: { + root: "ROOT INITIAL", + }, + }, + }); + currentRouter.initialize(); + + let key = "key"; + currentRouter.fetch(key, "root", "/foo"); + await fooDfd.resolve("FOO"); + expect(currentRouter.state.fetchers.get("key")?.data).toBe("FOO"); + + let rootDfd2 = createDeferred(); + let newRoutes: AgnosticDataRouteObject[] = [ + { + path: "/", + id: "root", + hasErrorBoundary: true, + loader: () => rootDfd2.promise, + children: [ + { + index: true, + id: "index", + }, + { + path: "foo", + id: "foo", + children: undefined, + }, + ], + }, + ]; + + currentRouter._internalSetRoutes(newRoutes); + + // Interrupt /foo navigation with a revalidation + currentRouter.revalidate(); + + expect(currentRouter.state.revalidation).toBe("loading"); + + // Should still expose be the og routes until navigation completes + expect(currentRouter.routes).toEqual(ogRoutes); + + // Resolve any loaders that should have ran (foo's loader has been removed) + await rootDfd2.resolve("ROOT*"); + expect(currentRouter.state.revalidation).toBe("idle"); + + // Routes should be updated + expect(currentRouter.routes).not.toEqual(ogRoutes); + expect(currentRouter.routes).toBe(newRoutes); + + // Loader data should be updated + expect(currentRouter.state.loaderData).toEqual({ + root: "ROOT*", + }); + // Fetcher should have been revalidated but thrown an errow since the + // loader was removed + expect(currentRouter.state.fetchers.get("key")?.data).toBe(undefined); + expect(currentRouter.state.errors).toEqual({ + root: new Error('Could not find the loader to run on the "foo" route'), + }); + }); + + it("should retain existing routes until revalidation completes on route removal (fetch)", async () => { + let rootDfd = createDeferred(); + let fooDfd = createDeferred(); + let ogRoutes: AgnosticDataRouteObject[] = [ + { + path: "/", + id: "root", + hasErrorBoundary: true, + loader: () => rootDfd.promise, + children: [ + { + index: true, + id: "index", + }, + { + path: "foo", + id: "foo", + loader: () => fooDfd.promise, + children: undefined, + }, + ], + }, + ]; + currentRouter = createRouter({ + routes: ogRoutes, + history: createMemoryHistory(), + hydrationData: { + loaderData: { + root: "ROOT INITIAL", + }, + }, + }); + currentRouter.initialize(); + + let key = "key"; + currentRouter.fetch(key, "root", "/foo"); + await fooDfd.resolve("FOO"); + expect(currentRouter.state.fetchers.get("key")?.data).toBe("FOO"); + + let rootDfd2 = createDeferred(); + let newRoutes: AgnosticDataRouteObject[] = [ + { + path: "/", + id: "root", + hasErrorBoundary: true, + loader: () => rootDfd2.promise, + children: [ + { + index: true, + id: "index", + }, + ], + }, + ]; + + currentRouter._internalSetRoutes(newRoutes); + + // Interrupt /foo navigation with a revalidation + currentRouter.revalidate(); + + expect(currentRouter.state.revalidation).toBe("loading"); + + // Should still expose be the og routes until navigation completes + expect(currentRouter.routes).toEqual(ogRoutes); + + // Resolve any loaders that should have ran (foo's loader has been removed) + await rootDfd2.resolve("ROOT*"); + expect(currentRouter.state.revalidation).toBe("idle"); + + // Routes should be updated + expect(currentRouter.routes).not.toEqual(ogRoutes); + expect(currentRouter.routes).toBe(newRoutes); + + // Loader data should be updated + expect(currentRouter.state.loaderData).toEqual({ + root: "ROOT*", + }); + // Fetcher should have been revalidated but theown a 404 wince the route was removed + expect(currentRouter.state.fetchers.get("key")?.data).toBe(undefined); + expect(currentRouter.state.errors).toEqual({ + root: new ErrorResponse( + 404, + "Not Found", + new Error('No route matches URL "/foo"'), + true + ), + }); + }); + }); }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 17fc05f191..34d10d0c5c 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -211,6 +211,15 @@ export interface Router { */ deleteBlocker(key: string): 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 @@ -556,8 +565,6 @@ interface HandleLoadersResult extends ShortCircuitable { interface FetchLoadMatch { routeId: string; path: string; - match: AgnosticDataRouteMatch; - matches: AgnosticDataRouteMatch[]; } /** @@ -565,6 +572,8 @@ interface FetchLoadMatch { */ interface RevalidatingFetcher extends FetchLoadMatch { key: string; + match: AgnosticDataRouteMatch | null; + matches: AgnosticDataRouteMatch[] | null; } /** @@ -644,6 +653,7 @@ export function createRouter(init: RouterInit): Router { ); let dataRoutes = convertRoutesToDataRoutes(init.routes); + let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined; // Cleanup function for history let unlistenHistory: (() => void) | null = null; // Externally-provided functions to call on all state changes @@ -921,6 +931,11 @@ export function createRouter(init: RouterInit): Router { isMutationMethod(state.navigation.formMethod) && location.state?._isRedirect !== true); + if (inFlightDataRoutes) { + dataRoutes = inFlightDataRoutes; + inFlightDataRoutes = undefined; + } + updateState({ ...newState, // matches, errors, fetchers go through as-is actionData, @@ -1108,14 +1123,15 @@ export function createRouter(init: RouterInit): Router { saveScrollPosition(state.location, state.matches); pendingPreventScrollReset = (opts && opts.preventScrollReset) === true; + let routesToUse = inFlightDataRoutes || dataRoutes; let loadingNavigation = opts && opts.overrideNavigation; - let matches = matchRoutes(dataRoutes, location, init.basename); + let matches = matchRoutes(routesToUse, 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); + getShortCircuitMatches(routesToUse); // Cancel all pending deferred on 404s since we don't keep any routes cancelActiveDeferreds(); completeNavigation(location, { @@ -1352,6 +1368,7 @@ export function createRouter(init: RouterInit): Router { } : undefined; + let routesToUse = inFlightDataRoutes || dataRoutes; let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( init.history, state, @@ -1361,9 +1378,11 @@ export function createRouter(init: RouterInit): Router { isRevalidationRequired, cancelledDeferredRoutes, cancelledFetcherLoads, + fetchLoadMatches, + routesToUse, + init.basename, pendingActionData, - pendingError, - fetchLoadMatches + pendingError ); // Cancel pending deferreds for no-longer-matched routes or routes we're @@ -1506,7 +1525,8 @@ export function createRouter(init: RouterInit): Router { if (fetchControllers.has(key)) abortFetcher(key); - let matches = matchRoutes(dataRoutes, href, init.basename); + let routesToUse = inFlightDataRoutes || dataRoutes; + let matches = matchRoutes(routesToUse, href, init.basename); if (!matches) { setFetcherError( key, @@ -1528,7 +1548,7 @@ export function createRouter(init: RouterInit): Router { // Store off the match so we can call it's shouldRevalidate on subsequent // revalidations - fetchLoadMatches.set(key, { routeId, path, match, matches }); + fetchLoadMatches.set(key, { routeId, path }); handleFetcherLoader(key, routeId, path, match, matches, submission); } @@ -1629,9 +1649,10 @@ export function createRouter(init: RouterInit): Router { nextLocation, abortController.signal ); + let routesToUse = inFlightDataRoutes || dataRoutes; let matches = state.navigation.state !== "idle" - ? matchRoutes(dataRoutes, state.navigation.location, init.basename) + ? matchRoutes(routesToUse, state.navigation.location, init.basename) : state.matches; invariant(matches, "Didn't find any matches after fetcher action"); @@ -1656,9 +1677,11 @@ export function createRouter(init: RouterInit): Router { isRevalidationRequired, cancelledDeferredRoutes, cancelledFetcherLoads, + fetchLoadMatches, + routesToUse, + init.basename, { [match.route.id]: actionResult.data }, - undefined, // No need to send through errors since we short circuit above - fetchLoadMatches + undefined // No need to send through errors since we short circuit above ); // Put all revalidating fetchers into the loading state, except for the @@ -1997,15 +2020,23 @@ export function createRouter(init: RouterInit): Router { ...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 - ) - ), + ...fetchersToLoad.map((f) => { + if (f.matches && f.match) { + return callLoaderOrAction( + "loader", + createClientSideRequest(init.history, f.path, request.signal), + f.match, + f.matches, + router.basename + ); + } else { + let error: ErrorResult = { + type: ResultType.error, + error: getInternalRouterError(404, { pathname: f.path }), + }; + return error; + } + }), ]); let loaderResults = results.slice(0, matchesToLoad.length); let fetcherResults = results.slice(matchesToLoad.length); @@ -2266,6 +2297,10 @@ export function createRouter(init: RouterInit): Router { return null; } + function _internalSetRoutes(newRoutes: AgnosticDataRouteObject[]) { + inFlightDataRoutes = newRoutes; + } + router = { get basename() { return init.basename; @@ -2293,6 +2328,9 @@ export function createRouter(init: RouterInit): Router { deleteBlocker, _internalFetchControllers: fetchControllers, _internalActiveDeferreds: activeDeferreds, + // TODO: Remove setRoutes, it's temporary to avoid dealing with + // updating the tree while validating the update algorithm. + _internalSetRoutes, }; return router; @@ -2891,9 +2929,11 @@ function getMatchesToLoad( isRevalidationRequired: boolean, cancelledDeferredRoutes: string[], cancelledFetcherLoads: string[], + fetchLoadMatches: Map, + routesToUse: AgnosticDataRouteObject[], + basename: string | undefined, pendingActionData?: RouteData, - pendingError?: RouteData, - fetchLoadMatches?: Map + pendingError?: RouteData ): [AgnosticDataRouteMatch[], RevalidatingFetcher[]] { let actionResult = pendingError ? Object.values(pendingError)[0] @@ -2951,34 +2991,55 @@ function getMatchesToLoad( // 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 }); - } - } + fetchLoadMatches.forEach((f, key) => { + // Don't revalidate if fetcher won't be present in the subsequent render + if (!matches.some((m) => m.route.id === f.routeId)) { + return; + } + + let fetcherMatches = matchRoutes(routesToUse, f.path, basename); + + // If the fetcher path no longer matches, push it in with null matches so + // we can trigger a 404 in callLoadersAndMaybeResolveData + if (!fetcherMatches) { + revalidatingFetchers.push({ key, ...f, matches: null, match: null }); + return; + } + + let fetcherMatch = getTargetMatch(fetcherMatches, f.path); + + if (cancelledFetcherLoads.includes(key)) { + revalidatingFetchers.push({ + key, + matches: fetcherMatches, + match: fetcherMatch, + ...f, + }); + return; + } + + // 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(fetcherMatch, { + currentUrl, + currentParams: state.matches[state.matches.length - 1].params, + nextUrl, + nextParams: matches[matches.length - 1].params, + ...submission, + actionResult, + defaultShouldRevalidate, }); + if (shouldRevalidate) { + revalidatingFetchers.push({ + key, + matches: fetcherMatches, + match: fetcherMatch, + ...f, + }); + } + }); return [navigationMatches, revalidatingFetchers]; } @@ -3361,7 +3422,7 @@ function processLoaderData( // Process fetcher non-redirect errors if (isErrorResult(result)) { - let boundaryMatch = findNearestBoundary(state.matches, match.route.id); + let boundaryMatch = findNearestBoundary(state.matches, match?.route.id); if (!(errors && errors[boundaryMatch.route.id])) { errors = { ...errors, @@ -3411,7 +3472,9 @@ function mergeLoaderData( // 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) { + } else if (loaderData[id] !== undefined && match.route.loader) { + // Preserve existing keys not included in newLoaderData and where a loader + // wasn't removed by HMR mergedLoaderData[id] = loaderData[id]; } @@ -3585,7 +3648,7 @@ function isMutationMethod(method?: string): method is MutationFormMethod { async function resolveDeferredResults( currentMatches: AgnosticDataRouteMatch[], - matchesToLoad: AgnosticDataRouteMatch[], + matchesToLoad: (AgnosticDataRouteMatch | null)[], results: DataResult[], signal: AbortSignal, isFetcher: boolean, @@ -3594,8 +3657,15 @@ async function resolveDeferredResults( for (let index = 0; index < results.length; index++) { let result = results[index]; let match = matchesToLoad[index]; + // If we don't have a match, then we can have a deferred result to do + // anything with. This is for revalidating fetchers where the route was + // removed during HMR + if (!match) { + continue; + } + let currentMatch = currentMatches.find( - (m) => m.route.id === match.route.id + (m) => m.route.id === match!.route.id ); let isRevalidatingLoader = currentMatch != null &&