From a4b11089c060228a186472c1b09160cf2b50db96 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 18:19:35 +0200 Subject: [PATCH] fix: debounce rich editor serialization --- docs/ABSTRACTIONS.md | 2 + src/App.tsx | 11 +- src/components/Editor.test.tsx | 71 ++++++++++-- src/components/Editor.tsx | 49 ++------- .../editorContentFlushRegistration.ts | 84 +++++++++++++++ src/hooks/editorChangeDebounce.ts | 75 +++++++++++++ src/hooks/useEditorTabSwap.rename.test.ts | 12 +++ src/hooks/useEditorTabSwap.test.ts | 102 +++++++++++++++++- src/hooks/useEditorTabSwap.ts | 38 ++++--- tests/smoke/long-note-editor-scaling.spec.ts | 101 +++++++++++++++++ 10 files changed, 478 insertions(+), 67 deletions(-) create mode 100644 src/components/editorContentFlushRegistration.ts create mode 100644 src/hooks/editorChangeDebounce.ts create mode 100644 tests/smoke/long-note-editor-scaling.spec.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index cc2070e8..6e0ed3bd 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -584,6 +584,8 @@ flowchart LR style E fill:#d4edda,stroke:#28a745,color:#000 ``` +Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path. + ### Wikilink Navigation Two navigation mechanisms: diff --git a/src/App.tsx b/src/App.tsx index 37814149..f3ccb0c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -555,8 +555,10 @@ function App() { onToast: (msg) => setToastMessage(msg), onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath), }) + const flushPendingEditorContentRef = useRef<((path: string) => void) | null>(null) const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null) const flushEditorStateBeforeAction = async (path: string) => { + flushPendingEditorContentRef.current?.(path) flushPendingRawContentRef.current?.(path) await appSave.flushBeforeAction(path) } @@ -980,10 +982,14 @@ function App() { }, [handleAppContentChange, recordAutoGitActivity]) const handleTrackedSave = useCallback(async (...args: Parameters) => { + if (notes.activeTabPath) { + flushPendingEditorContentRef.current?.(notes.activeTabPath) + flushPendingRawContentRef.current?.(notes.activeTabPath) + } const result = await handleAppSave(...args) recordAutoGitActivity() return result - }, [handleAppSave, recordAutoGitActivity]) + }, [handleAppSave, notes.activeTabPath, recordAutoGitActivity]) const seedAutoGitSavedChange = useCallback(async () => { if (isTauri()) { @@ -1029,7 +1035,7 @@ function App() { handleUpdateFrontmatter: notes.handleUpdateFrontmatter, handleDeleteProperty: notes.handleDeleteProperty, setToastMessage, createTypeEntry: notes.createTypeEntrySilent, - onBeforeAction: appSave.flushBeforeAction, + onBeforeAction: flushEditorStateBeforeAction, }) const deleteActions = useDeleteActions({ @@ -1673,6 +1679,7 @@ function App() { isConflicted={conflictFlow.isConflicted} onKeepMine={conflictFlow.handleKeepMine} onKeepTheirs={conflictFlow.handleKeepTheirs} + flushPendingEditorContentRef={flushPendingEditorContentRef} flushPendingRawContentRef={flushPendingRawContentRef} locale={appLocale} /> diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index d3ec3b4e..77c22b5a 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -43,6 +43,9 @@ const mockEditor = vi.hoisted(() => ({ const blockNoteCreation = vi.hoisted(() => ({ options: [] as unknown[], })) +const blockNoteViewState = vi.hoisted(() => ({ + onChange: null as (() => void) | null, +})) // Mock BlockNote components vi.mock('@blocknote/core', () => ({ @@ -81,16 +84,23 @@ vi.mock('@blocknote/react', () => ({ ComponentsContext: { Provider: ({ children }: PropsWithChildren) => <>{children}, }, - BlockNoteViewRaw: ({ children, editable }: PropsWithChildren<{ editable?: boolean }>) => ( -
-
- {children} -
- ), + BlockNoteViewRaw: ({ + children, + editable, + onChange, + }: PropsWithChildren<{ editable?: boolean; onChange?: () => void }>) => { + blockNoteViewState.onChange = onChange ?? null + return ( +
+
+ {children} +
+ ) + }, FormattingToolbarController: () => null, LinkToolbarController: () => null, EditLinkButton: () => null, @@ -224,6 +234,7 @@ function renderEditor(overrides: Partial = {}) { describe('Editor', () => { beforeEach(() => { blockNoteCreation.options = [] + blockNoteViewState.onChange = null }) it('shows empty state when no tabs are open', () => { @@ -387,6 +398,46 @@ describe('Editor', () => { }) }) + it('registers a rich-editor flush hook for pending BlockNote changes', async () => { + const onContentChange = vi.fn() + const flushPendingEditorContentRef = { current: null as ((path: string) => void) | null } + const originalMarkdownSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation() + mockEditor.replaceBlocks.mockClear() + + try { + renderEditor({ + tabs: [mockTab], + activeTabPath: mockEntry.path, + onContentChange, + flushPendingEditorContentRef, + }) + + await vi.waitFor(() => { + expect(blockNoteViewState.onChange).toEqual(expect.any(Function)) + expect(flushPendingEditorContentRef.current).toEqual(expect.any(Function)) + }) + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) + + mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('# Test Project\n\nEdited rich body.\n') + + act(() => { + blockNoteViewState.onChange?.() + }) + expect(onContentChange).not.toHaveBeenCalled() + + act(() => { + flushPendingEditorContentRef.current?.(mockEntry.path) + }) + + expect(onContentChange).toHaveBeenCalledWith( + mockEntry.path, + expect.stringContaining('Edited rich body.'), + ) + } finally { + mockEditor.blocksToMarkdownLossy.mockImplementation(originalMarkdownSerializer) + } + }) + it('disables native text assistance on the rich editor editable surface', () => { renderEditor({ tabs: [mockTab], diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 377a5a1e..2825b1b3 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -25,6 +25,7 @@ import { resolvePendingRawExitContent, resolveRawModeContent, } from './editorRawModeSync' +import { useRegisterEditorContentFlushes } from './editorContentFlushRegistration' import { useRawModeWithFlush } from './useRawModeWithFlush' import { createArrowLigaturesExtension } from './arrowLigaturesExtension' import { createMathInputExtension } from './mathInputExtension' @@ -104,6 +105,8 @@ interface EditorProps { onKeepMine?: (path: string) => void /** Resolve conflict by keeping the remote version. */ onKeepTheirs?: (path: string) => void + /** Registers a hook that flushes pending rich-editor changes into app state before external actions. */ + flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null> /** Registers a hook that flushes the raw editor buffer into app state before external actions. */ flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null> locale?: AppLocale @@ -219,7 +222,7 @@ function useEditorSetup({ })) }, [activeTabPath, setPendingRawExitContent, tabs]) - const { handleEditorChange, editorMountedRef } = useEditorTabSwap({ + const { handleEditorChange, flushPendingEditorChange, editorMountedRef } = useEditorTabSwap({ tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, vaultPath, }) useEditorFocus(editor, editorMountedRef) @@ -244,45 +247,11 @@ function useEditorSetup({ editor, activeTab, rawLatestContentRef, rawModeContent, rawMode, diffMode, diffContent, diffLoading, handleToggleDiffExclusive, handleToggleRawExclusive, - handleEditorChange, handleViewCommitDiff, + handleEditorChange, flushPendingEditorChange, handleViewCommitDiff, isLoadingNewTab, activeStatus, showDiffToggle, } } -function useRegisterRawContentFlush({ - activeTab, - rawLatestContentRef, - rawMode, - onContentChange, - flushPendingRawContentRef, -}: { - activeTab: Tab | null - rawLatestContentRef: React.MutableRefObject - rawMode: boolean - onContentChange?: (path: string, content: string) => void - flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null> -}) { - const flushPendingRawContent = useCallback((path: string) => { - if (!rawMode || !activeTab || activeTab.entry.path !== path) return - - const latestContent = rawLatestContentRef.current - if (latestContent === null || latestContent === activeTab.content) return - - onContentChange?.(path, latestContent) - }, [activeTab, onContentChange, rawLatestContentRef, rawMode]) - - useEffect(() => { - if (!flushPendingRawContentRef) return - - flushPendingRawContentRef.current = flushPendingRawContent - return () => { - if (flushPendingRawContentRef.current === flushPendingRawContent) { - flushPendingRawContentRef.current = null - } - } - }, [flushPendingRawContent, flushPendingRawContentRef]) -} - function useEditorFindCommand({ activeTab, findInNoteRef, @@ -558,7 +527,7 @@ export const Editor = memo(function Editor(props: EditorProps) { noteLayout, onToggleNoteLayout, onFileCreated, onFileModified, onVaultChanged, isConflicted, onKeepMine, onKeepTheirs, - flushPendingRawContentRef, findInNoteRef, + flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale, } = props @@ -566,7 +535,7 @@ export const Editor = memo(function Editor(props: EditorProps) { editor, activeTab, rawLatestContentRef, rawModeContent, rawMode, diffMode, diffContent, diffLoading, handleToggleDiffExclusive, handleToggleRawExclusive, - handleEditorChange, handleViewCommitDiff, + handleEditorChange, flushPendingEditorChange, handleViewCommitDiff, isLoadingNewTab, activeStatus, showDiffToggle, } = useEditorSetup({ tabs, activeTabPath, vaultPath, onContentChange, @@ -584,8 +553,10 @@ export const Editor = memo(function Editor(props: EditorProps) { rawMode, }) - useRegisterRawContentFlush({ + useRegisterEditorContentFlushes({ activeTab, + flushPendingEditorChange, + flushPendingEditorContentRef, rawLatestContentRef, rawMode, onContentChange, diff --git a/src/components/editorContentFlushRegistration.ts b/src/components/editorContentFlushRegistration.ts new file mode 100644 index 00000000..c4cb3fa9 --- /dev/null +++ b/src/components/editorContentFlushRegistration.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect } from 'react' +import type React from 'react' + +type Tab = { + entry: { path: string } + content: string +} + +export function useRegisterEditorContentFlushes({ + activeTab, + flushPendingEditorChange, + flushPendingEditorContentRef, + rawLatestContentRef, + rawMode, + onContentChange, + flushPendingRawContentRef, +}: { + activeTab: Tab | null + flushPendingEditorChange: () => boolean + flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null> + rawLatestContentRef: React.MutableRefObject + rawMode: boolean + onContentChange?: (path: string, content: string) => void + flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null> +}) { + useRegisterRichContentFlush({ activeTab, flushPendingEditorChange, flushPendingEditorContentRef }) + useRegisterRawContentFlush({ activeTab, rawLatestContentRef, rawMode, onContentChange, flushPendingRawContentRef }) +} + +function useRegisterRichContentFlush({ + activeTab, + flushPendingEditorChange, + flushPendingEditorContentRef, +}: { + activeTab: Tab | null + flushPendingEditorChange: () => boolean + flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null> +}) { + const flushPendingEditorContent = useCallback((path: string) => { + if (!activeTab || activeTab.entry.path !== path) return + flushPendingEditorChange() + }, [activeTab, flushPendingEditorChange]) + + useRegisteredFlushRef(flushPendingEditorContentRef, flushPendingEditorContent) +} + +function useRegisterRawContentFlush({ + activeTab, + rawLatestContentRef, + rawMode, + onContentChange, + flushPendingRawContentRef, +}: { + activeTab: Tab | null + rawLatestContentRef: React.MutableRefObject + rawMode: boolean + onContentChange?: (path: string, content: string) => void + flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null> +}) { + const flushPendingRawContent = useCallback((path: string) => { + if (!rawMode || !activeTab || activeTab.entry.path !== path) return + + const latestContent = rawLatestContentRef.current + if (latestContent === null || latestContent === activeTab.content) return + + onContentChange?.(path, latestContent) + }, [activeTab, onContentChange, rawLatestContentRef, rawMode]) + + useRegisteredFlushRef(flushPendingRawContentRef, flushPendingRawContent) +} + +function useRegisteredFlushRef( + ref: React.MutableRefObject<((path: string) => void) | null> | undefined, + flush: (path: string) => void, +) { + useEffect(() => { + if (!ref) return + + ref.current = flush + return () => { + if (ref.current === flush) ref.current = null + } + }, [flush, ref]) +} diff --git a/src/hooks/editorChangeDebounce.ts b/src/hooks/editorChangeDebounce.ts new file mode 100644 index 00000000..cf166f97 --- /dev/null +++ b/src/hooks/editorChangeDebounce.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' + +export const RICH_EDITOR_CHANGE_DEBOUNCE_MS = 150 + +export function useDebouncedEditorChange({ + onFlush, + suppressChangeRef, +}: { + onFlush: () => void + suppressChangeRef: MutableRefObject +}) { + const pendingRef = useRef(false) + const timerRef = useRef | null>(null) + + const clearTimer = useCallback(() => { + if (!timerRef.current) return + clearTimeout(timerRef.current) + timerRef.current = null + }, []) + + const flushPendingEditorChange = useCallback(() => { + if (!pendingRef.current) return false + clearTimer() + pendingRef.current = false + onFlush() + return true + }, [clearTimer, onFlush]) + + const handleEditorChange = useCallback(() => { + if (suppressChangeRef.current) return + + pendingRef.current = true + clearTimer() + timerRef.current = setTimeout(() => { + timerRef.current = null + void flushPendingEditorChange() + }, RICH_EDITOR_CHANGE_DEBOUNCE_MS) + }, [clearTimer, flushPendingEditorChange, suppressChangeRef]) + + useEffect(() => { + return () => { + void flushPendingEditorChange() + clearTimer() + } + }, [clearTimer, flushPendingEditorChange]) + + return { handleEditorChange, flushPendingEditorChange } +} + +export function consumeRawModeTransition( + prevRawModeRef: MutableRefObject, + rawMode: boolean | undefined, +) { + const rawModeJustEnded = prevRawModeRef.current && !rawMode + prevRawModeRef.current = !!rawMode + return rawModeJustEnded +} + +export function flushBeforeRawMode(options: { + rawMode?: boolean + flushPendingEditorChange: () => boolean +}) { + const { rawMode, flushPendingEditorChange } = options + if (!rawMode) return false + flushPendingEditorChange() + return true +} + +export function flushBeforePathChange(options: { + pathChanged: boolean + flushPendingEditorChange: () => boolean +}) { + const { pathChanged, flushPendingEditorChange } = options + if (pathChanged) flushPendingEditorChange() +} diff --git a/src/hooks/useEditorTabSwap.rename.test.ts b/src/hooks/useEditorTabSwap.rename.test.ts index 355762da..984b846d 100644 --- a/src/hooks/useEditorTabSwap.rename.test.ts +++ b/src/hooks/useEditorTabSwap.rename.test.ts @@ -105,6 +105,9 @@ async function expectRenameSessionContinues(options: { renamedTabArrivesLate: bo act(() => { result.current.handleEditorChange() }) + act(() => { + result.current.flushPendingEditorChange() + }) expect(onContentChange).toHaveBeenCalledWith( 'fresh-title.md', @@ -171,6 +174,9 @@ describe('useEditorTabSwap untitled rename continuity', () => { act(() => { result.current.handleEditorChange() }) + act(() => { + result.current.flushPendingEditorChange() + }) const syncedContent = onContentChange.mock.calls.at(-1)?.[1] expect(typeof syncedContent).toBe('string') @@ -211,6 +217,9 @@ describe('useEditorTabSwap untitled rename continuity', () => { act(() => { result.current.handleEditorChange() }) + act(() => { + result.current.flushPendingEditorChange() + }) const queryContent = onContentChange.mock.calls.at(-1)?.[1] expect(typeof queryContent).toBe('string') @@ -218,6 +227,9 @@ describe('useEditorTabSwap untitled rename continuity', () => { act(() => { result.current.handleEditorChange() }) + act(() => { + result.current.flushPendingEditorChange() + }) const insertedContent = onContentChange.mock.calls.at(-1)?.[1] expect(typeof insertedContent).toBe('string') diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 26479c1a..afa349db 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, useEditorTabSwap } from './useEditorTabSwap' +import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap' import { normalizeParsedImageBlocks } from './editorTabContent' import { cacheNoteContent, clearPrefetchCache } from './useTabManagement' @@ -254,6 +254,19 @@ function makeHeadingBlocks( }] } +function makeLongNoteBlocks(wordCount: number) { + const words = Array.from({ length: wordCount }, (_, index) => `word${index}`) + const paragraphs: unknown[] = [] + for (let index = 0; index < words.length; index += 20) { + paragraphs.push({ + type: 'paragraph', + content: [{ type: 'text', text: words.slice(index, index + 20).join(' '), styles: {} }], + children: [], + }) + } + return paragraphs +} + async function flushEditorTick() { await act(() => new Promise((resolve) => setTimeout(resolve, 0))) } @@ -299,6 +312,7 @@ async function createSwapHarness(options: { return { ...rendered, + docRef, mockEditor, async rerenderWith(nextProps: Partial) { currentProps = { ...currentProps, ...nextProps } @@ -701,12 +715,98 @@ describe('useEditorTabSwap raw mode sync', () => { result.current.handleEditorChange() }) + expect(onContentChange).not.toHaveBeenCalled() + + act(() => { + result.current.flushPendingEditorChange() + }) + expect(onContentChange).toHaveBeenCalledWith( 'a.md', '---\ntitle: Note A\n---\nInline $x^2$\n', ) }) + it('coalesces rich-editor serialization for a 4000-word note', async () => { + const tabA = makeTab('long.md', 'Long Note') + const onContentChange = vi.fn() + const { docRef, mockEditor, result } = await createSwapHarness({ + initialProps: { tabs: [tabA], activeTabPath: 'long.md', rawMode: false }, + onContentChange, + }) + const longBlocks = makeLongNoteBlocks(4200) + docRef.current = longBlocks + mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => ( + (blocks as Array<{ content?: Array<{ text?: string }> }>) + .map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '') + .join('\n\n') + )) + mockEditor.blocksToMarkdownLossy.mockClear() + + vi.useFakeTimers() + try { + act(() => { + result.current.handleEditorChange() + result.current.handleEditorChange() + result.current.handleEditorChange() + }) + expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled() + expect(onContentChange).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(RICH_EDITOR_CHANGE_DEBOUNCE_MS - 1) + }) + expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) + }) + + expect(mockEditor.blocksToMarkdownLossy).toHaveBeenCalledTimes(1) + expect(onContentChange).toHaveBeenCalledTimes(1) + expect(onContentChange.mock.calls[0][1].split(/\s+/).length).toBeGreaterThan(4000) + } finally { + vi.useRealTimers() + } + }) + + it('flushes pending rich-editor content before switching notes', async () => { + const tabA = makeTab('a.md', 'Note A') + const tabB = makeTab('b.md', 'Note B') + const onContentChange = vi.fn() + const { docRef, mockEditor, rerender, result } = await createSwapHarness({ + initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, + onContentChange, + }) + docRef.current = [{ + type: 'paragraph', + content: [{ type: 'text', text: 'Changed before switch', styles: {} }], + children: [], + }] + mockEditor.blocksToMarkdownLossy.mockReturnValue('Changed before switch\n') + + vi.useFakeTimers() + try { + act(() => { + result.current.handleEditorChange() + }) + + expect(onContentChange).not.toHaveBeenCalled() + + act(() => { + rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md', rawMode: false }) + }) + await act(async () => { await Promise.resolve() }) + + expect(onContentChange).toHaveBeenCalledWith( + 'a.md', + '---\ntitle: Note A\n---\nChanged before switch\n', + ) + } finally { + vi.useRealTimers() + } + }) + it('re-parses from tab.content when rawMode transitions from true to false', async () => { vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element) vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index cc14e9b7..bc7976de 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -10,6 +10,12 @@ import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages' import { getResolvedCachedNoteContent, NOTE_CONTENT_CACHE_RESOLVED_EVENT } from './useTabManagement' import { useEditorMountState, useLatestRef } from './editorTabSwapLifecycle' import { usePreparedNotePreload } from './usePreparedNotePreload' +import { + consumeRawModeTransition, + flushBeforePathChange, + flushBeforeRawMode, + useDebouncedEditorChange, +} from './editorChangeDebounce' import { repairMalformedEditorBlocks } from './editorBlockRepair' import { blankParagraphBlocks, @@ -23,6 +29,7 @@ import { import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection' import { resetTextSelectionBeforeContentSwap } from './editorTiptapSelection' export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent' +export { RICH_EDITOR_CHANGE_DEBOUNCE_MS } from './editorChangeDebounce' interface Tab { entry: VaultEntry @@ -365,8 +372,7 @@ function useEditorChangeHandler(options: { vaultPathRef, } = options - return useCallback(() => { - if (suppressChangeRef.current) return + const propagateEditorChange = useCallback(() => { const path = prevActivePathRef.current if (!path) return @@ -387,16 +393,9 @@ function useEditorChangeHandler(options: { sourceContent: nextContent, }) onContentChangeRef.current?.(path, nextContent) - }, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef, vaultPathRef]) -} + }, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, tabCacheRef, tabsRef, vaultPathRef]) -function consumeRawModeTransition( - prevRawModeRef: MutableRefObject, - rawMode: boolean | undefined, -) { - const rawModeJustEnded = prevRawModeRef.current && !rawMode - prevRawModeRef.current = !!rawMode - return rawModeJustEnded + return useDebouncedEditorChange({ onFlush: propagateEditorChange, suppressChangeRef }) } function cachePreviousTabOnPathChange(options: { @@ -983,6 +982,7 @@ function runTabSwapEffect(options: { rawSwapPendingRef: MutableRefObject suppressChangeRef: MutableRefObject pendingLocalContentRef: MutableRefObject + flushPendingEditorChange: () => boolean vaultPath?: string }) { const { @@ -998,11 +998,12 @@ function runTabSwapEffect(options: { rawSwapPendingRef, suppressChangeRef, pendingLocalContentRef, + flushPendingEditorChange, vaultPath, } = options const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode) - if (rawMode) return + if (flushBeforeRawMode({ rawMode, flushPendingEditorChange })) return const state = resolveTabSwapState({ tabs, activeTabPath, @@ -1010,6 +1011,7 @@ function runTabSwapEffect(options: { prevActivePathRef, rawModeJustEnded, }) + flushBeforePathChange({ pathChanged: state.pathChanged, flushPendingEditorChange }) if (shouldSkipScheduledTabSwap({ state, @@ -1053,6 +1055,7 @@ function useTabSwapEffect(options: { suppressChangeRef: MutableRefObject pendingLocalContentRef: MutableRefObject vaultPathRef: MutableRefObject + flushPendingEditorChange: () => boolean }) { const { tabs, @@ -1068,6 +1071,7 @@ function useTabSwapEffect(options: { suppressChangeRef, pendingLocalContentRef, vaultPathRef, + flushPendingEditorChange, } = options useEffect(() => { @@ -1084,6 +1088,7 @@ function useTabSwapEffect(options: { rawSwapPendingRef, suppressChangeRef, pendingLocalContentRef, + flushPendingEditorChange, vaultPath: vaultPathRef.current, }) }, [ @@ -1100,6 +1105,7 @@ function useTabSwapEffect(options: { tabs, pendingLocalContentRef, vaultPathRef, + flushPendingEditorChange, ]) } @@ -1112,7 +1118,8 @@ function useTabSwapEffect(options: { * - Cleaning up the block cache when tabs are closed * - Serializing editor blocks → markdown on change (suppressChangeRef) * - * Returns `handleEditorChange`, the onChange callback for SingleEditorView. + * Returns the onChange callback for SingleEditorView and a flush hook for + * save/navigation paths that need the latest rich-editor content immediately. */ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode, vaultPath }: UseEditorTabSwapOptions) { const tabCacheRef = useRef>(new Map()) @@ -1126,7 +1133,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, const onContentChangeRef = useLatestRef(onContentChange) const tabsRef = useLatestRef(tabs) const vaultPathRef = useLatestRef(vaultPath) - const handleEditorChange = useEditorChangeHandler({ + const { handleEditorChange, flushPendingEditorChange } = useEditorChangeHandler({ editor, tabsRef, onContentChangeRef, @@ -1153,6 +1160,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, suppressChangeRef, pendingLocalContentRef, vaultPathRef, + flushPendingEditorChange, }) usePreparedNotePreload({ eventName: NOTE_CONTENT_CACHE_RESOLVED_EVENT, @@ -1161,5 +1169,5 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, preparePath: preparePreloadedPath, }) - return { handleEditorChange, editorMountedRef } + return { handleEditorChange, flushPendingEditorChange, editorMountedRef } } diff --git a/tests/smoke/long-note-editor-scaling.spec.ts b/tests/smoke/long-note-editor-scaling.spec.ts new file mode 100644 index 00000000..59f8e7a9 --- /dev/null +++ b/tests/smoke/long-note-editor-scaling.spec.ts @@ -0,0 +1,101 @@ +import fs from 'fs' +import path from 'path' +import { test, expect, type Page } from '@playwright/test' +import { + createFixtureVaultCopy, + openFixtureVaultDesktopHarness, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' +import { sendShortcut } from './helpers' + +const LONG_NOTE_TITLE = 'Long Note Scaling QA' +const LONG_NOTE_RELATIVE_PATH = path.join('note', 'long-note-scaling-qa.md') +const PARAGRAPH_COUNT = 82 +const WORDS_PER_PARAGRAPH = 50 + +let tempVaultDir: string +let longNotePath: string + +function segmentLabel(index: number): string { + return `Segment ${String(index).padStart(3, '0')}` +} + +function makeLongNoteMarkdown(): string { + const paragraphs = Array.from({ length: PARAGRAPH_COUNT }, (_, paragraphIndex) => { + const segment = segmentLabel(paragraphIndex + 1) + const words = Array.from({ length: WORDS_PER_PARAGRAPH }, (_, wordIndex) => + `scale${String(paragraphIndex + 1).padStart(3, '0')}_${String(wordIndex + 1).padStart(2, '0')}`, + ) + return `${segment} ${words.join(' ')}` + }) + + return `---\ntype: note\n---\n\n# ${LONG_NOTE_TITLE}\n\n${paragraphs.join('\n\n')}\n` +} + +async function openNote(page: Page, title: string): Promise { + const noteList = page.locator('[data-testid="note-list-container"]') + await noteList.getByText(title, { exact: true }).click() + await expect(page.locator('.bn-editor h1').first()).toHaveText(title, { timeout: 8_000 }) +} + +async function appendTextToSegment(page: Page, segment: string, text: string): Promise { + const paragraph = page.locator('.bn-editor p').filter({ hasText: segment }).first() + await paragraph.scrollIntoViewIfNeeded() + await paragraph.click() + await page.keyboard.press('End') + await page.keyboard.type(text) + await expect(page.locator('.bn-editor')).toContainText(text.trim(), { timeout: 5_000 }) +} + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(120_000) + tempVaultDir = createFixtureVaultCopy() + longNotePath = path.join(tempVaultDir, LONG_NOTE_RELATIVE_PATH) + fs.writeFileSync(longNotePath, makeLongNoteMarkdown(), 'utf8') + await openFixtureVaultDesktopHarness(page, tempVaultDir, { expectedReadyTitle: LONG_NOTE_TITLE }) +}) + +test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) +}) + +test('long rich-editor notes stay editable across typing, wikilinks, save, and note switches', async ({ page }) => { + const beginningEdit = ` beginning edit ${Date.now()}` + const middleEdit = ` middle edit ${Date.now()}` + const endEdit = ` end edit ${Date.now()}` + + await openNote(page, LONG_NOTE_TITLE) + + await appendTextToSegment(page, segmentLabel(1), beginningEdit) + await appendTextToSegment(page, segmentLabel(41), middleEdit) + await appendTextToSegment(page, segmentLabel(82), endEdit) + + const wikilinkParagraph = page.locator('.bn-editor p').filter({ hasText: segmentLabel(41) }).first() + await wikilinkParagraph.click() + await page.keyboard.press('End') + await page.keyboard.type(' [[Alpha') + + const wikilinkMenu = page.locator('.wikilink-menu') + await expect(wikilinkMenu).toBeVisible({ timeout: 5_000 }) + await expect(wikilinkMenu).toContainText('Alpha Project') + await page.keyboard.press('Enter') + await expect(page.locator('.bn-editor .wikilink').filter({ hasText: 'Alpha Project' }).last()).toBeVisible() + + await page.keyboard.press('PageUp') + await page.keyboard.press('ArrowDown') + await page.keyboard.press('PageDown') + await sendShortcut(page, 's', ['Control']) + + await openNote(page, 'Note B') + await openNote(page, LONG_NOTE_TITLE) + + const editor = page.locator('.bn-editor') + await expect(editor).toContainText(beginningEdit.trim(), { timeout: 5_000 }) + await expect(editor).toContainText(middleEdit.trim(), { timeout: 5_000 }) + await expect(editor).toContainText(endEdit.trim(), { timeout: 5_000 }) + await expect(editor.locator('.wikilink').filter({ hasText: 'Alpha Project' }).last()).toBeVisible() + + await expect.poll(() => fs.readFileSync(longNotePath, 'utf8'), { timeout: 5_000 }).toContain(beginningEdit.trim()) + await expect.poll(() => fs.readFileSync(longNotePath, 'utf8'), { timeout: 5_000 }).toContain(middleEdit.trim()) + await expect.poll(() => fs.readFileSync(longNotePath, 'utf8'), { timeout: 5_000 }).toContain(endEdit.trim()) +})