fix: keep large vault startup responsive
This commit is contained in:
@@ -92,6 +92,12 @@ flowchart LR
|
||||
|
||||
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
|
||||
|
||||
#### Progressive Vault Loading
|
||||
|
||||
Vault opening is allowed to render the main app shell while the full entry scan is still in flight. `useVaultLoader` keeps `isLoading` true until entries are ready, but folders and saved views load independently so the sidebar can become useful before the note index completes. The status bar uses the vault activity badge during this initial indexing state, while command-palette and editor-shell interactions remain mounted instead of being hidden behind the full app skeleton. The full skeleton is reserved for app-level capability checks such as the initial Git-state probe.
|
||||
|
||||
Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.md](./LARGE-VAULT-LOADING-QA.md).
|
||||
|
||||
#### Note Opening Fast Path
|
||||
|
||||
Note opening uses a bounded in-memory fast path split across raw content and editor-ready blocks. `useTabManagement` owns the raw markdown prefetch cache and `useEditorTabSwap` owns the prepared BlockNote block cache. Cached or preloaded markdown is never rendered directly: before reusing it, the renderer calls the `validate_note_content` Tauri command, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
|
||||
|
||||
53
docs/LARGE-VAULT-LOADING-QA.md
Normal file
53
docs/LARGE-VAULT-LOADING-QA.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Large Vault Loading QA
|
||||
|
||||
Use this when validating startup responsiveness for large vaults. The goal is to make the bottleneck reproducible without using a real user vault.
|
||||
|
||||
## Synthetic Vault
|
||||
|
||||
Create a disposable vault with many markdown files:
|
||||
|
||||
```bash
|
||||
VAULT="$(mktemp -d /tmp/tolaria-large-vault.XXXXXX)"
|
||||
mkdir -p "$VAULT/type" "$VAULT/archive" "$VAULT/assets"
|
||||
cat > "$VAULT/type/project.md" <<'EOF'
|
||||
---
|
||||
is_a: Type
|
||||
---
|
||||
# Project
|
||||
EOF
|
||||
|
||||
for i in $(seq -w 1 20000); do
|
||||
cat > "$VAULT/project-$i.md" <<EOF
|
||||
---
|
||||
is_a: Project
|
||||
status: Active
|
||||
related_to:
|
||||
- "[[project-00001]]"
|
||||
---
|
||||
# Project $i
|
||||
|
||||
Synthetic body $i with enough text to exercise parsing and snippets.
|
||||
EOF
|
||||
done
|
||||
|
||||
git -C "$VAULT" init
|
||||
git -C "$VAULT" config user.email qa@example.invalid
|
||||
git -C "$VAULT" config user.name "Tolaria QA"
|
||||
git -C "$VAULT" add .
|
||||
git -C "$VAULT" commit -m "seed large vault"
|
||||
echo "$VAULT"
|
||||
```
|
||||
|
||||
## Manual QA
|
||||
|
||||
1. Start Tolaria with `pnpm tauri dev`.
|
||||
2. Open the synthetic vault path printed above.
|
||||
3. Verify the main shell renders before the full note list finishes indexing.
|
||||
4. Confirm the status bar shows vault activity while indexing is still in progress.
|
||||
5. Use keyboard-only flows while indexing continues:
|
||||
- Cmd+K opens the command palette.
|
||||
- Cmd+P opens quick open; results may be partial or empty until indexing finishes.
|
||||
- Create a new note with Cmd+N and type in the editor.
|
||||
6. Wait for indexing to finish and verify the note list/search state is consistent.
|
||||
|
||||
The synthetic vault lives under `/tmp`; remove it after QA if it is no longer needed.
|
||||
@@ -17,7 +17,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
|
||||
@@ -508,7 +508,7 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the app shell skeleton while the vault note scan is pending', async () => {
|
||||
it('keeps the app shell usable while the vault note scan is pending', async () => {
|
||||
let resolveListVault: ((value: typeof mockEntries) => void) | null = null
|
||||
const listVaultPromise = new Promise<typeof mockEntries>((resolve) => {
|
||||
resolveListVault = resolve
|
||||
@@ -518,9 +518,15 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('vault-loading-skeleton')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Select a note to start editing')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Select a note to start editing')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk')
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: 'p', code: 'KeyP', metaKey: true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(within(screen.getByTestId('quick-open-palette')).getByText('Reloading vault...')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolveListVault?.(mockEntries)
|
||||
@@ -529,6 +535,7 @@ describe('App', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('status-vault-reloading')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1609,8 +1609,8 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading skeleton while checking git status or scanning vault notes.
|
||||
if (!noteWindowParams && onboarding.state.status === 'ready' && (gitRepoState === 'checking' || vault.isLoading)) {
|
||||
// Show the full skeleton only while app-level vault capabilities are unknown.
|
||||
if (!noteWindowParams && onboarding.state.status === 'ready' && gitRepoState === 'checking') {
|
||||
return (
|
||||
<AppLoadingSkeleton
|
||||
noteListWidth={layout.noteListWidth}
|
||||
@@ -1713,11 +1713,11 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || vault.isLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
|
||||
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} isLoading={vault.isLoading} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} locale={appLocale} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
|
||||
@@ -2,15 +2,19 @@ import { useState, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { useNoteSearch } from '../hooks/useNoteSearch'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface QuickOpenPaletteProps {
|
||||
open: boolean
|
||||
entries: VaultEntry[]
|
||||
isLoading?: boolean
|
||||
onSelect: (entry: VaultEntry) => void
|
||||
onClose: () => void
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpenPaletteProps) {
|
||||
export function QuickOpenPalette({ open, entries, isLoading = false, onSelect, onClose, locale = 'en' }: QuickOpenPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { results, selectedIndex, setSelectedIndex, handleKeyDown } = useNoteSearch(entries, query)
|
||||
@@ -56,11 +60,11 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
className="flex w-[500px] max-w-[90vw] max-h-[400px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
className="h-auto rounded-none border-0 border-b border-border px-4 py-3 text-[15px] shadow-none focus-visible:ring-0"
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
placeholder={translate(locale, 'noteList.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
@@ -73,7 +77,7 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
onClose()
|
||||
}}
|
||||
onItemHover={(i) => setSelectedIndex(i)}
|
||||
emptyMessage="No matching notes"
|
||||
emptyMessage={isLoading ? translate(locale, 'status.vault.reloading') : translate(locale, 'noteList.empty.noMatching')}
|
||||
className="flex-1 overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import type { VaultEntry, ModifiedFile, GitCommit } from '../types'
|
||||
import type { VaultEntry, ModifiedFile, GitCommit, FolderNode } from '../types'
|
||||
import { useVaultLoader, resolveNoteStatus } from './useVaultLoader'
|
||||
|
||||
const mockEntries: VaultEntry[] = [
|
||||
@@ -227,6 +227,35 @@ describe('useVaultLoader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('loads folders while the initial note scan is still pending', async () => {
|
||||
const entriesLoad = createDeferred<VaultEntry[]>()
|
||||
const folders: FolderNode[] = [{ name: 'Projects', path: 'Projects', children: [] }]
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return entriesLoad.promise
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve(folders)
|
||||
if (cmd === 'list_views') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.folders).toEqual(folders)
|
||||
})
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
entriesLoad.resolve(mockEntries)
|
||||
await entriesLoad.promise
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toEqual(mockEntries)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('loads modified files on mount', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ async function loadInitialVaultState(options: {
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
const { path, isCurrentVaultPath, setEntries, setFolders, setIsLoading, setViews } = options
|
||||
const chromeLoad = loadVaultChrome({ vaultPath: path })
|
||||
const chromeLoad = loadVaultChrome({ vaultPath: path }).then(({ folders, views }) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setFolders(folders)
|
||||
setViews(views)
|
||||
})
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
@@ -59,10 +63,7 @@ async function loadInitialVaultState(options: {
|
||||
if (isCurrentVaultPath(path)) setIsLoading(false)
|
||||
}
|
||||
|
||||
const { folders, views } = await chromeLoad
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setFolders(folders)
|
||||
setViews(views)
|
||||
await chromeLoad
|
||||
}
|
||||
|
||||
function useCurrentVaultPathGuard(vaultPath: string) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { openCommandPalette, sendShortcut } from './helpers'
|
||||
|
||||
type MockHandlers = Record<string, (args?: unknown) => unknown>
|
||||
type MockWindow = Window & {
|
||||
@@ -41,7 +42,7 @@ const slowVaultEntries = [
|
||||
},
|
||||
]
|
||||
|
||||
test('slow vault open shows the app shell skeleton until notes load @smoke', async ({ page }) => {
|
||||
async function installSlowVaultMock(page: Page): Promise<void> {
|
||||
await page.addInitScript((entries) => {
|
||||
localStorage.setItem('tolaria_welcome_dismissed', '1')
|
||||
localStorage.setItem('tolaria:ai-agents-onboarding-dismissed', '1')
|
||||
@@ -95,17 +96,45 @@ test('slow vault open shows the app shell skeleton until notes load @smoke', asy
|
||||
},
|
||||
})
|
||||
}, slowVaultEntries)
|
||||
}
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
async function expectResponsiveShellWhileVaultLoads(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('vault-loading-skeleton')).not.toBeVisible()
|
||||
await expect(page.getByText('Select a note to start editing')).toBeVisible()
|
||||
await expect(page.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk')
|
||||
|
||||
await expect(page.getByTestId('vault-loading-skeleton')).toBeVisible()
|
||||
await expect(page.getByText('Select a note to start editing')).not.toBeVisible()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('quick-open-palette').getByText('Reloading vault...')).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByTestId('quick-open-palette')).not.toBeVisible()
|
||||
|
||||
await openCommandPalette(page)
|
||||
await expect(page.locator('input[placeholder="Type a command..."]')).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
async function resolveVaultScan(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(window as MockWindow).__resolveVaultScan?.()
|
||||
})
|
||||
}
|
||||
|
||||
async function expectLoadedVaultSearch(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('vault-loading-skeleton')).not.toBeVisible()
|
||||
await expect(page.getByTestId('status-vault-reloading')).not.toBeVisible()
|
||||
await expect(page.getByTestId('note-list-container')).toBeVisible()
|
||||
await expect(page.getByText('Large Vault Note')).toBeVisible()
|
||||
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('quick-open-palette').getByText('Large Vault Note')).toBeVisible()
|
||||
}
|
||||
|
||||
test('slow vault open keeps the app shell usable while notes load @smoke', async ({ page }) => {
|
||||
await installSlowVaultMock(page)
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
await expectResponsiveShellWhileVaultLoads(page)
|
||||
await resolveVaultScan(page)
|
||||
await expectLoadedVaultSearch(page)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user