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:
+12
-34
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import type { Me } from "./api/account";
|
||||
@@ -14,24 +14,19 @@ const reportPrefSaveFailure = (err: unknown) =>
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
|
||||
|
||||
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
|
||||
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted
|
||||
// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its
|
||||
// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache,
|
||||
// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds
|
||||
// all three into one hook so a new server-synced pref is a one-liner.
|
||||
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the
|
||||
// account across devices. usePref folds what every such pref used to hand-roll — seed local state, and
|
||||
// on change write the server + an optional local mirror + the `me` cache — into one hook, so a new
|
||||
// server-synced pref is a one-liner.
|
||||
//
|
||||
// Two mirror strategies, unified here (pick via `localKey`):
|
||||
// • Providers mounted ABOVE <App> (feed view, panel layouts, rail-collapse flags) render before
|
||||
// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on
|
||||
// reload — pass `localKey`.
|
||||
// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they
|
||||
// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`.
|
||||
// Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other
|
||||
// It seeds straight from the `me` cache, which is always populated when it mounts: BootGate mounts the
|
||||
// app tree only once /api/me has answered, keyed on the account, so there is nothing to adopt after
|
||||
// mount. The source of truth is the server; the `me` cache is kept in sync optimistically so other
|
||||
// consumers reading the same key (e.g. a second player instance) see the change at once.
|
||||
|
||||
export interface UsePrefOptions<T> {
|
||||
/** Also mirror to per-account localStorage under this base key, and seed synchronously from it on
|
||||
* first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */
|
||||
/** Also mirror to per-account localStorage under this base key; that copy seeds the value when the
|
||||
* server payload lacks the key. */
|
||||
localKey?: string;
|
||||
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
|
||||
* local state + cache still update instantly; only the network write is deferred. */
|
||||
@@ -70,8 +65,8 @@ export function useSavePref(): (patch: Record<string, unknown>) => void {
|
||||
);
|
||||
}
|
||||
|
||||
/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with
|
||||
* automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */
|
||||
/** A per-account preference persisted to the server (and optionally mirrored to localStorage).
|
||||
* Returns `[value, setValue]`; the setter is stable. */
|
||||
export function usePref<T>(
|
||||
serverKey: string,
|
||||
defaultValue: T,
|
||||
@@ -79,8 +74,6 @@ export function usePref<T>(
|
||||
): [T, (next: T) => void] {
|
||||
const { localKey, debounceMs, parse } = options;
|
||||
const qc = useQueryClient();
|
||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
|
||||
// Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which
|
||||
// would churn the memoized setter and the adopt effect). parse must be idempotent anyway.
|
||||
@@ -141,20 +134,5 @@ export function usePref<T>(
|
||||
[qc, serverKey, localKey, debounceMs],
|
||||
);
|
||||
|
||||
// Adopt the server value when the account becomes known / changes (login, account switch). Guarded on
|
||||
// the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key →
|
||||
// keep the current (seeded / user-set) value rather than clobbering it with the default.
|
||||
const prevId = useRef<number | null | undefined>(me?.id);
|
||||
useEffect(() => {
|
||||
if (me?.id === prevId.current) return;
|
||||
prevId.current = me?.id;
|
||||
const server = readServerPref(me, serverKey);
|
||||
if (server === undefined) return;
|
||||
const val = norm(server);
|
||||
setValue(val);
|
||||
if (localKey) writeAccount(localKey, val);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me?.id]);
|
||||
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user