From 78cdc390b31e938670156bb1bbeee4db85523a84 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Tue, 25 Aug 2026 22:44:19 +0500 Subject: [PATCH] web: stop the pixel scroll fallback fighting the block anchor --- apps/web/src/components/editor/index.tsx | 21 ++++++++++++++++++++- apps/web/src/components/editor/tiptap.tsx | 6 ++++++ packages/editor/src/hooks/use-editor.ts | 7 +++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/editor/index.tsx b/apps/web/src/components/editor/index.tsx index f37c3aa12..5ae7a6d7a 100644 --- a/apps/web/src/components/editor/index.tsx +++ b/apps/web/src/components/editor/index.tsx @@ -950,6 +950,16 @@ function isFile(e: DragEvent) { ); } +/** The editor is not always registered yet when a note first loads. */ +function retryScrollAnchor(sessionId: string, anchor: ScrollAnchor, tries = 5) { + if (tries <= 0) return; + requestAnimationFrame(() => { + const editor = useEditorManager.getState().getEditor(sessionId)?.editor; + if (editor?.restoreScrollAnchor(anchor)) return; + retryScrollAnchor(sessionId, anchor, tries - 1); + }); +} + function restoreScrollPosition(session: EditorSession, editor?: IEditor) { if (session?.activeBlockId) return scrollIntoViewById(session.activeBlockId); @@ -957,11 +967,20 @@ function restoreScrollPosition(session: EditorSession, editor?: IEditor) { // against a fully rendered document, and a paged one only knows estimated // heights until it renders. The editor reveals the page holding the block // before scrolling, so the position it lands on is the real one. + // + // Once there is an anchor the pixel path must not run at all, not even as a + // fallback: it scrolls to an offset that means something else now, and its + // ResizeObserver fires again every time a page renders and changes the + // document's height. const anchor = Config.get( `${session.id}:scroll-anchor`, null ); - if (anchor && editor?.restoreScrollAnchor(anchor)) return; + if (anchor) { + if (editor?.restoreScrollAnchor(anchor)) return; + retryScrollAnchor(session.id, anchor); + return; + } const scrollContainer = document.getElementById(`editorScroll_${session.id}`); const scrollPosition = Config.get(`${session.id}:scroll-position`, 0); diff --git a/apps/web/src/components/editor/tiptap.tsx b/apps/web/src/components/editor/tiptap.tsx index 2b3993f0d..b61e1bf0e 100644 --- a/apps/web/src/components/editor/tiptap.tsx +++ b/apps/web/src/components/editor/tiptap.tsx @@ -208,6 +208,12 @@ function TipTap(props: TipTapProps) { } = props; const autoSave = useRef(true); + + useEffect(() => { + profiler.count("editor.mounts"); + profiler.event("editor.mount", { id }); + return () => profiler.count("editor.unmounts"); + }, []); const { toolbarConfig } = useToolbarConfig(); const features = useAreFeaturesAvailable([ "callout", diff --git a/packages/editor/src/hooks/use-editor.ts b/packages/editor/src/hooks/use-editor.ts index 6ee931195..5cddf22ba 100644 --- a/packages/editor/src/hooks/use-editor.ts +++ b/packages/editor/src/hooks/use-editor.ts @@ -57,7 +57,8 @@ export const useEditor = ( // The Editor constructor already rendered a view into the same element, // so replacing it on the first run means rendering the whole document // twice. Only later runs (changed options) need a fresh view. - if (isFirstRun.current) { + const firstRun = isFirstRun.current; + if (firstRun) { isFirstRun.current = false; } else { profiler.time("editor.destroyView", () => destroyView(editor.view)); @@ -70,7 +71,9 @@ export const useEditor = ( }); } if (oldIsFocused && !editor.isFocused) editor.commands.focus(); - options.onCreate?.({ editor: editor }); + // The Editor constructor emits `create` for the view it made, so calling + // the option here as well would run every consumer twice on load. + if (!firstRun) options.onCreate?.({ editor: editor }); const { searchTerm, ...searchOptions } = useEditorSearchStore.getState(); if (!searchOptions.isSearching) {