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;