fix(boot): a new tab knows its account before anything renders

With Settings -> Appearance -> Startup turned off, every NEW tab or window still
opened on Home; only a same-tab reload honoured it. The provider tree mounted
before /api/me answered, so NavigationProvider's initializer could not read the
per-account opt-out, landed on Home and mirrored ?page=home into the address bar.
When App pinned the account a moment later, the re-hydrate re-read that mirror
and stayed on Home. The same mount-then-patch shape made a new tab start on
default filters, the default theme and the browser language, and correct each
after the first paint.

BootGate (main.tsx, above the providers) now owns the setup-status and /api/me
queries and mounts the tree only once the identity is settled: it pins the tab's
account and adopts the account's language in a layout effect, so the first frame
of the app is already right. The tree is keyed on the account id, which makes
"per-account state is read once, for this account" a structural guarantee.

With that, the whole re-hydrate machinery is gone: useAccountRehydrate, the
reactive active-account store, the markDirty guards, the pin-deferred saved-view
seed and the present-dimension bookkeeping of the shared Channels/Plex links.
PrefsProvider, FeedFiltersProvider, usePref and App's panel layouts seed from the
me cache instead of adopting it in an effect; the theme is applied in layout
effects. The app chunk is fetched in parallel with the probe, and the boot screen
only fades its label in after 400 ms, on one clock across the gate and the
Suspense fallback.

