fix: recover startup vault loading

This commit is contained in:
lucaronin
2026-05-12 11:37:14 +02:00
parent 442b5f0256
commit 96e8857a77
5 changed files with 142 additions and 31 deletions

View File

@@ -26,16 +26,8 @@
<title>Tolaria</title>
</head>
<body>
<pre id="tolaria-boot-diagnostics" style="position:fixed;left:24px;top:24px;z-index:2147483647;max-width:min(760px,calc(100vw - 48px));max-height:calc(100vh - 48px);overflow:auto;margin:0;padding:12px 14px;border-radius:8px;background:#111;color:#fff;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap">Tolaria boot: HTML parsed</pre>
<script>
(function () {
var diagnostics = document.getElementById('tolaria-boot-diagnostics');
function appendDiagnostic(message) {
if (!diagnostics) return;
if (!diagnostics.isConnected) document.body.appendChild(diagnostics);
diagnostics.hidden = false;
diagnostics.textContent += '\n' + message;
}
function isResizeObserverLoopMessage(message) {
return typeof message === 'string'
&& (
@@ -46,21 +38,14 @@
window.addEventListener('error', function (event) {
if (isResizeObserverLoopMessage(event.message)) {
event.preventDefault();
return;
}
appendDiagnostic('error: ' + event.message + ' @ ' + event.filename + ':' + event.lineno + ':' + event.colno);
});
window.addEventListener('unhandledrejection', function (event) {
var reason = event.reason && (event.reason.stack || event.reason.message || String(event.reason));
if (isResizeObserverLoopMessage(reason)) {
event.preventDefault();
return;
}
appendDiagnostic('unhandled rejection: ' + reason);
});
window.addEventListener('tolaria:frontend-ready', function () {
if (diagnostics) diagnostics.hidden = true;
}, { once: true });
var key = 'tolaria-theme';
var legacyKey = 'laputa-theme';

View File

@@ -0,0 +1,94 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VaultEntry } from '../types'
import { useVaultLoader } from './useVaultLoader'
const backendInvokeFn = vi.fn()
let mockIsTauri = true
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => backendInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => mockIsTauri,
mockInvoke: (command: string, args?: Record<string, unknown>) => backendInvokeFn(command, args),
}))
function makeEntry(path: string, title: string): VaultEntry {
return {
path,
filename: path.split('/').pop() ?? 'note.md',
title,
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
archived: false,
modifiedAt: 1,
createdAt: 1,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
}
}
function commandKey(command: string, args?: Record<string, unknown>): string {
return `${command}:${typeof args?.path === 'string' ? args.path : ''}`
}
describe('useVaultLoader empty cache recovery', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsTauri = true
})
it('reloads a background workspace when its cached startup listing is empty in Tauri mode', async () => {
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 commandResults = new Map<string, unknown>([
['reload_vault:/brian', [makeEntry('/brian/note/hello.md', 'Brian Hello')]],
['list_vault:/laputa', []],
['reload_vault:/laputa', [makeEntry('/laputa/note/hello.md', 'Laputa Hello')]],
['get_modified_files:', []],
['list_vault_folders:', []],
['list_views:', []],
])
backendInvokeFn.mockImplementation((command: string, args?: Record<string, unknown>) => {
return Promise.resolve(commandResults.get(commandKey(command, args)) ?? null)
})
const vaults = [brian, laputa]
const { result } = renderHook(() => useVaultLoader('/brian', vaults, '/brian', vaults))
await waitFor(() => {
expect(result.current.entries.map((entry) => entry.title).sort()).toEqual(['Brian Hello', 'Laputa Hello'])
})
const laputaLoads = backendInvokeFn.mock.calls
.filter(([command, args]) => {
return args?.path === '/laputa' && (command === 'list_vault' || command === 'reload_vault')
})
.map(([command]) => command)
expect(laputaLoads).toEqual(['list_vault', 'reload_vault'])
})
})

View File

