Compare commits

..

1 Commits

Author SHA1 Message Date
claude_code 82411f8707 fix(editor): не подставлять типографику повторно после её отмены (Ctrl+Z)
После срабатывания авто-подстановки Typography (например «1/2 » → «½») и её
отмены через Ctrl+Z повторное нажатие пробела снова триггерило то же input-rule
и подставляло символ заново.

Добавлено клиентское расширение CustomTypography (обёртка над
@tiptap/extension-typography) с ProseMirror-плагином «undo guard»:
- запоминает диапазон текста, восстановленный отменой (undo/redo), и подавляет
  typography input-rules, чьё совпадение пересекается с этим диапазоном, пока
  восстановленный текст не отредактируют;
- поддерживает обе системы истории: prosemirror-history (шаблонные редакторы) и
  yjs UndoManager (основной collab-редактор). Undo в yjs приходит как замена
  всего документа, поэтому регион вычисляется диффом документов
  (findDiffStart/findDiffEnd), а не по step-map;
- детекция yjs-транзакций — через импортированный ySyncPluginKey и канонический
  isChangeOrigin, без хрупких строковых ключей.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:14:19 +03:00
9 changed files with 228 additions and 522 deletions
@@ -23,7 +23,6 @@ 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();
@@ -123,9 +122,6 @@ 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`);
};
@@ -0,0 +1,186 @@
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 { Typography } from "@tiptap/extension-typography";
import { CustomTypography } from "./custom-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { Youtube } from "@tiptap/extension-youtube";
@@ -245,7 +245,9 @@ export const mainExtensions = [
return ReactMarkViewRenderer(SpoilerView);
},
}),
Typography,
// 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,
TrailingNode,
GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
@@ -13,30 +13,20 @@ 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>(
`${OPEN_TREE_NODES_KEY_PREFIX}${scopeKey}`,
{},
openTreeNodesStorage,
{ getOnInit: true },
),
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).
// 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 scopeKeyAtom = atom((get) => {
const currentUser = get(currentUserAtom);
const workspaceId = currentUser?.workspace?.id ?? "anon";
const userId = currentUser?.user?.id ?? "anon";
@@ -1,232 +0,0 @@
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,200 +1,8 @@
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";
// 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);
},
);
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
// Atom
export const appendNodeChildrenAtom = atom(
@@ -71,8 +71,7 @@ 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. `scopeKeyAtom` is also
// re-exported (the real module exports it for the persisted tree-data atom).
// unchanged while staying inside the test store.
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
const { atom } = await import("jotai");
type OpenMap = Record<string, boolean>;
@@ -87,17 +86,11 @@ vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
set(base, next);
},
);
// 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 };
return { openTreeNodesAtom };
});
import SpaceTree, { SpaceTreeApi } from "./space-tree";
import {
treeDataAtom,
flushPendingTreeDataWrites,
} from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeDataAtom } 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";
@@ -141,10 +134,6 @@ 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,66 +199,45 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
const openIdsRef = useRef(openIds);
openIdsRef.current = openIds;
// 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
// Reconnect refresh (#159 #8): on a socket reconnect, re-fetch and reconcile
// the children of every currently-open, already-loaded branch of THIS space,
// 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.
// No first-connect guard is needed: space-tree usually mounts AFTER the
// initial connect, so every `connect` it sees is a reconnect; the rare
// 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
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
useEffect(() => {
if (!socket) return;
const onConnect = () => {
refreshOpenBranches();
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);
}
}
};
socket.on("connect", onConnect);
return () => {
socket.off("connect", onConnect);
};
}, [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]);
}, [socket, setData]);
const handleToggle = useCallback(
async (id: string, isOpen: boolean) => {
@@ -354,17 +333,12 @@ 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>
)}
{/* 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 && (
{isDataLoaded && filteredData.length > 0 && (
<DocTree<SpaceTreeNode>
data={filteredData}
openIds={openIds}
-7
View File
@@ -1,7 +1,6 @@
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",
@@ -72,12 +71,6 @@ 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;