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:
2026-10-27 13:52:42 +01:00
parent 7b4e007680
commit 0d5c6273f1
50 changed files with 928 additions and 157 deletions
+12 -11
View File
@@ -503,6 +503,10 @@ export default function Feed({
const saved = savedOverrides[v.id];
return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v;
});
// The live search's cards, in the order picked for them, and the player's queue over them.
const ytShown = (rows: Video[]): Video[] =>
sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang);
const ytQueue = (rows: Video[]): Video[] => ytShown(rows).filter((v) => v.status !== "hidden");
const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
@@ -511,16 +515,18 @@ export default function Feed({
// goes hidden still drops out ("visible affects").
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
// A reload or a bookmark of the player reopens it once this list is in, the list as its queue again
// (the feed's filters are in the address under it). Not over a live search: that is not in the
// address, and a reload leaves it.
// (the feed's filters are in the address under it) — over a live search too, its results the
// queue, once they are in (or the search waits for its press, and it plays alone). Scoped to the
// list it was linked over: leaving the search first gives it up.
useOpenLinkedPlayer(
!ytActive && !query.isLoading,
(id) => playerQueue.find((v) => v.id === id),
ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading,
(id) => (ytActive ? ytQueue(ytLoaded) : playerQueue).find((v) => v.id === id),
(video, inList) => {
openVideo(video);
// Not in what this list has loaded (further down it, or no longer in it): it plays alone.
if (!inList) setActiveVideo((a) => a && { ...a, alone: true });
},
ytActive ? `yt:${ytSearch}` : "feed",
);
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale
@@ -534,12 +540,7 @@ export default function Feed({
// A separate data source rendered in the same cards/player. No watch-state view filter (the
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) {
const ytItems: Video[] = sortYtResults(
withOverrides(ytLoaded, true),
ytSort.key as SortKey,
ytSort.dir,
lang,
);
const ytItems: Video[] = ytShown(ytLoaded);
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
const ytError =
ytQuery.error instanceof HttpError
@@ -704,7 +705,7 @@ export default function Feed({
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
queue={ytPlayerQueue}
queue={activeVideo.alone ? undefined : ytPlayerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
@@ -24,6 +24,14 @@ vi.mock("./VirtualFeed", () => ({
),
}));
vi.mock("../../features/player/PlayerModal", () => ({
default: ({ video, queue }: { video: { id: string }; queue?: { id: string }[] }) => (
<p>
playing {video.id} in {queue ? queue.map((v) => v.id).join(",") : "no list"}
</p>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
@@ -48,6 +56,7 @@ beforeAll(() => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
});
const FILTERS: FeedFilters = {
@@ -106,14 +115,14 @@ function mount({
search_source: source,
search_enabled: enabled,
});
const tree = (count: number) => (
const tree = (count: number, ytSearch: string | null = "lofi") => (
<QueryClientProvider client={qc}>
<NavigationActionsContext.Provider value={nav}>
<NavigationStateContext.Provider
value={{
page: "feed",
channelView: null,
ytSearch: "lofi",
ytSearch,
ytCount: count,
ytSearchAsked: asked,
}}
@@ -124,7 +133,7 @@ function mount({
view="cards"
setView={() => {}}
canRead
ytSearch="lofi"
ytSearch={ytSearch}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
@@ -133,7 +142,10 @@ function mount({
</QueryClientProvider>
);
const view = render(tree(20));
return { showing: (count: number) => view.rerender(tree(count)) };
return {
showing: (count: number) => view.rerender(tree(count)),
leave: () => view.rerender(tree(20, null)),
};
}
const settle = () => new Promise((r) => setTimeout(r, 30));
@@ -216,3 +228,41 @@ describe("Feed — a live search the address reopened", () => {
expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull();
});
});
// A player opened over the search names itself in the address too: a reload or a bookmark opens it
// again over the search it was opened from — not later, over a list nobody linked.
describe("Feed — a player the address names over a live search", () => {
it("reopens over the results, them as its queue", async () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
vi.spyOn(api, "searchYoutube").mockResolvedValue({
items: [hit("a"), hit("b")],
next_cursor: null,
source: "scrape",
} as never);
const row = vi.spyOn(api, "videoRow");
mount({ source: "scrape", asked: false });
expect(await screen.findByText("playing b in a,b")).toBeTruthy();
expect(row).not.toHaveBeenCalled();
});
it("plays alone over a search that waits for its press", async () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
vi.spyOn(api, "videoRow").mockResolvedValue(hit("b"));
mount({ source: "api", asked: false });
expect(await screen.findByText("playing b in no list")).toBeTruthy();
});
it("is given up when the search is left before it opened, and leaves the address", async () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
vi.spyOn(api, "searchYoutube").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "feed").mockResolvedValue({ items: [hit("b")], next_cursor: null } as never);
const row = vi.spyOn(api, "videoRow").mockResolvedValue(hit("b"));
const { leave } = mount({ source: "scrape", asked: false });
leave();
await settle();
await settle();
expect(screen.queryByText(/^playing/)).toBeNull();
expect(row).not.toHaveBeenCalled();
expect(new URLSearchParams(window.location.search).get("watch")).toBeNull();
});
});
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import { type FeedFilters } from "../../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced";
import { ownKey, writeJSON } from "../../lib/storage";
import { logFilterChange } from "../../lib/filterLog";
import { useNavigation } from "../../features/navigation/navigationContext";
@@ -56,8 +57,11 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
// keys are cleared from the URL (localStorage still holds them; they re-mirror on return to the
// feed). The channel page keeps its own isolated filters, in keys of its own. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
// other providers own.
// The search text once the typing pauses, as the other pages' searches: one address-bar write per
// keystroke is churn a browser throttles.
const nav = useNavigation();
useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? filters : null));
const typedQ = useDebounced(filters.q ?? "", 300);
useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? { ...filters, q: typedQ } : null));
// A share link's filters (captured at mount) win over the account's stored view and are persisted
// to it once, so the view the link opened also survives a later plain reload.
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
//
// The feed's address names its view. The view and the order always (defaults too): an address naming
// none falls back to the view the account stored, so a bookmark of the default feed reopened whatever
// view was stored since. The search text once the typing pauses, as the other pages' searches.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NavigationProvider } from "../../features/navigation/NavigationProvider";
import { FeedFiltersProvider } from "./FeedFiltersProvider";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { qk } from "../../lib/queryKeys";
import { claimLocalData, LS } from "../../lib/storage";
import type { FeedFilters } from "../../lib/api";
const seen: { filters: FeedFilters | null; set: ((f: FeedFilters) => void) | null } = {
filters: null,
set: null,
};
function Probe() {
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
useEffect(() => {
seen.filters = filters;
seen.set = setFilters;
});
return null;
}
function mount() {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences: {} });
render(
<QueryClientProvider client={qc}>
<NavigationProvider>
<FeedFiltersProvider>
<Probe />
</FeedFiltersProvider>
</NavigationProvider>
</QueryClientProvider>,
);
}
const search = () => new URLSearchParams(window.location.search);
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
describe("FeedFiltersProvider · the address", () => {
it("names the default view and order", () => {
window.history.replaceState(null, "", "/?page=feed");
mount();
expect([search().get("show"), search().get("sort")]).toEqual(["unwatched", "newest"]);
});
it("a bookmark of the default feed opens it, not the view the account stored since", () => {
localStorage.setItem(LS.filters, JSON.stringify({ show: "all", includeShorts: true }));
window.history.replaceState(null, "", "/?page=feed&show=unwatched&sort=newest");
mount();
expect(seen.filters!.show).toBe("unwatched");
expect(seen.filters!.includeShorts).toBe(false);
});
it("names the search once the typing pauses", async () => {
window.history.replaceState(null, "", "/?page=feed");
mount();
act(() => seen.set!({ ...seen.filters!, q: "lo" }));
act(() => seen.set!({ ...seen.filters!, q: "lofi" }));
expect(search().get("q")).toBeNull();
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(search().get("q")).toBe("lofi");
});
});
+1
View File
@@ -19,6 +19,7 @@ export const feedModule: ModuleDef = {
Store: FeedStore,
// Its filters, and the live YouTube search shown in its place (lib/ytSearchUrlState).
urlKeys: [...FEED_KEYS, ...YT_SEARCH_KEYS],
typedUrlKeys: ["q", "yt"],
playsVideos: true,
};