Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ function cancelRun(run: NavigationRun | null): void {
run?.controller.abort();
}

function inFlightAbortController<TRouteId extends string, TModule, TData>(
match: RouteMatch<TRouteId, TModule, TData> | undefined,
): AbortController | undefined {
if (!match?.isFetching || match.abortController.signal.aborted) {
return undefined;
}
return match.abortController;
}

function canCacheMatch<TRouteId extends string, TModule, TData>(
match: RouteMatch<TRouteId, TModule, TData>,
): boolean {
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions test/router-joined-preload-stop.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>((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();
});
});
Loading