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:
@@ -110,7 +110,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
||||
const [sort, setSort] = useSortState(LS.channelSort);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusName, setFocusName] = useState<string | null>(null);
|
||||
const [focusNameRaw, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
@@ -120,14 +120,18 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
|
||||
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
|
||||
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
|
||||
// `view` is frozen at mount for the same reason `urlSeed` is: the comparison is against the tab
|
||||
// this load started on, and the three setters are stable by construction (useFilterState,
|
||||
// useSortState and useAccountPersistedState all return a useCallback). So the dep list can name
|
||||
// every value the body reads without the seed re-arming on a later tab switch.
|
||||
const [mountView] = useState(view);
|
||||
useLayoutEffect(() => {
|
||||
if (!urlSeed || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
|
||||
if (urlSeed.view !== mountView) setViewRaw(urlSeed.view);
|
||||
setFilters(urlSeed.filters);
|
||||
setSort(urlSeed.sort);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [urlSeed, mountView, setViewRaw, setFilters, setSort]);
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
||||
@@ -175,10 +179,11 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
setPage("channels");
|
||||
}, [setFilters, setView, setPage]);
|
||||
|
||||
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
|
||||
useEffect(() => {
|
||||
if (page !== "channels") setFocusName(null);
|
||||
}, [page]);
|
||||
// The manager focus only means anything ON the Channels page, so it is DERIVED from the page
|
||||
// rather than cleared by an effect when you leave. focusChannel sets the name and navigates in the
|
||||
// same handler, so the value is live by the time channels renders; everywhere else it reads null,
|
||||
// one render earlier than the effect used to make it.
|
||||
const focusName = page === "channels" ? focusNameRaw : null;
|
||||
|
||||
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
|
||||
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
|
||||
|
||||
Reference in New Issue
Block a user