fix(plex): a reload during a binge reopens the episode playing now
The player stepped to the next item in its own state while the history entry still held the item it opened with, so F5 restarted the first episode. The player now says every item it moves on to, and the page rewrites its entry with it (useHistorySubview's `follow`: no navigation, the overlays over the player stay open, and the entry under an overlay is rewritten when a step lands back on it). The player's URL is the item playing (`?v=player:<id>`), so a bookmark opens it; the list it plays from stays in the history entry.
This commit is contained in:
@@ -185,6 +185,84 @@ describe("useHistorySubview entry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A view that moves on by itself — the Plex player stepping to the next episode — is still the same
|
||||
// entry: no push, nothing for Back to step through. But the entry must say where it moved, or a reload
|
||||
// reopens the item the viewer started with (the owner, 2026-10-25: F5 during a binge restarted the
|
||||
// first episode).
|
||||
describe("useHistorySubview follow", () => {
|
||||
async function overlayOver() {
|
||||
const closed = vi.fn();
|
||||
const layer = renderHook(() => useBackToClose(closed));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
return { closed, layer };
|
||||
}
|
||||
|
||||
it("rewrites the entry it stands on, so a reload reopens the view it moved on to", () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
const before = window.history.length;
|
||||
act(() => result.current.follow("b"));
|
||||
expect(result.current.view).toBe("b");
|
||||
expect(window.history.state?._sub).toBe("b");
|
||||
expect(window.location.search).toBe("?v=b");
|
||||
expect(window.history.length).toBe(before);
|
||||
const { result: reloaded } = renderHook(() => useHistorySubview("root", url));
|
||||
expect(reloaded.current.view).toBe("b");
|
||||
});
|
||||
|
||||
it("is not a navigation: an overlay over the view stays open", async () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
const { closed, layer } = await overlayOver();
|
||||
act(() => result.current.follow("b"));
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
expect(window.history.state?._ov).toBeDefined();
|
||||
// A reload with the overlay up reopens the view it moved on to as well.
|
||||
expect(window.history.state?._sub).toBe("b");
|
||||
layer.unmount(); // the history goes back to the view's own entry before the next test
|
||||
await waitFor(() => expect(window.history.state?._ov).toBeUndefined());
|
||||
});
|
||||
|
||||
it("the entry under an overlay follows too: closing the overlay lands on the moved-on view", async () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
const { layer } = await overlayOver();
|
||||
act(() => result.current.follow("b"));
|
||||
layer.unmount(); // the overlay closes: its entry goes by a back() onto the view's own entry
|
||||
await waitFor(() => expect(window.history.state?._ov).toBeUndefined());
|
||||
expect(result.current.view).toBe("b");
|
||||
expect(window.history.state?._sub).toBe("b");
|
||||
expect(window.location.search).toBe("?v=b");
|
||||
});
|
||||
|
||||
it("a navigation on the same entry ends it: an overlay closing over it lands on the new view", async () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
act(() => result.current.follow("b"));
|
||||
act(() => result.current.open("c", { replace: true }));
|
||||
const { layer } = await overlayOver();
|
||||
layer.unmount();
|
||||
await waitFor(() => expect(window.history.state?._ov).toBeUndefined());
|
||||
expect(result.current.view).toBe("c");
|
||||
expect(window.history.state?._sub).toBe("c");
|
||||
});
|
||||
|
||||
it("does not follow onto another entry: Back to the view below restores that one", async () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
act(() => result.current.open("p"));
|
||||
act(() => result.current.follow("q"));
|
||||
act(() => window.history.back());
|
||||
await waitFor(() => expect(result.current.view).toBe("a"));
|
||||
expect(window.history.state?._sub).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
// Chrome keeps 50 entries a tab; when full, a new entry drops the oldest "skippable" one — one the page
|
||||
// left by a pushState with no user gesture — and that can be this page's own entry under the
|
||||
// sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// its own button pops exactly its entry without tripping the modals underneath.
|
||||
//
|
||||
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
||||
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
||||
import { urlWithParams } from "./urlState";
|
||||
import { useLatest } from "./useLatest";
|
||||
@@ -143,6 +143,9 @@ export function useHistorySubview<T>(
|
||||
* continuation of the navigation that just arrived here, not a step the user took inside the
|
||||
* module. See the `jumpTo` hand-off in PlexBrowse. */
|
||||
open: (next: T, opts?: { replace?: boolean }) => void;
|
||||
/** The view moved on by itself (the Plex player stepped to the next episode): the entry it stands
|
||||
* on says so from now on, without a navigation — the overlays over it stay open. */
|
||||
follow: (next: T) => void;
|
||||
back: () => void;
|
||||
} {
|
||||
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
|
||||
@@ -161,7 +164,16 @@ export function useHistorySubview<T>(
|
||||
return root;
|
||||
};
|
||||
const [view, setView] = useState<T>(readView);
|
||||
const readLandedView = useEffectEvent(readView);
|
||||
// Where `follow` moved the view, and on which entry. An overlay over the view holds an entry of its
|
||||
// own (a copy of the view's state, same sfScroll id), and only the current entry can be rewritten:
|
||||
// the view's own entry under the overlay still says where it was until a step lands back on it.
|
||||
const followedRef = useRef<{ entry: number; view: T } | null>(null);
|
||||
const readLandedView = useEffectEvent((): T => {
|
||||
const followed = followedRef.current;
|
||||
if (followed == null || peekNavId() !== followed.entry) return readView();
|
||||
writeSubEntry(followed.view, url);
|
||||
return followed.view;
|
||||
});
|
||||
useEffect(() => {
|
||||
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
|
||||
const onPop = () => setView(readLandedView());
|
||||
@@ -182,16 +194,15 @@ export function useHistorySubview<T>(
|
||||
const go = useCallback(
|
||||
(next: T, opts?: { replace?: boolean }) => {
|
||||
setView(next);
|
||||
const kept = url?.entry ? url.entry(next) : next;
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
// A navigation says where the view is now, on the entry it lands on or rewrites.
|
||||
followedRef.current = null;
|
||||
if (opts?.replace) {
|
||||
// Same ENTRY, so it keeps its sfScroll id: PageScroller keys offsets per (entry, view), and
|
||||
// the view key has changed, so the sub-view still opens at the top without minting an id
|
||||
// that would orphan the one this entry already carries.
|
||||
window.history.replaceState({ ...window.history.state, _sub: kept }, "", href);
|
||||
writeSubEntry(next, url);
|
||||
notifyNav();
|
||||
return;
|
||||
}
|
||||
const kept = url?.entry ? url.entry(next) : next;
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
// 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.
|
||||
window.history.pushState(
|
||||
@@ -218,9 +229,26 @@ export function useHistorySubview<T>(
|
||||
}),
|
||||
[go, rootRef],
|
||||
);
|
||||
const follow = useCallback(
|
||||
(next: T) => {
|
||||
setView(next);
|
||||
followedRef.current = { entry: ensureNavId(), view: next };
|
||||
writeSubEntry(next, url);
|
||||
},
|
||||
[url],
|
||||
);
|
||||
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
|
||||
// can now write `[sub]` and mean it, instead of re-running on every parent render.
|
||||
return useMemo(() => ({ view, open, back }), [view, open, back]);
|
||||
return useMemo(() => ({ view, open, follow, back }), [view, open, follow, back]);
|
||||
}
|
||||
|
||||
// Rewrite the CURRENT entry to hold `next`. Same entry, so it keeps its sfScroll id: PageScroller keys
|
||||
// offsets per (entry, view), and the view key has changed, so the sub-view still opens at the top
|
||||
// without minting an id that would orphan the one this entry already carries.
|
||||
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
|
||||
const kept = url?.entry ? url.entry(next) : next;
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
window.history.replaceState({ ...window.history.state, _sub: kept }, "", href);
|
||||
}
|
||||
|
||||
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
|
||||
|
||||
Reference in New Issue
Block a user