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:
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user