From 2624d32b501f0a4dfaa817fc90164e44df81e716 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 21:23:44 +0200 Subject: [PATCH] fix: keep large vault startup responsive --- docs/ARCHITECTURE.md | 6 +++ docs/LARGE-VAULT-LOADING-QA.md | 53 ++++++++++++++++++++++ package.json | 2 +- src/App.test.tsx | 13 ++++-- src/App.tsx | 8 ++-- src/components/QuickOpenPalette.tsx | 14 ++++-- src/hooks/useVaultLoader.test.ts | 31 ++++++++++++- src/hooks/useVaultLoader.ts | 11 +++-- tests/smoke/vault-loading-skeleton.spec.ts | 39 ++++++++++++++-- 9 files changed, 153 insertions(+), 24 deletions(-) create mode 100644 docs/LARGE-VAULT-LOADING-QA.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b49f5dc3..72ccb5f4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/LARGE-VAULT-LOADING-QA.md b/docs/LARGE-VAULT-LOADING-QA.md new file mode 100644 index 00000000..62ff005b --- /dev/null +++ b/docs/LARGE-VAULT-LOADING-QA.md @@ -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" < { }) }) - 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((resolve) => { resolveListVault = resolve @@ -518,9 +518,15 @@ describe('App', () => { render() 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) }) }) diff --git a/src/App.tsx b/src/App.tsx index e7f85794..dc6f3930 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - 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} /> + 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} /> setToastMessage(null)} /> - + 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(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()} > - 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" /> diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 21c06828..444a3317 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -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() + 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')) diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 8f8de1cd..de73c4a2 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -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) { diff --git a/tests/smoke/vault-loading-skeleton.spec.ts b/tests/smoke/vault-loading-skeleton.spec.ts index 0baf4604..63a6bf9d 100644 --- a/tests/smoke/vault-loading-skeleton.spec.ts +++ b/tests/smoke/vault-loading-skeleton.spec.ts @@ -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 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 { 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 { + 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 { await page.evaluate(() => { (window as MockWindow).__resolveVaultScan?.() }) +} +async function expectLoadedVaultSearch(page: Page): Promise { 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) })