fix: keep note switches instant

This commit is contained in:
lucaronin
2026-04-19 11:29:04 +02:00
parent 862af864d3
commit b22da65d44
4 changed files with 457 additions and 236 deletions

View File

@@ -60,11 +60,7 @@ describe('extractEditorBody', () => {
describe('getH1TextFromBlocks', () => {
it('returns text from H1 heading block', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [{ type: 'text', text: 'My Title', styles: {} }],
}]
const blocks = makeHeadingBlocks([{ type: 'text', text: 'My Title', styles: {} }])
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
})
@@ -81,41 +77,25 @@ describe('getH1TextFromBlocks', () => {
})
it('returns null for H2 heading', () => {
const blocks = [{
type: 'heading',
props: { level: 2 },
content: [{ type: 'text', text: 'Subtitle' }],
}]
const blocks = makeHeadingBlocks([{ type: 'text', text: 'Subtitle' }], 2)
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('concatenates multiple text spans', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
],
}]
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
])
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
})
it('returns null for empty H1 content', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [],
}]
const blocks = makeHeadingBlocks([])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for whitespace-only H1', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [{ type: 'text', text: ' ' }],
}]
const blocks = makeHeadingBlocks([{ type: 'text', text: ' ' }])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
@@ -125,14 +105,10 @@ describe('getH1TextFromBlocks', () => {
})
it('filters non-text inline content', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
],
}]
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
])
expect(getH1TextFromBlocks(blocks)).toBe('Title')
})
})
@@ -187,7 +163,7 @@ function makeBlankBodyTab(path: string, title = 'Untitled Note 1') {
}
function makeMockEditor(docRef: { current: unknown[] }) {
return {
const editor = {
document: docRef.current,
get prosemirrorView() { return {} },
onMount: (cb: () => void) => { cb(); return () => {} },
@@ -199,39 +175,92 @@ function makeMockEditor(docRef: { current: unknown[] }) {
_tiptapEditor: { commands: { setContent: vi.fn() } },
_docRef: docRef,
}
Object.defineProperty(editor, 'document', { get: () => docRef.current })
return editor
}
function makeHeadingBlocks(
content: Array<Record<string, unknown>>,
level = 1,
) {
return [{
type: 'heading',
props: { level },
content,
}]
}
async function flushEditorTick() {
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)))
}
function installEditorDomSpies(scrollTop = 0) {
const scrollEl = { scrollTop }
const frameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0)
return 0
})
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
return { scrollEl, frameSpy }
}
type SwapHarnessProps = {
tabs: ReturnType<typeof makeTab>[]
activeTabPath: string | null
rawMode?: boolean
}
async function createSwapHarness(options: {
initialProps: SwapHarnessProps
onContentChange?: (path: string, content: string) => void
setupEditor?: (editor: ReturnType<typeof makeMockEditor>) => void
}) {
installEditorDomSpies()
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
options.setupEditor?.(mockEditor)
let currentProps = options.initialProps
const rendered = renderHook(
(props: SwapHarnessProps) => useEditorTabSwap({
...props,
editor: mockEditor as never,
onContentChange: options.onContentChange,
}),
{ initialProps: currentProps },
)
await flushEditorTick()
return {
...rendered,
mockEditor,
async rerenderWith(nextProps: Partial<SwapHarnessProps>) {
currentProps = { ...currentProps, ...nextProps }
rendered.rerender(currentProps)
await flushEditorTick()
},
}
}
describe('useEditorTabSwap raw mode sync', () => {
afterEach(() => { vi.restoreAllMocks() })
it('swaps in the new note when the path updates before tabs catch up', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'March 2024')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
rerender({ tabs: [tabA], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [tabA], activeTabPath: 'b.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [tabB] })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
expect.stringContaining('March 2024'),
@@ -240,31 +269,18 @@ describe('useEditorTabSwap raw mode sync', () => {
})
it('signals when the target tab content has been applied', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const swapListener = vi.fn()
window.addEventListener('laputa:editor-tab-swapped', swapListener)
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'March 2024')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
swapListener.mockClear()
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(swapListener).toHaveBeenCalledTimes(1)
const event = swapListener.mock.calls[0][0] as CustomEvent
@@ -274,59 +290,33 @@ describe('useEditorTabSwap raw mode sync', () => {
})
it('hard-resets the editor when the target note body is blank', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeBlankBodyTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
})
mockEditor._tiptapEditor.commands.setContent.mockClear()
mockEditor.replaceBlocks.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<p></p>')
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
})
it('renders empty H1 untitled notes via TipTap HTML content', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeUntitledTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
mockEditor._tiptapEditor.commands.setContent.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
@@ -334,35 +324,43 @@ describe('useEditorTabSwap raw mode sync', () => {
})
it('renders empty H1 typed notes with template content under the title', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
mockEditor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const typedUntitledTab = makeUntitledTab('untitled.md', 'Untitled Project 1', '## Objective\n\n')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
},
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor._tiptapEditor.commands.setContent.mockClear()
rerender({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md' })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n')
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<h1></h1><h2>Objective</h2><p></p>')
})
it('reuses cached editor blocks when reopening a recently visited note', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
await rerenderWith({ tabs: [tabA], activeTabPath: 'a.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -11,7 +11,17 @@ interface Tab {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
type EditorBlocks = any[]
type CachedTabState = { blocks: EditorBlocks; scrollTop: number }
type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: string }
const TAB_STATE_CACHE_LIMIT = 24
interface TabSwapState {
cache: Map<string, CachedTabState>
prevPath: string | null
pathChanged: boolean
activeTab: Tab | undefined
previousTab: Tab | undefined
rawModeJustEnded: boolean
}
interface UseEditorTabSwapOptions {
tabs: Tab[]
@@ -93,15 +103,19 @@ function readEditorScrollTop(): number {
function cacheEditorState(
cache: Map<string, CachedTabState>,
path: string,
blocks: EditorBlocks,
nextState: CachedTabState,
) {
cache.set(path, {
blocks,
scrollTop: readEditorScrollTop(),
})
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(preprocessed: string): EditorBlocks | null {
function buildFastPathBlocks(options: { preprocessed: string }): EditorBlocks | null {
const { preprocessed } = options
const trimmed = preprocessed.trim()
if (!trimmed) {
@@ -124,11 +138,13 @@ function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
]
}
function isBlankBodyContent(content: string): boolean {
function isBlankBodyContent(options: { content: string }): boolean {
const { content } = options
return extractEditorBody(content).trim() === ''
}
function extractBodyRemainderAfterEmptyH1(content: string): string | null {
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
@@ -166,23 +182,22 @@ async function resolveBlocksForTarget(
content: string,
): Promise<CachedTabState> {
const cached = cache.get(targetPath)
if (cached) return cached
if (cached?.sourceContent === content) return cached
const body = extractEditorBody(content)
const preprocessed = preProcessWikilinks(body)
const fastPathBlocks = buildFastPathBlocks(preprocessed)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = { blocks: fastPathBlocks, scrollTop: 0 }
cache.set(targetPath, nextState)
const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content }
cacheEditorState(cache, targetPath, nextState)
return nextState
}
const parsed = await parseMarkdownBlocks(editor, preprocessed)
const withWikilinks = injectWikilinks(parsed)
if (withWikilinks.length > 0) {
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
}
return { blocks: withWikilinks, scrollTop: 0 }
const nextState = { blocks: withWikilinks, scrollTop: 0, sourceContent: content }
cacheEditorState(cache, targetPath, nextState)
return nextState
}
function applyBlocksToEditor(
@@ -262,7 +277,7 @@ async function resolveEmptyHeadingHtml(
editor: ReturnType<typeof useCreateBlockNote>,
content: string,
): Promise<string | null> {
const remainder = extractBodyRemainderAfterEmptyH1(content)
const remainder = extractBodyRemainderAfterEmptyH1({ content })
if (remainder === null) return null
if (!remainder.trim()) return '<h1></h1><p></p>'
@@ -271,7 +286,11 @@ async function resolveEmptyHeadingHtml(
return `<h1></h1>${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}`
}
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
function findActiveTab(options: {
tabs: Tab[]
activeTabPath: string | null
}): Tab | undefined {
const { tabs, activeTabPath } = options
return activeTabPath
? tabs.find(tab => tab.entry.path === activeTabPath)
: undefined
@@ -282,11 +301,16 @@ function serializeEditorBody(editor: ReturnType<typeof useCreateBlockNote>): str
return compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof editor.document))
}
function normalizeTabBody(content: string): string {
function normalizeTabBody(options: { content: string }): string {
const { content } = options
return compactMarkdown(extractEditorBody(content))
}
function renameBodiesOverlap(currentBody: string, nextBody: string): boolean {
function renameBodiesOverlap(options: {
currentBody: string
nextBody: string
}): boolean {
const { currentBody, nextBody } = options
const current = currentBody.trimEnd()
const next = nextBody.trimEnd()
return current === next
@@ -305,10 +329,10 @@ function isUntitledRenameTransition(
const currentHeading = getH1TextFromBlocks(editor.document)
if (!currentHeading || slugifyPathStem(currentHeading) !== pathStem(nextPath)) return false
return renameBodiesOverlap(
serializeEditorBody(editor),
normalizeTabBody(activeTab.content),
)
return renameBodiesOverlap({
currentBody: serializeEditorBody(editor),
nextBody: normalizeTabBody({ content: activeTab.content }),
})
}
function useLatestRef<T>(value: T): MutableRefObject<T> {
@@ -382,21 +406,27 @@ function consumeRawModeTransition(
function cachePreviousTabOnPathChange(options: {
prevPath: string | null
previousTab: Tab | undefined
pathChanged: boolean
editorMountedRef: MutableRefObject<boolean>
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
}) {
const { prevPath, pathChanged, editorMountedRef, cache, editor } = options
if (!prevPath || !pathChanged || !editorMountedRef.current) return
cacheEditorState(cache, prevPath, editor.document)
const { prevPath, previousTab, pathChanged, editorMountedRef, cache, editor } = options
if (!prevPath || !previousTab || !pathChanged || !editorMountedRef.current) return
cacheEditorState(cache, prevPath, {
blocks: editor.document,
scrollTop: readEditorScrollTop(),
sourceContent: previousTab.content,
})
}
function shouldWaitForActiveTab(
pathChanged: boolean,
activeTabPath: string | null,
activeTab: Tab | undefined,
) {
function shouldWaitForActiveTab(options: {
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
}) {
const { pathChanged, activeTabPath, activeTab } = options
return pathChanged && !!activeTabPath && !activeTab
}
@@ -405,6 +435,7 @@ function syncActivePathTransition(options: {
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
previousTab: Tab | undefined
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
@@ -415,14 +446,22 @@ function syncActivePathTransition(options: {
pathChanged,
activeTabPath,
activeTab,
previousTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
} = options
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true
cachePreviousTabOnPathChange({
prevPath,
previousTab,
pathChanged,
editorMountedRef,
cache,
editor,
})
if (shouldWaitForActiveTab({ pathChanged, activeTabPath, activeTab })) return true
if (!preserveUntitledRenameState({
prevPath,
@@ -495,7 +534,11 @@ function cacheStableActivePath(options: {
} = options
if (!activeTabPath || !activeTab || !editorMountedRef.current) return
cacheEditorState(cache, activeTabPath, editor.document)
cacheEditorState(cache, activeTabPath, {
blocks: editor.document,
scrollTop: readEditorScrollTop(),
sourceContent: activeTab.content,
})
}
function preserveUntitledRenameState(options: {
@@ -530,15 +573,21 @@ function preserveUntitledRenameState(options: {
return true
}
function signalTabSwap(path: string) {
function signalTabSwap(options: { path: string }) {
const { path } = options
requestAnimationFrame(() => signalEditorTabSwapped(path))
}
function clearStaleSwap(
targetPath: string,
function clearStaleSwap(options: {
targetPath: string
prevActivePathRef: MutableRefObject<string | null>,
suppressChangeRef: MutableRefObject<boolean>,
): boolean {
}): boolean {
const {
targetPath,
prevActivePathRef,
suppressChangeRef,
} = options
if (prevActivePathRef.current === targetPath) return false
suppressChangeRef.current = false
return true
@@ -547,19 +596,25 @@ function clearStaleSwap(
function applyBlankTabState(options: {
cache: Map<string, CachedTabState>
targetPath: string
content: string
editor: ReturnType<typeof useCreateBlockNote>
suppressChangeRef: MutableRefObject<boolean>
}) {
const {
cache,
targetPath,
content,
editor,
suppressChangeRef,
} = options
cache.set(targetPath, { blocks: blankParagraphBlocks(), scrollTop: 0 })
cacheEditorState(cache, targetPath, {
blocks: blankParagraphBlocks(),
scrollTop: 0,
sourceContent: content,
})
applyBlankStateToEditor(editor, suppressChangeRef)
signalTabSwap(targetPath)
signalTabSwap({ path: targetPath })
}
function scheduleEmptyHeadingSwap(options: {
@@ -577,13 +632,13 @@ function scheduleEmptyHeadingSwap(options: {
suppressChangeRef,
} = options
if (extractBodyRemainderAfterEmptyH1(content) === null) return false
if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false
void resolveEmptyHeadingHtml(editor, content)
.then((html) => {
if (prevActivePathRef.current !== targetPath || !html) return
applyHtmlStateToEditor(editor, html, suppressChangeRef)
signalTabSwap(targetPath)
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
@@ -614,7 +669,7 @@ function scheduleParsedBlockSwap(options: {
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
signalTabSwap(targetPath)
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
@@ -646,11 +701,17 @@ function scheduleTabSwap(options: {
suppressChangeRef.current = true
const doSwap = () => {
if (clearStaleSwap(targetPath, prevActivePathRef, suppressChangeRef)) return
if (clearStaleSwap({ targetPath, prevActivePathRef, suppressChangeRef })) return
rawSwapPendingRef.current = false
if (isBlankBodyContent(activeTab.content)) {
applyBlankTabState({ cache, targetPath, editor, suppressChangeRef })
if (isBlankBodyContent({ content: activeTab.content })) {
applyBlankTabState({
cache,
targetPath,
content: activeTab.content,
editor,
suppressChangeRef,
})
return
}
@@ -681,6 +742,75 @@ function scheduleTabSwap(options: {
pendingSwapRef.current = doSwap
}
function resolveTabSwapState(options: {
tabs: Tab[]
activeTabPath: string | null
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
prevActivePathRef: MutableRefObject<string | null>
rawModeJustEnded: boolean
}): TabSwapState {
const {
tabs,
activeTabPath,
tabCacheRef,
prevActivePathRef,
rawModeJustEnded,
} = options
const prevPath = prevActivePathRef.current
return {
cache: tabCacheRef.current,
prevPath,
pathChanged: prevPath !== activeTabPath,
activeTab: findActiveTab({ tabs, activeTabPath }),
previousTab: findActiveTab({ tabs, activeTabPath: prevPath }),
rawModeJustEnded,
}
}
function shouldSkipScheduledTabSwap(options: {
state: TabSwapState
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
prevActivePathRef: MutableRefObject<string | null>
rawSwapPendingRef: MutableRefObject<boolean>
}) {
const {
state,
activeTabPath,
editor,
editorMountedRef,
prevActivePathRef,
rawSwapPendingRef,
} = options
if (syncActivePathTransition({
prevPath: state.prevPath,
pathChanged: state.pathChanged,
activeTabPath,
activeTab: state.activeTab,
previousTab: state.previousTab,
cache: state.cache,
editor,
editorMountedRef,
prevActivePathRef,
})) {
return true
}
return handleStableActivePath({
pathChanged: state.pathChanged,
rawModeJustEnded: state.rawModeJustEnded,
activeTabPath,
activeTab: state.activeTab,
cache: state.cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})
}
function runTabSwapEffect(options: {
tabs: Tab[]
activeTabPath: string | null
@@ -708,46 +838,34 @@ function runTabSwapEffect(options: {
suppressChangeRef,
} = options
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
const activeTab = findActiveTab(tabs, activeTabPath)
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
if (syncActivePathTransition({
prevPath,
pathChanged,
const state = resolveTabSwapState({
tabs,
activeTabPath,
tabCacheRef,
prevActivePathRef,
rawModeJustEnded,
})
if (shouldSkipScheduledTabSwap({
state,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
})) {
return
}
if (handleStableActivePath({
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})) {
return
}
if (!activeTabPath || !activeTab) return
if (!activeTabPath || !state.activeTab) return
scheduleTabSwap({
editor,
cache,
cache: state.cache,
targetPath: activeTabPath,
activeTab,
activeTab: state.activeTab,
pendingSwapRef,
prevActivePathRef,
rawSwapPendingRef,
@@ -811,23 +929,6 @@ function useTabSwapEffect(options: {
])
}
function useTabCacheCleanup(
tabs: Tab[],
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>,
) {
const tabPathsRef = useRef<Set<string>>(new Set())
useEffect(() => {
const currentPaths = new Set(tabs.map(t => t.entry.path))
for (const path of tabPathsRef.current) {
if (!currentPaths.has(path)) {
tabCacheRef.current.delete(path)
}
}
tabPathsRef.current = currentPaths
}, [tabs, tabCacheRef])
}
/**
* Manages the tab content-swap machinery for the BlockNote editor.
*
@@ -871,7 +972,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
rawSwapPendingRef,
suppressChangeRef,
})
useTabCacheCleanup(tabs, tabCacheRef)
return { handleEditorChange, editorMountedRef }
}

View File

@@ -266,6 +266,20 @@ describe('useTabManagement (single-note model)', () => {
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
})
it('activates a warmed note immediately while reusing the cached content', async () => {
cacheNoteContent('/vault/note/warm.md', '# Warm content')
const { result } = renderHook(() => useTabManagement())
act(() => {
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/warm.md', title: 'Warm' }))
})
expect(result.current.activeTabPath).toBe('/vault/note/warm.md')
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].content).toBe('# Warm content')
})
it('reuses cached content when reopening a recently loaded note', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)

View File

@@ -14,14 +14,15 @@ type NotePath = VaultEntry['path']
// 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.
const prefetchCache = new Map<string, Promise<string>>()
const NOTE_CONTENT_CACHE_LIMIT = 48
interface NoteContentCacheEntry {
path: NotePath
promise: Promise<string>
value: string | null
}
const prefetchCache = new Map<string, NoteContentCacheEntry>()
const NOTE_CONTENT_CACHE_LIMIT = 48
function trimPrefetchCache(): void {
while (prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT) {
const oldestPath = prefetchCache.keys().next().value
@@ -30,23 +31,35 @@ function trimPrefetchCache(): void {
}
}
function rememberNoteContent({ path, promise }: NoteContentCacheEntry): Promise<string> {
function rememberNoteContent(entry: NoteContentCacheEntry): NoteContentCacheEntry {
const { path } = entry
if (prefetchCache.has(path)) prefetchCache.delete(path)
prefetchCache.set(path, promise)
prefetchCache.set(path, entry)
trimPrefetchCache()
return promise
return entry
}
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): Promise<string> {
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): NoteContentCacheEntry {
const cacheEntry: NoteContentCacheEntry = {
path,
promise: Promise.resolve(''),
value: null,
}
const promise = (isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
).catch((err) => {
prefetchCache.delete(path)
throw err
})
)
.then((content) => {
cacheEntry.value = content
return content
})
.catch((err) => {
prefetchCache.delete(path)
throw err
})
return rememberNoteContent({ path, promise })
cacheEntry.promise = promise
return rememberNoteContent(cacheEntry)
}
/** Prefetch a note's content into the in-memory cache.
@@ -58,7 +71,11 @@ export function prefetchNoteContent(path: string): void {
}
export function cacheNoteContent(path: string, content: string): void {
rememberNoteContent({ path, promise: Promise.resolve(content) })
rememberNoteContent({
path,
promise: Promise.resolve(content),
value: content,
})
}
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
@@ -66,8 +83,12 @@ export function clearPrefetchCache(): void {
prefetchCache.clear()
}
function getCachedNoteContent(path: string): string | null {
return prefetchCache.get(path)?.value ?? null
}
async function loadNoteContent(path: string): Promise<string> {
return prefetchCache.get(path) ?? requestNoteContent({ path })
return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise
}
export type { Tab }
@@ -102,6 +123,76 @@ function isAlreadyViewingPath(
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
}
function startEntryNavigation(options: {
entry: VaultEntry
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
}) {
const {
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
} = options
const seq = ++navSeqRef.current
const cachedContent = getCachedNoteContent(entry.path)
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
if (cachedContent !== null) {
setSingleTab(tabsRef, setTabs, { entry, content: cachedContent })
}
return { seq, cachedContent }
}
function shouldApplyLoadedEntry(options: {
seq: number
navSeqRef: React.MutableRefObject<number>
cachedContent: string | null
content: string
activeTabPathRef: React.MutableRefObject<string | null>
path: string
}) {
const {
seq,
navSeqRef,
cachedContent,
content,
activeTabPathRef,
path,
} = options
if (navSeqRef.current !== seq) return false
return cachedContent !== content || activeTabPathRef.current !== path
}
function handleEntryLoadFailure(options: {
entry: VaultEntry
seq: number
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
error: unknown
}) {
const {
entry,
seq,
navSeqRef,
tabsRef,
setTabs,
error,
} = options
console.warn('Failed to load note content:', error)
if (navSeqRef.current !== seq) return
setSingleTab(tabsRef, setTabs, { entry, content: '' })
}
async function navigateToEntry(options: {
entry: VaultEntry
navSeqRef: React.MutableRefObject<number>
@@ -125,17 +216,35 @@ async function navigateToEntry(options: {
return
}
const seq = ++navSeqRef.current
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
const { seq, cachedContent } = startEntryNavigation({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
try {
const content = await loadNoteContent(entry.path)
if (navSeqRef.current !== seq) return
if (!shouldApplyLoadedEntry({
seq,
navSeqRef,
cachedContent,
content,
activeTabPathRef,
path: entry.path,
})) return
setSingleTab(tabsRef, setTabs, { entry, content })
} catch (err) {
console.warn('Failed to load note content:', err)
if (navSeqRef.current !== seq) return
setSingleTab(tabsRef, setTabs, { entry, content: '' })
handleEntryLoadFailure({
entry,
seq,
navSeqRef,
tabsRef,
setTabs,
error: err,
})
}
}