Files
gitmost/apps/client/src/features/editor/hooks/use-page-content-cache.ts
T
agent_coder a6ff7623db perf(editor): cut per-keystroke work on the typing hot path (#343)
The editor lagged while typing (worse with doc size, and under collaboration the
same cost is paid for every REMOTE keystroke). ProseMirror itself was fine — the
overhead was the surrounding work done on every transaction. Behavior is 1:1;
only WHEN work runs changed.

- getJSON() off the keystroke path: `onUpdate` no longer serializes the whole doc
  synchronously — the serialization now runs inside a 3s debounce (new hook
  use-page-content-cache.ts), flushed on unmount so the last snapshot isn't lost.
- footnote numbering: merged 3 per-docChanged O(n) doc walks into one, and
  short-circuit the whole-doc renumber when the doc has no footnotes and the
  transaction didn't insert one (step-slice scan — covers typing/paste/collab).
- toolbar: replaced per-keystroke `editor.can().undo()/.redo()` dry-runs with
  cheap history-depth reads (Yjs undoManager stack length / pm-history depth).
- render side-effect bug: `remote.attach()` moved out of the render body into a
  useEffect.
- debounced the TOC all-headings rescan and memoized the slash-command suggestion
  build (was rebuilt twice per keystroke).
- node menus (image/video/audio/pdf/callout/subpages): the per-transaction
  selectors early-return a cheap isActive check instead of running getAttributes +
  multiple alignment probes while their node type is inactive (shouldShow still
  controls display — appears exactly when it did).
- code blocks: the global selectionUpdate listener is now added only for mermaid
  blocks (the only consumer of the selected state), eliminating N listeners +
  N setStates per caret move for normal code blocks.

Deferred (documented, collab hot-path risk): full conditional menu MOUNTING
(menu-less-frame risk on same-tx context switch) and code-block re-tokenization
debounce / language-persist (self-dispatching meta tx + node-attr writes interact
with collab/undo). The route split from #342 already keeps lowlight off startup.

Gate: editor-ext build + 252/252 tests, client editor tests pass, tsc --noEmit 0,
client build ok. New tests: footnote no-footnote-doc → 0 traversals + numbering
unchanged; page-content-cache onUpdate-no-sync-getJSON + flush-on-unmount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:10:04 +03:00

51 lines
2.1 KiB
TypeScript

import type { MutableRefObject } from "react";
import { useDebouncedCallback } from "@mantine/hooks";
import type { Editor } from "@tiptap/react";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
/**
* Off-keystroke local page-cache updater (issue #343, PART 3).
*
* The editor's `onUpdate` fires on every keystroke — and, under collaboration,
* on every REMOTE keystroke too. Serializing the WHOLE document with
* `editor.getJSON()` on that hot path is expensive, and the previous 3s debounce
* only guarded the cache WRITE, not the serialization: `getJSON()` still ran per
* keystroke.
*
* This hook moves the serialization INSIDE the debounced callback, so the
* full-doc traversal happens at most once per `delay`, not per keystroke. Call
* the returned function from `onUpdate` (it only schedules the debounce); the
* `getJSON()` snapshot is taken when the debounce fires.
*
* On unmount/navigation the pending snapshot is FLUSHED (via `flushOnUnmount`)
* so the last edits within the debounce window aren't lost from the local cache.
* The source of truth is collab/Yjs, but the cache must not go stale.
*
* IMPORTANT: call this hook BEFORE `useEditor`. React runs effect cleanups in
* declaration order on unmount, so the debounce's flush cleanup must be declared
* before `useEditor`'s teardown to run while the editor is still alive; the
* `isDestroyed` guard keeps a flush that still races teardown safe (it skips).
*/
export function usePageContentCache(
editorRef: MutableRefObject<Editor | null>,
slugId: string | undefined,
delay = 3000,
) {
return useDebouncedCallback(
() => {
const e = editorRef.current;
if (!e || e.isDestroyed || e.isEmpty) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
// getJSON() (full-doc serialization) runs HERE, off the keystroke path.
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: e.getJSON(),
});
}
},
{ delay, flushOnUnmount: true },
);
}