Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/history-depth-capped-length.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/router": patch
---

Stamp history depth from the current entry instead of `history.length`, so `useBeforeLeave` and scroll restoration keep working on Back once the browser's session history is full (#694)
25 changes: 22 additions & 3 deletions src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,16 @@ export function createBeforeLeave(): BeforeLeaveLifecycle {

let depth: number;
export function saveCurrentDepth() {
if (!window.history.state || window.history.state._depth == null) {
window.history.replaceState({ ...window.history.state, _depth: window.history.length - 1 }, "");
const h = window.history;
let d = h.state && h.state._depth;
if (d == null) {
// An entry the router didn't push (e.g. a `#anchor` jump) sits one above
// the last known one. `history.length` stops growing once the browser's
// session history is full, so only fall back to it when nothing is known.
d = depth == null ? h.length - 1 : depth + 1;
h.replaceState({ ...h.state, _depth: d }, "");
}
depth = window.history.state._depth;
depth = (h.state && h.state._depth) ?? d;
}
if (!isServer) {
saveCurrentDepth();
Expand All @@ -63,6 +69,19 @@ export function keepDepth(state: any) {
};
}

// A pushed entry sits one above the current one. Stamp it from the current
// depth, not `history.length`: once the browser's session history is full (50
// entries in Chromium and Firefox) a push drops the oldest entry and `length`
// stays the same, so `length - 1` would give every new entry the depth of the
// one below it.
export function nextDepth(state: any) {
const current = (window.history.state && window.history.state._depth) ?? depth;
return {
...state,
_depth: (current ?? window.history.length - 1) + 1
};
}

export function notifyIfNotBlocked(
notify: (value?: string | LocationChange) => void,
block: (delta: number | null) => boolean
Expand Down
4 changes: 2 additions & 2 deletions src/routers/HashRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { JSX } from "solid-js";
import { setupNativeEvents } from "../data/events.js";
import type { BaseRouterProps } from "./components.jsx";
import { createRouter, scrollToHash, bindEvent } from "./createRouter.js";
import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
import { createBeforeLeave, keepDepth, nextDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";

export function hashParser(str: string) {
const to = str.replace(/^.*?#/, "");
Expand All @@ -27,7 +27,7 @@ export function HashRouter(props: HashRouterProps): JSX.Element {
if (replace) {
window.history.replaceState(keepDepth(state), "", "#" + value);
} else {
window.history.pushState(state, "", "#" + value);
window.history.pushState(nextDepth(state), "", "#" + value);
}
const hashIndex = value.indexOf("#");
const hash = hashIndex >= 0 ? value.slice(hashIndex + 1) : "";
Expand Down
4 changes: 2 additions & 2 deletions src/routers/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { StaticRouter } from "./StaticRouter.js";
import { setupNativeEvents } from "../data/events.js";
import type { BaseRouterProps } from "./components.jsx";
import type { JSX } from "solid-js";
import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
import { createBeforeLeave, keepDepth, nextDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
import { createScrollRestoration } from "./scrollRestoration.js";

export type RouterProps = BaseRouterProps & {
Expand Down Expand Up @@ -40,7 +40,7 @@ export function Router(props: RouterProps): JSX.Element {
if (replace) {
window.history.replaceState(keepDepth(state), "", value);
} else {
window.history.pushState(state, "", value);
window.history.pushState(nextDepth(state), "", value);
}
scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
saveCurrentDepth();
Expand Down
98 changes: 98 additions & 0 deletions test/history-depth.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { render } from "solid-js/web";
import { vi } from "vitest";
import { HashRouter, Router, Route, useNavigate } from "../src/index.js";
import type { Navigator } from "../src/index.js";
import { notifyIfNotBlocked } from "../src/lifecycle.js";

// #694: browsers cap session history (50 entries in Chromium and Firefox).
// Once full, a push drops the oldest entry and history.length stops growing,
// so depths taken from history.length repeat.
describe("history depth when history.length is capped (#694)", () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});

const url = () => window.location.pathname + window.location.search + window.location.hash;

async function pushThree(RouterComponent: typeof Router | typeof HashRouter, hash = false) {
let navigate!: Navigator;
const Page = () => {
navigate = useNavigate();
return null;
};
const dispose = render(
() => (
<RouterComponent>
<Route path="/:n?" component={Page} />
</RouterComponent>
),
document.body
);
try {
const depths: number[] = [];
for (let i = 1; i <= 3; i++) {
navigate(`/${i}`);
await vi.waitFor(() => expect(url()).toBe(hash ? `/#/${i}` : `/${i}`));
depths.push(window.history.state._depth);
}
navigate("/3?replaced", { replace: true });
await vi.waitFor(() => expect(url()).toBe(hash ? "/#/3?replaced" : "/3?replaced"));
depths.push(window.history.state._depth);
return depths;
} finally {
dispose();
}
}

test("Router stamps increasing depths on push", async () => {
window.history.replaceState({ _depth: 48 }, "", "/");
vi.spyOn(window.history, "length", "get").mockReturnValue(50);
expect(await pushThree(Router)).toEqual([49, 50, 51, 51]);
});

test("HashRouter stamps increasing depths on push", async () => {
window.history.replaceState({ _depth: 48 }, "", "/");
vi.spyOn(window.history, "length", "get").mockReturnValue(50);
expect(await pushThree(HashRouter, true)).toEqual([49, 50, 51, 51]);
});

test("Router keeps user state when stamping the depth on push", async () => {
window.history.replaceState({ _depth: 3 }, "", "/");
let navigate!: Navigator;
const dispose = render(
() => (
<Router>
<Route
path="/*"
component={() => {
navigate = useNavigate();
return null;
}}
/>
</Router>
),
document.body
);
try {
navigate("/next", { state: { from: "test" } });
await vi.waitFor(() => expect(url()).toBe("/next"));
expect(window.history.state).toEqual({ from: "test", _depth: 4 });
} finally {
dispose();
}
});

test("an entry pushed outside the router counts up from the last depth", () => {
window.history.replaceState({ _depth: 49 }, "", "/");
vi.spyOn(window.history, "length", "get").mockReturnValue(50);
const notify = vi.fn();
const handler = notifyIfNotBlocked(notify, () => false);
handler(); // record the current depth
notify.mockClear();
window.history.pushState(null, "", "/#anchor");
handler();
expect(window.history.state._depth).toBe(50);
expect(notify).toHaveBeenCalledTimes(1);
});
});
Loading