From 69cb42f830f94f94fda69972323376ef9c30b246 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Tue, 28 Apr 2026 06:56:47 +0000 Subject: [PATCH 1/6] Defer DocumentEditor content load to fix modal open delay Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/editor/DocumentEditor.tsx | 46 +++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/components/editor/DocumentEditor.tsx b/src/components/editor/DocumentEditor.tsx index 0ebf08a0..6f325458 100644 --- a/src/components/editor/DocumentEditor.tsx +++ b/src/components/editor/DocumentEditor.tsx @@ -910,11 +910,8 @@ export function DocumentEditor({ }, }), ], - ...(contentType === "markdown" && typeof content === "string" - ? { content, contentType: "markdown" as const } - : content - ? { content } - : {}), + // NOTE: initial content intentionally omitted — content is set via the + // useEffect below after first paint to avoid blocking modal-open animation. onUpdate: ({ editor }) => { const md = typeof editor.getMarkdown === "function" ? editor.getMarkdown() : ""; @@ -930,11 +927,17 @@ export function DocumentEditor({ }, }); - // Sync incoming content only when it actually differs from the live editor state. + const hasLoadedInitialContentRef = useRef(false); + + // Sync incoming content only when it actually differs from the live editor + // state. The first content load is deferred to after the modal's open paint + // to avoid blocking the open animation with markdown parsing + KaTeX renders. useEffect(() => { if (!editor) return; - queueMicrotask(() => { + const applyContent = () => { + if (editor.isDestroyed) return; + if (contentType === "markdown" && typeof content === "string") { if (editor.getMarkdown?.() === content) return; editor.commands.setContent(content, { @@ -949,8 +952,35 @@ export function DocumentEditor({ return; editor.commands.setContent(nextContent, { emitUpdate: false }); + }; + + if (hasLoadedInitialContentRef.current) { + // External update (e.g., ZeroDB sync from another tab). Apply immediately. + applyContent(); + return; + } + + // Initial mount: defer content load until after the modal's open paint to + // avoid blocking the animation. Two rAFs ensures we run after at least one + // committed frame is on screen. + let rafA: number | null = null; + let rafB: number | null = null; + rafA = requestAnimationFrame(() => { + rafB = requestAnimationFrame(() => { + if (editor.isDestroyed) return; + applyContent(); + hasLoadedInitialContentRef.current = true; + if (autofocus) { + editor.commands.focus("end"); + } + }); }); - }, [editor, content, contentType]); + + return () => { + if (rafA != null) cancelAnimationFrame(rafA); + if (rafB != null) cancelAnimationFrame(rafB); + }; + }, [editor, content, contentType, autofocus]); return (
Date: Tue, 28 Apr 2026 07:01:04 +0000 Subject: [PATCH 2/6] Address Greptile feedback: ref autofocus + reset on editor recreation Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/editor/DocumentEditor.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/editor/DocumentEditor.tsx b/src/components/editor/DocumentEditor.tsx index 6f325458..58ce0aca 100644 --- a/src/components/editor/DocumentEditor.tsx +++ b/src/components/editor/DocumentEditor.tsx @@ -928,6 +928,14 @@ export function DocumentEditor({ }); const hasLoadedInitialContentRef = useRef(false); + const autofocusRef = useRef(autofocus); + autofocusRef.current = autofocus; + + // Reset the initial-load flag when the editor instance itself changes so a + // recreated editor still benefits from the deferred-paint optimization. + useEffect(() => { + hasLoadedInitialContentRef.current = false; + }, [editor]); // Sync incoming content only when it actually differs from the live editor // state. The first content load is deferred to after the modal's open paint @@ -970,7 +978,7 @@ export function DocumentEditor({ if (editor.isDestroyed) return; applyContent(); hasLoadedInitialContentRef.current = true; - if (autofocus) { + if (autofocusRef.current) { editor.commands.focus("end"); } }); @@ -980,7 +988,7 @@ export function DocumentEditor({ if (rafA != null) cancelAnimationFrame(rafA); if (rafB != null) cancelAnimationFrame(rafB); }; - }, [editor, content, contentType, autofocus]); + }, [editor, content, contentType]); return (
Date: Wed, 29 Apr 2026 04:23:06 +0000 Subject: [PATCH 3/6] Fix cursor landing at end of doc instead of start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The explicit editor.commands.focus("end") call was overriding Tiptap's native autofocus behavior. Per Tiptap's resolveFocusPosition, autofocus: true means "start" of doc — so the original (pre-defer) code placed the cursor at start. Tiptap natively focuses the empty doc at position 0 via setTimeout(0) from mount(); after deferred setContent replaces the doc, the cursor maps to position 0 of the new doc which is the start. No explicit focus call needed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/editor/DocumentEditor.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/editor/DocumentEditor.tsx b/src/components/editor/DocumentEditor.tsx index 58ce0aca..eb02bd0e 100644 --- a/src/components/editor/DocumentEditor.tsx +++ b/src/components/editor/DocumentEditor.tsx @@ -928,8 +928,6 @@ export function DocumentEditor({ }); const hasLoadedInitialContentRef = useRef(false); - const autofocusRef = useRef(autofocus); - autofocusRef.current = autofocus; // Reset the initial-load flag when the editor instance itself changes so a // recreated editor still benefits from the deferred-paint optimization. @@ -978,9 +976,6 @@ export function DocumentEditor({ if (editor.isDestroyed) return; applyContent(); hasLoadedInitialContentRef.current = true; - if (autofocusRef.current) { - editor.commands.focus("end"); - } }); }); From 4ef269acd910bf00e6793086b87bf4565610610b Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:36:37 +0000 Subject: [PATCH 4/6] Cache KaTeX renders via shared LRU to skip per-open repeat work The Mathematics extension's NodeView calls katex.render() synchronously every time a math node mounts, with no memoization. Reopening the same doc (or rendering duplicate expressions across docs) repays the full parse + DOM-build cost. Add a CachedMathematics drop-in replacement that wraps BlockMath and InlineMath with NodeViews backed by a module-scoped LRU cache around katex.renderToString. Cache key = `${latex}\0${JSON.stringify(opts)}`, shared across all editor instances. Memory cost ~75KB at the 500-entry cap. Visual output, classes, dataset attrs, and click handling are identical to upstream. Caveat: assumes katexOptions.macros is not used (\gdef would mutate the shared macros object across renders). Documented in the file. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/editor/DocumentEditor.tsx | 4 +- .../editor/cached-mathematics-extension.ts | 190 ++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/cached-mathematics-extension.ts diff --git a/src/components/editor/DocumentEditor.tsx b/src/components/editor/DocumentEditor.tsx index eb02bd0e..69f1df35 100644 --- a/src/components/editor/DocumentEditor.tsx +++ b/src/components/editor/DocumentEditor.tsx @@ -22,7 +22,7 @@ import { Highlight } from "@tiptap/extension-highlight"; import { Subscript } from "@tiptap/extension-subscript"; import { Superscript } from "@tiptap/extension-superscript"; import { Selection } from "@tiptap/extensions"; -import { Mathematics } from "@tiptap/extension-mathematics"; +import { CachedMathematics } from "@/components/editor/cached-mathematics-extension"; import { TableKit } from "@tiptap/extension-table"; import { Markdown } from "@tiptap/markdown"; import { CustomCodeBlock } from "@/components/tiptap-node/code-block-node/code-block-extension"; @@ -887,7 +887,7 @@ export function DocumentEditor({ Superscript, Subscript, Selection, - Mathematics.configure({ + CachedMathematics.configure({ katexOptions: { throwOnError: false, }, diff --git a/src/components/editor/cached-mathematics-extension.ts b/src/components/editor/cached-mathematics-extension.ts new file mode 100644 index 00000000..0bab3740 --- /dev/null +++ b/src/components/editor/cached-mathematics-extension.ts @@ -0,0 +1,190 @@ +"use client"; + +import { Extension } from "@tiptap/core"; +import { + BlockMath, + InlineMath, + type MathematicsOptions, +} from "@tiptap/extension-mathematics"; +import katex, { type KatexOptions } from "katex"; + +/** + * LRU cache for KaTeX render results, shared across all editor instances. + * + * KaTeX rendering is deterministic given (latex, options), so caching by + * `${latex}\0${JSON.stringify(options)}` is safe and lets reopens of the + * same doc — and duplicate expressions across docs — skip the synchronous + * parse + DOM-build cost. + * + * Caveat: this assumes `katexOptions.macros` is not used. `\gdef` mutates + * the shared `macros` object across renders, which would make cached HTML + * stale. We do not use `macros` in this codebase. + */ +const CACHE_MAX = 500; + +type Cached = { html: string; error: boolean }; + +const renderCache = new Map(); + +function cachedRenderToString( + latex: string, + options: KatexOptions | undefined, +): Cached { + const key = `${latex}\u0000${options ? JSON.stringify(options) : ""}`; + const existing = renderCache.get(key); + if (existing) { + // LRU touch: move to end of insertion order. + renderCache.delete(key); + renderCache.set(key, existing); + return existing; + } + + let result: Cached; + try { + result = { html: katex.renderToString(latex, options), error: false }; + } catch { + result = { html: "", error: true }; + } + + if (renderCache.size >= CACHE_MAX) { + const oldest = renderCache.keys().next().value; + if (oldest !== undefined) { + renderCache.delete(oldest); + } + } + renderCache.set(key, result); + return result; +} + +const CachedInlineMath = InlineMath.extend({ + addNodeView() { + return ({ node, getPos }) => { + const wrapper = document.createElement("span"); + wrapper.className = "tiptap-mathematics-render"; + + if (this.editor.isEditable) { + wrapper.classList.add("tiptap-mathematics-render--editable"); + } + + wrapper.dataset.type = "inline-math"; + wrapper.setAttribute("data-latex", node.attrs.latex); + + const result = cachedRenderToString( + node.attrs.latex, + this.options.katexOptions, + ); + if (result.error) { + wrapper.textContent = node.attrs.latex; + wrapper.classList.add("inline-math-error"); + } else { + wrapper.innerHTML = result.html; + } + + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const pos = getPos(); + if (pos == null) return; + if (this.options.onClick) { + this.options.onClick(node, pos); + } + }; + + if (this.options.onClick) { + wrapper.addEventListener("click", handleClick); + } + + return { + dom: wrapper, + destroy: () => { + wrapper.removeEventListener("click", handleClick); + }, + }; + }; + }, +}); + +const CachedBlockMath = BlockMath.extend({ + addNodeView() { + return ({ node, getPos }) => { + const wrapper = document.createElement("div"); + const innerWrapper = document.createElement("div"); + wrapper.className = "tiptap-mathematics-render"; + + if (this.editor.isEditable) { + wrapper.classList.add("tiptap-mathematics-render--editable"); + } + + innerWrapper.className = "block-math-inner"; + wrapper.dataset.type = "block-math"; + wrapper.setAttribute("data-latex", node.attrs.latex); + wrapper.appendChild(innerWrapper); + + const result = cachedRenderToString( + node.attrs.latex, + this.options.katexOptions, + ); + if (result.error) { + // Match upstream behavior: replace wrapper contents with raw latex on error. + wrapper.textContent = node.attrs.latex; + wrapper.classList.add("block-math-error"); + } else { + innerWrapper.innerHTML = result.html; + } + + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const pos = getPos(); + if (pos == null) return; + if (this.options.onClick) { + this.options.onClick(node, pos); + } + }; + + if (this.options.onClick) { + wrapper.addEventListener("click", handleClick); + } + + return { + dom: wrapper, + destroy: () => { + wrapper.removeEventListener("click", handleClick); + }, + }; + }; + }, +}); + +/** + * Drop-in replacement for `Mathematics` from `@tiptap/extension-mathematics` + * that renders math via a shared LRU cache around `katex.renderToString`. + * + * Same options shape as the upstream `Mathematics` extension. + */ +export const CachedMathematics = Extension.create({ + name: "Mathematics", + + addOptions() { + return { + inlineOptions: undefined, + blockOptions: undefined, + katexOptions: undefined, + }; + }, + + addExtensions() { + return [ + CachedBlockMath.configure({ + ...this.options.blockOptions, + katexOptions: this.options.katexOptions, + }), + CachedInlineMath.configure({ + ...this.options.inlineOptions, + katexOptions: this.options.katexOptions, + }), + ]; + }, +}); + +export default CachedMathematics; From f91448ca62956ed95f3a7cbf074c39904f400d04 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:43:03 +0000 Subject: [PATCH 5/6] Force cursor to start after deferred setContent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setContent uses tr.replaceWith over the full doc range, and ProseMirror's selection mapping for a position inside a fully-replaced range biases to the END of the replacement. So even though Tiptap's native autofocus runs on the empty doc and lands at position 0, the later setContent shifts the cursor to the doc end. Explicitly call focus("start") (or setTextSelection(0) when autofocus is false) after applyContent in the initial-load rAF to match the original autofocus:true semantics — cursor at start, optionally focused. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/editor/DocumentEditor.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/editor/DocumentEditor.tsx b/src/components/editor/DocumentEditor.tsx index 69f1df35..2bfd88e0 100644 --- a/src/components/editor/DocumentEditor.tsx +++ b/src/components/editor/DocumentEditor.tsx @@ -928,6 +928,8 @@ export function DocumentEditor({ }); const hasLoadedInitialContentRef = useRef(false); + const autofocusRef = useRef(autofocus); + autofocusRef.current = autofocus; // Reset the initial-load flag when the editor instance itself changes so a // recreated editor still benefits from the deferred-paint optimization. @@ -976,6 +978,14 @@ export function DocumentEditor({ if (editor.isDestroyed) return; applyContent(); hasLoadedInitialContentRef.current = true; + // setContent uses tr.replaceWith over the full doc range, which makes + // ProseMirror's selection mapping land at the END of the replacement. + // Move the cursor to the start to match Tiptap's native autofocus:true. + if (autofocusRef.current) { + editor.commands.focus("start"); + } else { + editor.commands.setTextSelection(0); + } }); }); From b008639556bad2797a9868630e8bf9762915730d Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:52:56 +0000 Subject: [PATCH 6/6] Revert KaTeX render cache Removing the CachedMathematics wrapper. The rAF-deferred setContent in DocumentEditor already solves the original "dialog feels delayed on open" complaint by unblocking the modal-open animation. The cache only helps repeat opens / cross-doc duplicate expressions, which is a marginal win for typical usage, and costs a custom NodeView that has to stay in sync with upstream BlockMath/InlineMath plus a footgun for future katexOptions.macros usage. Easier to add back later if perf telemetry actually justifies it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/editor/DocumentEditor.tsx | 4 +- .../editor/cached-mathematics-extension.ts | 190 ------------------ 2 files changed, 2 insertions(+), 192 deletions(-) delete mode 100644 src/components/editor/cached-mathematics-extension.ts diff --git a/src/components/editor/DocumentEditor.tsx b/src/components/editor/DocumentEditor.tsx index 2bfd88e0..5bfa1cca 100644 --- a/src/components/editor/DocumentEditor.tsx +++ b/src/components/editor/DocumentEditor.tsx @@ -22,7 +22,7 @@ import { Highlight } from "@tiptap/extension-highlight"; import { Subscript } from "@tiptap/extension-subscript"; import { Superscript } from "@tiptap/extension-superscript"; import { Selection } from "@tiptap/extensions"; -import { CachedMathematics } from "@/components/editor/cached-mathematics-extension"; +import { Mathematics } from "@tiptap/extension-mathematics"; import { TableKit } from "@tiptap/extension-table"; import { Markdown } from "@tiptap/markdown"; import { CustomCodeBlock } from "@/components/tiptap-node/code-block-node/code-block-extension"; @@ -887,7 +887,7 @@ export function DocumentEditor({ Superscript, Subscript, Selection, - CachedMathematics.configure({ + Mathematics.configure({ katexOptions: { throwOnError: false, }, diff --git a/src/components/editor/cached-mathematics-extension.ts b/src/components/editor/cached-mathematics-extension.ts deleted file mode 100644 index 0bab3740..00000000 --- a/src/components/editor/cached-mathematics-extension.ts +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import { Extension } from "@tiptap/core"; -import { - BlockMath, - InlineMath, - type MathematicsOptions, -} from "@tiptap/extension-mathematics"; -import katex, { type KatexOptions } from "katex"; - -/** - * LRU cache for KaTeX render results, shared across all editor instances. - * - * KaTeX rendering is deterministic given (latex, options), so caching by - * `${latex}\0${JSON.stringify(options)}` is safe and lets reopens of the - * same doc — and duplicate expressions across docs — skip the synchronous - * parse + DOM-build cost. - * - * Caveat: this assumes `katexOptions.macros` is not used. `\gdef` mutates - * the shared `macros` object across renders, which would make cached HTML - * stale. We do not use `macros` in this codebase. - */ -const CACHE_MAX = 500; - -type Cached = { html: string; error: boolean }; - -const renderCache = new Map(); - -function cachedRenderToString( - latex: string, - options: KatexOptions | undefined, -): Cached { - const key = `${latex}\u0000${options ? JSON.stringify(options) : ""}`; - const existing = renderCache.get(key); - if (existing) { - // LRU touch: move to end of insertion order. - renderCache.delete(key); - renderCache.set(key, existing); - return existing; - } - - let result: Cached; - try { - result = { html: katex.renderToString(latex, options), error: false }; - } catch { - result = { html: "", error: true }; - } - - if (renderCache.size >= CACHE_MAX) { - const oldest = renderCache.keys().next().value; - if (oldest !== undefined) { - renderCache.delete(oldest); - } - } - renderCache.set(key, result); - return result; -} - -const CachedInlineMath = InlineMath.extend({ - addNodeView() { - return ({ node, getPos }) => { - const wrapper = document.createElement("span"); - wrapper.className = "tiptap-mathematics-render"; - - if (this.editor.isEditable) { - wrapper.classList.add("tiptap-mathematics-render--editable"); - } - - wrapper.dataset.type = "inline-math"; - wrapper.setAttribute("data-latex", node.attrs.latex); - - const result = cachedRenderToString( - node.attrs.latex, - this.options.katexOptions, - ); - if (result.error) { - wrapper.textContent = node.attrs.latex; - wrapper.classList.add("inline-math-error"); - } else { - wrapper.innerHTML = result.html; - } - - const handleClick = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const pos = getPos(); - if (pos == null) return; - if (this.options.onClick) { - this.options.onClick(node, pos); - } - }; - - if (this.options.onClick) { - wrapper.addEventListener("click", handleClick); - } - - return { - dom: wrapper, - destroy: () => { - wrapper.removeEventListener("click", handleClick); - }, - }; - }; - }, -}); - -const CachedBlockMath = BlockMath.extend({ - addNodeView() { - return ({ node, getPos }) => { - const wrapper = document.createElement("div"); - const innerWrapper = document.createElement("div"); - wrapper.className = "tiptap-mathematics-render"; - - if (this.editor.isEditable) { - wrapper.classList.add("tiptap-mathematics-render--editable"); - } - - innerWrapper.className = "block-math-inner"; - wrapper.dataset.type = "block-math"; - wrapper.setAttribute("data-latex", node.attrs.latex); - wrapper.appendChild(innerWrapper); - - const result = cachedRenderToString( - node.attrs.latex, - this.options.katexOptions, - ); - if (result.error) { - // Match upstream behavior: replace wrapper contents with raw latex on error. - wrapper.textContent = node.attrs.latex; - wrapper.classList.add("block-math-error"); - } else { - innerWrapper.innerHTML = result.html; - } - - const handleClick = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const pos = getPos(); - if (pos == null) return; - if (this.options.onClick) { - this.options.onClick(node, pos); - } - }; - - if (this.options.onClick) { - wrapper.addEventListener("click", handleClick); - } - - return { - dom: wrapper, - destroy: () => { - wrapper.removeEventListener("click", handleClick); - }, - }; - }; - }, -}); - -/** - * Drop-in replacement for `Mathematics` from `@tiptap/extension-mathematics` - * that renders math via a shared LRU cache around `katex.renderToString`. - * - * Same options shape as the upstream `Mathematics` extension. - */ -export const CachedMathematics = Extension.create({ - name: "Mathematics", - - addOptions() { - return { - inlineOptions: undefined, - blockOptions: undefined, - katexOptions: undefined, - }; - }, - - addExtensions() { - return [ - CachedBlockMath.configure({ - ...this.options.blockOptions, - katexOptions: this.options.katexOptions, - }), - CachedInlineMath.configure({ - ...this.options.inlineOptions, - katexOptions: this.options.katexOptions, - }), - ]; - }, -}); - -export default CachedMathematics;