From d39bdf35d16c705e810f95fd7a225683b5ca33ea Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Tue, 25 Aug 2026 18:35:40 +0500 Subject: [PATCH] web: expose the editor profiler and its benchmarks --- apps/web/src/components/editor/profiling.ts | 206 ++++++++++++++++++++ apps/web/src/components/editor/tiptap.tsx | 32 ++- 2 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/editor/profiling.ts diff --git a/apps/web/src/components/editor/profiling.ts b/apps/web/src/components/editor/profiling.ts new file mode 100644 index 000000000..c4fc0d88d --- /dev/null +++ b/apps/web/src/components/editor/profiling.ts @@ -0,0 +1,206 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { Editor, getHTMLFromFragment, profiler } from "@notesnook/editor"; + +let activeEditor: Editor | undefined; + +export function setProfiledEditor(editor: Editor | undefined) { + activeEditor = editor; +} + +function requireEditor(): Editor | undefined { + if (!activeEditor || activeEditor.isDestroyed) { + console.warn("[profiler] no active editor. open a note first."); + return undefined; + } + return activeEditor; +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +function afterPaint(): Promise { + return new Promise((resolve) => + requestAnimationFrame(() => setTimeout(() => resolve(), 0)) + ); +} + +function findScrollParent(node: HTMLElement): HTMLElement | undefined { + let current: HTMLElement | null = node.parentElement; + while (current) { + const overflowY = getComputedStyle(current).overflowY; + if ( + (overflowY === "auto" || overflowY === "scroll") && + current.scrollHeight > current.clientHeight + ) + return current; + current = current.parentElement; + } + return undefined; +} + +function documentStats(editor: Editor) { + const dom = editor.view.dom; + return { + topLevelBlocks: editor.state.doc.childCount, + docNodeSize: editor.state.doc.nodeSize, + characters: editor.state.doc.textContent.length, + domTopLevelElements: dom.children.length, + domTotalElements: dom.getElementsByTagName("*").length, + placeholders: dom.querySelectorAll("[data-virtual-placeholder]").length, + virtualization: !!( + editor.storage.virtualization as { enabled?: boolean } | undefined + )?.enabled + }; +} + +async function typeTest(count = 200, delayMs = 0) { + const editor = requireEditor(); + if (!editor) return; + + const dom = editor.view.dom as HTMLElement; + editor.commands.focus(); + profiler.event("bench.type.start", { count }); + + const start = performance.now(); + for (let i = 0; i < count; i++) { + dom.dispatchEvent( + new KeyboardEvent("keydown", { + key: "a", + bubbles: true, + cancelable: true + }) + ); + editor.view.dispatch(editor.state.tr.insertText("a")); + await afterPaint(); + if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); + } + const elapsed = performance.now() - start; + + profiler.event("bench.type.end", { count, elapsed }); + console.log( + `[profiler] typed ${count} characters in ${Math.round( + elapsed + )}ms (${Math.round(elapsed / count)}ms/char incl. paint)` + ); + return profiler.print(`type:${count}`); +} + +async function scrollTest(steps = 40) { + const editor = requireEditor(); + if (!editor) return; + + const container = findScrollParent(editor.view.dom); + if (!container) { + console.warn("[profiler] no scroll container found."); + return; + } + + const max = container.scrollHeight - container.clientHeight; + const step = max / steps; + profiler.event("bench.scroll.start", { steps, max }); + + const frames: number[] = []; + const start = performance.now(); + for (let i = 0; i <= steps; i++) { + const stepStart = performance.now(); + container.scrollTop = step * i; + await afterPaint(); + const duration = performance.now() - stepStart; + frames.push(duration); + profiler.record("bench.scrollStep", duration); + } + const elapsed = performance.now() - start; + + const slowest = Math.max(...frames); + profiler.event("bench.scroll.end", { elapsed, slowest }); + console.log( + `[profiler] scrolled ${steps} steps in ${Math.round( + elapsed + )}ms (slowest step ${Math.round(slowest)}ms)` + ); + container.scrollTop = 0; + await nextFrame(); + return profiler.print(`scroll:${steps}`); +} + +function serializeTest(runs = 5) { + const editor = requireEditor(); + if (!editor) return; + + for (let i = 0; i < runs; i++) + profiler.time("bench.getHTML", () => + getHTMLFromFragment(editor.state.doc.content, editor.schema) + ); + return profiler.print(`serialize:${runs}`); +} + +export function installProfilerGlobals() { + const api = { + profiler, + enable(options?: { timeline?: boolean }) { + profiler.enable(options); + console.log( + "[profiler] enabled and persisted. reload to profile editor startup." + ); + return api; + }, + disable() { + profiler.disable(); + console.log("[profiler] disabled."); + return api; + }, + reset() { + profiler.reset(); + return api; + }, + report: (label?: string) => profiler.report(label), + print: (label?: string) => profiler.print(label), + snapshot: (label: string) => profiler.snapshot(label), + compare: (baseline: string, candidate: string) => + profiler.compare(baseline, candidate), + snapshots: () => profiler.listSnapshots(), + stats() { + const editor = requireEditor(); + if (!editor) return; + const stats = documentStats(editor); + console.table(stats); + return stats; + }, + copy(label?: string) { + const json = JSON.stringify(profiler.report(label), null, 2); + navigator.clipboard?.writeText(json); + console.log("[profiler] report copied to clipboard."); + return json; + }, + typeTest, + scrollTest, + serializeTest + }; + + (globalThis as unknown as Record).editorProfiler = api; + console.log( + profiler.enabled + ? "[profiler] on by default. window.editorProfiler.print() for a report, .disable() to turn off." + : "[profiler] off. window.editorProfiler.enable() to turn it back on." + ); + return api; +} diff --git a/apps/web/src/components/editor/tiptap.tsx b/apps/web/src/components/editor/tiptap.tsx index 670e1ea9c..35b67788e 100644 --- a/apps/web/src/components/editor/tiptap.tsx +++ b/apps/web/src/components/editor/tiptap.tsx @@ -38,8 +38,10 @@ import { getTableOfContents, getChangedNodes, LinkAttributes, + profiler, type Selection } from "@notesnook/editor"; +import { installProfilerGlobals, setProfiledEditor } from "./profiling"; import { Box, Flex } from "@theme-ui/components"; import { PropsWithChildren, @@ -138,6 +140,7 @@ function countSpaces(text: string) { } function updateNoteStatistics(id: string, content: () => Fragment) { + const end = profiler.start("statistics.update"); const fragment = content(); const documentText = fragment.textBetween(0, fragment.size, "\n", " "); useEditorManager.getState().updateEditor(id, { @@ -160,10 +163,14 @@ function updateNoteStatistics(id: string, content: () => Fragment) { } } }); + end(); + profiler.count("statistics.updates"); } const deferredUpdateNoteStatistics = debounce(updateNoteStatistics, 1000); +installProfilerGlobals(); + function TipTap(props: TipTapProps) { const { id, @@ -247,7 +254,9 @@ function TipTap(props: TipTapProps) { event.preventDefault(); onChange?.( () => - getHTMLFromFragment(editor.state.doc.content, editor.schema), + profiler.time("serialize.getHTML", () => + getHTMLFromFragment(editor.state.doc.content, editor.schema) + ), false ); } @@ -282,6 +291,13 @@ function TipTap(props: TipTapProps) { autofocus: "start", onFocus, onCreate: async ({ editor }) => { + setProfiledEditor(editor as Editor); + profiler.setContext("virtualization", !!virtualization); + profiler.setContext("noteId", id); + profiler.setContext("topLevelBlocks", editor.state.doc.childCount); + profiler.setContext("characters", editor.state.doc.textContent.length); + profiler.event("editor.created"); + if (oldNonce.current !== nonce) editor.commands.focus("start", { scrollIntoView: true }); oldNonce.current = nonce; @@ -289,7 +305,9 @@ function TipTap(props: TipTapProps) { const instance = toIEditor(editor as Editor); if (onLoad) onLoad(instance); - const totalWords = getTotalWords(editor as Editor); + const totalWords = profiler.time("statistics.totalWords", () => + getTotalWords(editor as Editor) + ); useEditorManager.getState().setEditor(id, { editor: instance, canRedo: editor.can().redo(), @@ -341,11 +359,15 @@ function TipTap(props: TipTapProps) { if (!autoSave.current) return; onChange( - () => getHTMLFromFragment(editor.state.doc.content, editor.schema), + () => + profiler.time("serialize.getHTML", () => + getHTMLFromFragment(editor.state.doc.content, editor.schema) + ), ignoreEdit ); }, onDestroy: () => { + setProfiledEditor(undefined); useEditorManager.getState().setEditor(id); }, onTransaction: ({ editor, transaction }) => { @@ -819,7 +841,9 @@ function toIEditor(editor: Editor): IEditor { ), startSearch: () => editor.commands.startSearch(), getContent: () => - getHTMLFromFragment(editor.state.doc.content, editor.schema), + profiler.time("serialize.getHTML", () => + getHTMLFromFragment(editor.state.doc.content, editor.schema) + ), getSelection: () => { const { from, to } = editor.state.selection; return { from, to };