Module switches no longer flash a placeholder either. Measured frame by frame
in Chromium and WebKit, every first visit to a module showed "Loading..." for
17-70 ms: the lazy chunk's Suspense fallback, then often the first fetch's
LoadingState. Pages are now lazyWithPreload components, and App fetches the
chunks of every module the account can reach (plus the channel page) once the
first page is idle; a preloaded page renders synchronously and never suspends.
LoadingState, the one shared loading visual, fades in only after 300 ms, so a
fast load shows nothing in between and a slow one still gets its spinner.
This commit is contained in:
2026-09-14 22:29:33 +02:00
parent 6169e32ca5
commit 5346efcd10
34 changed files with 636 additions and 731 deletions
+57 -111
View File
@@ -1,4 +1,12 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { parseSortState, type SortState } from "../lib/columnSort";
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
@@ -10,7 +18,6 @@ import {
type PlexUrlState,
} from "../lib/plexUrlState";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
@@ -20,10 +27,8 @@ import { useNavigation } from "./NavigationProvider";
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds
// them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above
// <App>, which pins it only after /api/me, so a fresh tab would otherwise show defaults until a reload
// (an account SWITCH does full-reload, but the FIRST load does not). `playlistOpen` is an ephemeral
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
// (BootGate pins the tab's account first; an account switch reloads). `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
@@ -95,7 +100,7 @@ export function usePlexActions(): PlexActions {
return useContext(ActionsContext);
}
// The per-account init readers, shared by the initial useState seed AND the account-pin re-hydrate.
// The per-account init readers, shared by the useState seeds and the shared-link seed's base.
function readPlexScope(): string {
const v = getAccountRaw(LS.plexLibrary) ?? "both";
return ["movie", "show", "both"].includes(v) ? v : "both";
@@ -144,45 +149,31 @@ function readPlexFilters(): PlexFilters {
return EMPTY_PLEX_FILTERS;
}
}
// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present`
// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns
// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value.
// The provider must therefore persist/keep only the PRESENT dimensions on account-pin and re-read the
// account's real stored value for the absent ones — otherwise a partial link silently resets them.
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
type PlexUrlSeed = { state: PlexUrlState; present: PlexSeedPresent };
function readPlexUrlSeed(): PlexUrlSeed | null {
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
function readPlexUrlSeed(): PlexUrlState | null {
const params = new URLSearchParams(window.location.search);
if (!hasPlexParams(params)) return null;
const state = plexParamsToState(params, {
return plexParamsToState(params, {
scope: readPlexScope(),
sort: readPlexSort(),
show: readPlexShow(),
filters: readPlexFilters(),
});
return {
state,
present: {
scope: params.has("pscope"),
sort: params.has("psort"),
show: params.has("pshow"),
filters: params.has("pf"),
},
};
}
export function PlexProvider({ children }: { children: ReactNode }) {
// Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin,
// is persisted to the account instead of being clobbered by the stored view (see the rehydrate).
// Captured once: the shared-link content state (or null). Seeds the slices below and is persisted to
// the account (the effect further down), so the shared view also survives a later plain reload.
const [urlSeed] = useState(readPlexUrlSeed);
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [scope, setScopeState] = useState<string>(() => urlSeed?.state.scope ?? readPlexScope());
const [show, setShowState] = useState<string>(() => urlSeed?.state.show ?? readPlexShow());
const [sort, setSortState] = useState<SortState>(() => urlSeed?.state.sort ?? readPlexSort());
const [scope, setScopeState] = useState<string>(() => urlSeed?.scope ?? readPlexScope());
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
const [sort, setSortState] = useState<SortState>(() => urlSeed?.sort ?? readPlexSort());
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(),
urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(),
);
const filters = useMemo<PlexFilters>(() => {
try {
@@ -203,87 +194,42 @@ export function PlexProvider({ children }: { children: ReactNode }) {
// PlexBrowse consumes it into an info/show history subview then clears it.
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
// A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL
// state to this account (so the shared view wins and survives a later plain reload), and adopt only the
// non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
const markDirty = useAccountRehydrate(() => {
if (urlSeed) {
// Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist
// it to this now-pinned account (so it wins and survives a later plain reload). A dimension the
// URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that
// default over the account's real stored value — re-read the stored value instead, exactly like a
// normal load. This is the fix for a partial shared link silently resetting the omitted slices.
const { state, present } = urlSeed;
if (present.scope) setAccountRaw(LS.plexLibrary, state.scope);
else setScopeState(readPlexScope());
if (present.show) setAccountRaw(LS.plexShow, state.show);
else setShowState(readPlexShow());
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(state.sort));
else setSortState(readPlexSort());
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(state.filters));
else setFiltersRaw(readPlexFiltersRaw());
setQState(readPlexQ());
setFieldState(readPlexField());
return;
}
setScopeState(readPlexScope());
setShowState(readPlexShow());
setSortState(readPlexSort());
setFiltersRaw(readPlexFiltersRaw());
setQState(readPlexQ());
setFieldState(readPlexField());
});
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
// later plain reload. The seed's base was the stored view, so a dimension the link omitted is written
// back unchanged. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
useEffect(() => {
if (!urlSeed) return;
setAccountRaw(LS.plexLibrary, urlSeed.scope);
setAccountRaw(LS.plexShow, urlSeed.show);
setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
}, [urlSeed]);
const setScope = useCallback(
(v: string) => {
markDirty();
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
},
[markDirty],
);
const setShow = useCallback(
(v: string) => {
markDirty();
setShowState(v);
setAccountRaw(LS.plexShow, v);
},
[markDirty],
);
const setSort = useCallback(
(v: SortState) => {
markDirty();
setSortState(v);
setAccountRaw(LS.plexSort, JSON.stringify(v));
},
[markDirty],
);
const setFilters = useCallback(
(f: PlexFilters) => {
markDirty();
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
},
[markDirty],
);
const setQ = useCallback(
(v: string) => {
markDirty();
setQState(v);
setAccountRaw(LS.plexQ, v);
},
[markDirty],
);
const setField = useCallback(
(v: PlexSearchField) => {
markDirty();
setFieldState(v);
setAccountRaw(LS.plexField, v);
},
[markDirty],
);
const setScope = useCallback((v: string) => {
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
}, []);
const setShow = useCallback((v: string) => {
setShowState(v);
setAccountRaw(LS.plexShow, v);
}, []);
const setSort = useCallback((v: SortState) => {
setSortState(v);
setAccountRaw(LS.plexSort, JSON.stringify(v));
}, []);
const setFilters = useCallback((f: PlexFilters) => {
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
}, []);
const setQ = useCallback((v: string) => {
setQState(v);
setAccountRaw(LS.plexQ, v);
}, []);
const setField = useCallback((v: PlexSearchField) => {
setFieldState(v);
setAccountRaw(LS.plexField, v);
}, []);
const state = useMemo<PlexState>(
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),