feat(client): persist page-tree open/closed state per workspace+user

The sidebar page tree previously kept its expanded/collapsed state in an
in-memory jotai atom, so it reset on every reload. Persist it in
localStorage instead, scoped by `${workspaceId}:${userId}` so multiple
accounts sharing a browser origin don't leak tree state into each other.

- open-tree-nodes-atom: replace the in-memory atom with a facade over an
  atomFamily of atomWithStorage, scoped via currentUserAtom; keep the same
  [OpenMap, functional-updater setter] public API so space-tree needs no
  change.
- Use an explicit synchronous createJSONStorage + getOnInit: true so the
  saved state is read at atom init — no collapse-then-expand flicker on
  reload and no write against an un-hydrated empty map.
- README / README.ru: document persistent page-tree state in the
  "What's different from Docmost" table.
This commit is contained in:
vvzvlad
2026-06-18 05:42:12 +03:00
parent 1968879fe5
commit ca0622ef01
3 changed files with 47 additions and 1 deletions

View File

@@ -1,5 +1,49 @@
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>;
export const openTreeNodesAtom = atom<OpenMap>({});
// 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);
// 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>(`openTreeNodes:${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).
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);
},
);