diff --git a/CHANGELOG.md b/CHANGELOG.md index 6420d8c..9cb8da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Abort a preload joined by navigation when the router stops so a late loader result cannot replace the next active match. - Reject loader redirect cycles and chains longer than 10 redirects during navigation and preload; even terminating chains above the limit now fail. Thanks @SebTardif. - Restore strict dependency engine checks with pnpm 11 so unsupported development installs fail early. Thanks @dependabot. - Ignore cancelled preload failures and redirects after stopping so late results cannot start new loads or remove replacement cached matches. diff --git a/src/router.ts b/src/router.ts index 1906f9e..353d92a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -82,6 +82,15 @@ function cancelRun(run: NavigationRun | null): void { run?.controller.abort(); } +function inFlightAbortController( + match: RouteMatch | undefined, +): AbortController | undefined { + if (!match?.isFetching || match.abortController.signal.aborted) { + return undefined; + } + return match.abortController; +} + function canCacheMatch( match: RouteMatch, ): boolean { @@ -215,7 +224,8 @@ export function createRouter< } cancelRun(currentRun); - const controller = new AbortController(); + const controller = + inFlightAbortController(sameMatch ? previous : cached) ?? new AbortController(); const cause: RouteLoadCause = revalidating ? "revalidate" : "navigation"; const match = sameMatch && previous diff --git a/test/router-joined-preload-stop.test.ts b/test/router-joined-preload-stop.test.ts new file mode 100644 index 0000000..02dd6e0 --- /dev/null +++ b/test/router-joined-preload-stop.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { createRouter, type RouteLocation, type RouterHistory } from "../src/index"; + +function createMemoryHistory(initial: RouteLocation): RouterHistory { + let current = initial; + const listeners = new Set<(location: RouteLocation) => void>(); + return { + location: () => current, + push(nextLocation) { + current = nextLocation; + for (const listener of listeners) { + listener(nextLocation); + } + }, + replace(nextLocation) { + current = nextLocation; + for (const listener of listeners) { + listener(nextLocation); + } + }, + listen(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +describe("joined preload stop", () => { + it("aborts a preload adopted by navigate when the router stops", async () => { + let finishAlice!: (value: string) => void; + const alicePending = new Promise((resolve) => { + finishAlice = resolve; + }); + let signal: AbortSignal | undefined; + const router = createRouter<"a", string, string, string>({ + routes: [ + { + id: "a", + path: "/a", + component: () => "view-a", + loader: (context, options) => { + if (context !== "alice") { + return context; + } + signal = options.signal; + return alicePending; + }, + }, + ], + }); + + const preload = router.preloadRoute("a", "alice"); + const navigation = router.navigate("a", "alice"); + expect(signal).toBeDefined(); + router.stop(); + expect(signal?.aborted).toBe(true); + + const history = createMemoryHistory({ pathname: "/", search: "", hash: "" }); + await router.start(history, "", "bob"); + await router.navigate("a", "bob"); + expect(router.getState().matches).toMatchObject([ + { routeId: "a", status: "success", data: "bob", preload: false }, + ]); + + finishAlice("alice"); + await preload; + await navigation; + expect(router.getState().matches).toMatchObject([ + { routeId: "a", status: "success", data: "bob", preload: false }, + ]); + router.stop(); + }); +});