@@ -931,7 +931,7 @@ function loadMissingWorkspaceEntries({
vaultPath: string
vaults?: VaultOption[]
}) {
void loadWorkspaceEntries(vault, defaultWorkspacePath)
void loadWorkspaceEntries(vault, defaultWorkspacePath, { reloadIfEmpty: true })
.then((loadedEntries) => {
if (!isCurrentVaultPath(vaultPath)) return
loadedPaths.add(vault.path)

View File

@@ -24,6 +24,11 @@ interface MountedVaultEntriesOptions extends VaultPathOptions {
type MountedVaultFoldersOptions = MountedVaultEntriesOptions
interface WorkspaceEntryLoadOptions {
forceReload?: boolean
reloadIfEmpty?: boolean
}
interface CommitWithPushOptions extends VaultPathOptions {
message: string
}
@@ -63,24 +68,53 @@ function loadVaultEntriesWithCommand({ vaultPath, command }: VaultPathOptions &
.then((entries) => normalizeVaultEntries(entries, vaultPath))
}
function shouldSkipMountedVault(vault: VaultOption): boolean {
return vault.available === false || vault.mounted === false || !vault.path.trim()
}
function shouldReloadEmptyWorkspaceResult(entries: VaultEntry[], options: WorkspaceEntryLoadOptions): boolean {
return entries.length === 0 && options.reloadIfEmpty === true && !options.forceReload && isTauri()
}
function shouldIncludeFallbackVault(
byPath: Map<string, VaultOption>,
vaultPath: string,
includeFallbackVault: boolean,
): boolean {
if (!includeFallbackVault) return false
if (!vaultPath.trim()) return false
return !byPath.has(vaultPath)
}
function loadWorkspaceEntriesWithCommand(
vault: VaultOption,
command: 'list_vault' | 'reload_vault',
defaultWorkspacePath?: string | null,
): Promise<VaultEntry[]> {
const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath })
return tauriCall<unknown>({ command, tauriArgs: { path: vault.path } })
.then((entries) => normalizeVaultEntries(entries, vault.path, workspace))
}
export function loadWorkspaceEntries(
vault: VaultOption,
defaultWorkspacePath?: string | null,
options: { forceReload?: boolean } = {},
options: WorkspaceEntryLoadOptions = {},
): Promise<VaultEntry[]> {
const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath })
const command = options.forceReload && isTauri() ? 'reload_vault' : 'list_vault'
return tauriCall<unknown>({ command, tauriArgs: { path: vault.path } })
.then((entries) => normalizeVaultEntries(entries, vault.path, workspace))
return loadWorkspaceEntriesWithCommand(vault, command, defaultWorkspacePath)
.then((entries) => shouldReloadEmptyWorkspaceResult(entries, options)
? loadWorkspaceEntriesWithCommand(vault, 'reload_vault', defaultWorkspacePath)
: entries)
}
function uniqueMountedVaults({ vaultPath, vaults = [], includeFallbackVault = true }: MountedVaultEntriesOptions): VaultOption[] {
const byPath = new Map<string, VaultOption>()
for (const vault of vaults) {
if (vault.available === false || vault.mounted === false || !vault.path.trim()) continue
if (shouldSkipMountedVault(vault)) continue
byPath.set(vault.path, vault)
}
if (includeFallbackVault && vaultPath.trim() && !byPath.has(vaultPath)) {
if (shouldIncludeFallbackVault(byPath, vaultPath, includeFallbackVault)) {
byPath.set(vaultPath, { label: vaultPath.split('/').filter(Boolean).pop() || 'Workspace', path: vaultPath, mounted: true, available: true })
}
return [...byPath.values()]

View File

@@ -4,13 +4,13 @@ import { describe, expect, it } from 'vitest'
function firstInlineScriptFromIndex(): string {
const indexHtml = readFileSync(`${process.cwd()}/index.html`, 'utf8')
const match = indexHtml.match(/<script>\s*([\s\S]*?)\s*<\/script>/)
if (!match) throw new Error('index.html boot diagnostics script was not found')
if (!match) throw new Error('index.html startup script was not found')
return match[1]
}
describe('index boot diagnostics', () => {
describe('index startup script', () => {
it('does not show the boot overlay for ResizeObserver loop notifications', () => {
document.body.innerHTML = '<pre id="tolaria-boot-diagnostics">Tolaria boot: HTML parsed</pre>'
document.body.innerHTML = ''
new Function(firstInlineScriptFromIndex())()
const event = new ErrorEvent('error', {
@@ -20,11 +20,11 @@ describe('index boot diagnostics', () => {
window.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(document.getElementById('tolaria-boot-diagnostics')?.textContent).toBe('Tolaria boot: HTML parsed')
expect(document.body.children).toHaveLength(0)
})
it('still shows the boot overlay for real startup errors', () => {
document.body.innerHTML = '<pre id="tolaria-boot-diagnostics">Tolaria boot: HTML parsed</pre>'
it('does not create a visible boot overlay for real startup errors', () => {
document.body.innerHTML = ''
new Function(firstInlineScriptFromIndex())()
window.dispatchEvent(new ErrorEvent('error', {
@@ -34,8 +34,6 @@ describe('index boot diagnostics', () => {
colno: 2,
}))
expect(document.getElementById('tolaria-boot-diagnostics')?.textContent).toContain(
'error: startup failed @ app.js:1:2',
)
expect(document.body.children).toHaveLength(0)
})
})