From e84bd1a8cd286436e4a5c429205a7eab6bf7d69b Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 11 May 2026 19:58:01 +0200 Subject: [PATCH] fix: keep note opening responsive --- src/components/Editor.test.tsx | 20 +++- src/components/EditorRightPanel.tsx | 138 +++++++++++++++++++------- src/hooks/editorContentSwapApply.ts | 16 ++- src/hooks/editorParsedBlockPreload.ts | 2 + src/hooks/useEditorTabSwap.test.ts | 31 +----- src/hooks/useEditorTabSwap.ts | 31 +++++- src/hooks/useVaultLoader.test.ts | 30 ++++++ src/hooks/useVaultLoader.ts | 1 + src/hooks/vaultLoaderCommands.ts | 4 +- 9 files changed, 200 insertions(+), 73 deletions(-) diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 4b132dc6..22d53e9c 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -220,6 +220,20 @@ function renderEditor(overrides: Partial = {}) { return render() } +async function flushEditorSwapWork() { + for (let i = 0; i < 4; i += 1) { + await act(async () => { + if (typeof window.requestAnimationFrame === 'function') { + await new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()) + }) + } + await new Promise(resolve => setTimeout(resolve, 0)) + await Promise.resolve() + }) + } +} + describe('Editor', () => { beforeEach(() => { blockNoteCreation.options = [] @@ -407,7 +421,7 @@ describe('Editor', () => { expect(blockNoteViewState.onChange).toEqual(expect.any(Function)) expect(flushPendingEditorContentRef.current).toEqual(expect.any(Function)) }) - await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) + await flushEditorSwapWork() mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('# Test Project\n\nEdited rich body.\n') @@ -556,8 +570,8 @@ describe('Editor', () => { // Regression: editor content did not appear on first load because BlockNote's // replaceBlocks/insertBlocks internally calls flushSync, which fails silently - // when invoked from within React's useEffect. Fix: defer via queueMicrotask. - it('applies parsed content blocks via deferred microtask (regression: flushSync-in-lifecycle)', async () => { + // when invoked from within React's useEffect. Fix: defer outside the effect. + it('applies parsed content blocks after deferred swap work (regression: flushSync-in-lifecycle)', async () => { const testBlocks = [ { id: 'b1', type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }], props: {}, children: [] }, ] diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index 87709721..3be33300 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -54,21 +54,51 @@ interface EditorRightPanelProps { dateDisplayFormat?: DateDisplayFormat } -export function EditorRightPanel({ - showAIChat, showTableOfContents, inspectorCollapsed, inspectorWidth, - editor, - defaultAiAgent = DEFAULT_AI_AGENT, defaultAiTarget, defaultAiAgentReadiness, defaultAiAgentReady = true, - onUnsupportedAiPaste, - inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, - vaultPaths, - noteList, noteListFilter, - onToggleInspector, onToggleAIChat, onToggleTableOfContents, onNavigateWikilink, onViewCommitDiff, - onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onChangeWorkspace, onInitializeProperties, onToggleRawEditor, onOpenNote, - onFileCreated, onFileModified, onVaultChanged, - workspaces, +type AiPanelSectionProps = Pick< + EditorRightPanelProps, + | 'defaultAiAgent' + | 'defaultAiAgentReadiness' + | 'defaultAiAgentReady' + | 'defaultAiTarget' + | 'entries' + | 'inspectorEntry' + | 'inspectorWidth' + | 'locale' + | 'noteList' + | 'noteListFilter' + | 'onFileCreated' + | 'onFileModified' + | 'onOpenNote' + | 'onToggleAIChat' + | 'onUnsupportedAiPaste' + | 'onVaultChanged' + | 'vaultPath' + | 'vaultPaths' +> & { + activeNoteContent: string | null +} + +function AiPanelSection({ + activeNoteContent, + defaultAiAgent = DEFAULT_AI_AGENT, + defaultAiAgentReadiness, + defaultAiAgentReady = true, + defaultAiTarget, + entries, + inspectorEntry, + inspectorWidth, locale, - dateDisplayFormat, -}: EditorRightPanelProps) { + noteList, + noteListFilter, + onFileCreated, + onFileModified, + onOpenNote, + onToggleAIChat, + onUnsupportedAiPaste, + onVaultChanged, + vaultPath, + vaultPaths, +}: AiPanelSectionProps) { const aiPanelController = useAiPanelController({ vaultPath, vaultPaths, @@ -77,7 +107,7 @@ export function EditorRightPanel({ defaultAiAgentReady, defaultAiAgentReadiness, activeEntry: inspectorEntry, - activeNoteContent: inspectorContent, + activeNoteContent, entries, noteList, noteListFilter, @@ -98,6 +128,43 @@ export function EditorRightPanel({ return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat) }, [handleNewChat]) + return ( +
+ onToggleAIChat?.()} + onOpenNote={onOpenNote} + onUnsupportedAiPaste={onUnsupportedAiPaste} + defaultAiAgent={defaultAiAgent} + defaultAiTarget={defaultAiTarget} + defaultAiAgentReadiness={defaultAiAgentReadiness} + defaultAiAgentReady={defaultAiAgentReady} + locale={locale} + activeEntry={inspectorEntry} + entries={entries} + /> +
+ ) +} + +export function EditorRightPanel({ + showAIChat, showTableOfContents, inspectorCollapsed, inspectorWidth, + editor, + defaultAiAgent = DEFAULT_AI_AGENT, defaultAiTarget, defaultAiAgentReadiness, defaultAiAgentReady = true, + onUnsupportedAiPaste, + inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, + vaultPaths, + noteList, noteListFilter, + onToggleInspector, onToggleAIChat, onToggleTableOfContents, onNavigateWikilink, onViewCommitDiff, + onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onChangeWorkspace, onInitializeProperties, onToggleRawEditor, onOpenNote, + onFileCreated, onFileModified, onVaultChanged, + workspaces, + locale, + dateDisplayFormat, +}: EditorRightPanelProps) { if (!inspectorCollapsed) { return (
- onToggleAIChat?.()} - onOpenNote={onOpenNote} - onUnsupportedAiPaste={onUnsupportedAiPaste} - defaultAiAgent={defaultAiAgent} - defaultAiTarget={defaultAiTarget} - defaultAiAgentReadiness={defaultAiAgentReadiness} - defaultAiAgentReady={defaultAiAgentReady} - locale={locale} - activeEntry={inspectorEntry} - entries={entries} - /> -
- ) + return } return null diff --git a/src/hooks/editorContentSwapApply.ts b/src/hooks/editorContentSwapApply.ts index 801e1bdd..a9a681f2 100644 --- a/src/hooks/editorContentSwapApply.ts +++ b/src/hooks/editorContentSwapApply.ts @@ -93,10 +93,24 @@ function commitAppliedEditorContent(options: AppliedEditorContentCommit) { targetPath, } = options - requestAnimationFrame(() => { + requestNextFrame(() => { editorContentPathRef.current = targetPath suppressChangeRef.current = false const scrollEl = document.querySelector(EDITOR_CONTAINER_SELECTOR) if (scrollEl) scrollEl.scrollTop = scrollTop }) } + +function requestNextFrame(callback: FrameRequestCallback): void { + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(callback) + return + } + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(callback) + return + } + + setTimeout(() => callback(Date.now()), 0) +} diff --git a/src/hooks/editorParsedBlockPreload.ts b/src/hooks/editorParsedBlockPreload.ts index a172da6b..8eff7542 100644 --- a/src/hooks/editorParsedBlockPreload.ts +++ b/src/hooks/editorParsedBlockPreload.ts @@ -5,6 +5,7 @@ import { subscribeNoteContentResolved } from './noteContentCache' export const PARSED_BLOCK_PRELOAD_MIN_BYTES = 32 * 1024 export const PARSED_BLOCK_PRELOAD_DELAY_MS = 1800 export const PARSED_BLOCK_PRELOAD_FOREGROUND_IDLE_MS = 1500 +export const PARSED_BLOCK_PRELOAD_ENABLED = false type PrepareParsedBlocks = (event: NoteContentResolvedEvent) => Promise @@ -17,6 +18,7 @@ interface ParsedBlockPreloadOptions { } function canPreloadParsedBlocks(event: NoteContentResolvedEvent, activeTabPath: string | null): boolean { + if (!PARSED_BLOCK_PRELOAD_ENABLED) return false const { entry } = event if (!entry || entry.path === activeTabPath) return false if ((entry.fileKind ?? 'markdown') !== 'markdown') return false diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 688b98d9..72febb9f 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -300,12 +300,6 @@ function flushQueuedFrames(frameCallbacks: FrameRequestCallback[]) { }) } -function flushQueuedMicrotasks(queued: VoidFunction[]) { - act(() => { - queued.splice(0).forEach((callback) => callback()) - }) -} - type SwapHarnessProps = { tabs: ReturnType[] activeTabPath: string | null @@ -775,25 +769,15 @@ describe('useEditorTabSwap raw mode sync', () => { await act(() => new Promise(r => setTimeout(r, 0))) - const queued: Array<() => void> = [] - vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((cb: VoidFunction) => { - queued.push(cb) - }) - rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md' }) - expect(queued).toHaveLength(1) - act(() => { result.current.handleEditorChange() }) expect(onContentChange).not.toHaveBeenCalled() - await act(async () => { - queued.shift()?.() - await Promise.resolve() - }) + await flushEditorTick() }) it('ignores delayed programmatic change events until a swapped note frame commits', async () => { @@ -838,19 +822,8 @@ describe('useEditorTabSwap raw mode sync', () => { }) docRef.current = staleAlphaBlocks - const queued: VoidFunction[] = [] - vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((cb: VoidFunction) => { - queued.push(cb) - }) - rerender({ tabs: [tabB], activeTabPath: 'b.md' }) - act(() => { - queued.shift()?.() - }) - await act(async () => { - await Promise.resolve() - }) - flushQueuedMicrotasks(queued) + await flushEditorTick() act(() => { result.current.handleEditorChange() diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 59deb4c7..e1b5b562 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -567,13 +567,38 @@ function preserveUntitledRenameState(options: { editorMountedRef, editorContentPathRef, }) - requestAnimationFrame(() => signalEditorTabSwapped(activeTabPath)) + requestNextFrame(() => signalEditorTabSwapped(activeTabPath)) return true } function signalTabSwap(options: { path: string }) { const { path } = options - requestAnimationFrame(() => signalEditorTabSwapped(path)) + requestNextFrame(() => signalEditorTabSwapped(path)) +} + +function requestNextFrame(callback: FrameRequestCallback): void { + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(callback) + return + } + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(callback) + return + } + + setTimeout(() => callback(Date.now()), 0) +} + +function schedulePostPaint(callback: () => void): void { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + setTimeout(callback, 0) + return + } + + window.requestAnimationFrame(() => { + window.setTimeout(callback, 0) + }) } function clearStaleSwap(options: { @@ -787,7 +812,7 @@ function scheduleTabSwap(options: { } if (editor.prosemirrorView) { - queueMicrotask(doSwap) + schedulePostPaint(doSwap) return } pendingSwapRef.current = doSwap diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 9410938e..0050c5c4 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -657,6 +657,36 @@ describe('useVaultLoader', () => { expect(issuedCommands).not.toContain('list_vault') }) + it('freshly reloads the active mounted workspace on startup in Tauri mode', async () => { + await enableTauriMode() + const brian = { label: 'Brian', path: '/brian', alias: 'brian', available: true, mounted: true } + const laputa = { label: 'Laputa', path: '/laputa', alias: 'laputa', available: true, mounted: true } + const vaults = [laputa, brian] + + backendInvokeFn.mockImplementation(((cmd: string, args?: Record) => { + if (cmd === 'reload_vault' && args?.path === '/laputa') { + return Promise.resolve([ + { ...mockEntries[0], path: '/laputa/note/alpha.md', filename: 'alpha.md', title: 'Alpha' }, + ]) + } + if (cmd === 'list_vault' && args?.path === '/laputa') return Promise.resolve([]) + if (cmd === 'list_vault_folders' || cmd === 'list_views' || cmd === 'get_modified_files') return Promise.resolve([]) + return Promise.resolve(null) + }) as typeof defaultMockInvoke) + + const { result } = renderHook(() => useVaultLoader('/laputa', vaults, '/laputa', vaults)) + + await waitFor(() => { + expect(result.current.entries.map((entry) => entry.title)).toEqual(['Alpha']) + }) + + const laputaLoadCommands = backendInvokeFn.mock.calls + .filter(([, args]) => args?.path === '/laputa') + .map(([command]) => command) + expect(laputaLoadCommands).toContain('reload_vault') + expect(laputaLoadCommands).not.toContain('list_vault') + }) + it('marks the vault unavailable when the initial load finds a missing active vault', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) backendInvokeFn.mockImplementation(((cmd: string) => { diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index ef7414f5..d25177ea 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -72,6 +72,7 @@ async function loadInitialVaultEntriesState(options: Pick< vaultPath: path, vaults: initialVaultsForPath(path, options.vaults), defaultWorkspacePath: options.defaultWorkspacePath, + forceReload: true, }) if (isCurrentVaultPath(path)) { handleVaultAvailable(path) diff --git a/src/hooks/vaultLoaderCommands.ts b/src/hooks/vaultLoaderCommands.ts index bb61cbe4..ddcd3ae4 100644 --- a/src/hooks/vaultLoaderCommands.ts +++ b/src/hooks/vaultLoaderCommands.ts @@ -162,10 +162,10 @@ export function loadVaultViews({ vaultPath }: VaultPathOptions): Promise { +export async function loadVaultData({ vaultPath, vaults, defaultWorkspacePath, forceReload }: MountedVaultEntriesOptions): Promise { if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing') const entries = vaults?.length - ? await loadMountedVaultEntries({ vaultPath, vaults, defaultWorkspacePath }) + ? await loadMountedVaultEntries({ vaultPath, vaults, defaultWorkspacePath, forceReload }) : await loadVaultEntries({ vaultPath }) console.log(`Vault scan complete: ${entries.length} entries found`) return { entries }