fix(history): a sub-view reads only its own module's from the entry it mounts on
The entry's _sub now names its owner (_subOf, the module's address key). Signed out on a Plex season, the sign-in card took the season for one of its modes and rendered a card with an empty button; the other way round, Plex took the card's register view for one of its own. An entry an older build wrote is read as the module's only when its address names one of that module's views.
This commit is contained in:
@@ -63,7 +63,7 @@ describe("useHistorySubview seed", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignores the URL entirely when no SubviewUrl is supplied (state-only mirror)", () => {
|
it("ignores the URL entirely when no SubviewUrl is supplied (state-only mirror)", () => {
|
||||||
setUrl("?v=a", { _sub: "fromState" });
|
setUrl("?v=a", { _sub: "fromState", _subOf: "" });
|
||||||
const { result } = renderHook(() => useHistorySubview("root"));
|
const { result } = renderHook(() => useHistorySubview("root"));
|
||||||
expect(result.current.view).toBe("fromState");
|
expect(result.current.view).toBe("fromState");
|
||||||
// A URL param with no url config must NOT leak in.
|
// A URL param with no url config must NOT leak in.
|
||||||
@@ -128,6 +128,55 @@ describe("useHistorySubview open", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One entry, two modules: the sign-in card mounts on the entry a Plex season stood on (signed out
|
||||||
|
// there), and Plex on one the card's register view stood on (signed in from it). Each reads only its
|
||||||
|
// own sub-view: another's is not one of its views (the card rendered a mode it has no button for).
|
||||||
|
describe("useHistorySubview on an entry another module's sub-view stood on", () => {
|
||||||
|
const other: SubviewUrl<string> = {
|
||||||
|
key: "m",
|
||||||
|
encode: (v) => (v === "signin" ? null : v),
|
||||||
|
decode: (raw) => (raw === "register" ? raw : null),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("opens at its own root, not on the other's view", () => {
|
||||||
|
const plex = renderHook(() => useHistorySubview("root", url));
|
||||||
|
act(() => plex.result.current.open("season"));
|
||||||
|
plex.unmount();
|
||||||
|
const card = renderHook(() => useHistorySubview("signin", other));
|
||||||
|
expect(card.result.current.view).toBe("signin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the other way round too", () => {
|
||||||
|
const card = renderHook(() => useHistorySubview("signin", other));
|
||||||
|
act(() => card.result.current.open("register"));
|
||||||
|
card.unmount();
|
||||||
|
window.history.replaceState(window.history.state, "", "/");
|
||||||
|
const plex = renderHook(() => useHistorySubview("root", url));
|
||||||
|
expect(plex.result.current.view).toBe("root");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an older build's entry, which says nothing: its own only when its address names its view", () => {
|
||||||
|
setUrl("?v=season", { _sub: "season" });
|
||||||
|
expect(renderHook(() => useHistorySubview("signin", other)).result.current.view).toBe("signin");
|
||||||
|
expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads its own from an entry it rewrote in place, which no address names", () => {
|
||||||
|
const first = renderHook(() => useHistorySubview("root"));
|
||||||
|
act(() => first.result.current.open("x", { replace: true }));
|
||||||
|
first.unmount();
|
||||||
|
expect(renderHook(() => useHistorySubview("root")).result.current.view).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reads its own", () => {
|
||||||
|
const plex = renderHook(() => useHistorySubview("root", url));
|
||||||
|
act(() => plex.result.current.open("season"));
|
||||||
|
plex.unmount();
|
||||||
|
window.history.replaceState(window.history.state, "", "/");
|
||||||
|
expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("useHistorySubview popstate", () => {
|
describe("useHistorySubview popstate", () => {
|
||||||
it("restores the sub-view from state._sub on Back/Forward", () => {
|
it("restores the sub-view from state._sub on Back/Forward", () => {
|
||||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ export interface SubviewUrl<T> {
|
|||||||
entry?: (view: T) => T;
|
entry?: (view: T) => T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whose sub-view an entry's `_sub` is: the key its module mirrors to (a state-only one has none). */
|
||||||
|
const subOwner = (url: { key: string } | undefined): string => url?.key ?? "";
|
||||||
|
|
||||||
export function useHistorySubview<T>(
|
export function useHistorySubview<T>(
|
||||||
root: T,
|
root: T,
|
||||||
url?: SubviewUrl<T>,
|
url?: SubviewUrl<T>,
|
||||||
@@ -158,14 +161,18 @@ export function useHistorySubview<T>(
|
|||||||
// ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a
|
// ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a
|
||||||
// bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and
|
// bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and
|
||||||
// reading history.state alone would wrongly collapse the bookmarked sub-view to root.
|
// reading history.state alone would wrongly collapse the bookmarked sub-view to root.
|
||||||
|
// The entry's _sub is this module's only when it says so (`_subOf`): one entry can carry another
|
||||||
|
// module's — the sign-in card mounts on the entry a Plex season stood on when signed out there, and
|
||||||
|
// took that season for one of its modes (a card with no button for it). An entry an older build
|
||||||
|
// wrote says nothing: its _sub is this module's when its address names one of this module's views.
|
||||||
const readView = (): T => {
|
const readView = (): T => {
|
||||||
const fromState = window.history.state?._sub as T | undefined;
|
const st = window.history.state;
|
||||||
|
const raw = url ? new URLSearchParams(window.location.search).get(url.key) : null;
|
||||||
|
const decoded = url && raw != null ? url.decode(raw) : null;
|
||||||
|
const owned = st?._subOf === undefined ? decoded != null : st._subOf === subOwner(url);
|
||||||
|
const fromState = owned ? (st?._sub as T | undefined) : undefined;
|
||||||
if (fromState != null) return fromState;
|
if (fromState != null) return fromState;
|
||||||
if (url) {
|
if (decoded != null) return decoded;
|
||||||
const raw = new URLSearchParams(window.location.search).get(url.key);
|
|
||||||
const decoded = raw != null ? url.decode(raw) : null;
|
|
||||||
if (decoded != null) return decoded;
|
|
||||||
}
|
|
||||||
return root;
|
return root;
|
||||||
};
|
};
|
||||||
const [view, setView] = useState<T>(readView);
|
const [view, setView] = useState<T>(readView);
|
||||||
@@ -211,7 +218,7 @@ export function useHistorySubview<T>(
|
|||||||
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
|
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
|
||||||
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
|
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
|
||||||
window.history.pushState(
|
window.history.pushState(
|
||||||
{ ...rememberNavPosition(), _sub: kept, sfScroll: nextNavId() },
|
{ ...rememberNavPosition(), _sub: kept, _subOf: subOwner(url), sfScroll: nextNavId() },
|
||||||
"",
|
"",
|
||||||
href,
|
href,
|
||||||
);
|
);
|
||||||
@@ -253,7 +260,7 @@ export function useHistorySubview<T>(
|
|||||||
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
|
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
|
||||||
const kept = url?.entry ? url.entry(next) : next;
|
const kept = url?.entry ? url.entry(next) : next;
|
||||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||||
replaceView({ ...window.history.state, _sub: kept }, href);
|
replaceView({ ...window.history.state, _sub: kept, _subOf: subOwner(url) }, href);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
|
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ beforeAll(() => {
|
|||||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
});
|
});
|
||||||
beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" } }, ""));
|
beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" }, _subOf: "v" }, ""));
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|||||||
Reference in New Issue
Block a user