Compare commits

..

1 Commits

Author SHA1 Message Date
claude_code b349676eae feat(tree): paint sidebar tree instantly from localStorage boot cache
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>
2026-07-02 15:51:15 +03:00
9 changed files with 522 additions and 228 deletions
@@ -23,6 +23,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
export default function useAuth() {
const { t } = useTranslation();
@@ -122,6 +123,9 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
// Purge the persisted sidebar tree caches (they contain page titles) so
// nothing readable is left in localStorage on a shared machine.
clearPersistedTreeCaches();
await logout();
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};
@@ -1,186 +0,0 @@
import { InputRule } from "@tiptap/core";
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from "@tiptap/pm/state";
import { Typography } from "@tiptap/extension-typography";
import { isChangeOrigin } from "@tiptap/extension-collaboration";
import { ySyncPluginKey } from "@tiptap/y-tiptap";
// Region restored by the latest undo — while it is intact, typography
// input rules overlapping it must not fire again.
interface UndoGuardRange {
from: number;
to: number;
}
const undoGuardKey = new PluginKey<UndoGuardRange | null>(
"typographyUndoGuard",
);
// prosemirror-history does not export its plugin key, so template-editor
// undo/redo is detected via the stable stringified key. Only one
// PluginKey("history") exists in the dependency tree, so "history$" is stable.
const HISTORY_META = "history$";
const isUndoRedoTransaction = (tr: Transaction): boolean => {
if (tr.getMeta(HISTORY_META)) {
return true;
}
// Read yjs undo/redo meta via the real ySyncPluginKey object (imported, not
// a fragile stringified key), which y-tiptap sets on Y.UndoManager changes.
const ySyncMeta = tr.getMeta(ySyncPluginKey) as
| { isUndoRedoOperation?: boolean }
| undefined;
return !!ySyncMeta?.isUndoRedoOperation;
};
interface DocChange {
from: number;
oldTo: number;
newTo: number;
}
// Compute the minimal changed region between two docs. yjs undo/redo (and any
// remote change) arrives as a whole-document replace step, so the transaction
// step maps are useless — diff the docs to recover the real minimal change.
// Returns null when the docs are identical.
const findChangedRange = (
oldState: EditorState,
newState: EditorState,
): DocChange | null => {
const start = oldState.doc.content.findDiffStart(newState.doc.content);
const end = oldState.doc.content.findDiffEnd(newState.doc.content);
if (start == null || end == null) {
return null;
}
let { a: oldTo, b: newTo } = end;
// Normalize overlapping diff bounds (repeated-content edge case).
if (oldTo < start) {
newTo += start - oldTo;
oldTo = start;
}
return { from: start, oldTo, newTo };
};
// Map an armed guard range across a single document change described by a diff.
// Returns null when the change touches the guarded text itself (the restored
// substitution was edited, so the guard must be released).
const mapRangeThroughChange = (
range: UndoGuardRange,
change: DocChange,
): UndoGuardRange | null => {
// Strict intersection: an edit exactly at a guard boundary (e.g. the user
// typing the suppressed space right after the restored text, or deleting it)
// must NOT drop the guard.
if (change.from < range.to && change.oldTo > range.from) {
return null;
}
// Change fully before the guard: shift the guard by the length delta.
if (change.oldTo <= range.from) {
const delta = change.newTo - change.oldTo;
return { from: range.from + delta, to: range.to + delta };
}
// Change fully after the guard: positions are unaffected.
return range;
};
// Detect history/remote transactions that may arrive as a whole-document
// replace step: prosemirror-history undo/redo, or any yjs remote-origin change
// (isChangeOrigin is the canonical predicate already used across the app).
const isHistoryOrRemoteTransaction = (tr: Transaction): boolean =>
!!tr.getMeta(HISTORY_META) || isChangeOrigin(tr);
export const CustomTypography = Typography.extend({
addProseMirrorPlugins() {
return [
...(this.parent?.() ?? []),
new Plugin({
key: undoGuardKey,
state: {
init: () => null,
apply(tr, prev, oldState, newState): UndoGuardRange | null {
if (tr.docChanged && isHistoryOrRemoteTransaction(tr)) {
const change = findChangedRange(oldState, newState);
if (change == null) {
// Attribute-only or otherwise content-neutral change: keep the
// guard.
return prev;
}
// Arm the guard only when the LOCAL user's undo/redo REPLACED text
// (deleted + inserted) — the signature of reverting an input-rule
// substitution. Pure insertions/deletions and remote peer edits
// must not arm it.
if (
isUndoRedoTransaction(tr) &&
change.oldTo > change.from &&
change.newTo > change.from
) {
return { from: change.from, to: change.newTo };
}
// Non-arming history/remote change: map the existing guard through
// the real diff instead of the (whole-document) step map.
if (!prev) {
return null;
}
return mapRangeThroughChange(prev, change);
}
if (!prev) {
return null;
}
if (!tr.docChanged) {
return prev;
}
// Ordinary local edit: minimal step maps are accurate and cheap.
let range: UndoGuardRange | null = prev;
for (const stepMap of tr.mapping.maps) {
const { from: rangeFrom, to: rangeTo } = range;
let touched = false;
stepMap.forEach((fromA, toA) => {
if (fromA < rangeTo && toA > rangeFrom) {
touched = true;
}
});
if (touched) {
range = null;
break;
}
range = {
from: stepMap.map(rangeFrom, 1),
to: stepMap.map(rangeTo, -1),
};
}
return range && range.to > range.from ? range : null;
},
},
}),
];
},
addInputRules() {
// Wrap every typography rule: skip it when its match overlaps the text
// just restored by undo, so an undone substitution is not re-applied.
return (this.parent?.() ?? []).map(
(rule) =>
new InputRule({
find: rule.find,
undoable: rule.undoable,
handler: (props) => {
const guard = undoGuardKey.getState(props.state);
if (
guard &&
props.range.from < guard.to &&
props.range.to > guard.from
) {
// Returning null skips this rule and lets the typed character
// be inserted as plain text.
return null;
}
return rule.handler(props);
},
}),
);
},
});
@@ -6,7 +6,7 @@ import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { CustomTypography } from "./custom-typography";
import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { Youtube } from "@tiptap/extension-youtube";
@@ -245,9 +245,7 @@ export const mainExtensions = [
return ReactMarkViewRenderer(SpoilerView);
},
}),
// Typography with an undo guard: does not re-apply a substitution the user
// just undid (e.g. Ctrl+Z on "1/2" -> "½" followed by another space).
CustomTypography,
Typography,
TrailingNode,
GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
@@ -13,20 +13,30 @@ export type OpenMap = Record<string, boolean>;
// `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>(`openTreeNodes:${scopeKey}`, {}, openTreeNodesStorage, {
getOnInit: true,
}),
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).
const scopeKeyAtom = atom((get) => {
// 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";
@@ -0,0 +1,232 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { SpaceTreeNode } from "@/features/page/tree/types";
import type { ICurrentUser } from "@/features/user/types/user.types";
// The persisted tree-data atom hydrates from localStorage ONCE, at family-atom
// creation (`getOnInit: true`). To exercise hydration deterministically each
// test imports a FRESH module instance (fresh atomFamily) after seeding the
// storage stub from vitest.setup.ts. jotai itself is externalized by vitest, so
// `createStore` can stay a static import — atoms are plain objects and any
// store works with any module instance.
import { createStore } from "jotai";
// Storage key for the default scope: no currentUser -> "anon:anon" (see
// scopeKeyAtom in open-tree-nodes-atom.ts) with the `v1` cache-shape version.
const ANON_KEY = "treeData:v1:anon:anon";
const DEBOUNCE_MS = 500;
async function freshImport() {
vi.resetModules();
const treeDataModule = await import("./tree-data-atom");
const userModule = await import(
"@/features/user/atoms/current-user-atom"
);
return {
treeDataAtom: treeDataModule.treeDataAtom,
flushPendingTreeDataWrites: treeDataModule.flushPendingTreeDataWrites,
clearPersistedTreeCaches: treeDataModule.clearPersistedTreeCaches,
currentUserAtom: userModule.currentUserAtom,
};
}
function node(id: string): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: false,
children: [],
};
}
// Every persisted tree key currently in storage — asserting on the whole
// prefix (not one known key) catches writes that resurrect under ANY scope.
function persistedTreeDataKeys(): string[] {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null && key.startsWith("treeData:v1:")) keys.push(key);
}
return keys;
}
function currentUser(workspaceId: string, userId: string): ICurrentUser {
return {
user: { id: userId },
workspace: { id: workspaceId },
} as unknown as ICurrentUser;
}
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("treeDataAtom (localStorage-persisted)", () => {
it("reads [] from a fresh store with empty storage", async () => {
const { treeDataAtom } = await freshImport();
const store = createStore();
expect(store.get(treeDataAtom)).toEqual([]);
});
it("persists through the debounced setItem and hydrates a fresh module back", async () => {
vi.useFakeTimers();
const setItemSpy = vi.spyOn(localStorage, "setItem");
const { treeDataAtom } = await freshImport();
const store = createStore();
store.set(treeDataAtom, [node("a")]);
// Second write inside the debounce window — must coalesce into ONE flush
// carrying only the latest value.
vi.advanceTimersByTime(DEBOUNCE_MS / 2);
store.set(treeDataAtom, [node("a"), node("b")]);
// Nothing flushed yet: the write is trailing-debounced.
expect(localStorage.getItem(ANON_KEY)).toBeNull();
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
expect(setItemSpy).toHaveBeenCalledTimes(1);
expect(JSON.parse(localStorage.getItem(ANON_KEY)!)).toEqual([
node("a"),
node("b"),
]);
// A fresh module (fresh atom family -> getOnInit re-reads storage) and a
// fresh store hydrate the persisted tree back — the reload scenario.
const second = await freshImport();
const store2 = createStore();
expect(store2.get(second.treeDataAtom)).toEqual([node("a"), node("b")]);
});
it("reads [] (without throwing) when storage holds corrupted JSON", async () => {
localStorage.setItem(ANON_KEY, "{definitely not JSON!!!");
const { treeDataAtom } = await freshImport();
const store = createStore();
expect(store.get(treeDataAtom)).toEqual([]);
});
it("reads [] when storage holds valid JSON of a non-array shape", async () => {
localStorage.setItem(ANON_KEY, JSON.stringify({ id: "not-a-tree" }));
const { treeDataAtom } = await freshImport();
const store = createStore();
expect(store.get(treeDataAtom)).toEqual([]);
});
it("supports functional-updater writes", async () => {
const { treeDataAtom } = await freshImport();
const store = createStore();
store.set(treeDataAtom, [node("a")]);
store.set(treeDataAtom, (prev) => [...prev, node("b")]);
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a", "b"]);
});
it("isolates trees between (workspace, user) scopes", async () => {
const { treeDataAtom, currentUserAtom } = await freshImport();
const store = createStore();
store.set(currentUserAtom, currentUser("w1", "u1"));
store.set(treeDataAtom, [node("a")]);
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
// Another account on the same browser origin must NOT see u1's tree.
store.set(currentUserAtom, currentUser("w2", "u2"));
expect(store.get(treeDataAtom)).toEqual([]);
store.set(treeDataAtom, [node("b")]);
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["b"]);
// Switching back resolves the original scope's tree untouched.
store.set(currentUserAtom, currentUser("w1", "u1"));
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
});
it("clearPersistedTreeCaches removes all tree keys and discards pending writes", async () => {
vi.useFakeTimers();
// Stale caches across scopes plus an UNRELATED key that must survive.
localStorage.setItem("treeData:v1:a:b", JSON.stringify([node("stale")]));
localStorage.setItem("openTreeNodes:a:b", JSON.stringify({ p1: true }));
localStorage.setItem("currentUser", JSON.stringify({ user: { id: "b" } }));
const { treeDataAtom, clearPersistedTreeCaches } = await freshImport();
const store = createStore();
// Queue a debounced write (not flushed yet) for the anon scope.
store.set(treeDataAtom, [node("pending")]);
expect(localStorage.getItem(ANON_KEY)).toBeNull();
clearPersistedTreeCaches();
// Both prefixed caches are swept; the unrelated key is untouched.
expect(localStorage.getItem("treeData:v1:a:b")).toBeNull();
expect(localStorage.getItem("openTreeNodes:a:b")).toBeNull();
expect(localStorage.getItem("currentUser")).toBe(
JSON.stringify({ user: { id: "b" } }),
);
// The queued write was DISCARDED, not merely delayed: the debounce timer
// firing later must not resurrect a tree key after logout.
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
expect(localStorage.getItem(ANON_KEY)).toBeNull();
});
it("clearPersistedTreeCaches discards queued writes even when flushed DIRECTLY", async () => {
vi.useFakeTimers();
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
await freshImport();
const store = createStore();
// Queue a debounced write, then clear. Calling the flush directly (not via
// the debounce timer) isolates the pending-queue discard from the timer
// cancel: if the queue survived, this flush would resurrect the key even
// though the timer never fired.
store.set(treeDataAtom, [node("pending")]);
clearPersistedTreeCaches();
flushPendingTreeDataWrites();
expect(localStorage.getItem(ANON_KEY)).toBeNull();
expect(persistedTreeDataKeys()).toEqual([]);
});
it("disables persistence after clearPersistedTreeCaches: NEW writes never reach storage", async () => {
vi.useFakeTimers();
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
await freshImport();
const store = createStore();
clearPersistedTreeCaches();
// The resurrection scenario: a websocket tree event lands while `await
// logout()` is still in flight, AFTER the sweep. The write must not be
// queued, must not arm a new debounce timer, and must not survive the
// beforeunload flush fired by the logout redirect.
store.set(treeDataAtom, [node("late")]);
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
flushPendingTreeDataWrites(); // what the beforeunload handler runs
expect(persistedTreeDataKeys()).toEqual([]);
// Only PERSISTENCE is disabled: the in-memory atom keeps working, so the
// UI stays intact during the brief pre-redirect window.
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["late"]);
});
});
@@ -1,8 +1,200 @@
import { atom } from "jotai";
import { atomFamily, atomWithStorage } from "jotai/utils";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { appendNodeChildren } from "../utils";
import {
OPEN_TREE_NODES_KEY_PREFIX,
scopeKeyAtom,
} from "./open-tree-nodes-atom";
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
// The sidebar tree is persisted to localStorage so a page reload can paint the
// last-known tree IMMEDIATELY (no blank sidebar while the root query runs) and
// then reconcile with the server in the background. localStorage is a BOOT
// CACHE only — the in-memory atom stays the source of truth while the app runs.
// Trailing-debounce machinery for the localStorage writes. The tree is
// rewritten on every lazy load / drag / socket event; serializing a large tree
// on each update would burn CPU and thrash the storage quota, so writes are
// coalesced (~500 ms per burst) and only the latest value per key is flushed.
const WRITE_DEBOUNCE_MS = 500;
// Single source of truth for the tree-cache localStorage key prefix. The `v1`
// segment versions the cached node shape (bump it when SpaceTreeNode changes
// incompatibly). Shared by the storage key construction below AND the logout
// sweep in clearPersistedTreeCaches() so the two can never drift apart.
export const TREE_DATA_KEY_PREFIX = "treeData:v1:";
// Size guard: skip persisting trees whose JSON exceeds ~4M chars. localStorage
// quota is typically ~5 MB per origin; a huge tree must not evict everything
// else or spam QuotaExceededError on every debounce tick.
const MAX_SERIALIZED_LENGTH = 4_000_000;
const pendingWrites = new Map<string, SpaceTreeNode[]>();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
let writeFailureWarned = false;
// Persistence kill-switch, armed by clearPersistedTreeCaches(). Once set, the
// debounced setItem and the flush become no-ops so nothing can be written back
// to localStorage AFTER the logout sweep: a websocket tree event landing while
// `await logout()` is still in flight would otherwise re-queue a write that
// the `beforeunload` flush (fired by the redirect) silently resurrects.
// Intentionally never reset: every caller of clearPersistedTreeCaches()
// immediately navigates away with a full page load
// (window.location.replace/href), so this module instance is torn down anyway.
// Only PERSISTENCE stops — the in-memory atoms keep working, so the UI stays
// intact during the brief pre-redirect window.
let persistenceDisabled = false;
function writeNow(key: string, value: SpaceTreeNode[]): void {
try {
const serialized = JSON.stringify(value);
if (serialized.length > MAX_SERIALIZED_LENGTH) {
console.warn("[tree] cached tree too large to persist; skipping", key);
return;
}
localStorage.setItem(key, serialized);
} catch (err) {
// QuotaExceededError, private mode, jsdom shims without working storage…
// The cache is best-effort: warn once, keep the in-memory tree working.
if (!writeFailureWarned) {
writeFailureWarned = true;
console.warn("[tree] failed to persist tree cache", err);
}
}
}
// Exported so tests can force the debounced write synchronously; production
// code must never need it (the beforeunload hook below covers reloads).
export function flushPendingTreeDataWrites(): void {
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (persistenceDisabled) {
// Belt-and-braces: after logout nothing may reach localStorage, even via
// the beforeunload flush racing the redirect. Drop anything queued.
pendingWrites.clear();
return;
}
for (const [key, value] of pendingWrites) {
writeNow(key, value);
}
pendingWrites.clear();
}
// Logout hygiene: the tree cache stores PAGE TITLES, so leaving it behind
// would keep them readable in localStorage on a shared machine after logout.
// Sweep by key prefix (not just the current scope) so stale scopes — old
// users, the `anon:anon` fallback — are purged too. Pending debounced writes
// are DISCARDED first (not flushed): a queued write firing after the sweep
// would silently resurrect a removed key.
export function clearPersistedTreeCaches(): void {
// Disable persistence FIRST so no write can be queued (or flushed) between
// the sweep below and the full-page navigation every caller performs next.
persistenceDisabled = true;
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTimer = null;
}
pendingWrites.clear();
try {
// Collect matching keys BEFORE removing: deleting while iterating
// `localStorage.key(i)` shifts the indices and skips entries.
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (
key !== null &&
(key.startsWith(TREE_DATA_KEY_PREFIX) ||
key.startsWith(OPEN_TREE_NODES_KEY_PREFIX))
) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
} catch {
// Best-effort: disabled storage / jsdom shims must never break logout.
}
}
// Flush the pending debounced write on unload so a reload right after a tree
// change doesn't lose the newest state (the debounce would otherwise eat it).
if (
typeof window !== "undefined" &&
typeof window.addEventListener === "function"
) {
window.addEventListener("beforeunload", flushPendingTreeDataWrites);
}
// Custom sync storage for the tree cache. Deliberately NO `subscribe` key:
// cross-tab sync would REPLACE this tab's tree wholesale and clobber in-flight
// lazy loads; websockets already keep every open tab live. Each tab keeps its
// own in-memory tree — localStorage only seeds the next boot.
const treeDataStorage = {
getItem: (key: string, initialValue: SpaceTreeNode[]): SpaceTreeNode[] => {
// Defensive: jsdom test shims may lack methods, stored JSON may be
// corrupted or of a wrong shape. Any failure falls back to the empty tree.
try {
const raw = localStorage.getItem(key);
if (raw === null) return initialValue;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as SpaceTreeNode[]) : initialValue;
} catch {
return initialValue;
}
},
setItem: (key: string, newValue: SpaceTreeNode[]): void => {
// After logout the cache must stay purged: neither queue the write nor arm
// a new flush timer (see persistenceDisabled above). The in-memory atom
// value is unaffected — only the localStorage mirror is frozen.
if (persistenceDisabled) return;
pendingWrites.set(key, newValue);
if (flushTimer !== null) clearTimeout(flushTimer);
flushTimer = setTimeout(flushPendingTreeDataWrites, WRITE_DEBOUNCE_MS);
},
removeItem: (key: string): void => {
pendingWrites.delete(key);
try {
localStorage.removeItem(key);
} catch {
/* best-effort cache — ignore */
}
},
};
// One persisted tree per (workspace, user) — same scoping rationale as the
// open-map atom (accounts sharing a browser origin must not leak trees).
// `getOnInit: true` reads localStorage synchronously at atom init, so the very
// first render already has the cached tree — no blank-then-jump sidebar.
const treeDataFamily = atomFamily((scopeKey: string) =>
atomWithStorage<SpaceTreeNode[]>(
`${TREE_DATA_KEY_PREFIX}${scopeKey}`,
[],
treeDataStorage,
{ getOnInit: true },
),
);
// Public facade — same read value (SpaceTreeNode[]) and same setter shape
// (value OR functional updater) as the previous in-memory atom, transparently
// routed to the persisted tree of the current workspace/user.
export const treeDataAtom = atom(
(get) => get(treeDataFamily(get(scopeKeyAtom))),
(
get,
set,
update: SpaceTreeNode[] | ((prev: SpaceTreeNode[]) => SpaceTreeNode[]),
) => {
const target = treeDataFamily(get(scopeKeyAtom));
const next =
typeof update === "function"
? (update as (prev: SpaceTreeNode[]) => SpaceTreeNode[])(get(target))
: update;
set(target, next);
},
);
// Atom
export const appendNodeChildrenAtom = atom(
@@ -71,7 +71,8 @@ vi.mock("@mantine/core", () => ({
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
// plain in-memory atom with the same read value (OpenMap) and the same setter
// shape (value OR functional updater) so the component's open-state logic runs
// unchanged while staying inside the test store.
// unchanged while staying inside the test store. `scopeKeyAtom` is also
// re-exported (the real module exports it for the persisted tree-data atom).
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
const { atom } = await import("jotai");
type OpenMap = Record<string, boolean>;
@@ -86,11 +87,17 @@ vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
set(base, next);
},
);
return { openTreeNodesAtom };
// Fixed scope key: the tree-data atom family resolves through this, so all
// tests read/write the same (empty at start of each test) storage key.
const scopeKeyAtom = atom(() => "test-workspace:test-user");
return { openTreeNodesAtom, scopeKeyAtom };
});
import SpaceTree, { SpaceTreeApi } from "./space-tree";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import {
treeDataAtom,
flushPendingTreeDataWrites,
} from "@/features/page/tree/atoms/tree-data-atom.ts";
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
import { createStore, Provider } from "jotai";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -134,6 +141,10 @@ function renderTree(store: ReturnType<typeof createStore>) {
beforeEach(() => {
getSpaceTreeMock.mockReset();
notificationsShowMock.mockReset();
// The tree-data atom persists via a ~500 ms trailing debounce; flush it NOW
// (cancelling the timer) so a previous test's pending write can't land in
// storage mid-test after the clear below.
flushPendingTreeDataWrites();
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
// fresh jotai store anyway, so cross-test open-state never leaks.
try {
@@ -199,45 +199,66 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
const openIdsRef = useRef(openIds);
openIdsRef.current = openIds;
// Reconnect refresh (#159 #8): on a socket reconnect, re-fetch and reconcile
// the children of every currently-open, already-loaded branch of THIS space,
// Re-fetch and reconcile the children of every currently-open, already-loaded
// branch of THIS space. Shared by the socket reconnect handler and the
// post-load cache refresh below. The ROOT level is reconciled separately by
// the root-query refetch + mergeRootTrees; an UNLOADED branch is skipped
// (lazy-load fetches it fresh on expand). Reads refs so it always sees the
// latest tree/open-state/space without re-creating the callback.
const refreshOpenBranches = useCallback(async () => {
const effectSpaceId = spaceIdRef.current;
const branchIds = loadedOpenBranchIds(
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
openIdsRef.current,
);
if (branchIds.length === 0) return;
for (const id of branchIds) {
try {
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
// reconcile sees the server's CURRENT children (handler-order
// independent — no reliance on the global reconnect invalidation).
const fresh = await fetchAllAncestorChildren(
{ pageId: id, spaceId: effectSpaceId },
{ fresh: true },
);
if (spaceIdRef.current !== effectSpaceId) return; // space switched
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
} catch (err) {
console.error("[tree] open branch refresh failed", err);
}
}
}, [setData]);
// Reconnect refresh (#159 #8): on a socket reconnect, refresh open branches
// so a move/rename/delete that happened INSIDE a loaded branch while events
// were missed (laptop sleep / wifi gap) is reflected instead of left stale.
// The ROOT level is reconciled separately by the root-query refetch +
// mergeRootTrees; an UNLOADED branch is skipped (lazy-load fetches it fresh on
// expand). No first-connect guard is needed: space-tree usually mounts AFTER
// the initial connect, so every `connect` it sees is a reconnect; the rare
// No first-connect guard is needed: space-tree usually mounts AFTER the
// initial connect, so every `connect` it sees is a reconnect; the rare
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
useEffect(() => {
if (!socket) return;
const onConnect = async () => {
const effectSpaceId = spaceIdRef.current;
const branchIds = loadedOpenBranchIds(
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
openIdsRef.current,
);
if (branchIds.length === 0) return;
for (const id of branchIds) {
try {
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
// reconcile sees the server's CURRENT children (handler-order
// independent — no reliance on the global reconnect invalidation).
const fresh = await fetchAllAncestorChildren(
{ pageId: id, spaceId: effectSpaceId },
{ fresh: true },
);
if (spaceIdRef.current !== effectSpaceId) return; // space switched
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
} catch (err) {
console.error("[tree] reconnect branch refresh failed", err);
}
}
const onConnect = () => {
refreshOpenBranches();
};
socket.on("connect", onConnect);
return () => {
socket.off("connect", onConnect);
};
}, [socket, setData]);
}, [socket, refreshOpenBranches]);
// Post-load cache refresh: the sidebar paints instantly from the
// localStorage-cached tree, so children of open branches may be stale. Once
// the server root set has been merged for this space (isDataLoaded flips
// true), refresh every open, already-loaded branch ONCE per space per mount.
// dataRef.current is already up to date here: refs are assigned during
// render, and this effect runs after the merge-triggered re-render commit.
const refreshedSpacesRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!isDataLoaded) return;
if (refreshedSpacesRef.current.has(spaceId)) return;
refreshedSpacesRef.current.add(spaceId);
refreshOpenBranches();
}, [isDataLoaded, spaceId, refreshOpenBranches]);
const handleToggle = useCallback(
async (id: string, isOpen: boolean) => {
@@ -333,12 +354,17 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
return (
<div className={classes.treeContainer}>
{/* "No pages yet" only after the SERVER confirmed the space is empty —
never while just the localStorage cache is empty. */}
{isDataLoaded && filteredData.length === 0 && (
<Text size="xs" c="dimmed" py="xs" px="sm">
{t("No pages yet")}
</Text>
)}
{isDataLoaded && filteredData.length > 0 && (
{/* Cache-first paint: render as soon as ANY data exists (synchronous
localStorage hydration) instead of waiting for the server round-trip;
the background merge/refresh reconciles it afterwards. */}
{filteredData.length > 0 && (
<DocTree<SpaceTreeNode>
data={filteredData}
openIds={openIds}
+7
View File
@@ -1,6 +1,7 @@
import axios, { AxiosInstance } from "axios";
import APP_ROUTE from "@/lib/app-route.ts";
import { isCloud } from "@/lib/config.ts";
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
const api: AxiosInstance = axios.create({
baseURL: "/api",
@@ -71,6 +72,12 @@ function redirectToLogin() {
"/invites",
];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
// Forced logout (401 / expired session) must purge the persisted sidebar
// tree caches too: they contain page titles, and on a shared machine most
// sessions end via cookie expiry — not the logout button — so this is the
// only cleanup that runs on that path. It also disables further cache
// persistence until the full page load below.
clearPersistedTreeCaches();
const redirectTo = window.location.pathname;
if (redirectTo === APP_ROUTE.HOME) {
window.location.href = APP_ROUTE.AUTH.LOGIN;