refactor(frontend): the context providers adjust state in render, not after it (E27 S2)

Eleven suppressions across the five providers. The recurring shape here was "an effect that
undoes something when you navigate away", and every one of them ran a commit LATE — the new
page painted once while the old module's state was still live.

Adjusted during render instead (React discards that pass rather than committing it, so there
is no in-between frame):
- SavedViewsProvider reset the sticky view and closed its modal from an effect on `source`, so
  the first Plex render carried the feed's sticky id — long enough for a stale view chip. It
  also followed the active view into stickyId from a second effect; that one converges by
  construction, since preferSticky returns the sticky match when there is one.
- NavigationProvider ended a live YouTube search from an effect on `page`. Keyed on the page
  CHANGE rather than derived from it, because "ended" has to be permanent — deriving would have
  quietly brought the search back on return to the feed.
- ChannelsProvider's manager focus is now simply DERIVED from the page: it means nothing off
  the Channels page, and focusChannel sets it and navigates in one handler.

Honest deps by freezing what is genuinely mount-time:
- FeedFiltersProvider persisted a share link's filters with an empty dep list; listing `filters`
  honestly would have re-persisted on every edit and spun. The mount value is frozen into its
  own state, so all three deps are stable and the effect says what it does.
- ChannelsProvider's URL-seed layout effect and NavigationProvider's initial history stamp get
  the same treatment. The three slice setters they call were already stable useCallbacks
  (useFilterState / useSortState / useAccountPersistedState all return one).
- SavedViewsProvider reads `cfg` through a ref: it re-memoizes on every filter edit, and the
  per-source seed guard already enforces once. That also made the set-state finding go away —
  with the render value gone, the checker can see the seed for what it is.

WizardProvider keeps its one escape hatch, now inline with the invariant: auto-opening is a
one-way decision taken from server data as it arrives, and deriving it would reopen a wizard
the user had just closed.

Suppressions 89 -> 78; exhaustive-deps 27 -> 23.
This commit is contained in:
2026-09-21 00:12:44 +02:00
parent 6a7ff330f6
commit ea6d829c29
6 changed files with 59 additions and 50 deletions
+20 -11
View File
@@ -129,11 +129,15 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
const [modalOpen, setModalOpen] = useState(false);
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
// and a feed modal shouldn't linger open on plex.
useEffect(() => {
// and a feed modal shouldn't linger open on plex. Adjusted DURING RENDER against the previous
// source rather than from an effect, so the first plex render never commits carrying the feed's
// sticky id — which is exactly long enough for a stale view chip to paint.
const [prevSource, setPrevSource] = useState(source);
if (prevSource !== source) {
setPrevSource(source);
setStickyId(null);
setModalOpen(false);
}, [source]);
}
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
@@ -146,17 +150,21 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
// before this provider mounts.
const seededRef = useRef<Set<ViewSource>>(new Set());
// `cfg` re-memoizes on every filter edit, so it is read through a ref: the seed must fire on the
// views query's first resolve for a source and never again, which `seededRef` enforces. Listing
// cfg would re-arm nothing (the guard still holds) but would re-run the effect on every keystroke
// in the filter bar. The ref keeps the dep list honest without that.
const cfgRef = useRef(cfg);
cfgRef.current = cfg;
useEffect(() => {
const data = viewsQuery.data;
if (!data || seededRef.current.has(source)) return;
seededRef.current.add(source);
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
const c = cfgRef.current;
const def = pickDefaultToSeed(data, c.canSeedDefault());
if (!def) return;
cfg.setFilters(cfg.asFilters(def.filters));
c.setFilters(c.asFilters(def.filters));
setStickyId(def.id);
// cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewsQuery.data, source]);
const currentKey = cfg.keyOf(cfg.filters);
@@ -168,9 +176,10 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
),
[views, currentKey, stickyId, cfg],
);
useEffect(() => {
if (activeView) setStickyId(activeView.id);
}, [activeView]);
// The sticky id follows whichever view is active — also adjusted during render. It converges by
// construction: preferSticky returns the sticky match when there is one, so the very write below
// makes its own condition false on the next pass.
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
const stickyView = useMemo(
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),