fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made under a dialog, a drawer or a sub-view is not undone by the next reload. - A view pushed from the entry a reload under a dialog started from no longer inherits its overlay mark; a reload under two overlays closes onto the page in one step. - The linked player reopens over a live search, gives itself up when its list moves first, and plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve. - The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and Playlists searches end with their page; the feed always names its view and debounces its search. - Search-box keys are declared per module and kept out of the client log below TRACE. - Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the Files sort and viewer added).
This commit is contained in:
@@ -86,8 +86,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
|
||||
// so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
|
||||
// Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
|
||||
// asked to search for (sent from the search box, or "Search YouTube again" pressed). Anything else
|
||||
// — a reload, a link, a Back onto an older search — runs only when it costs no quota (Feed decides).
|
||||
// asked to search for (sent from the search box, or "Search YouTube again" pressed), on the entries
|
||||
// it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or
|
||||
// a Forward onto a search — runs only when it costs no quota (Feed decides).
|
||||
const [ytSearch, setYtSearch] = useState<string | null>(() =>
|
||||
page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null,
|
||||
);
|
||||
@@ -96,10 +97,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
const [ytAsked, setYtAsked] = useState<string | null>(null);
|
||||
const ytSearchRef = useLatest(ytSearch);
|
||||
const ytCountRef = useLatest(ytCount);
|
||||
// The search ends: off the screen, and no longer asked for.
|
||||
const endYtSearch = useCallback(() => {
|
||||
setYtSearch(null);
|
||||
setYtAsked(null);
|
||||
}, []);
|
||||
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
|
||||
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
|
||||
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
|
||||
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
|
||||
// it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a
|
||||
// fresh bookmark has only the ?chan param.
|
||||
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
|
||||
channelViewFrom(window.history.state, window.location.search),
|
||||
);
|
||||
@@ -134,6 +141,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
// A live search under that channel ends too: the page opens at its root, which the address
|
||||
// below names — the Feed press over a channel opened from a search included.
|
||||
endYtSearch();
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
|
||||
@@ -151,7 +161,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
notifyNav();
|
||||
then?.();
|
||||
}),
|
||||
[pageRef, channelViewRef],
|
||||
[pageRef, channelViewRef, endYtSearch],
|
||||
);
|
||||
|
||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
|
||||
@@ -179,11 +189,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
|
||||
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
|
||||
// entry's channel marks and the ?chan param.
|
||||
// A live search under it (the channel opened from its results) is on screen again, and named
|
||||
// again: the channel's address carried none of it.
|
||||
setChannelView(null);
|
||||
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
|
||||
window.history.replaceState(st, "", urlWithParams(closedChannelParams()));
|
||||
window.history.replaceState(
|
||||
st,
|
||||
"",
|
||||
urlWithParams({
|
||||
...closedChannelParams(),
|
||||
...ytSearchParams(ytSearchRef.current, ytCountRef.current),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [ytSearchRef, ytCountRef]);
|
||||
|
||||
const enterYtSearch = useCallback((q: string) => {
|
||||
setYtSearch(q);
|
||||
@@ -230,11 +249,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
|
||||
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
|
||||
else {
|
||||
setYtSearch(null);
|
||||
endYtSearch();
|
||||
const { _yt: _q, ...st } = window.history.state ?? {};
|
||||
window.history.replaceState(st, "", urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT)));
|
||||
}
|
||||
}, []);
|
||||
}, [endYtSearch]);
|
||||
|
||||
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
|
||||
// DURING the render that changes the page, against the page we were last on: as an effect it ran
|
||||
@@ -244,7 +263,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
|
||||
if (pageSearchWasOn !== page) {
|
||||
setPageSearchWasOn(page);
|
||||
if (page !== "feed") setYtSearch(null);
|
||||
if (page !== "feed") {
|
||||
setYtSearch(null);
|
||||
setYtAsked(null);
|
||||
}
|
||||
}
|
||||
|
||||
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
|
||||
@@ -286,8 +308,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// page survives F5.
|
||||
const st = window.history.state || {};
|
||||
// Mirror the resolved initial page to the address bar (the feed too) so a page reached from the
|
||||
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
|
||||
// ?chan / ?v / ?pl / filter params the loaders already read.
|
||||
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams keeps the
|
||||
// shell's keys (?chan, a player's) and the page's own; other pages' go (below).
|
||||
// A retired page's link — or the remembered last page, when that is what opened — also opens its
|
||||
// part of the page that replaced it (an `at` tab).
|
||||
const linked = new URLSearchParams(window.location.search).get("page");
|
||||
@@ -325,9 +347,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
setPageState(p);
|
||||
setAccountRaw(PAGE_KEY, p);
|
||||
// The YouTube search is read from the address the browser restored, so Back/Forward opens or
|
||||
// clears it to match the entry we landed on — not asked for: an API search waits for a press.
|
||||
// clears it to match the entry we landed on. One landed on without it has ended: a Forward back
|
||||
// onto it is not asked for, and an API search waits for a press.
|
||||
const here = new URLSearchParams(window.location.search);
|
||||
setYtSearch(p === "feed" ? ytSearchFrom(here) : null);
|
||||
const landed = p === "feed" ? ytSearchFrom(here) : null;
|
||||
setYtSearch(landed);
|
||||
if (landed == null) setYtAsked(null);
|
||||
setYtCountState(ytCountFrom(here));
|
||||
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
|
||||
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
|
||||
|
||||
@@ -188,6 +188,57 @@ describe("NavigationProvider · the YouTube search in the address", () => {
|
||||
expect(search().get("ytn")).toBeNull();
|
||||
});
|
||||
|
||||
it("a search left by Back and reached again by Forward is not asked for", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
expect(result.current.ytSearchAsked).toBe(true);
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
expect(result.current.ytSearch).toBe("lofi");
|
||||
expect(result.current.ytSearchAsked).toBe(false);
|
||||
});
|
||||
|
||||
it("a search left in place is not asked for when it is opened again by a link", () => {
|
||||
window.history.replaceState(null, "", "/?page=feed&yt=lofi");
|
||||
const { result } = render();
|
||||
act(() => result.current.askYtSearch());
|
||||
stubNoEntryBelow();
|
||||
act(() => result.current.exitYtSearch());
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
expect(result.current.ytSearchAsked).toBe(false);
|
||||
});
|
||||
|
||||
it("the Feed press over a channel opened from the search opens the plain feed", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
act(() => result.current.openChannel("UC1"));
|
||||
act(() => result.current.setPage("feed"));
|
||||
expect(result.current.channelView).toBeNull();
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
expect(search().get("yt")).toBeNull();
|
||||
});
|
||||
|
||||
it("a channel closed in place over the search names the search again", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
act(() => result.current.setYtCount(40));
|
||||
act(() => result.current.openChannel("UC1"));
|
||||
expect(search().get("yt")).toBeNull();
|
||||
stubNoEntryBelow();
|
||||
act(() => result.current.closeChannel());
|
||||
expect(result.current.ytSearch).toBe("lofi");
|
||||
expect([search().get("yt"), search().get("ytn")]).toEqual(["lofi", "40"]);
|
||||
});
|
||||
|
||||
it("a page switch takes the search off the address", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
|
||||
@@ -55,6 +55,10 @@ const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys);
|
||||
export function moduleUrlKeys(): readonly string[] {
|
||||
return ALL_URL_KEYS;
|
||||
}
|
||||
/** Every module's address-bar keys that carry typed text — what the client log keeps out (main.tsx). */
|
||||
export function typedUrlKeys(): readonly string[] {
|
||||
return ALL.flatMap((m) => m.typedUrlKeys ?? []);
|
||||
}
|
||||
export function foreignUrlKeys(page: Page): readonly string[] {
|
||||
return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys);
|
||||
}
|
||||
|
||||
@@ -4,43 +4,58 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||
import { dropFromAddress } from "../../lib/urlState";
|
||||
import { clearParams } from "../../lib/urlState";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
|
||||
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
|
||||
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
|
||||
* item is in it (`find`), so the player plays on through the list; else alone, its row fetched from
|
||||
* the catalog. A row the catalog no longer has is said, not swallowed. */
|
||||
* the catalog. A row the catalog no longer has is said, not swallowed.
|
||||
*
|
||||
* `scope` names the list the address put it over (a playlist, a live search): when the host moves to
|
||||
* another before it opened, the address under it named something else, and the player is given up —
|
||||
* opened later, it would start over a list nobody linked, unasked. */
|
||||
export function useOpenLinkedPlayer(
|
||||
ready: boolean,
|
||||
find: (id: string) => Video | undefined,
|
||||
open: (video: Video, inList: boolean) => void,
|
||||
scope: string | number | null = null,
|
||||
): void {
|
||||
const { t } = useTranslation();
|
||||
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
|
||||
const [linkedScope] = useState(scope);
|
||||
// Opened (or said to be gone) once; a ref, as nothing on screen reads it.
|
||||
const done = useRef(linked == null);
|
||||
const listed = ready && linked != null ? find(linked) : undefined;
|
||||
const moved = scope !== linkedScope;
|
||||
const listed = ready && !moved && linked != null ? find(linked) : undefined;
|
||||
const row = useQuery({
|
||||
queryKey: qk.videoRow(linked ?? ""),
|
||||
queryFn: () => api.videoRow(linked!),
|
||||
enabled: ready && linked != null && !listed,
|
||||
enabled: ready && !moved && linked != null && !listed,
|
||||
retry: false,
|
||||
});
|
||||
const openIt = useEffectEvent(open);
|
||||
const failed = useEffectEvent((err: unknown) => {
|
||||
notifyActionError(err, t("player.linkedFailed"));
|
||||
dropFromAddress(PLAYER_KEYS);
|
||||
clearParams(...PLAYER_KEYS);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (done.current) return;
|
||||
if (moved) {
|
||||
done.current = true;
|
||||
// The address goes on naming it under the new list (a reload would open it there) — unless a
|
||||
// player opened since names its own.
|
||||
if (linkedPlayer()?.watch === linked) clearParams(...PLAYER_KEYS);
|
||||
return;
|
||||
}
|
||||
// Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills
|
||||
// it here too (a disabled query still reads the cache) — the shell's player over a page that
|
||||
// plays its own would open a second one.
|
||||
if (done.current || !ready) return;
|
||||
if (!ready) return;
|
||||
if (listed) openIt(listed, true);
|
||||
else if (row.data) openIt(row.data, false);
|
||||
else if (row.isError) failed(row.error);
|
||||
else return;
|
||||
done.current = true;
|
||||
}, [ready, listed, row.data, row.isError, row.error]);
|
||||
}, [linked, moved, ready, listed, row.data, row.isError, row.error]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user