From c8ac12f3dc173b8d3d22d51ef7402f1b8d6bb5a1 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 1 May 2026 23:14:36 +0200 Subject: [PATCH] feat: cache note content and parsed editor blocks --- docs/ABSTRACTIONS.md | 4 +- docs/ARCHITECTURE.md | 4 +- ...correctness-and-responsiveness-contract.md | 39 +++ docs/adr/README.md | 1 + src-tauri/src/commands/memory.rs | 167 +++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 1 + src/components/Editor.test.tsx | 2 + src/components/Editor.tsx | 5 +- src/components/EditorMemoryProbe.tsx | 82 +++++ src/components/HiddenEditorMemoryProbe.tsx | 74 ++++ src/components/NoteItem.tsx | 4 +- src/components/NoteList.keyboard.test.tsx | 2 +- .../editor-content/EditorContentLayout.tsx | 5 +- src/components/editorMemoryProbeRuntime.ts | 82 +++++ src/components/editorMemoryProbeTypes.ts | 61 ++++ .../note-list/noteListHooks.extra.test.tsx | 6 +- src/components/note-list/noteListHooks.ts | 2 +- src/components/note-list/useNoteListModel.tsx | 34 +- .../useEditorMemoryProbeController.ts | 131 +++++++ src/hooks/editorBlockResolution.ts | 216 ++++++++++++ src/hooks/editorChangeDebounce.ts | 2 +- src/hooks/editorContentSwapApply.ts | 13 +- src/hooks/editorParsedBlockCache.ts | 92 +++++ src/hooks/editorParsedBlockPreload.ts | 110 ++++++ src/hooks/editorSwapToken.ts | 77 ++++ src/hooks/noteContentCache.ts | 288 +++++++++++++++ src/hooks/useEditorSaveWithLinks.ts | 121 +++++-- src/hooks/useEditorTabSwap.test.ts | 101 +++++- src/hooks/useEditorTabSwap.ts | 330 +++++++----------- src/hooks/useNoteActions.hook.test.ts | 5 +- src/hooks/useNoteCreation.ts | 2 +- src/hooks/usePreparedNotePreload.ts | 85 ----- src/hooks/useTabManagement.test.ts | 46 ++- src/hooks/useTabManagement.ts | 250 ++----------- src/hooks/useVaultLoader.extra.test.ts | 1 + src/hooks/useVaultLoader.ts | 1 + 37 files changed, 1859 insertions(+), 589 deletions(-) create mode 100644 docs/adr/0105-editor-correctness-and-responsiveness-contract.md create mode 100644 src-tauri/src/commands/memory.rs create mode 100644 src/components/EditorMemoryProbe.tsx create mode 100644 src/components/HiddenEditorMemoryProbe.tsx create mode 100644 src/components/editorMemoryProbeRuntime.ts create mode 100644 src/components/editorMemoryProbeTypes.ts create mode 100644 src/components/useEditorMemoryProbeController.ts create mode 100644 src/hooks/editorBlockResolution.ts create mode 100644 src/hooks/editorParsedBlockCache.ts create mode 100644 src/hooks/editorParsedBlockPreload.ts create mode 100644 src/hooks/editorSwapToken.ts create mode 100644 src/hooks/noteContentCache.ts delete mode 100644 src/hooks/usePreparedNotePreload.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 1bfe19d1..c8f43b16 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -166,9 +166,9 @@ Asset previewability is inferred in the renderer from the filename extension (`s ### Note Content Freshness -The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. Before showing cached markdown or editor-ready blocks, `useTabManagement` validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery. +The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery. -Prepared BlockNote blocks in `useEditorTabSwap` are keyed by path plus source content. They can be built ahead of time from prefetched markdown, but they are reused only when the validated raw content for that path is identical to the source content that produced the blocks. +`useEditorTabSwap` may reuse BlockNote blocks that were already opened successfully or warmed from prefetched raw content, keyed by vault, path, and exact source content. Background warming is limited to likely next large Markdown notes and defers while the editor is unmounted, raw mode is active, or recent typing/navigation is still inside the foreground idle window. Every async editor swap carries a generation and source-content token so stale conversion results cannot overwrite newer file content or dirty editor state. ### Entity Types (isA / type) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9acc189a..43e9e8fd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -102,9 +102,9 @@ Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.m #### 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. +Note opening uses bounded in-memory fast paths for raw content and parsed editor blocks. `useTabManagement` owns the markdown/text prefetch cache and treats every cached value as a performance hint only: identity-matched entries (`modifiedAt` + `fileSize`) can be reused immediately, while identity-missing or identity-mismatched cached text is checked with `validate_note_content`, 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. -The note list opportunistically preloads visible and adjacent markdown/text entries after a short idle delay. Once raw content resolves, the editor prepares BlockNote blocks in the background when it is mounted and not in raw mode. This targets the expensive markdown-to-editor conversion stage while keeping filesystem content authoritative and keeping preload memory bounded by the same cache limits as ordinary note switches. +The note list opportunistically preloads visible and adjacent markdown/text entries after a short delay. When a large warmed Markdown note resolves, `useEditorTabSwap` may parse it into a bounded parsed-block cache only after foreground editor work has been idle and the rich editor is mounted. Parsed blocks are keyed by vault, path, and exact source content; every async swap carries a generation/source-content token so stale conversion results cannot overwrite newer file content or dirty editor state. The editor never renders a preview surface that later morphs into BlockNote. See [ADR-0105](./adr/0105-editor-correctness-and-responsiveness-contract.md). ## Tech Stack diff --git a/docs/adr/0105-editor-correctness-and-responsiveness-contract.md b/docs/adr/0105-editor-correctness-and-responsiveness-contract.md new file mode 100644 index 00000000..2cde082c --- /dev/null +++ b/docs/adr/0105-editor-correctness-and-responsiveness-contract.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0105" +title: "Editor correctness and responsiveness contract" +status: active +date: 2026-05-01 +--- + +## Context + +Tolaria notes are durable Markdown files on disk, but the rich editor renders them through BlockNote blocks. Large notes exposed a tempting optimization: show a fast Markdown preview first, then hydrate BlockNote later. In practice, that creates two renderers for the same document, visible flicker, delayed click-time lag, and more places for stale async work to race with the currently selected note. + +The editor must optimize for the product priorities in this order: + +1. no crashes +2. no stale content, race-condition overwrites, or lost edits +3. responsive typing and cursor movement +4. fast note-list-to-editor loading + +## Decision + +**Tolaria keeps a single direct editor surface for Markdown notes and treats editor content swaps as generation-checked, source-content-checked operations.** Fast loading may use raw file-content prefetching and a bounded parsed-block cache, but it must not show a separate preview that later swaps into the editor. Parsed BlockNote blocks are reusable only when their source Markdown exactly matches the content being opened, and background parsing must run only after recent typing/navigation has gone idle. + +## Options considered + +- **Single direct editor surface with guarded swaps plus bounded caches** (chosen): preserves one visual representation of the document, rejects stale async parse results, validates or identity-checks cached disk content before opening, and keeps typing work debounced. Cons: very large notes can still wait on BlockNote conversion when they were not warmed. +- **Fast Markdown preview followed by hidden BlockNote hydration**: improves first paint but creates flicker, delayed edit-time stalls, and duplicated rendering semantics. +- **Unbounded or eager background BlockNote parsing for likely next notes**: can make some opens faster, but competes with typing/navigation and introduces stale parse-result hazards unless heavily scheduled, bounded, and invalidated. +- **Always raw mode for large notes**: strongest responsiveness for huge files, but changes the editing experience abruptly and should be an explicit fallback rather than the default. + +## Consequences + +- Async editor work must prove it still matches both the latest swap generation and the latest tab source content before touching BlockNote. +- Cached raw note content must be validated against disk before it is shown unless it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`, or the content was just authored by Tolaria in the current process. +- Cached parsed BlockNote blocks must be keyed by vault, path, and exact source content, cloned on read/write, and bounded by entry count plus source byte budget. +- Background parsed-block warming is allowed only for likely next large Markdown notes after a foreground idle window; active typing, raw mode, and editor mount state must defer it. +- Dirty local editor content remains authoritative. External filesystem refreshes may replace clean notes, but must not overwrite unsaved local edits. +- Per-keystroke editor work must stay minimal. Serialization, metadata derivation, autosave, and cache updates should be debounced, coalesced, or scheduled away from active typing. +- Future large-note optimizations should target true progressive/chunked conversion or explicit raw/read-only fallback states, not a visually different preview that morphs into BlockNote. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0106888f..249dae67 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -157,3 +157,4 @@ proposed → active → superseded | [0102](0102-low-end-safe-autosave-idle-window.md) | Low-end-safe autosave idle window | active | | [0103](0103-adapter-specific-ai-permission-semantics.md) | Adapter-specific AI permission semantics | active | | [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active | +| [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active | diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs new file mode 100644 index 00000000..76ef2456 --- /dev/null +++ b/src-tauri/src/commands/memory.rs @@ -0,0 +1,167 @@ +use serde::Serialize; +use std::process::Command; + +const WEBKIT_AUX_PID_WINDOW: u32 = 512; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessMemoryEntry { + pub pid: u32, + pub parent_pid: u32, + pub rss_bytes: u64, + pub role: String, + pub command: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessMemorySnapshot { + pub current_pid: u32, + pub total_rss_bytes: u64, + pub entries: Vec, +} + +struct ProcessRow { + pid: u32, + parent_pid: u32, + rss_kib: u64, + command: String, +} + +#[tauri::command] +pub fn get_process_memory_snapshot() -> Result { + let current_pid = std::process::id(); + let entries = collect_related_process_memory(current_pid)?; + let total_rss_bytes = entries.iter().map(|entry| entry.rss_bytes).sum(); + + Ok(ProcessMemorySnapshot { + current_pid, + total_rss_bytes, + entries, + }) +} + +fn collect_related_process_memory(current_pid: u32) -> Result, String> { + let rows = read_process_rows()?; + Ok(rows + .into_iter() + .filter_map(|row| related_process_entry(row, current_pid)) + .collect()) +} + +fn related_process_entry(row: ProcessRow, current_pid: u32) -> Option { + let role = classify_related_process(&row, current_pid)?; + Some(ProcessMemoryEntry { + pid: row.pid, + parent_pid: row.parent_pid, + rss_bytes: row.rss_kib.saturating_mul(1024), + role, + command: row.command, + }) +} + +fn classify_related_process(row: &ProcessRow, current_pid: u32) -> Option { + if row.pid == current_pid { + return Some("app".to_string()); + } + if !is_nearby_webkit_auxiliary(row, current_pid) { + return None; + } + + if row.command.contains("WebKit.WebContent") { + return Some("webkit-webcontent".to_string()); + } + if row.command.contains("WebKit.GPU") { + return Some("webkit-gpu".to_string()); + } + if row.command.contains("WebKit.Networking") { + return Some("webkit-networking".to_string()); + } + Some("webkit".to_string()) +} + +fn is_nearby_webkit_auxiliary(row: &ProcessRow, current_pid: u32) -> bool { + row.pid > current_pid + && row.pid.saturating_sub(current_pid) <= WEBKIT_AUX_PID_WINDOW + && row.command.contains("com.apple.WebKit.") +} + +fn parse_process_row(line: &str) -> Option { + let mut fields = line.split_whitespace(); + let pid = fields.next()?.parse().ok()?; + let parent_pid = fields.next()?.parse().ok()?; + let rss_kib = fields.next()?.parse().ok()?; + let command = fields.collect::>().join(" "); + if command.is_empty() { + return None; + } + + Some(ProcessRow { + pid, + parent_pid, + rss_kib, + command, + }) +} + +#[cfg(unix)] +fn read_process_rows() -> Result, String> { + let output = Command::new("ps") + .args(["-axo", "pid=,ppid=,rss=,command="]) + .output() + .map_err(|error| format!("Failed to sample process memory: {error}"))?; + + if !output.status.success() { + return Err("Failed to sample process memory with ps".to_string()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout.lines().filter_map(parse_process_row).collect()) +} + +#[cfg(not(unix))] +fn read_process_rows() -> Result, String> { + Err("Process memory snapshots are only implemented on Unix platforms".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ps_rows_with_spaced_commands() { + let row = parse_process_row(" 42 1 1024 /System/WebKit WebContent").unwrap(); + + assert_eq!(row.pid, 42); + assert_eq!(row.parent_pid, 1); + assert_eq!(row.rss_kib, 1024); + assert_eq!(row.command, "/System/WebKit WebContent"); + } + + #[test] + fn classifies_nearby_webkit_auxiliaries() { + let row = ProcessRow { + pid: 120, + parent_pid: 1, + rss_kib: 10, + command: "/System/com.apple.WebKit.WebContent.xpc".to_string(), + }; + + assert_eq!( + classify_related_process(&row, 100), + Some("webkit-webcontent".to_string()), + ); + } + + #[test] + fn ignores_unrelated_webkit_auxiliaries() { + let row = ProcessRow { + pid: 900, + parent_pid: 1, + rss_kib: 10, + command: "/System/com.apple.WebKit.WebContent.xpc".to_string(), + }; + + assert_eq!(classify_related_process(&row, 100), None); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8adc3630..1d880360 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ mod folders; mod git; pub mod git_clone; mod git_connect; +mod memory; mod system; mod vault; mod version; @@ -15,6 +16,7 @@ pub use delete::*; pub use folders::*; pub use git::*; pub use git_connect::*; +pub use memory::*; pub use system::*; pub use vault::*; pub use version::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 738c206a..be850201 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -581,6 +581,7 @@ macro_rules! app_invoke_handler { commands::copy_text_to_clipboard, commands::read_text_from_clipboard, commands::sync_mcp_bridge_vault, + commands::get_process_memory_snapshot, commands::repair_vault, commands::reinit_telemetry, commands::list_views, diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 4d5c77c8..e0af4ed7 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -153,6 +153,7 @@ import { import type { VaultEntry } from '../types' import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore' import { TooltipProvider } from '@/components/ui/tooltip' +import { clearParsedNoteBlockCache } from '../hooks/editorParsedBlockCache' type EditorComponentProps = ComponentProps @@ -235,6 +236,7 @@ describe('Editor', () => { beforeEach(() => { blockNoteCreation.options = [] blockNoteViewState.onChange = null + clearParsedNoteBlockCache() }) it('shows empty state when no tabs are open', () => { diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index fa5174ae..25267cc0 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -17,6 +17,7 @@ import { useDragRegion } from '../hooks/useDragRegion' import { formatShortcutDisplay } from '../hooks/appCommandCatalog' import { EditorRightPanel } from './EditorRightPanel' import { EditorContent } from './EditorContent' +import { EditorMemoryProbe } from './EditorMemoryProbe' import { FilePreview } from './FilePreview' import { schema } from './editorSchema' import type { RawEditorFindRequest } from './RawEditorFindBar' @@ -513,6 +514,7 @@ function EditorLayout({ locale={locale} /> + ) } @@ -538,7 +540,6 @@ export const Editor = memo(function Editor(props: EditorProps) { isConflicted, onKeepMine, onKeepTheirs, flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale, } = props - const { editor, activeTab, rawLatestContentRef, rawModeContent, rawMode, diffMode, diffContent, diffLoading, @@ -560,7 +561,6 @@ export const Editor = memo(function Editor(props: EditorProps) { handleToggleRawExclusive, rawMode, }) - useRegisterEditorContentFlushes({ activeTab, flushPendingEditorChange, @@ -570,7 +570,6 @@ export const Editor = memo(function Editor(props: EditorProps) { onContentChange, flushPendingRawContentRef, }) - return ( { + if (!import.meta.env.DEV) return + window.__tolariaEditorMemoryProbe = api + return () => { + if (window.__tolariaEditorMemoryProbe?.run === api.run) { + delete window.__tolariaEditorMemoryProbe + } + } + }, [api]) +} + +function useEditorMemoryProbeShortcut(runAndCopy: EditorMemoryProbeApi['runAndCopy']): void { + useEffect(() => { + if (!import.meta.env.DEV) return + const handleKeyDown = (event: KeyboardEvent) => { + if (!event.metaKey || !event.altKey || !event.shiftKey || event.code !== 'KeyM') return + event.preventDefault() + event.stopPropagation() + void runAndCopy() + } + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [runAndCopy]) +} + +export function EditorMemoryProbe({ + entries, + locale, + vaultPath, +}: { + entries: VaultEntry[] + locale?: AppLocale + vaultPath?: string +}) { + const controller = useEditorMemoryProbeController(entries) + useEditorMemoryProbeBridge(controller) + useEditorMemoryProbeShortcut(controller.runAndCopy) + + if (!import.meta.env.DEV || controller.targets.length === 0) return null + + return ( + + ) +} diff --git a/src/components/HiddenEditorMemoryProbe.tsx b/src/components/HiddenEditorMemoryProbe.tsx new file mode 100644 index 00000000..7a8110d0 --- /dev/null +++ b/src/components/HiddenEditorMemoryProbe.tsx @@ -0,0 +1,74 @@ +import { useEffect } from 'react' +import { useCreateBlockNote } from '@blocknote/react' +import { uploadImageFile } from '../hooks/useImageDrop' +import { useEditorTabSwap } from '../hooks/useEditorTabSwap' +import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' +import type { AppLocale } from '../lib/i18n' +import type { VaultEntry } from '../types' +import { createArrowLigaturesExtension } from './arrowLigaturesExtension' +import { createMathInputExtension } from './mathInputExtension' +import { schema } from './editorSchema' +import type { ProbeTarget } from './editorMemoryProbeTypes' +import { SingleEditorView } from './SingleEditorView' + +function useProbeEditor(target: ProbeTarget, vaultPath?: string) { + const editor = useCreateBlockNote({ + schema, + uploadFile: (file: File) => uploadImageFile(file, vaultPath), + _tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE }, + extensions: [createArrowLigaturesExtension(), createMathInputExtension()], + }) + useEditorTabSwap({ + tabs: [{ entry: target.entry, content: target.content }], + activeTabPath: target.entry.path, + editor, + rawMode: false, + vaultPath, + }) + return editor +} + +function useProbeReadySignal(target: ProbeTarget, onReady: (path: string) => void): void { + useEffect(() => { + const handleSwap = (event: Event) => { + const detail = (event as CustomEvent<{ path?: string }>).detail + if (detail?.path === target.entry.path) onReady(target.entry.path) + } + window.addEventListener('laputa:editor-tab-swapped', handleSwap) + return () => window.removeEventListener('laputa:editor-tab-swapped', handleSwap) + }, [onReady, target.entry.path]) +} + +export function HiddenEditorMemoryProbe({ + entries, + locale, + onReady, + target, + vaultPath, +}: { + entries: VaultEntry[] + locale?: AppLocale + onReady: (path: string) => void + target: ProbeTarget + vaultPath?: string +}) { + const editor = useProbeEditor(target, vaultPath) + useProbeReadySignal(target, onReady) + + return ( + + ) +} diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index 3422f147..b5ae8794 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -328,7 +328,7 @@ type NoteItemProps = { allEntries?: VaultEntry[] displayPropsOverride?: string[] | null onClickNote: (entry: VaultEntry, e: ReactMouseEvent) => void - onPrefetch?: (path: string) => void + onPrefetch?: (entry: VaultEntry) => void onContextMenu?: (entry: VaultEntry, e: ReactMouseEvent) => void } @@ -409,7 +409,7 @@ function resolveNoteItemSurfaceProps({ style: resolveNoteItemSurfaceStyle({ isUnavailableBinary, isSelected, isMultiSelected, typeColor, typeLightColor }), onClick: createNoteItemClickHandler(entry, isUnavailableBinary, onClickNote), onContextMenu: onContextMenu ? (event) => onContextMenu(entry, event) : undefined, - onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry.path) : undefined, + onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry) : undefined, testId: resolveNoteItemTestId({ isMultiSelected, previewKind, isUnavailableBinary }), title: resolveNoteItemTitle({ previewKind, isUnavailableBinary }), } diff --git a/src/components/NoteList.keyboard.test.tsx b/src/components/NoteList.keyboard.test.tsx index ee3c27c0..8b45cb0e 100644 --- a/src/components/NoteList.keyboard.test.tsx +++ b/src/components/NoteList.keyboard.test.tsx @@ -115,6 +115,6 @@ describe('NoteList keyboard activation', () => { fireEvent.mouseEnter(noteRow!) - expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1].path) + expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1]) }) }) diff --git a/src/components/editor-content/EditorContentLayout.tsx b/src/components/editor-content/EditorContentLayout.tsx index 5b398ec8..4cbf7fcf 100644 --- a/src/components/editor-content/EditorContentLayout.tsx +++ b/src/components/editor-content/EditorContentLayout.tsx @@ -361,7 +361,10 @@ function EditorCanvas({ if (!showEditor) return null return ( - +
{ + return new Promise(resolve => window.setTimeout(resolve, ms)) +} + +function contentBytes(content: string): number { + return new TextEncoder().encode(content).byteLength +} + +export function summarizeTarget({ entry, content }: ProbeTarget): ProbeTargetSummary { + return { + path: entry.path, + fileSize: entry.fileSize, + contentBytes: contentBytes(content), + lineCount: content.split('\n').length, + } +} + +export function memoryDelta( + snapshot: ProcessMemorySnapshot | null, + baseline: ProcessMemorySnapshot | null, +): number | null { + if (!snapshot || !baseline) return null + return snapshot.totalRssBytes - baseline.totalRssBytes +} + +export function selectProbeEntries(entries: VaultEntry[], options: ProbeRunOptions): VaultEntry[] { + const markdownEntries = entries.filter(entry => (entry.fileKind ?? 'markdown') === 'markdown') + if (options.paths?.length) { + const wantedPaths = new Set(options.paths) + return markdownEntries.filter(entry => wantedPaths.has(entry.path)) + } + + return [...markdownEntries] + .sort((left, right) => right.fileSize - left.fileSize) + .slice(0, options.limit ?? DEFAULT_PROBE_LIMIT) +} + +export function resolveMountCounts(targetCount: number, batchSize: number): number[] { + const counts: number[] = [] + for (let count = batchSize; count < targetCount; count += batchSize) { + counts.push(count) + } + if (targetCount > 0) counts.push(targetCount) + return counts +} + +export async function readMemorySnapshot(): Promise { + if (!isTauri()) return null + return invoke('get_process_memory_snapshot') +} + +export async function loadProbeTarget(entry: VaultEntry): Promise { + const content = await invoke('get_note_content', { path: entry.path }) + return { entry, content } +} + +export async function copyProbeResult(result: ProbeResult): Promise { + if (!isTauri()) return + await invoke('copy_text_to_clipboard', { + text: JSON.stringify(result, null, 2), + }) +} + +export function settleAfterMount(settleMs: number): Promise { + return wait(settleMs).then(() => new Promise(resolve => requestAnimationFrame(() => resolve()))) +} diff --git a/src/components/editorMemoryProbeTypes.ts b/src/components/editorMemoryProbeTypes.ts new file mode 100644 index 00000000..13e59599 --- /dev/null +++ b/src/components/editorMemoryProbeTypes.ts @@ -0,0 +1,61 @@ +import type { VaultEntry } from '../types' + +export interface ProbeTarget { + entry: VaultEntry + content: string +} + +export interface ProcessMemoryEntry { + pid: number + parentPid: number + rssBytes: number + role: string + command: string +} + +export interface ProcessMemorySnapshot { + currentPid: number + totalRssBytes: number + entries: ProcessMemoryEntry[] +} + +export interface ProbeRunOptions { + paths?: string[] + limit?: number + batchSize?: number + settleMs?: number +} + +export interface ProbeStep { + mountedCount: number + mountedPaths: string[] + snapshot: ProcessMemorySnapshot | null + deltaBytes: number | null +} + +export interface ProbeTargetSummary { + path: string + fileSize: number + contentBytes: number + lineCount: number +} + +export interface ProbeResult { + targets: ProbeTargetSummary[] + baseline: ProcessMemorySnapshot | null + afterContentLoad: ProcessMemorySnapshot | null + contentLoadDeltaBytes: number | null + steps: ProbeStep[] +} + +export interface ProbeWaiter { + paths: Set + resolve: () => void + timer: number +} + +export interface EditorMemoryProbeApi { + run: (options?: ProbeRunOptions) => Promise + runAndCopy: (options?: ProbeRunOptions) => Promise + clear: () => void +} diff --git a/src/components/note-list/noteListHooks.extra.test.tsx b/src/components/note-list/noteListHooks.extra.test.tsx index 03e0b285..28912c33 100644 --- a/src/components/note-list/noteListHooks.extra.test.tsx +++ b/src/components/note-list/noteListHooks.extra.test.tsx @@ -63,7 +63,7 @@ vi.mock('../../hooks/useNoteListKeyboard', () => ({ })) vi.mock('../../hooks/useTabManagement', () => ({ - prefetchNoteContent: (path: string) => prefetchNoteContentMock(path), + prefetchNoteContent: (entry: VaultEntry) => prefetchNoteContentMock(entry), })) vi.mock('./noteListUtils', async () => { @@ -475,8 +475,8 @@ describe('noteListHooks extra', () => { expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry) expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry) expect(onAutoTriggerDiff).toHaveBeenCalledOnce() - expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry.path) - expect(prefetchNoteContentMock).not.toHaveBeenCalledWith(imageEntry.path) + expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry) + expect(prefetchNoteContentMock).not.toHaveBeenCalledWith(imageEntry) vi.useRealTimers() }) diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts index db00895c..bf647639 100644 --- a/src/components/note-list/noteListHooks.ts +++ b/src/components/note-list/noteListHooks.ts @@ -1046,7 +1046,7 @@ function useKeyboardInteractionState({ }, [onOpenDeletedNote, onReplaceActiveTab]) const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => { - if (canPrefetchEntryContent(entry)) prefetchNoteContent(entry.path) + if (canPrefetchEntryContent(entry)) prefetchNoteContent(entry) }, []) const handleNeighborhoodOpen = useCallback(async (entry: VaultEntry) => { diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index 5db96adf..a6fd5ecf 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -32,8 +32,10 @@ import { useChangesContextMenu } from './NoteListChangesMenu' import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents' type EntitySelection = Extract -const LIKELY_NEXT_PRELOAD_LIMIT = 8 -const ADJACENT_PRELOAD_RADIUS = 2 +const LIKELY_NEXT_PRELOAD_LIMIT = 6 +const ADJACENT_PRELOAD_RADIUS = 3 +const LIKELY_NEXT_PRELOAD_START_DELAY_MS = 350 +const LIKELY_NEXT_PRELOAD_STEP_DELAY_MS = 180 function useViewFlags(selection: SidebarSelection) { const isSectionGroup = selection.kind === 'sectionGroup' @@ -58,7 +60,13 @@ function likelyNextPreloadEntries(entries: VaultEntry[], selectedNotePath: strin : Math.min(entries.length, LIKELY_NEXT_PRELOAD_LIMIT) return entries .slice(start, end) - .filter((entry) => !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary') + .map((entry, offset) => ({ entry, index: start + offset })) + .sort((left, right) => { + if (selectedIndex < 0) return 0 + return Math.abs(left.index - selectedIndex) - Math.abs(right.index - selectedIndex) + }) + .map(({ entry }) => entry) + .filter((entry) => entry.path !== selectedNotePath && !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary') .slice(0, LIKELY_NEXT_PRELOAD_LIMIT) } @@ -67,11 +75,23 @@ function useLikelyNextPreload(entries: VaultEntry[], selectedNotePath: string | const candidates = likelyNextPreloadEntries(entries, selectedNotePath) if (candidates.length === 0) return - const timer = window.setTimeout(() => { - for (const entry of candidates) prefetchNoteContent(entry.path) - }, 100) + let stepTimer: number | null = null + let candidateIndex = 0 + const startTimer = window.setTimeout(() => { + const preloadNext = () => { + const entry = candidates[candidateIndex] + if (!entry) return + candidateIndex += 1 + prefetchNoteContent(entry) + stepTimer = window.setTimeout(preloadNext, LIKELY_NEXT_PRELOAD_STEP_DELAY_MS) + } + preloadNext() + }, LIKELY_NEXT_PRELOAD_START_DELAY_MS) - return () => window.clearTimeout(timer) + return () => { + window.clearTimeout(startTimer) + if (stepTimer !== null) window.clearTimeout(stepTimer) + } }, [entries, selectedNotePath]) } diff --git a/src/components/useEditorMemoryProbeController.ts b/src/components/useEditorMemoryProbeController.ts new file mode 100644 index 00000000..b4afaf48 --- /dev/null +++ b/src/components/useEditorMemoryProbeController.ts @@ -0,0 +1,131 @@ +import { useCallback, useRef, useState } from 'react' +import type { VaultEntry } from '../types' +import type { + ProbeResult, + ProbeRunOptions, + ProbeStep, + ProbeTarget, + ProbeWaiter, +} from './editorMemoryProbeTypes' +import { + copyProbeResult, + DEFAULT_PROBE_BATCH_SIZE, + DEFAULT_PROBE_SETTLE_MS, + loadProbeTarget, + memoryDelta, + readMemorySnapshot, + resolveMountCounts, + selectProbeEntries, + settleAfterMount, + summarizeTarget, + PROBE_READY_TIMEOUT_MS, +} from './editorMemoryProbeRuntime' + +function createProbeStep( + mountedTargets: ProbeTarget[], + snapshot: Awaited>, + baseline: Awaited>, +): ProbeStep { + return { + mountedCount: mountedTargets.length, + mountedPaths: mountedTargets.map(target => target.entry.path), + snapshot, + deltaBytes: memoryDelta(snapshot, baseline), + } +} + +function useProbeReadiness() { + const readyPathsRef = useRef(new Set()) + const waiterRef = useRef(null) + + const resolveWaiterIfReady = useCallback(() => { + const waiter = waiterRef.current + if (!waiter) return + for (const path of waiter.paths) { + if (!readyPathsRef.current.has(path)) return + } + + window.clearTimeout(waiter.timer) + waiterRef.current = null + waiter.resolve() + }, []) + + const handleReady = useCallback((path: string) => { + readyPathsRef.current.add(path) + resolveWaiterIfReady() + }, [resolveWaiterIfReady]) + + const waitForReadyPaths = useCallback((paths: string[]) => { + return new Promise((resolve) => { + const unresolvedPaths = paths.filter(path => !readyPathsRef.current.has(path)) + if (unresolvedPaths.length === 0) { + resolve() + return + } + + const timer = window.setTimeout(() => { + waiterRef.current = null + console.warn('[memory-probe] Timed out waiting for hidden editors:', unresolvedPaths) + resolve() + }, PROBE_READY_TIMEOUT_MS) + waiterRef.current = { paths: new Set(paths), resolve, timer } + }) + }, []) + + const clearReadiness = useCallback(() => { + if (waiterRef.current) { + window.clearTimeout(waiterRef.current.timer) + waiterRef.current = null + } + readyPathsRef.current.clear() + }, []) + + return { clearReadiness, handleReady, waitForReadyPaths } +} + +export function useEditorMemoryProbeController(entries: VaultEntry[]) { + const [targets, setTargets] = useState([]) + const { clearReadiness, handleReady, waitForReadyPaths } = useProbeReadiness() + + const clear = useCallback(() => { + clearReadiness() + setTargets([]) + }, [clearReadiness]) + + const run = useCallback(async (options: ProbeRunOptions = {}): Promise => { + clear() + await settleAfterMount(options.settleMs ?? DEFAULT_PROBE_SETTLE_MS) + const baseline = await readMemorySnapshot() + const selectedEntries = selectProbeEntries(entries, options) + const loadedTargets = await Promise.all(selectedEntries.map(loadProbeTarget)) + const afterContentLoad = await readMemorySnapshot() + const batchSize = Math.max(1, options.batchSize ?? DEFAULT_PROBE_BATCH_SIZE) + const settleMs = options.settleMs ?? DEFAULT_PROBE_SETTLE_MS + const steps: ProbeStep[] = [] + + for (const count of resolveMountCounts(loadedTargets.length, batchSize)) { + const mountedTargets = loadedTargets.slice(0, count) + setTargets(mountedTargets) + await waitForReadyPaths(mountedTargets.map(target => target.entry.path)) + await settleAfterMount(settleMs) + steps.push(createProbeStep(mountedTargets, await readMemorySnapshot(), baseline)) + } + + return { + targets: loadedTargets.map(summarizeTarget), + baseline, + afterContentLoad, + contentLoadDeltaBytes: memoryDelta(afterContentLoad, baseline), + steps, + } + }, [clear, entries, waitForReadyPaths]) + + const runAndCopy = useCallback(async (options: ProbeRunOptions = {}) => { + const result = await run(options) + await copyProbeResult(result) + console.info('[memory-probe] Result copied to clipboard', result) + return result + }, [run]) + + return { clear, handleReady, run, runAndCopy, targets } +} diff --git a/src/hooks/editorBlockResolution.ts b/src/hooks/editorBlockResolution.ts new file mode 100644 index 00000000..2dfffb22 --- /dev/null +++ b/src/hooks/editorBlockResolution.ts @@ -0,0 +1,216 @@ +import type { useCreateBlockNote } from '@blocknote/react' +import { preProcessWikilinks, injectWikilinks } from '../utils/wikilinks' +import { preProcessMathMarkdown, injectMathInBlocks } from '../utils/mathMarkdown' +import { preProcessMermaidMarkdown, injectMermaidInBlocks } from '../utils/mermaidMarkdown' +import { resolveImageUrls } from '../utils/vaultImages' +import { repairMalformedEditorBlocks } from './editorBlockRepair' +import { + blankParagraphBlocks, + extractEditorBody, +} from './editorTabContent' +import { + parseMarkdownBlocksWithFallback, + type MarkdownParseResult, +} from './editorMarkdownParseFallback' +import { + cacheParsedNoteBlocks, + readParsedNoteBlocks, + type EditorBlocks, +} from './editorParsedBlockCache' + +export type { EditorBlocks } + +type NotePath = string +type NoteContent = string +type MarkdownBody = string +type PreprocessedMarkdown = string +type VaultPath = string + +export type CachedTabState = { + blocks: EditorBlocks + scrollTop: number + sourceContent: NoteContent +} + +const TAB_STATE_CACHE_LIMIT = 24 + +export function cacheEditorState( + cache: Map, + path: NotePath, + nextState: CachedTabState, +) { + if (cache.has(path)) cache.delete(path) + cache.set(path, nextState) + while (cache.size > TAB_STATE_CACHE_LIMIT) { + const oldestPath = cache.keys().next().value + if (!oldestPath) return + cache.delete(oldestPath) + } +} + +export function cacheParsedEditorState(path: NotePath, nextState: CachedTabState, vaultPath?: VaultPath): void { + cacheParsedNoteBlocks({ + path, + blocks: nextState.blocks, + scrollTop: nextState.scrollTop, + sourceContent: nextState.sourceContent, + vaultPath, + }) +} + +export function cacheResolvedEditorState( + cache: Map, + path: NotePath, + nextState: CachedTabState, + vaultPath?: VaultPath, +): CachedTabState { + cacheEditorState(cache, path, nextState) + cacheParsedEditorState(path, nextState, vaultPath) + return nextState +} + +function buildFastPathBlocks(options: { preprocessed: PreprocessedMarkdown }): EditorBlocks | null { + const { preprocessed } = options + const trimmed = preprocessed.trim() + + if (!trimmed) return [{ type: 'paragraph', content: [] }] + if (trimmed === '#') return [emptyHeadingBlock(), { type: 'paragraph', content: [], children: [] }] + + const h1OnlyMatch = trimmed.match(/^# (.+)$/) + if (!h1OnlyMatch) return null + + return [ + { + type: 'heading', + props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, + content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], + children: [], + }, + { type: 'paragraph', content: [], children: [] }, + ] +} + +function emptyHeadingBlock(): Record { + return { + type: 'heading', + props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, + content: [], + children: [], + } +} + +export function isBlankBodyContent(options: { content: NoteContent }): boolean { + const { content } = options + return extractEditorBody(content).trim() === '' +} + +function extractBodyRemainderAfterEmptyH1(options: { content: NoteContent }): MarkdownBody | null { + const { content } = options + const body = extractEditorBody(content) + const [firstLine, secondLine, ...rest] = body.split('\n') + if (!firstLine) return null + + const normalizedFirstLine = firstLine.trimEnd() + if (normalizedFirstLine !== '#' && normalizedFirstLine !== '# ') return null + return secondLine === '' ? rest.join('\n').trimStart() : [secondLine, ...rest].join('\n').trimStart() +} + +export function startsWithEmptyHeading(options: { content: NoteContent }): boolean { + return extractBodyRemainderAfterEmptyH1(options) !== null +} + +async function parseMarkdownBlocks( + editor: ReturnType, + preprocessed: PreprocessedMarkdown, +): Promise { + const result = editor.tryParseMarkdownToBlocks(preprocessed) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks + if (result && typeof (result as any).then === 'function') { + return (result as unknown as Promise) + } + return result as EditorBlocks +} + +function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath): PreprocessedMarkdown { + const withMermaid = preProcessMermaidMarkdown({ markdown }) + const withImages = vaultPath ? resolveImageUrls(withMermaid, vaultPath) : withMermaid + const withWikilinks = preProcessWikilinks(withImages) + return preProcessMathMarkdown({ markdown: withWikilinks }) +} + +function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks { + const withWikilinks = injectWikilinks(blocks) + const withMath = injectMathInBlocks(withWikilinks) + return injectMermaidInBlocks(withMath) as EditorBlocks +} + +function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks { + const parseSafeBlocks = repairMalformedEditorBlocks(parsed.blocks) as EditorBlocks + if (parsed.usedSourceFallback) return parseSafeBlocks + return repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks +} + +export async function resolveBlocksForTarget( + options: { + editor: ReturnType + cache: Map + targetPath: NotePath + content: NoteContent + vaultPath?: VaultPath + }, +): Promise { + const { editor, cache, targetPath, content, vaultPath } = options + const cached = cache.get(targetPath) + if (cached?.sourceContent === content) return cached + + const parsedCache = readParsedNoteBlocks({ path: targetPath, content, vaultPath }) + if (parsedCache) { + return cacheResolvedEditorState(cache, targetPath, { + blocks: parsedCache.blocks, + scrollTop: parsedCache.scrollTop, + sourceContent: content, + }, vaultPath) + } + + const body = extractEditorBody(content) + const preprocessed = preProcessEditorMarkdown(body, vaultPath) + const fastPathBlocks = buildFastPathBlocks({ preprocessed }) + if (fastPathBlocks) { + return cacheResolvedEditorState(cache, targetPath, { + blocks: repairMalformedEditorBlocks(fastPathBlocks) as EditorBlocks, + scrollTop: 0, + sourceContent: content, + }, vaultPath) + } + + const parsed = await parseMarkdownBlocksWithFallback({ + parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown), + preprocessed, + sourceMarkdown: body, + context: targetPath, + }) + return cacheResolvedEditorState(cache, targetPath, { + blocks: repairParsedMarkdownBlocks(parsed), + scrollTop: 0, + sourceContent: content, + }, vaultPath) +} + +export async function resolveEmptyHeadingBlocks( + editor: ReturnType, + content: NoteContent, + vaultPath?: VaultPath, + targetPath: NotePath = 'empty heading note', +): Promise { + const remainder = extractBodyRemainderAfterEmptyH1({ content }) + if (remainder === null) return null + if (!remainder.trim()) return [emptyHeadingBlock(), ...blankParagraphBlocks()] as EditorBlocks + + const parsed = await parseMarkdownBlocksWithFallback({ + parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown), + preprocessed: preProcessEditorMarkdown(remainder, vaultPath), + sourceMarkdown: remainder, + context: targetPath, + }) + return [emptyHeadingBlock(), ...repairParsedMarkdownBlocks(parsed)] as EditorBlocks +} diff --git a/src/hooks/editorChangeDebounce.ts b/src/hooks/editorChangeDebounce.ts index cf166f97..1e8eaa8b 100644 --- a/src/hooks/editorChangeDebounce.ts +++ b/src/hooks/editorChangeDebounce.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' -export const RICH_EDITOR_CHANGE_DEBOUNCE_MS = 150 +export const RICH_EDITOR_CHANGE_DEBOUNCE_MS = 500 export function useDebouncedEditorChange({ onFlush, diff --git a/src/hooks/editorContentSwapApply.ts b/src/hooks/editorContentSwapApply.ts index b7dd573e..341d5e46 100644 --- a/src/hooks/editorContentSwapApply.ts +++ b/src/hooks/editorContentSwapApply.ts @@ -30,7 +30,7 @@ interface ApplyHtmlStateToEditorOptions extends Omit() +const sourceSizeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null + +function cacheKey(path: string, vaultPath?: string): string { + return `${vaultPath ?? ''}\0${path}` +} + +function sourceBytes(content: string): number { + return sourceSizeEncoder ? sourceSizeEncoder.encode(content).byteLength : content.length +} + +function cloneBlocks(blocks: EditorBlocks): EditorBlocks { + if (typeof structuredClone === 'function') return structuredClone(blocks) + return JSON.parse(JSON.stringify(blocks)) as EditorBlocks +} + +function retainedSourceBytes(): number { + let totalBytes = 0 + for (const entry of parsedBlockCache.values()) totalBytes += entry.sourceBytes + return totalBytes +} + +function trimParsedBlockCache(): void { + while ( + parsedBlockCache.size > PARSED_NOTE_BLOCK_CACHE_LIMIT + || retainedSourceBytes() > PARSED_NOTE_BLOCK_CACHE_MAX_SOURCE_BYTES + ) { + const oldestKey = parsedBlockCache.keys().next().value + if (!oldestKey) return + parsedBlockCache.delete(oldestKey) + } +} + +export function cacheParsedNoteBlocks(entry: Omit): void { + const nextSourceBytes = sourceBytes(entry.sourceContent) + if (nextSourceBytes > PARSED_NOTE_BLOCK_ENTRY_MAX_BYTES) { + parsedBlockCache.delete(cacheKey(entry.path, entry.vaultPath)) + return + } + + const key = cacheKey(entry.path, entry.vaultPath) + if (parsedBlockCache.has(key)) parsedBlockCache.delete(key) + parsedBlockCache.set(key, { + ...entry, + blocks: cloneBlocks(entry.blocks), + sourceBytes: nextSourceBytes, + }) + trimParsedBlockCache() +} + +export function readParsedNoteBlocks(options: { + content: string + path: string + vaultPath?: string +}): { blocks: EditorBlocks; scrollTop: number } | null { + const key = cacheKey(options.path, options.vaultPath) + const entry = parsedBlockCache.get(key) + if (!entry || entry.sourceContent !== options.content) return null + + parsedBlockCache.delete(key) + parsedBlockCache.set(key, entry) + return { + blocks: cloneBlocks(entry.blocks), + scrollTop: entry.scrollTop, + } +} + +export function clearParsedNoteBlockCache(path?: string): void { + if (!path) { + parsedBlockCache.clear() + return + } + + for (const [key, entry] of parsedBlockCache) { + if (entry.path === path) parsedBlockCache.delete(key) + } +} diff --git a/src/hooks/editorParsedBlockPreload.ts b/src/hooks/editorParsedBlockPreload.ts new file mode 100644 index 00000000..a172da6b --- /dev/null +++ b/src/hooks/editorParsedBlockPreload.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { NoteContentResolvedEvent } from './noteContentCache' +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 + +type PrepareParsedBlocks = (event: NoteContentResolvedEvent) => Promise + +interface ParsedBlockPreloadOptions { + activeTabPathRef: MutableRefObject + editorMountedRef: MutableRefObject + foregroundWorkAtRef: MutableRefObject + prepareParsedBlocks: PrepareParsedBlocks + rawModeRef: MutableRefObject +} + +function canPreloadParsedBlocks(event: NoteContentResolvedEvent, activeTabPath: string | null): boolean { + const { entry } = event + if (!entry || entry.path === activeTabPath) return false + if ((entry.fileKind ?? 'markdown') !== 'markdown') return false + return entry.fileSize >= PARSED_BLOCK_PRELOAD_MIN_BYTES +} + +function shouldDeferParsedPreload(options: { + editorMountedRef: MutableRefObject + foregroundWorkAtRef: MutableRefObject + rawModeRef: MutableRefObject +}) { + const { editorMountedRef, foregroundWorkAtRef, rawModeRef } = options + if (!editorMountedRef.current || rawModeRef.current) return true + return Date.now() - foregroundWorkAtRef.current < PARSED_BLOCK_PRELOAD_FOREGROUND_IDLE_MS +} + +function takeNextCandidate( + queue: Map, + activeTabPath: string | null, +): NoteContentResolvedEvent | null { + for (const candidate of queue.values()) { + queue.delete(candidate.path) + if (candidate.path !== activeTabPath) return candidate + } + return null +} + +function clearScheduledTimer(timerRef: MutableRefObject): void { + if (timerRef.current === null) return + window.clearTimeout(timerRef.current) + timerRef.current = null +} + +export function useParsedBlockPreload({ + activeTabPathRef, + editorMountedRef, + foregroundWorkAtRef, + prepareParsedBlocks, + rawModeRef, +}: ParsedBlockPreloadOptions) { + const queueRef = useRef>(new Map()) + const runningRef = useRef(false) + const timerRef = useRef(null) + const runNextRef = useRef<() => void>(() => {}) + + const scheduleNext = useCallback(() => { + if (timerRef.current !== null) return + timerRef.current = window.setTimeout(() => { + timerRef.current = null + runNextRef.current() + }, PARSED_BLOCK_PRELOAD_DELAY_MS) + }, []) + + const runNext = useCallback(async () => { + if (runningRef.current) return + if (shouldDeferParsedPreload({ editorMountedRef, foregroundWorkAtRef, rawModeRef })) { + scheduleNext() + return + } + + const next = takeNextCandidate(queueRef.current, activeTabPathRef.current) + if (!next) return + runningRef.current = true + try { + await prepareParsedBlocks(next) + } catch (error) { + console.warn('Failed to preload parsed note blocks:', error) + } finally { + runningRef.current = false + if (queueRef.current.size > 0) scheduleNext() + } + }, [activeTabPathRef, editorMountedRef, foregroundWorkAtRef, prepareParsedBlocks, rawModeRef, scheduleNext]) + + useEffect(() => { + runNextRef.current = () => { void runNext() } + }, [runNext]) + + useEffect(() => { + const queue = queueRef.current + const unsubscribe = subscribeNoteContentResolved((event) => { + if (!canPreloadParsedBlocks(event, activeTabPathRef.current)) return + queue.set(event.path, event) + scheduleNext() + }) + return () => { + unsubscribe() + clearScheduledTimer(timerRef) + queue.clear() + } + }, [activeTabPathRef, scheduleNext]) +} diff --git a/src/hooks/editorSwapToken.ts b/src/hooks/editorSwapToken.ts new file mode 100644 index 00000000..2f43f7cb --- /dev/null +++ b/src/hooks/editorSwapToken.ts @@ -0,0 +1,77 @@ +import type { MutableRefObject } from 'react' + +export interface SwapToken { + seq: number + path: string + content: string +} + +interface SwapTab { + entry: { path: string } + content: string +} + +export function createSwapToken( + swapSeqRef: MutableRefObject, + path: string, + content: string, +): SwapToken { + const seq = swapSeqRef.current + 1 + swapSeqRef.current = seq + return { seq, path, content } +} + +export function invalidatePendingSwap(options: { + pendingSwapRef: MutableRefObject<(() => void) | null> + swapSeqRef: MutableRefObject +}): void { + options.swapSeqRef.current += 1 + options.pendingSwapRef.current = null +} + +function activeTabMatchesSwapToken(options: { + tabsRef: MutableRefObject + token: SwapToken +}): boolean { + const { tabsRef, token } = options + const activeTab = tabsRef.current.find(tab => tab.entry.path === token.path) + return activeTab?.content === token.content +} + +function isCurrentSwapToken(options: { + prevActivePathRef: MutableRefObject + swapSeqRef: MutableRefObject + tabsRef: MutableRefObject + token: SwapToken +}): boolean { + const { + prevActivePathRef, + swapSeqRef, + tabsRef, + token, + } = options + + return swapSeqRef.current === token.seq + && prevActivePathRef.current === token.path + && activeTabMatchesSwapToken({ tabsRef, token }) +} + +export function shouldAbortSwap(options: { + prevActivePathRef: MutableRefObject + suppressChangeRef: MutableRefObject + swapSeqRef: MutableRefObject + tabsRef: MutableRefObject + token: SwapToken +}): boolean { + const { + prevActivePathRef, + suppressChangeRef, + swapSeqRef, + tabsRef, + token, + } = options + + if (isCurrentSwapToken({ prevActivePathRef, swapSeqRef, tabsRef, token })) return false + if (swapSeqRef.current === token.seq) suppressChangeRef.current = false + return true +} diff --git a/src/hooks/noteContentCache.ts b/src/hooks/noteContentCache.ts new file mode 100644 index 00000000..5a6a6c79 --- /dev/null +++ b/src/hooks/noteContentCache.ts @@ -0,0 +1,288 @@ +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { VaultEntry } from '../types' +import { markNoteOpenTrace } from '../utils/noteOpenPerformance' +import { getNoteWindowParams, isNoteWindow } from '../utils/windowMode' + +type NotePath = VaultEntry['path'] + +export interface NoteContentIdentity { + modifiedAt: number | null + fileSize: number | null +} + +export interface NoteContentCacheEntry { + path: NotePath + promise: Promise + value: string | null + byteSize: number + identity: NoteContentIdentity | null +} + +export interface NoteContentResolvedEvent { + entry: VaultEntry | null + path: NotePath + content: string +} + +type NoteContentResolvedListener = (event: NoteContentResolvedEvent) => void + +const prefetchCache = new Map() +const resolvedListeners = new Set() +const contentSizeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null + +export const NOTE_CONTENT_CACHE_LIMIT = 48 +export const NOTE_CONTENT_ENTRY_MAX_BYTES = 2 * 1024 * 1024 +export const NOTE_CONTENT_CACHE_MAX_BYTES = 24 * 1024 * 1024 + +export function subscribeNoteContentResolved(listener: NoteContentResolvedListener): () => void { + resolvedListeners.add(listener) + return () => resolvedListeners.delete(listener) +} + +function emitNoteContentResolved(event: NoteContentResolvedEvent): void { + for (const listener of resolvedListeners) { + try { + listener(event) + } catch (error) { + console.warn('Note content cache listener failed:', error) + } + } +} + +function measureNoteContentBytes(content: string): number { + return contentSizeEncoder ? contentSizeEncoder.encode(content).byteLength : content.length +} + +function noteContentIdentity(entry: VaultEntry): NoteContentIdentity { + return { + modifiedAt: entry.modifiedAt, + fileSize: entry.fileSize, + } +} + +function isCompleteIdentity(identity: NoteContentIdentity | null): identity is NoteContentIdentity { + return identity !== null && identity.modifiedAt !== null && identity.fileSize !== null +} + +function sameIdentity(left: NoteContentIdentity | null, right: NoteContentIdentity | null): boolean { + return isCompleteIdentity(left) + && isCompleteIdentity(right) + && left.modifiedAt === right.modifiedAt + && left.fileSize === right.fileSize +} + +function targetPath(target: string | VaultEntry): NotePath { + return typeof target === 'string' ? target : target.path +} + +function targetEntry(target: string | VaultEntry): VaultEntry | null { + return typeof target === 'string' ? null : target +} + +function targetIdentity(target: string | VaultEntry): NoteContentIdentity | null { + const entry = targetEntry(target) + return entry ? noteContentIdentity(entry) : null +} + +function getRetainedPrefetchCacheBytes(): number { + let totalBytes = 0 + for (const entry of prefetchCache.values()) totalBytes += entry.byteSize + return totalBytes +} + +function dropOldestPrefetchEntry(): void { + const oldestPath = prefetchCache.keys().next().value + if (!oldestPath) return + prefetchCache.delete(oldestPath) +} + +function trimPrefetchCache(): void { + while ( + prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT + || getRetainedPrefetchCacheBytes() > NOTE_CONTENT_CACHE_MAX_BYTES + ) { + if (prefetchCache.size === 0) return + dropOldestPrefetchEntry() + } +} + +function rememberNoteContent(entry: NoteContentCacheEntry): NoteContentCacheEntry { + const { path } = entry + if (prefetchCache.has(path)) prefetchCache.delete(path) + prefetchCache.set(path, entry) + trimPrefetchCache() + return entry +} + +function retainResolvedNoteContent(entry: NoteContentCacheEntry, content: string, sourceEntry: VaultEntry | null): void { + if (prefetchCache.get(entry.path) !== entry) return + const byteSize = measureNoteContentBytes(content) + if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) { + prefetchCache.delete(entry.path) + return + } + + entry.value = content + entry.byteSize = byteSize + rememberNoteContent(entry) + emitNoteContentResolved({ entry: sourceEntry, path: entry.path, content }) +} + +function getNoteContentCommandPayload(path: string): { path: string; vaultPath?: string } { + if (!isNoteWindow()) return { path } + + const noteWindowParams = getNoteWindowParams() + return noteWindowParams ? { path, vaultPath: noteWindowParams.vaultPath } : { path } +} + +function getValidateNoteContentCommandPayload(path: string, content: string): { path: string; content: string; vaultPath?: string } { + return { ...getNoteContentCommandPayload(path), content } +} + +function shouldReuseExistingRequest(existing: NoteContentCacheEntry, identity: NoteContentIdentity | null): boolean { + if (!isCompleteIdentity(identity) || !isCompleteIdentity(existing.identity)) return true + return sameIdentity(existing.identity, identity) +} + +function requestNoteContent(target: string | VaultEntry): NoteContentCacheEntry { + const path = targetPath(target) + const sourceEntry = targetEntry(target) + const identity = targetIdentity(target) + const cacheEntry: NoteContentCacheEntry = { + path, + promise: Promise.resolve(''), + value: null, + byteSize: 0, + identity, + } + const commandPayload = getNoteContentCommandPayload(path) + const promise = (isTauri() + ? invoke('get_note_content', commandPayload) + : mockInvoke('get_note_content', commandPayload) + ) + .then((content) => { + retainResolvedNoteContent(cacheEntry, content, sourceEntry) + return content + }) + .catch((err) => { + if (prefetchCache.get(path) === cacheEntry) prefetchCache.delete(path) + throw err + }) + + cacheEntry.promise = promise + return rememberNoteContent(cacheEntry) +} + +export function prefetchNoteContent(target: string | VaultEntry): void { + const path = targetPath(target) + const identity = targetIdentity(target) + const existing = prefetchCache.get(path) + if (existing && shouldReuseExistingRequest(existing, identity)) return + + void requestNoteContent(target).promise.catch((error) => { + if (isNoActiveVaultSelectedError(error) || isUnreadableNoteContentError(error)) return + console.warn('Failed to prefetch note content:', error) + }) +} + +export function cacheNoteContent(path: string, content: string, entry?: VaultEntry): void { + const byteSize = measureNoteContentBytes(content) + if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) { + prefetchCache.delete(path) + return + } + + rememberNoteContent({ + path, + promise: Promise.resolve(content), + value: content, + byteSize, + identity: entry ? noteContentIdentity(entry) : null, + }) + emitNoteContentResolved({ entry: entry ?? null, path, content }) +} + +export function clearNoteContentCache(): void { + prefetchCache.clear() +} + +export function hasResolvedCachedContent(entry: NoteContentCacheEntry | null): entry is NoteContentCacheEntry & { value: string } { + return !!entry && entry.value !== null +} + +export function getCachedNoteContentEntry(path: string): NoteContentCacheEntry | null { + return prefetchCache.get(path) ?? null +} + +async function validateCachedNoteContent(entry: NoteContentCacheEntry): Promise { + if (entry.value === null) return false + const payload = getValidateNoteContentCommandPayload(entry.path, entry.value) + return isTauri() + ? invoke('validate_note_content', payload) + : mockInvoke('validate_note_content', payload) +} + +function canTrustCachedContentIdentity(entry: VaultEntry, cachedEntry: NoteContentCacheEntry): boolean { + return sameIdentity(noteContentIdentity(entry), cachedEntry.identity) +} + +function canUseExistingContentRequest(target: VaultEntry, existing: NoteContentCacheEntry | undefined, forceFresh: boolean): existing is NoteContentCacheEntry { + if (forceFresh || !existing) return false + return shouldReuseExistingRequest(existing, noteContentIdentity(target)) +} + +async function loadNoteContent(target: VaultEntry, forceFresh = false): Promise { + const existing = prefetchCache.get(target.path) + if (canUseExistingContentRequest(target, existing, forceFresh)) { + return existing.promise + } + return requestNoteContent(target).promise +} + +async function loadCachedContentIfFresh(entry: VaultEntry, cachedEntry: NoteContentCacheEntry): Promise { + if (cachedEntry.value === null) return null + if (canTrustCachedContentIdentity(entry, cachedEntry)) { + rememberNoteContent(cachedEntry) + return cachedEntry.value + } + + markNoteOpenTrace(entry.path, 'freshnessCheckStart') + const isFresh = await validateCachedNoteContent(cachedEntry) + markNoteOpenTrace(entry.path, 'freshnessCheckEnd') + if (isFresh) { + rememberNoteContent(cachedEntry) + return cachedEntry.value + } + prefetchCache.delete(entry.path) + return null +} + +export async function loadContentForOpen(options: { + entry: VaultEntry + forceReload: boolean + cachedEntry: NoteContentCacheEntry | null +}): Promise { + const { entry, forceReload, cachedEntry } = options + + if (!forceReload && hasResolvedCachedContent(cachedEntry)) { + const cachedContent = await loadCachedContentIfFresh(entry, cachedEntry) + if (cachedContent !== null) return cachedContent + } + + return loadNoteContent(entry, forceReload || hasResolvedCachedContent(cachedEntry)) +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + return String(error) +} + +export function isNoActiveVaultSelectedError(error: unknown): boolean { + return /no active vault selected/i.test(errorMessage(error)) +} + +export function isUnreadableNoteContentError(error: unknown): boolean { + return /not valid utf-8 text|invalid utf-8|stream did not contain valid utf-8/i.test(errorMessage(error)) +} diff --git a/src/hooks/useEditorSaveWithLinks.ts b/src/hooks/useEditorSaveWithLinks.ts index 994e0757..59045d8b 100644 --- a/src/hooks/useEditorSaveWithLinks.ts +++ b/src/hooks/useEditorSaveWithLinks.ts @@ -1,6 +1,6 @@ -import { startTransition, useCallback, useRef } from 'react' +import { startTransition, useCallback, useRef, type MutableRefObject } from 'react' import { useEditorSave } from './useEditorSave' -import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks' +import { extractOutgoingLinks, extractSnippet, countWords, splitFrontmatter } from '../utils/wikilinks' import { deriveRawEditorEntryState } from './rawEditorEntryState' import { deriveDisplayTitleState } from '../utils/noteTitle' import { detectFrontmatterState } from '../utils/frontmatter' @@ -8,6 +8,7 @@ import type { VaultEntry } from '../types' import type { AppLocale } from '../lib/i18n' const EMPTY_DERIVED_ENTRY_STATE_KEY = JSON.stringify(deriveRawEditorEntryState('')) +type UpdateEntry = (path: string, patch: Partial) => void function shouldSyncFrontmatterState(content: string): boolean { const frontmatterState = detectFrontmatterState(content) @@ -15,6 +16,79 @@ function shouldSyncFrontmatterState(content: string): boolean { return !(frontmatterState === 'none' && content.startsWith('---\n')) } +function frontmatterSyncKey(content: string): string | null { + if (!shouldSyncFrontmatterState(content)) return null + return splitFrontmatter(content)[0] +} + +function syncOutgoingLinks(options: { + content: string + path: string + prevLinksKeyRef: MutableRefObject + updateEntry: UpdateEntry +}): void { + const { content, path, prevLinksKeyRef, updateEntry } = options + const links = content.includes('[[') ? extractOutgoingLinks(content) : [] + const key = links.join('\0') + if (key === prevLinksKeyRef.current) return + + prevLinksKeyRef.current = key + updateEntry(path, { outgoingLinks: links }) +} + +function resolveFrontmatterPatch(options: { + content: string + prevFmSourceRef: MutableRefObject +}): Partial | null { + const { content, prevFmSourceRef } = options + const fmSource = frontmatterSyncKey(content) + if (fmSource === null || fmSource === prevFmSourceRef.current) return null + + prevFmSourceRef.current = fmSource + return deriveRawEditorEntryState(content) +} + +function syncFrontmatterMetadata(options: { + content: string + path: string + prevFmKeyRef: MutableRefObject + prevFmSourceRef: MutableRefObject + updateEntry: UpdateEntry +}): string | null { + const { content, path, prevFmKeyRef, prevFmSourceRef, updateEntry } = options + const frontmatterPatch = resolveFrontmatterPatch({ content, prevFmSourceRef }) + if (!frontmatterPatch) return null + + const frontmatterTitle = typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null + const fmPatch = { ...frontmatterPatch } + delete fmPatch.title + const fmKey = JSON.stringify(fmPatch) + if (fmKey !== prevFmKeyRef.current) { + prevFmKeyRef.current = fmKey + updateEntry(path, fmPatch) + } + return frontmatterTitle +} + +function syncDisplayTitle(options: { + content: string + frontmatterTitle: string | null + path: string + prevTitleKeyRef: MutableRefObject + updateEntry: UpdateEntry +}): void { + const { content, frontmatterTitle, path, prevTitleKeyRef, updateEntry } = options + const filename = path.split('/').pop() ?? path + const titlePatch = deriveDisplayTitleState({ content, filename, frontmatterTitle }) + const titleKey = JSON.stringify(titlePatch) + if (titleKey === prevTitleKeyRef.current) return + + prevTitleKeyRef.current = titleKey + startTransition(() => { + updateEntry(path, titlePatch) + }) +} + export function useEditorSaveWithLinks(config: { updateEntry: (path: string, patch: Partial) => void setTabs: Parameters[0]['setTabs'] @@ -39,41 +113,26 @@ export function useEditorSaveWithLinks(config: { const editor = useEditorSave({ ...config, updateVaultContent: saveContent }) const { handleContentChange: rawOnChange } = editor const prevLinksKeyRef = useRef('') + const prevFmSourceRef = useRef(null) const prevFmKeyRef = useRef(EMPTY_DERIVED_ENTRY_STATE_KEY) const prevTitleKeyRef = useRef('') const handleContentChange = useCallback((path: string, content: string) => { rawOnChange(path, content) - const links = extractOutgoingLinks(content) - const key = links.join('\0') - if (key !== prevLinksKeyRef.current) { - prevLinksKeyRef.current = key - updateEntry(path, { outgoingLinks: links }) - } - const frontmatterPatch = shouldSyncFrontmatterState(content) - ? deriveRawEditorEntryState(content) - : null - const filename = path.split('/').pop() ?? path - const titlePatch = deriveDisplayTitleState({ + syncOutgoingLinks({ content, path, prevLinksKeyRef, updateEntry }) + const frontmatterTitle = syncFrontmatterMetadata({ content, - filename, - frontmatterTitle: typeof frontmatterPatch?.title === 'string' ? frontmatterPatch.title : null, + path, + prevFmKeyRef, + prevFmSourceRef, + updateEntry, + }) + syncDisplayTitle({ + content, + frontmatterTitle, + path, + prevTitleKeyRef, + updateEntry, }) - if (frontmatterPatch) { - const fmPatch = { ...frontmatterPatch } - delete fmPatch.title - const fmKey = JSON.stringify(fmPatch) - if (fmKey !== prevFmKeyRef.current) { - prevFmKeyRef.current = fmKey - updateEntry(path, fmPatch) - } - } - const titleKey = JSON.stringify(titlePatch) - if (titleKey !== prevTitleKeyRef.current) { - prevTitleKeyRef.current = titleKey - startTransition(() => { - updateEntry(path, titlePatch) - }) - } }, [rawOnChange, updateEntry]) return { ...editor, handleContentChange } } diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 6883a66b..f57c4cce 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -3,6 +3,7 @@ import { renderHook, act } from '@testing-library/react' import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap' import { normalizeParsedImageBlocks } from './editorTabContent' import { cacheNoteContent, clearPrefetchCache } from './useTabManagement' +import { cacheParsedNoteBlocks, clearParsedNoteBlockCache } from './editorParsedBlockCache' describe('extractEditorBody', () => { it('strips frontmatter and preserves H1 heading for new note content', () => { @@ -199,6 +200,10 @@ describe('normalizeParsedImageBlocks', () => { const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] +function makeTextParagraphBlock(text: string) { + return { type: 'paragraph', content: [{ type: 'text', text, styles: {} }], children: [] } +} + function makeTab(path: string, title: string) { return { entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never, @@ -271,6 +276,14 @@ async function flushEditorTick() { await act(() => new Promise((resolve) => setTimeout(resolve, 0))) } +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + function installEditorDomSpies(scrollTop = 0) { const scrollEl = { scrollTop } const frameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { @@ -335,7 +348,10 @@ async function createSwapHarness(options: { } describe('useEditorTabSwap raw mode sync', () => { - afterEach(() => { vi.restoreAllMocks() }) + afterEach(() => { + clearParsedNoteBlockCache() + vi.restoreAllMocks() + }) it('swaps in the new note when the path updates before tabs catch up', async () => { const tabA = makeTab('a.md', 'Note A') @@ -474,7 +490,34 @@ describe('useEditorTabSwap raw mode sync', () => { expect(mockEditor.replaceBlocks).toHaveBeenCalled() }) - it('prepares prefetched note blocks before the note is opened', async () => { + it('uses parsed block cache for a note that was warmed before opening', async () => { + const tabA = makeTab('a.md', 'Note A') + const tabB = makeTab('b.md', 'Note B') + const warmedBlocks = [makeTextParagraphBlock('Warmed body')] + cacheParsedNoteBlocks({ + path: tabB.entry.path, + sourceContent: tabB.content, + blocks: warmedBlocks, + scrollTop: 0, + }) + + const { mockEditor, rerenderWith } = await createSwapHarness({ + initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, + }) + mockEditor.tryParseMarkdownToBlocks.mockClear() + mockEditor.replaceBlocks.mockClear() + + await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) + + expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() + expect(mockEditor.replaceBlocks.mock.calls[0][1]).toEqual([ + expect.objectContaining({ + content: [{ type: 'text', text: 'Warmed body', styles: {} }], + }), + ]) + }) + + it('does not preparse prefetched note blocks in the background', async () => { clearPrefetchCache() const tabA = makeTab('a.md', 'Note A') const tabB = { @@ -488,16 +531,15 @@ describe('useEditorTabSwap raw mode sync', () => { mockEditor.tryParseMarkdownToBlocks.mockClear() cacheNoteContent('b.md', tabB.content) - await act(() => new Promise((resolve) => setTimeout(resolve, 100))) + await flushEditorTick() + + expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() + + await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith( expect.stringContaining('Prepared body.'), ) - - mockEditor.tryParseMarkdownToBlocks.mockClear() - await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' }) - - expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled() clearPrefetchCache() }) @@ -555,6 +597,49 @@ describe('useEditorTabSwap raw mode sync', () => { expect(mockEditor.replaceBlocks).toHaveBeenCalled() }) + it('ignores stale same-path parse results when tab content refreshes', async () => { + const staleParse = createDeferred() + const freshParse = createDeferred() + const tabA = makeTab('a.md', 'Note A') + const refreshedTabA = { + ...tabA, + content: '---\ntitle: Note A\n---\n\n# Note A\n\nFresh filesystem content.', + } + const staleBlocks = [makeTextParagraphBlock('Stale content')] + const freshBlocks = [makeTextParagraphBlock('Fresh filesystem content')] + + const { mockEditor, rerenderWith } = await createSwapHarness({ + initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false }, + setupEditor: (editor) => { + editor.tryParseMarkdownToBlocks + .mockReturnValueOnce(staleParse.promise) + .mockReturnValueOnce(freshParse.promise) + }, + }) + + await rerenderWith({ tabs: [refreshedTabA], activeTabPath: 'a.md' }) + mockEditor.replaceBlocks.mockClear() + + await act(async () => { + staleParse.resolve(staleBlocks) + await Promise.resolve() + }) + + expect(mockEditor.replaceBlocks).not.toHaveBeenCalled() + + await act(async () => { + freshParse.resolve(freshBlocks) + await Promise.resolve() + }) + + expect(mockEditor.replaceBlocks).toHaveBeenCalledTimes(1) + expect(mockEditor.replaceBlocks.mock.calls[0][1]).toEqual([ + expect.objectContaining({ + content: [{ type: 'text', text: 'Fresh filesystem content', styles: {} }], + }), + ]) + }) + it('re-parses when the active tab content changes without a path change', async () => { const tabA = makeTab('a.md', 'Note A') const refreshedTabA = { diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 1bc6a01f..406a0818 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -1,15 +1,12 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' import type { useCreateBlockNote } from '@blocknote/react' import type { VaultEntry } from '../types' -import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks' +import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks' import { compactMarkdown } from '../utils/compact-markdown' -import { injectMathInBlocks, preProcessMathMarkdown } from '../utils/mathMarkdown' -import { injectMermaidInBlocks, preProcessMermaidMarkdown, serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown' +import { serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown' import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance' -import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages' -import { getResolvedCachedNoteContent, NOTE_CONTENT_CACHE_RESOLVED_EVENT } from './useTabManagement' +import { portableImageUrls } from '../utils/vaultImages' import { useEditorMountState, useLatestRef } from './editorTabSwapLifecycle' -import { usePreparedNotePreload } from './usePreparedNotePreload' import { applyBlankStateToEditor, applyBlocksToEditor, @@ -21,7 +18,6 @@ import { flushBeforeRawMode, useDebouncedEditorChange, } from './editorChangeDebounce' -import { repairMalformedEditorBlocks } from './editorBlockRepair' import { blankParagraphBlocks, extractEditorBody, @@ -32,9 +28,22 @@ import { } from './editorTabContent' import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection' import { - parseMarkdownBlocksWithFallback, - type MarkdownParseResult, -} from './editorMarkdownParseFallback' + cacheEditorState, + cacheParsedEditorState, + cacheResolvedEditorState, + isBlankBodyContent, + resolveBlocksForTarget, + resolveEmptyHeadingBlocks, + startsWithEmptyHeading, + type CachedTabState, +} from './editorBlockResolution' +import { + createSwapToken, + invalidatePendingSwap, + shouldAbortSwap, + type SwapToken, +} from './editorSwapToken' +import { useParsedBlockPreload } from './editorParsedBlockPreload' export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent' export { RICH_EDITOR_CHANGE_DEBOUNCE_MS } from './editorChangeDebounce' @@ -43,11 +52,7 @@ interface Tab { content: string } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays -type EditorBlocks = any[] -type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: string } type PendingLocalContent = { path: string; content: string } -const TAB_STATE_CACHE_LIMIT = 24 interface TabSwapState { cache: Map @@ -74,9 +79,11 @@ interface RunTabSwapEffectOptions { editor: ReturnType rawMode?: boolean tabCacheRef: MutableRefObject> + tabsRef: MutableRefObject prevActivePathRef: MutableRefObject editorMountedRef: MutableRefObject pendingSwapRef: MutableRefObject<(() => void) | null> + swapSeqRef: MutableRefObject prevRawModeRef: MutableRefObject rawSwapPendingRef: MutableRefObject suppressChangeRef: MutableRefObject @@ -90,6 +97,8 @@ interface UseTabSwapEffectOptions extends Omit } +type ParsedBlockPreloadEvent = { path: string; content: string } + function signalEditorTabSwapped(path: string): void { window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path }, @@ -102,181 +111,6 @@ function readEditorScrollTop(): number { return scrollEl?.scrollTop ?? 0 } -function cacheEditorState( - cache: Map, - path: string, - nextState: CachedTabState, -) { - if (cache.has(path)) cache.delete(path) - cache.set(path, nextState) - while (cache.size > TAB_STATE_CACHE_LIMIT) { - const oldestPath = cache.keys().next().value - if (!oldestPath) return - cache.delete(oldestPath) - } -} - -function buildFastPathBlocks(options: { preprocessed: string }): EditorBlocks | null { - const { preprocessed } = options - const trimmed = preprocessed.trim() - - if (!trimmed) { - return [{ type: 'paragraph', content: [] }] - } - - if (trimmed === '#') { - return [ - { type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [], children: [] }, - { type: 'paragraph', content: [], children: [] }, - ] - } - - const h1OnlyMatch = trimmed.match(/^# (.+)$/) - if (!h1OnlyMatch) return null - - return [ - { type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] }, - { type: 'paragraph', content: [], children: [] }, - ] -} - -function emptyHeadingBlock(): Record { - return { - type: 'heading', - props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, - content: [], - children: [], - } -} - -function isBlankBodyContent(options: { content: string }): boolean { - const { content } = options - return extractEditorBody(content).trim() === '' -} - -function extractBodyRemainderAfterEmptyH1(options: { content: string }): string | null { - const { content } = options - const body = extractEditorBody(content) - const [firstLine, secondLine, ...rest] = body.split('\n') - if (!firstLine) return null - - const normalizedFirstLine = firstLine.trimEnd() - if (normalizedFirstLine !== '#' && normalizedFirstLine !== '# ') return null - - if (secondLine === '') { - return rest.join('\n').trimStart() - } - - return [secondLine, ...rest].join('\n').trimStart() -} - -async function parseMarkdownBlocks( - editor: ReturnType, - preprocessed: string, -): Promise { - const result = editor.tryParseMarkdownToBlocks(preprocessed) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks - if (result && typeof (result as any).then === 'function') { - return (result as unknown as Promise) - } - return result as EditorBlocks -} - -function preProcessEditorMarkdown(markdown: string, vaultPath?: string): string { - const withMermaid = preProcessMermaidMarkdown({ markdown }) - const withImages = vaultPath ? resolveImageUrls(withMermaid, vaultPath) : withMermaid - const withWikilinks = preProcessWikilinks(withImages) - return preProcessMathMarkdown({ markdown: withWikilinks }) -} - -function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks { - const withWikilinks = injectWikilinks(blocks) - const withMath = injectMathInBlocks(withWikilinks) - return injectMermaidInBlocks(withMath) as EditorBlocks -} - -function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks { - const parseSafeBlocks = repairMalformedEditorBlocks(parsed.blocks) as EditorBlocks - if (parsed.usedSourceFallback) return parseSafeBlocks - return repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks -} - -async function resolveBlocksForTarget( - options: { - editor: ReturnType - cache: Map - targetPath: string - content: string - vaultPath?: string - }, -): Promise { - const { editor, cache, targetPath, content, vaultPath } = options - const cached = cache.get(targetPath) - if (cached?.sourceContent === content) return cached - - const body = extractEditorBody(content) - const preprocessed = preProcessEditorMarkdown(body, vaultPath) - const fastPathBlocks = buildFastPathBlocks({ preprocessed }) - if (fastPathBlocks) { - const nextState = { - blocks: repairMalformedEditorBlocks(fastPathBlocks) as EditorBlocks, - scrollTop: 0, - sourceContent: content, - } - cacheEditorState(cache, targetPath, nextState) - return nextState - } - - const parsed = await parseMarkdownBlocksWithFallback({ - parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown), - preprocessed, - sourceMarkdown: body, - context: targetPath, - }) - const nextState = { - blocks: repairParsedMarkdownBlocks(parsed), - scrollTop: 0, - sourceContent: content, - } - cacheEditorState(cache, targetPath, nextState) - return nextState -} - -function shouldPrepareCachedContent(cache: Map, path: string, content: string): boolean { - return cache.get(path)?.sourceContent !== content -} - -async function prepareCachedNoteContent(options: { - editor: ReturnType - cache: Map - path: string - vaultPath?: string -}): Promise { - const { editor, cache, path, vaultPath } = options - const cached = getResolvedCachedNoteContent(path) - if (!cached || !shouldPrepareCachedContent(cache, path, cached.content)) return - await resolveBlocksForTarget({ editor, cache, targetPath: path, content: cached.content, vaultPath }) -} - -async function resolveEmptyHeadingBlocks( - editor: ReturnType, - content: string, - vaultPath?: string, - targetPath = 'empty heading note', -): Promise { - const remainder = extractBodyRemainderAfterEmptyH1({ content }) - if (remainder === null) return null - if (!remainder.trim()) return [emptyHeadingBlock(), ...blankParagraphBlocks()] as EditorBlocks - - const parsed = await parseMarkdownBlocksWithFallback({ - parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown), - preprocessed: preProcessEditorMarkdown(remainder, vaultPath), - sourceMarkdown: remainder, - context: targetPath, - }) - return [emptyHeadingBlock(), ...repairParsedMarkdownBlocks(parsed)] as EditorBlocks -} - function findActiveTab(options: { tabs: Tab[] activeTabPath: string | null @@ -380,11 +214,11 @@ function useEditorChangeHandler(options: { const [frontmatter] = splitFrontmatter(tab.content) const nextContent = `${frontmatter}${bodyMarkdown}` pendingLocalContentRef.current = { path, content: nextContent } - cacheEditorState(tabCacheRef.current, path, { + cacheResolvedEditorState(tabCacheRef.current, path, { blocks, scrollTop: readEditorScrollTop(), sourceContent: nextContent, - }) + }, vaultPathRef.current) onContentChangeRef.current?.(path, nextContent) }, [editor, editorContentPathRef, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, tabCacheRef, tabsRef, vaultPathRef]) @@ -752,7 +586,7 @@ function applyBlankTabState(options: { editor: ReturnType suppressChangeRef: MutableRefObject editorContentPathRef: EditorContentPathRef -}) { +}): boolean { const { cache, targetPath, @@ -767,8 +601,12 @@ function applyBlankTabState(options: { scrollTop: 0, sourceContent: content, }) - applyBlankStateToEditor({ editor, suppressChangeRef, editorContentPathRef, targetPath }) + if (!applyBlankStateToEditor({ editor, suppressChangeRef, editorContentPathRef, targetPath })) { + return false + } + signalTabSwap({ path: targetPath }) + return true } function scheduleEmptyHeadingSwap(options: { @@ -778,6 +616,9 @@ function scheduleEmptyHeadingSwap(options: { prevActivePathRef: MutableRefObject suppressChangeRef: MutableRefObject editorContentPathRef: EditorContentPathRef + swapSeqRef: MutableRefObject + tabsRef: MutableRefObject + token: SwapToken vaultPath?: string }) { const { @@ -787,19 +628,23 @@ function scheduleEmptyHeadingSwap(options: { prevActivePathRef, suppressChangeRef, editorContentPathRef, + swapSeqRef, + tabsRef, + token, vaultPath, } = options - if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false + if (!startsWithEmptyHeading({ content })) return false void resolveEmptyHeadingBlocks(editor, content, vaultPath, targetPath) .then((blocks) => { - if (prevActivePathRef.current !== targetPath || !blocks) return - applyBlocksToEditor({ editor, blocks, scrollTop: 0, suppressChangeRef, editorContentPathRef, targetPath }) + if (!blocks || shouldAbortSwap({ prevActivePathRef, suppressChangeRef, swapSeqRef, tabsRef, token })) return + cacheParsedEditorState(targetPath, { blocks, scrollTop: 0, sourceContent: content }, vaultPath) + if (!applyBlocksToEditor({ editor, blocks, scrollTop: 0, suppressChangeRef, editorContentPathRef, targetPath })) return signalTabSwap({ path: targetPath }) }) .catch((err: unknown) => { - suppressChangeRef.current = false + if (swapSeqRef.current === token.seq) suppressChangeRef.current = false console.error('Failed to render empty heading state:', err) failNoteOpenTrace(targetPath, 'empty-heading-swap-failed') }) @@ -815,6 +660,9 @@ function scheduleParsedBlockSwap(options: { prevActivePathRef: MutableRefObject suppressChangeRef: MutableRefObject editorContentPathRef: EditorContentPathRef + swapSeqRef: MutableRefObject + tabsRef: MutableRefObject + token: SwapToken vaultPath?: string }) { const { @@ -825,17 +673,20 @@ function scheduleParsedBlockSwap(options: { prevActivePathRef, suppressChangeRef, editorContentPathRef, + swapSeqRef, + tabsRef, + token, vaultPath, } = options void resolveBlocksForTarget({ editor, cache, targetPath, content, vaultPath }) .then(({ blocks, scrollTop }) => { - if (prevActivePathRef.current !== targetPath) return - applyBlocksToEditor({ editor, blocks, scrollTop, suppressChangeRef, editorContentPathRef, targetPath }) + if (shouldAbortSwap({ prevActivePathRef, suppressChangeRef, swapSeqRef, tabsRef, token })) return + if (!applyBlocksToEditor({ editor, blocks, scrollTop, suppressChangeRef, editorContentPathRef, targetPath })) return signalTabSwap({ path: targetPath }) }) .catch((err: unknown) => { - suppressChangeRef.current = false + if (swapSeqRef.current === token.seq) suppressChangeRef.current = false console.error('Failed to parse/swap editor content:', err) failNoteOpenTrace(targetPath, 'parsed-swap-failed') }) @@ -848,6 +699,8 @@ function scheduleTabSwap(options: { activeTab: Tab clearDomSelection: boolean pendingSwapRef: MutableRefObject<(() => void) | null> + swapSeqRef: MutableRefObject + tabsRef: MutableRefObject prevActivePathRef: MutableRefObject rawSwapPendingRef: MutableRefObject suppressChangeRef: MutableRefObject @@ -861,6 +714,8 @@ function scheduleTabSwap(options: { activeTab, clearDomSelection, pendingSwapRef, + swapSeqRef, + tabsRef, prevActivePathRef, rawSwapPendingRef, suppressChangeRef, @@ -868,9 +723,11 @@ function scheduleTabSwap(options: { vaultPath, } = options + const token = createSwapToken(swapSeqRef, targetPath, activeTab.content) suppressChangeRef.current = true const doSwap = () => { + if (shouldAbortSwap({ prevActivePathRef, suppressChangeRef, swapSeqRef, tabsRef, token })) return if (clearStaleSwap({ targetPath, prevActivePathRef, suppressChangeRef })) return rawSwapPendingRef.current = false if (clearDomSelection) clearEditorDomSelection() @@ -894,6 +751,9 @@ function scheduleTabSwap(options: { prevActivePathRef, suppressChangeRef, editorContentPathRef, + swapSeqRef, + tabsRef, + token, vaultPath, })) { return @@ -907,6 +767,9 @@ function scheduleTabSwap(options: { prevActivePathRef, suppressChangeRef, editorContentPathRef, + swapSeqRef, + tabsRef, + token, vaultPath, }) } @@ -1005,9 +868,11 @@ function runTabSwapEffect(options: RunTabSwapEffectOptions) { editor, rawMode, tabCacheRef, + tabsRef, prevActivePathRef, editorMountedRef, pendingSwapRef, + swapSeqRef, prevRawModeRef, rawSwapPendingRef, suppressChangeRef, @@ -1026,6 +891,7 @@ function runTabSwapEffect(options: RunTabSwapEffectOptions) { prevActivePathRef, rawModeJustEnded, }) + if (state.pathChanged) invalidatePendingSwap({ pendingSwapRef, swapSeqRef }) flushBeforePathChange({ pathChanged: state.pathChanged, flushPendingEditorChange }) if (shouldSkipScheduledTabSwap({ @@ -1050,6 +916,8 @@ function runTabSwapEffect(options: RunTabSwapEffectOptions) { activeTab: state.activeTab, clearDomSelection: state.pathChanged, pendingSwapRef, + swapSeqRef, + tabsRef, prevActivePathRef, rawSwapPendingRef, suppressChangeRef, @@ -1065,9 +933,11 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) { editor, rawMode, tabCacheRef, + tabsRef, prevActivePathRef, editorMountedRef, pendingSwapRef, + swapSeqRef, prevRawModeRef, rawSwapPendingRef, suppressChangeRef, @@ -1084,9 +954,11 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) { editor, rawMode, tabCacheRef, + tabsRef, editorMountedRef, prevActivePathRef, pendingSwapRef, + swapSeqRef, prevRawModeRef, rawSwapPendingRef, suppressChangeRef, @@ -1100,6 +972,7 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) { editor, editorMountedRef, pendingSwapRef, + swapSeqRef, prevActivePathRef, prevRawModeRef, rawMode, @@ -1107,6 +980,7 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) { suppressChangeRef, editorContentPathRef, tabCacheRef, + tabsRef, tabs, pendingLocalContentRef, vaultPathRef, @@ -1114,6 +988,38 @@ function useTabSwapEffect(options: UseTabSwapEffectOptions) { ]) } +function useForegroundWorkTracker( + activeTabPath: string | null, + handleEditorChange: () => void, +) { + const foregroundWorkAtRef = useRef(0) + useEffect(() => { + foregroundWorkAtRef.current = Date.now() + }, [activeTabPath]) + const handleForegroundEditorChange = useCallback(() => { + foregroundWorkAtRef.current = Date.now() + handleEditorChange() + }, [handleEditorChange]) + return { foregroundWorkAtRef, handleForegroundEditorChange } +} + +function usePrepareParsedBlocks(options: { + editor: ReturnType + tabCacheRef: MutableRefObject> + vaultPathRef: MutableRefObject +}) { + const { editor, tabCacheRef, vaultPathRef } = options + return useCallback(async (event: ParsedBlockPreloadEvent) => { + await resolveBlocksForTarget({ + editor, + cache: tabCacheRef.current, + targetPath: event.path, + content: event.content, + vaultPath: vaultPathRef.current, + }) + }, [editor, tabCacheRef, vaultPathRef]) +} + /** * Manages the tab content-swap machinery for the BlockNote editor. * @@ -1130,10 +1036,13 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, const tabCacheRef = useRef>(new Map()) const pendingLocalContentRef = useRef(null) const prevActivePathRef = useRef(null) + const activeTabPathLatestRef = useLatestRef(activeTabPath) const editorContentPathRef = useRef(null) const editorMountedRef = useRef(false) const pendingSwapRef = useRef<(() => void) | null>(null) + const swapSeqRef = useRef(0) const prevRawModeRef = useRef(!!rawMode) + const rawModeLatestRef = useLatestRef(!!rawMode) const rawSwapPendingRef = useRef(false) const suppressChangeRef = useRef(false) const onContentChangeRef = useLatestRef(onContentChange) @@ -1150,18 +1059,27 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, pendingLocalContentRef, vaultPathRef, }) - const preparePreloadedPath = useCallback((path: string) => prepareCachedNoteContent({ editor, cache: tabCacheRef.current, path, vaultPath: vaultPathRef.current }), [editor, tabCacheRef, vaultPathRef]) - + const { foregroundWorkAtRef, handleForegroundEditorChange } = useForegroundWorkTracker(activeTabPath, handleEditorChange) + const prepareParsedBlocks = usePrepareParsedBlocks({ editor, tabCacheRef, vaultPathRef }) useEditorMountState(editor, editorMountedRef, pendingSwapRef) + useParsedBlockPreload({ + activeTabPathRef: activeTabPathLatestRef, + editorMountedRef, + foregroundWorkAtRef, + prepareParsedBlocks, + rawModeRef: rawModeLatestRef, + }) useTabSwapEffect({ tabs, activeTabPath, editor, rawMode, tabCacheRef, + tabsRef, prevActivePathRef, editorMountedRef, pendingSwapRef, + swapSeqRef, prevRawModeRef, rawSwapPendingRef, suppressChangeRef, @@ -1170,12 +1088,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, vaultPathRef, flushPendingEditorChange, }) - usePreparedNotePreload({ - eventName: NOTE_CONTENT_CACHE_RESOLVED_EVENT, - editorMountedRef, - rawMode, - preparePath: preparePreloadedPath, - }) - return { handleEditorChange, flushPendingEditorChange, editorMountedRef } + return { handleEditorChange: handleForegroundEditorChange, flushPendingEditorChange, editorMountedRef } } diff --git a/src/hooks/useNoteActions.hook.test.ts b/src/hooks/useNoteActions.hook.test.ts index a45ab20b..065aff2e 100644 --- a/src/hooks/useNoteActions.hook.test.ts +++ b/src/hooks/useNoteActions.hook.test.ts @@ -499,7 +499,7 @@ describe('useNoteActions hook', () => { }) describe('note open is read-only', () => { - it('does not sync title or reload entry when opening or freshness-validating a note', async () => { + it('does not sync title or reload entry when reopening an identity-matched cached note', async () => { vi.mocked(isTauri).mockReturnValue(true) const entry = makeEntry({ path: '/test/vault/qa-test.md', filename: 'qa-test.md', title: 'Qa Test' }) vi.mocked(invoke).mockImplementation(async (command) => { @@ -516,10 +516,9 @@ describe('useNoteActions hook', () => { const desyncedEntry = { ...entry, title: 'Wrong Title Desynced' } await act(async () => { await result.current.handleSelectNote(desyncedEntry) }) - expect(vi.mocked(invoke)).toHaveBeenCalledTimes(callCountAfterFirstOpen + 1) + expect(vi.mocked(invoke)).toHaveBeenCalledTimes(callCountAfterFirstOpen) expect(vi.mocked(invoke).mock.calls).toEqual([ ['get_note_content', { path: '/test/vault/qa-test.md' }], - ['validate_note_content', { path: '/test/vault/qa-test.md', content: '# Qa Test\n' }], ]) expect(result.current.tabs[0].entry.title).toBe('Qa Test') }) diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index 9e1918b5..21200831 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -470,7 +470,7 @@ async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Pr const didPersist = await persistImmediateEntry(deps, entry, content) if (!didPersist) return false - cacheNoteContent(entry.path, content) + cacheNoteContent(entry.path, content, entry) deps.openTabWithContent(entry, content) addEntryWithMock(entry, content, deps.addEntry) signalFocusEditor({ path: entry.path, selectTitle: true }) diff --git a/src/hooks/usePreparedNotePreload.ts b/src/hooks/usePreparedNotePreload.ts deleted file mode 100644 index 1ccfe7ea..00000000 --- a/src/hooks/usePreparedNotePreload.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useEffect, type MutableRefObject } from 'react' - -const PREPARED_NOTE_PRELOAD_DELAY_MS = 75 - -interface PreparedNotePreloadOptions { - eventName: string - editorMountedRef: MutableRefObject - rawMode?: boolean - preparePath: (path: string) => Promise | void -} - -interface PreloadQueueState { - timer: number | null - cancelled: boolean - paths: string[] -} - -interface PreloadRunnerOptions extends Omit { - state: PreloadQueueState -} - -function canSchedulePreload(state: PreloadQueueState): boolean { - return state.timer === null && state.paths.length > 0 -} - -function canRunPreload({ state, rawMode, editorMountedRef }: PreloadRunnerOptions): boolean { - return !state.cancelled && !rawMode && editorMountedRef.current -} - -function schedulePreparedPreload(options: PreloadRunnerOptions): void { - const { state } = options - if (!canSchedulePreload(state)) return - - state.timer = window.setTimeout(() => { - state.timer = null - if (!canRunPreload(options)) return - - const nextPath = state.paths.shift() - if (!nextPath) return - Promise.resolve(options.preparePath(nextPath)).finally(() => { - schedulePreparedPreload(options) - }) - }, PREPARED_NOTE_PRELOAD_DELAY_MS) -} - -function enqueuePreloadPath(state: PreloadQueueState, path: string): void { - if (state.paths.includes(path)) return - state.paths.push(path) -} - -function resolvedContentPath(event: Event): string | null { - return (event as CustomEvent<{ path?: string }>).detail?.path ?? null -} - -function cancelPreloadQueue(state: PreloadQueueState): void { - state.cancelled = true - if (state.timer !== null) window.clearTimeout(state.timer) -} - -export function usePreparedNotePreload({ - eventName, - editorMountedRef, - rawMode, - preparePath, -}: PreparedNotePreloadOptions) { - useEffect(() => { - if (typeof window === 'undefined') return - - const state: PreloadQueueState = { timer: null, cancelled: false, paths: [] } - const runner = { state, editorMountedRef, rawMode, preparePath } - - const handleResolvedContent = (event: Event) => { - const path = resolvedContentPath(event) - if (!path) return - enqueuePreloadPath(state, path) - schedulePreparedPreload(runner) - } - - window.addEventListener(eventName, handleResolvedContent) - return () => { - cancelPreloadQueue(state) - window.removeEventListener(eventName, handleResolvedContent) - } - }, [editorMountedRef, eventName, preparePath, rawMode]) -} diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index 99eb9a8e..81d27ede 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -56,9 +56,9 @@ async function replaceActiveNote(result: HookState, overrides: Partial expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)) return mockInvoke } @@ -354,6 +354,24 @@ describe('useTabManagement (single-note model)', () => { await replaceActiveNote(result, { path: '/vault/a.md' }) expectSingleActiveTab(result, '/vault/a.md') }) + + it('validates cached content before replacing with a different active note', async () => { + cacheNoteContent('/vault/b.md', '# Stale cached B') + vi.mocked(mockInvoke).mockImplementation((cmd: string) => { + if (cmd === 'validate_note_content') return Promise.resolve(false) + return Promise.resolve('# Fresh disk B') + }) + + const { result } = renderHook(() => useTabManagement()) + await selectNote(result, { path: '/vault/a.md', title: 'A' }) + await replaceActiveNote(result, { path: '/vault/b.md', title: 'B' }) + + expect(result.current.tabs[0].content).toBe('# Fresh disk B') + expect(vi.mocked(mockInvoke)).toHaveBeenCalledWith('validate_note_content', { + path: '/vault/b.md', + content: '# Stale cached B', + }) + }) }) describe('openTabWithContent', () => { @@ -420,6 +438,21 @@ describe('useTabManagement (single-note model)', () => { }) }) + it('uses identity-matched prefetched content without re-reading the file', async () => { + const entry = makeEntry({ + path: '/vault/note/pre.md', + modifiedAt: 1700000001, + fileSize: 19, + }) + const mockInvoke = await prefetchResolvedContent(entry.path, '# Prefetched content', entry) + + const { result } = renderHook(() => useTabManagement()) + await selectNote(result, entry) + + expect(result.current.tabs[0].content).toBe('# Prefetched content') + expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1) + }) + it('does not paint cached content until freshness validation passes', async () => { const freshness = createDeferred() cacheNoteContent('/vault/note/stale.md', '# Stale cached content') @@ -611,7 +644,7 @@ describe('useTabManagement (single-note model)', () => { expect(result.current.tabs[0].entry.path).toBe('/vault/a.md') expect(result.current.tabs[0].content).toBe('# A content') - expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(3) + expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2) }) it('refreshes an already-open clean note when cached content is stale on disk', async () => { @@ -621,7 +654,12 @@ describe('useTabManagement (single-note model)', () => { await selectNote(result, { path: '/vault/a.md', title: 'A' }) mockNoteContent({ '/vault/a.md': '# External edit' }) - await selectNote(result, { path: '/vault/a.md', title: 'A' }) + await selectNote(result, { + path: '/vault/a.md', + title: 'A', + modifiedAt: 1700000001, + fileSize: 15, + }) expect(result.current.tabs[0].entry.path).toBe('/vault/a.md') expect(result.current.tabs[0].content).toBe('# External edit') diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index e8f41408..5f428bf1 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -1,6 +1,4 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { invoke } from '@tauri-apps/api/core' -import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' import { beginNoteOpenTrace, @@ -8,216 +6,44 @@ import { finishNoteOpenTrace, markNoteOpenTrace, } from '../utils/noteOpenPerformance' -import { getNoteWindowParams, isNoteWindow } from '../utils/windowMode' +import { + cacheNoteContent as cacheNoteContentInMemory, + clearNoteContentCache, + getCachedNoteContentEntry, + hasResolvedCachedContent, + isNoActiveVaultSelectedError, + isUnreadableNoteContentError, + loadContentForOpen, + NOTE_CONTENT_CACHE_LIMIT, + NOTE_CONTENT_CACHE_MAX_BYTES, + NOTE_CONTENT_ENTRY_MAX_BYTES, + prefetchNoteContent as prefetchNoteContentInMemory, +} from './noteContentCache' +import { clearParsedNoteBlockCache } from './editorParsedBlockCache' interface Tab { entry: VaultEntry content: string } -type NotePath = VaultEntry['path'] - -// --- Content prefetch cache --- -// Stores in-flight or recently loaded note content promises, keyed by path. -// Cleared on vault reload to prevent stale content after external edits. -// Latency profile: deduplicates rapid note switches and keeps revisits instant. -interface NoteContentCacheEntry { - path: NotePath - promise: Promise - value: string | null - byteSize: number +export { + NOTE_CONTENT_CACHE_LIMIT, + NOTE_CONTENT_CACHE_MAX_BYTES, + NOTE_CONTENT_ENTRY_MAX_BYTES, } -const prefetchCache = new Map() -const contentSizeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null - -export const NOTE_CONTENT_CACHE_RESOLVED_EVENT = 'laputa:note-content-cache-resolved' -export const NOTE_CONTENT_CACHE_LIMIT = 48 -export const NOTE_CONTENT_ENTRY_MAX_BYTES = 256 * 1024 -export const NOTE_CONTENT_CACHE_MAX_BYTES = 1024 * 1024 - -function measureNoteContentBytes(content: string): number { - return contentSizeEncoder ? contentSizeEncoder.encode(content).byteLength : content.length +export function prefetchNoteContent(target: string | VaultEntry): void { + prefetchNoteContentInMemory(target) } -function getRetainedPrefetchCacheBytes(): number { - let totalBytes = 0 - for (const entry of prefetchCache.values()) { - totalBytes += entry.byteSize - } - return totalBytes +export function cacheNoteContent(path: string, content: string, entry?: VaultEntry): void { + cacheNoteContentInMemory(path, content, entry) } -function dropOldestPrefetchEntry(): void { - const oldestPath = prefetchCache.keys().next().value - if (!oldestPath) return - prefetchCache.delete(oldestPath) -} - -function trimPrefetchCache(): void { - while ( - prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT - || getRetainedPrefetchCacheBytes() > NOTE_CONTENT_CACHE_MAX_BYTES - ) { - if (prefetchCache.size === 0) return - dropOldestPrefetchEntry() - } -} - -function rememberNoteContent(entry: NoteContentCacheEntry): NoteContentCacheEntry { - const { path } = entry - if (prefetchCache.has(path)) prefetchCache.delete(path) - prefetchCache.set(path, entry) - trimPrefetchCache() - return entry -} - -function retainResolvedNoteContent(entry: NoteContentCacheEntry, content: string): void { - const byteSize = measureNoteContentBytes(content) - if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) { - prefetchCache.delete(entry.path) - return - } - - entry.value = content - entry.byteSize = byteSize - rememberNoteContent(entry) - dispatchResolvedNoteContent(entry.path) -} - -function getNoteContentCommandPayload(path: string): { path: string; vaultPath?: string } { - if (!isNoteWindow()) { - return { path } - } - - const noteWindowParams = getNoteWindowParams() - return noteWindowParams - ? { path, vaultPath: noteWindowParams.vaultPath } - : { path } -} - -function getValidateNoteContentCommandPayload(path: string, content: string): { path: string; content: string; vaultPath?: string } { - return { ...getNoteContentCommandPayload(path), content } -} - -function dispatchResolvedNoteContent(path: string): void { - if (typeof window === 'undefined') return - window.dispatchEvent(new CustomEvent(NOTE_CONTENT_CACHE_RESOLVED_EVENT, { - detail: { path }, - })) -} - -function requestNoteContent({ path }: Pick): NoteContentCacheEntry { - const cacheEntry: NoteContentCacheEntry = { - path, - promise: Promise.resolve(''), - value: null, - byteSize: 0, - } - const commandPayload = getNoteContentCommandPayload(path) - const promise = (isTauri() - ? invoke('get_note_content', commandPayload) - : mockInvoke('get_note_content', commandPayload) - ) - .then((content) => { - retainResolvedNoteContent(cacheEntry, content) - return content - }) - .catch((err) => { - prefetchCache.delete(path) - throw err - }) - - cacheEntry.promise = promise - return rememberNoteContent(cacheEntry) -} - -/** Prefetch a note's content into the in-memory cache. - * Safe to call multiple times — deduplicates concurrent requests for the same path. - * Cache is short-lived: cleared on vault reload via clearPrefetchCache(). */ -export function prefetchNoteContent(path: string): void { - if (prefetchCache.has(path)) return - void requestNoteContent({ path }).promise.catch((error) => { - if (isNoActiveVaultSelectedError(error) || isUnreadableNoteContentError(error)) return - console.warn('Failed to prefetch note content:', error) - }) -} - -export function cacheNoteContent(path: string, content: string): void { - const byteSize = measureNoteContentBytes(content) - if (byteSize > NOTE_CONTENT_ENTRY_MAX_BYTES) { - prefetchCache.delete(path) - return - } - - rememberNoteContent({ - path, - promise: Promise.resolve(content), - value: content, - byteSize, - }) - dispatchResolvedNoteContent(path) -} - -/** Clear the prefetch cache. Call on vault reload to prevent stale content. */ +/** Clear note-open caches. Call on vault reload to prevent stale content. */ export function clearPrefetchCache(): void { - prefetchCache.clear() -} - -export function getResolvedCachedNoteContent(path: string): { path: string; content: string } | null { - const entry = prefetchCache.get(path) - if (!entry || entry.value === null) return null - return { path: entry.path, content: entry.value } -} - -function hasResolvedCachedContent(entry: NoteContentCacheEntry | null): entry is NoteContentCacheEntry & { value: string } { - if (!entry) return false - return entry.value !== null -} - -function getCachedNoteContentEntry(path: string): NoteContentCacheEntry | null { - return prefetchCache.get(path) ?? null -} - -async function validateCachedNoteContent(entry: NoteContentCacheEntry): Promise { - if (entry.value === null) return false - const payload = getValidateNoteContentCommandPayload(entry.path, entry.value) - return isTauri() - ? invoke('validate_note_content', payload) - : mockInvoke('validate_note_content', payload) -} - -async function loadNoteContent(path: string, forceFresh = false): Promise { - if (forceFresh) return requestNoteContent({ path }).promise - return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise -} - -async function loadCachedContentIfFresh(entry: NoteContentCacheEntry): Promise { - if (entry.value === null) return null - markNoteOpenTrace(entry.path, 'freshnessCheckStart') - const isFresh = await validateCachedNoteContent(entry) - markNoteOpenTrace(entry.path, 'freshnessCheckEnd') - if (isFresh) { - rememberNoteContent(entry) - return entry.value - } - prefetchCache.delete(entry.path) - return null -} - -async function loadContentForOpen(options: { - path: string - forceReload: boolean - cachedEntry: NoteContentCacheEntry | null -}): Promise { - const { path, forceReload, cachedEntry } = options - - if (!forceReload && hasResolvedCachedContent(cachedEntry)) { - const cachedContent = await loadCachedContentIfFresh(cachedEntry) - if (cachedContent !== null) return cachedContent - } - - return loadNoteContent(path, forceReload || hasResolvedCachedContent(cachedEntry)) + clearNoteContentCache() + clearParsedNoteBlockCache() } export type { Tab } @@ -354,24 +180,6 @@ function isMissingNotePathError(error: unknown): boolean { return /does not exist|not found|enoent/i.test(message) } -function isNoActiveVaultSelectedError(error: unknown): boolean { - const message = error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : String(error) - return /no active vault selected/i.test(message) -} - -function isUnreadableNoteContentError(error: unknown): boolean { - const message = error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : String(error) - return /not valid utf-8 text|invalid utf-8|stream did not contain valid utf-8/i.test(message) -} - function shouldApplyLoadedEntry(options: { seq: number navSeqRef: React.MutableRefObject @@ -576,7 +384,7 @@ async function loadTextEntry(options: Required { requestedActiveTabPathRef.current = entry.path void executeNavigationWithBoundary(entry.path, () => { + cacheNoteContent(entry.path, content, entry) setSingleTab(tabsRef, setTabs, { entry, content }) syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path) }).then((navigated) => { @@ -702,12 +511,13 @@ export function useTabManagement(options: TabManagementOptions = {}) { const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { requestedActiveTabPathRef.current = entry.path - if (!pathsMatch(entry.path, activeTabPathRef.current)) { + const replacingDifferentEntry = !pathsMatch(entry.path, activeTabPathRef.current) + if (replacingDifferentEntry) { beginNoteOpenTrace(entry.path, 'replace-active-tab') } const navigated = await executeNavigationWithBoundary(entry.path, () => navigateToEntry({ entry, - forceReload: true, + forceReload: !replacingDifferentEntry, navSeqRef, tabsRef, activeTabPathRef, diff --git a/src/hooks/useVaultLoader.extra.test.ts b/src/hooks/useVaultLoader.extra.test.ts index e4cd5926..610059ba 100644 --- a/src/hooks/useVaultLoader.extra.test.ts +++ b/src/hooks/useVaultLoader.extra.test.ts @@ -191,6 +191,7 @@ describe('useVaultLoader extra', () => { const { result } = renderHook(() => useVaultLoader('/vault')) await waitForEntries(result) + vi.mocked(clearPrefetchCache).mockClear() backendInvokeFn.mockImplementation((command: string) => { if (command === 'reload_vault') { diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index de73c4a2..3ee9f80e 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -191,6 +191,7 @@ function useInitialVaultLoad({ }) { useEffect(() => { const path = vaultPath + clearPrefetchCache() resetVaultState({ clearNewPaths: tracker.clear, clearUnsaved: unsaved.clearAll,