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.
62 lines
3.3 KiB
TypeScript
62 lines
3.3 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { api } from "../lib/api";
|
|
import { shouldAutoOpenOnboarding } from "../lib/onboarding";
|
|
import { WizardActionsContext, type WizardActions } from "./wizardContext";
|
|
|
|
const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
|
|
|
|
// Owns the onboarding wizard's open state (and the wizard render itself), lifted out of App so any
|
|
// module can trigger "connect YouTube" via a hook instead of an onOpenWizard prop threaded through
|
|
// the tree. Mirrors the other split-context providers; composed in main.tsx around <App/>.
|
|
//
|
|
// The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from
|
|
// granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from
|
|
// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (BootGate owns
|
|
// the fetching query), so it re-runs the auto-open check when the account's grants change.
|
|
//
|
|
// The actions context + hook live in wizardContext.ts.
|
|
|
|
export function WizardProvider({ children }: { children: ReactNode }) {
|
|
const [open, setOpen] = useState(false);
|
|
// Passive observer of the account BootGate fetches (enabled:false → reads cache, re-renders on update).
|
|
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
|
|
|
const openWizard = useCallback(() => setOpen(true), []);
|
|
const closeWizard = useCallback(() => setOpen(false), []);
|
|
|
|
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after each
|
|
// consent redirect). Re-checked when the account or its grants change.
|
|
// Keyed on the fields that matter, NOT the `me` object: the 60 s refetch hands out a new object,
|
|
// and re-running on that would reopen a wizard the user just closed. `can_write` is read by nothing
|
|
// here but is a trigger: a completed write grant resumes the flow.
|
|
const meId = me?.id;
|
|
const canRead = me?.can_read;
|
|
const canWrite = me?.can_write;
|
|
const isDemo = me?.is_demo;
|
|
const googleEnabled = me?.google_enabled;
|
|
useEffect(() => {
|
|
if (meId == null || canRead == null) return;
|
|
const account = { can_read: canRead, is_demo: isDemo, google_enabled: googleEnabled };
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent.
|
|
if (shouldAutoOpenOnboarding(account)) setOpen(true);
|
|
}, [meId, canRead, canWrite, isDemo, googleEnabled]);
|
|
|
|
const actions = useMemo<WizardActions>(
|
|
() => ({ openWizard, closeWizard }),
|
|
[openWizard, closeWizard],
|
|
);
|
|
|
|
return (
|
|
<WizardActionsContext.Provider value={actions}>
|
|
{children}
|
|
{/* The wizard is a full-screen overlay; the demo account can't connect YouTube, so it never
|
|
opens there. Lazy — its chunk loads only when first opened. */}
|
|
<Suspense fallback={null}>
|
|
{open && me && !me.is_demo && <OnboardingWizard me={me} onClose={closeWizard} />}
|
|
</Suspense>
|
|
</WizardActionsContext.Provider>
|
|
);
|
|
}
|