refactor(account): address account-pin re-hydrate review — extract module, harden guards

Fixes the 5 findings from /code-review high on the account-pin work:
- Extract the reactive-account primitives (ACTIVE_ACCOUNT_KEY, activeAccountId,
  notifyActiveAccountChange, useActiveAccountId) into a new dependency-free
  lib/activeAccount.ts, breaking the storage.ts <-> useAccountRehydrate.ts import cycle.
  storage.ts / api/core.ts / useAccountRehydrate.ts now import from it.
- NavigationProvider: the page re-hydrate now PRESERVES existing history.state markers
  (spread, not overwrite), and openChannel/enterYtSearch markDirty so a sub-view opened
  before the account pins isn't clobbered by the page override.
- useAccountRehydrate: reset the dirty flag after each account transition, so a future
  non-reloading account switch can't skip the re-hydrate and leak the prior account's state.
- Doc-link useAccountRehydrate to the pre-existing useEffect([me?.id]) server-adopt pattern.
- Tests: model a mutable pinned account and add useSortState integration tests for the
  null->id re-hydrate + the markDirty no-clobber guard (vitest 115->117).

gate-green: vite build + vitest 117 + eslint-0 + knip + prettier. Self-E2E'd on a fresh
tab: still lands on Plex with Movies/In-progress/Year restored, zero console errors.
This commit is contained in:
2026-08-14 03:12:50 +02:00
parent 9917d908eb
commit 726c382524
8 changed files with 126 additions and 67 deletions
+39
View File
@@ -0,0 +1,39 @@
import { useSyncExternalStore } from "react";
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts), plus its reactive
// wrapper. Its OWN module (imports nothing internal) so it can be shared by both `storage.ts` (which
// keys per-account localStorage on it) and `useAccountRehydrate.ts` without a circular import — the
// account id is a lower-level primitive than the storage helpers that consume it.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
export function activeAccountId(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
// Reactive active-account. setActiveAccount/clearActiveAccount write sessionStorage — a silent op that
// triggers no React render — so this tab's per-account state (sort/filter/view/layout, all keyed by the
// account) would never re-read once the account is finally known. That bites exactly once, on first
// load: the provider tree above <App> mounts with NO account, then <App> pins it after /api/me resolves.
// The account setters call notifyActiveAccountChange() so useActiveAccountId subscribers can re-hydrate.
const accountListeners = new Set<() => void>();
function subscribeActiveAccount(cb: () => void): () => void {
accountListeners.add(cb);
return () => accountListeners.delete(cb);
}
export function notifyActiveAccountChange(): void {
for (const cb of [...accountListeners]) cb();
}
/** The current tab's active account id, as reactive state — re-renders when it is (re)pinned. The
* snapshot is a primitive (number | null), so useSyncExternalStore's Object.is check is stable. */
export function useActiveAccountId(): number | null {
return useSyncExternalStore(subscribeActiveAccount, activeAccountId, activeAccountId);
}