b349676eae
On page reload the sidebar tree rendered nothing until every root page
was fetched (paginated), and children of expanded branches arrived even
later (breadcrumbs effect / socket connect) — the tree visibly jumped a
couple of seconds after load.
- treeDataAtom is now a facade over atomFamily(atomWithStorage) keyed
treeData:v1:{workspaceId}:{userId} with getOnInit: true — the cached
tree hydrates synchronously and paints on the very first render,
together with the already-persisted open-branches map. Public atom
interface unchanged (value or functional updater), all call sites
untouched.
- Custom sync storage: debounced writes (500ms, coalesced, size guard,
beforeunload flush), defensive reads (corrupted JSON -> []), no
cross-tab subscribe (localStorage is a boot cache only).
- SpaceTree renders on cached data immediately; "No pages yet" still
waits for the server. Once server roots merge, open loaded branches
are re-fetched fresh and reconciled once per space (shared
refreshOpenBranches, also used by the socket reconnect handler).
- Logout hygiene: clearPersistedTreeCaches() purges treeData:v1:* and
openTreeNodes:* by prefix and disables further persistence (kill
switch closes the websocket-write-vs-beforeunload-flush resurrection
race). Wired into both handleLogout and the 401 redirectToLogin path,
since cached trees contain page titles.
- Tests: tree-data-atom.test.ts (hydration, debounce round-trip,
corrupted JSON, scope isolation, logout purge, persistence kill
switch); expand-all suite adapted. 144 tree tests / full client suite
green, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
60 lines
2.5 KiB
TypeScript
60 lines
2.5 KiB
TypeScript
import { atom } from "jotai";
|
|
import {
|
|
atomFamily,
|
|
atomWithStorage,
|
|
createJSONStorage,
|
|
} from "jotai/utils";
|
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
|
|
|
export type OpenMap = Record<string, boolean>;
|
|
|
|
// Explicit synchronous localStorage so `getOnInit` resolves to the sync overload
|
|
// (the default storage is typed sync+async, which would widen the value type to
|
|
// `OpenMap | Promise<OpenMap>` and break the functional-updater setter below).
|
|
const openTreeNodesStorage = createJSONStorage<OpenMap>(() => localStorage);
|
|
|
|
// Single source of truth for the open-map localStorage key prefix. Exported so
|
|
// the logout cache sweep (tree-data-atom.ts) removes keys by the SAME prefix
|
|
// used to write them — a rename here can never silently desync the cleanup.
|
|
export const OPEN_TREE_NODES_KEY_PREFIX = "openTreeNodes:";
|
|
|
|
// One persisted open/closed map per (workspace, user). Scoping the localStorage
|
|
// key prevents accounts that share a browser origin from leaking tree state.
|
|
// `getOnInit: true` reads localStorage synchronously at atom init (not on mount),
|
|
// so the first render already has the saved state — no collapse-then-expand
|
|
// flicker on reload, and writes never run against an un-hydrated empty map.
|
|
const openTreeNodesFamily = atomFamily((scopeKey: string) =>
|
|
atomWithStorage<OpenMap>(
|
|
`${OPEN_TREE_NODES_KEY_PREFIX}${scopeKey}`,
|
|
{},
|
|
openTreeNodesStorage,
|
|
{ getOnInit: true },
|
|
),
|
|
);
|
|
|
|
// Resolve the storage scope from the current user. Fall back to "anon" for the
|
|
// workspace/user parts when nothing is loaded yet (logged out / first paint).
|
|
// Shared by the open-map atom below and the persisted tree-data atom
|
|
// (tree-data-atom.ts) so both caches are scoped identically.
|
|
export const scopeKeyAtom = atom((get) => {
|
|
const currentUser = get(currentUserAtom);
|
|
const workspaceId = currentUser?.workspace?.id ?? "anon";
|
|
const userId = currentUser?.user?.id ?? "anon";
|
|
return `${workspaceId}:${userId}`;
|
|
});
|
|
|
|
// Public facade — same read value (OpenMap) and same setter shape (value OR
|
|
// functional updater) as the previous in-memory atom, but transparently routed
|
|
// to the localStorage-backed map for the current workspace/user.
|
|
export const openTreeNodesAtom = atom(
|
|
(get) => get(openTreeNodesFamily(get(scopeKeyAtom))),
|
|
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
|
|
const target = openTreeNodesFamily(get(scopeKeyAtom));
|
|
const next =
|
|
typeof update === "function"
|
|
? (update as (prev: OpenMap) => OpenMap)(get(target))
|
|
: update;
|
|
set(target, next);
|
|
},
|
|
);
|