fix: keep note opening responsive

This commit is contained in:
lucaronin
2026-05-11 19:58:01 +02:00
parent 8ff4cfde4f
commit e84bd1a8cd
9 changed files with 200 additions and 73 deletions

View File

@@ -220,6 +220,20 @@ function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
return render(<Editor {...defaultProps} {...overrides} />)
}
async function flushEditorSwapWork() {
for (let i = 0; i < 4; i += 1) {
await act(async () => {
if (typeof window.requestAnimationFrame === 'function') {
await new Promise<void>((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: [] },
]

View File

@@ -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 (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
>
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}
defaultAiTarget={defaultAiTarget}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
locale={locale}
activeEntry={inspectorEntry}
entries={entries}
/>
</div>
)
}
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 (
<div
@@ -149,26 +216,27 @@ export function EditorRightPanel({
}
if (showAIChat) {
return (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
>
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}
defaultAiTarget={defaultAiTarget}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
locale={locale}
activeEntry={inspectorEntry}
entries={entries}
/>
</div>
)
return <AiPanelSection
activeNoteContent={inspectorContent}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
defaultAiTarget={defaultAiTarget}
entries={entries}
inspectorEntry={inspectorEntry}
inspectorWidth={inspectorWidth}
locale={locale}
noteList={noteList}
noteListFilter={noteListFilter}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onOpenNote={onOpenNote}
onToggleAIChat={onToggleAIChat}
onUnsupportedAiPaste={onUnsupportedAiPaste}
onVaultChanged={onVaultChanged}
vaultPath={vaultPath}
vaultPaths={vaultPaths}
/>
}
return null

View File

@@ -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)
}

View File

@@ -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<void>
@@ -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

View File

@@ -300,12 +300,6 @@ function flushQueuedFrames(frameCallbacks: FrameRequestCallback[]) {
})
}
function flushQueuedMicrotasks(queued: VoidFunction[]) {
act(() => {
queued.splice(0).forEach((callback) => callback())
})
}
type SwapHarnessProps = {
tabs: ReturnType<typeof makeTab>[]
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()

View File

@@ -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

View File

@@ -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<string, unknown>) => {
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) => {

View File

@@ -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)

View File

@@ -162,10 +162,10 @@ export function loadVaultViews({ vaultPath }: VaultPathOptions): Promise<ViewFil
.then(normalizeViewFiles)
}
export async function loadVaultData({ vaultPath, vaults, defaultWorkspacePath }: MountedVaultEntriesOptions): Promise<LoadedVaultData> {
export async function loadVaultData({ vaultPath, vaults, defaultWorkspacePath, forceReload }: MountedVaultEntriesOptions): Promise<LoadedVaultData> {
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 }