From c7a1eac7781692b92ebda94c04a86cdc669ae140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 12 Oct 2026 18:34:59 -0300 Subject: [PATCH] Keep React commits from walking the focused editor's DOM --- CHANGELOG.md | 1 + .../plugins/reactSelectionOptOut.test.tsx | 89 +++++++++++++++++++ .../editor/plugins/reactSelectionOptOut.ts | 35 ++++++++ .../editor/utils/createMilkdownEditor.ts | 2 + 4 files changed, 127 insertions(+) create mode 100644 src/features/editor/plugins/reactSelectionOptOut.test.tsx create mode 100644 src/features/editor/plugins/reactSelectionOptOut.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 573c9a98..dcf3f412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Keep typing responsive in documents with many code blocks. Every keystroke used to highlight all of the document's code again, so typing slowed sharply as a document's code grew; now an edit highlights only the code blocks it changes, and typing outside code highlights nothing. Line numbers for code blocks, when shown, are now updated the same way instead of being redrawn for every block on each keystroke. - Type faster in documents with many headings. Every keystroke used to work out again which headings could fold and redraw every fold marker and folded section, which took a growing share of each keystroke as headings were added; now an edit updates only the headings and folded sections it can change. - Type without long pauses in large documents. Every keystroke used to update the block gutters of the whole document, so in several hundred kilobytes of Markdown with many headings and lists each character could take seconds to appear. The gutters now keep only the blocks in view up to date, and large documents open faster too. +- Type faster in large documents. While the editor had focus, every update to the status bar, the outline, or the rest of the window went through the whole document first, adding about 30 to 60 ms to each keystroke in a 1 MB document. The status bar's counts and the outline still update as you type. - Open documents with many list items faster. Reading each item's indentation searched back toward the start of the file, so opening took longer with every item and with the file's size, adding about a second to a 1 MB document and much more to larger ones. - Draw the scrollbars of wide code blocks, tables, and display math in the same thin, rounded style as the rest of Leafdown instead of the system default. Help code samples, Find in folder's skipped-file lists, the code block language and wiki link suggestion lists, and menus cut short by a small window match it too. - Show a file opened from File Explorer, File > Open, recent files, or a drop as soon as it is read instead of after its whole folder has been scanned. A file in a large folder such as Documents could leave the window on the welcome screen for several seconds, looking as if the file had not opened. The article navigator shows placeholder rows until the folder is ready, and a folder that cannot be scanned reports its error without closing the document. diff --git a/src/features/editor/plugins/reactSelectionOptOut.test.tsx b/src/features/editor/plugins/reactSelectionOptOut.test.tsx new file mode 100644 index 00000000..74b2c03d --- /dev/null +++ b/src/features/editor/plugins/reactSelectionOptOut.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import type { EditorView } from "@milkdown/kit/prose/view"; +import { describe, expect, it, vi } from "vitest"; + +import { TWO_PARAGRAPH_MARKDOWN } from "@/test/fixtures/editorMarkdown"; +import { setupMilkdownEditorMount } from "@/test/utils/milkdown"; +import { setSelectionAtDocumentEnd } from "@/test/utils/prosemirror"; +import { act, render } from "@/test/utils/react"; + +const mountEditor = setupMilkdownEditorMount(); + +const findNodeValueGetter = (node: Node) => { + for ( + let prototype: object | null = Object.getPrototypeOf(node); + prototype; + prototype = Object.getPrototypeOf(prototype) + ) { + const property = Object.getOwnPropertyDescriptor(prototype, "nodeValue"); + + if (property?.get) { + return { owner: prototype, get: property.get }; + } + } + + throw new Error("nodeValue has no getter"); +}; + +const countEditorTextReadsDuringCommit = (view: EditorView, commit: () => void) => { + const textNode = view.dom.querySelector("p")?.firstChild; + + if (!textNode) { + throw new Error("editor has no text node"); + } + + const { owner, get } = findNodeValueGetter(textNode); + let reads = 0; + const spy = vi.spyOn(owner as Node, "nodeValue", "get").mockImplementation(function (this: Node) { + if (view.dom.contains(this)) reads += 1; + return get.call(this) as string | null; + }); + + try { + act(commit); + } finally { + spy.mockRestore(); + } + + return reads; +}; + +describe("React selection opt-out plugin", () => { + it("keeps the editable attribute while its property no longer reads as true", async () => { + const { view } = await mountEditor(TWO_PARAGRAPH_MARKDOWN); + + expect(view.dom).toHaveAttribute("contenteditable", "true"); + expect(view.dom.contentEditable).toBe("inherit"); + + view.setProps({ editable: () => false }); + + expect(view.dom).toHaveAttribute("contenteditable", "false"); + expect(view.dom.contentEditable).toBe("false"); + }); + + it("removes its property when the editor is destroyed", async () => { + const mounted = await mountEditor(TWO_PARAGRAPH_MARKDOWN); + const { dom } = mounted.view; + + await mounted.destroy(); + + expect(Object.hasOwn(dom, "contentEditable")).toBe(false); + }); + + it("keeps React commits from walking the focused editor's text", async () => { + const { view } = await mountEditor(TWO_PARAGRAPH_MARKDOWN); + let count = 0; + const { rerender } = render({count}); + const commit = () => rerender({++count}); + view.focus(); + setSelectionAtDocumentEnd(view); + + expect(document.activeElement).toBe(view.dom); + expect(countEditorTextReadsDuringCommit(view, commit)).toBe(0); + + Reflect.deleteProperty(view.dom, "contentEditable"); + + expect(countEditorTextReadsDuringCommit(view, commit)).toBeGreaterThan(0); + }); +}); diff --git a/src/features/editor/plugins/reactSelectionOptOut.ts b/src/features/editor/plugins/reactSelectionOptOut.ts new file mode 100644 index 00000000..0932e717 --- /dev/null +++ b/src/features/editor/plugins/reactSelectionOptOut.ts @@ -0,0 +1,35 @@ +import { Plugin } from "@milkdown/kit/prose/state"; +import { $prose } from "@milkdown/kit/utils"; + +/** + * Before every commit, React DOM saves the selection of a focused element whose `contentEditable` + * property reads "true" by counting text offsets across every node beneath it, so each React update + * while the editor has focus walks the whole document. The editor's selection is ProseMirror's to + * keep, so its root reports "inherit" to that property read. The attribute, `isContentEditable`, + * and editing are unchanged. + */ +export const createLeafdownReactSelectionOptOutPlugin = () => + $prose( + () => + new Plugin({ + view: (view) => { + const dom = view.dom; + const prototype: object = Object.getPrototypeOf(dom); + + Object.defineProperty(dom, "contentEditable", { + configurable: true, + get: () => { + const value: unknown = Reflect.get(prototype, "contentEditable", dom); + return value === "true" ? "inherit" : value; + }, + set: (value: string) => Reflect.set(prototype, "contentEditable", value, dom), + }); + + return { + destroy: () => { + Reflect.deleteProperty(dom, "contentEditable"); + }, + }; + }, + }), + ); diff --git a/src/features/editor/utils/createMilkdownEditor.ts b/src/features/editor/utils/createMilkdownEditor.ts index 446e4698..fe231961 100644 --- a/src/features/editor/utils/createMilkdownEditor.ts +++ b/src/features/editor/utils/createMilkdownEditor.ts @@ -147,6 +147,7 @@ import { } from "../plugins/math"; import { createLeafdownMermaidModePlugin } from "../plugins/mermaidMode"; import { createLeafdownPrevailingFormPlugin } from "../plugins/prevailingForm"; +import { createLeafdownReactSelectionOptOutPlugin } from "../plugins/reactSelectionOptOut"; import { commitReferenceDefinitionFields, createLeafdownReferenceDefinitionFieldsPlugin, @@ -438,6 +439,7 @@ export const createMilkdownEditor = async ({ .use(createLeafdownTaskListCheckboxPlugin()) .use(createLeafdownPrevailingFormPlugin()) .use(createLeafdownTrailingParagraphPlugin()) + .use(createLeafdownReactSelectionOptOutPlugin()) .use(createLeafdownDirtyTrackerPlugin(() => onContentChanged?.())) .config((ctx) => { ctx.set(rootCtx, root);