fix: keep note opening responsive
This commit is contained in:
@@ -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: [] },
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user