fix: preserve scroll position independently per editor tab (#162)

Cache scrollTop alongside blocks in the tab swap cache. On tab leave,
capture the scroll container position; on tab restore, apply it via
requestAnimationFrame after blocks are rendered.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-03-02 01:50:06 +01:00
committed by GitHub
parent 4d13628d0b
commit b521736102
2 changed files with 174 additions and 12 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './useEditorTabSwap'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, useEditorTabSwap } from './useEditorTabSwap'
describe('extractEditorBody', () => {
it('strips frontmatter and preserves H1 heading for new note content', () => {
@@ -156,3 +157,150 @@ describe('replaceTitleInFrontmatter', () => {
expect(replaceTitleInFrontmatter('', 'Title')).toBe('')
})
})
describe('useEditorTabSwap scroll position', () => {
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }]
function makeTab(path: string, title: string) {
return {
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`,
}
}
function makeMockEditor(docRef: { current: unknown[] }) {
const mountCallbacks: Array<() => void> = []
return {
document: docRef.current,
get prosemirrorView() { return {} },
onMount: (cb: () => void) => { mountCallbacks.push(cb); return () => {} },
replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }),
insertBlocks: vi.fn(),
blocksToMarkdownLossy: vi.fn(() => ''),
blocksToHTMLLossy: vi.fn(() => ''),
tryParseMarkdownToBlocks: vi.fn(() => blocksA),
_tiptapEditor: { commands: { setContent: vi.fn() } },
// Make document getter dynamic
_docRef: docRef,
}
}
afterEach(() => { vi.restoreAllMocks() })
it('saves scroll position when switching tabs and restores it when switching back', async () => {
const scrollEl = { scrollTop: 0 }
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
// Override document to be dynamic
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const { rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: mockEditor as never,
}),
{ initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } },
)
// Flush the microtask for initial content swap
await act(() => new Promise(r => setTimeout(r, 0)))
// Simulate scrolling in tab A
scrollEl.scrollTop = 350
// Switch to tab B
rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
// rAF should have been called to set scroll to 0 (new tab, no cached scroll)
expect(rAF).toHaveBeenCalled()
// Switch back to tab A
docRef.current = blocksB // simulate B's content in editor
scrollEl.scrollTop = 0 // B is at top
rerender({ tabs: [tabA, tabB], activeTabPath: 'a.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
// The last rAF call should restore A's scroll position (350)
const lastRAFCall = rAF.mock.calls[rAF.mock.calls.length - 1]
expect(lastRAFCall).toBeDefined()
// Execute the callback to verify scrollTop is set
scrollEl.scrollTop = 0
;(lastRAFCall[0] as (n: number) => void)(0)
expect(scrollEl.scrollTop).toBe(350)
})
it('defaults to scroll top 0 for newly opened tabs', async () => {
const scrollEl = { scrollTop: 0 }
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
const rAF = 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')
renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: mockEditor as never,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md' } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
// For a fresh tab, scroll should go to 0
expect(rAF).toHaveBeenCalled()
expect(scrollEl.scrollTop).toBe(0)
})
it('cleans up scroll cache when a tab is closed', async () => {
const scrollEl = { scrollTop: 100 }
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl 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', 'Note B')
const { rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: mockEditor as never,
}),
{ initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
// Switch to B (caches A's scroll at 100)
rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
// Close tab A (only tab B remains)
rerender({ tabs: [tabB], activeTabPath: 'b.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
// Reopen tab A — should start at scroll 0, not the cached 100
const tabANew = makeTab('a.md', 'Note A')
scrollEl.scrollTop = 0
rerender({ tabs: [tabB, tabANew], activeTabPath: 'a.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(scrollEl.scrollTop).toBe(0)
})
})

View File

@@ -61,9 +61,9 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
*/
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) {
// Cache parsed blocks per tab path for instant switching
// Cache parsed blocks + scroll position per tab path for instant switching
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
const tabCacheRef = useRef<Map<string, any[]>>(new Map())
const tabCacheRef = useRef<Map<string, { blocks: any[]; scrollTop: number }>>(new Map())
const prevActivePathRef = useRef<string | null>(null)
const editorMountedRef = useRef(false)
const pendingSwapRef = useRef<(() => void) | null>(null)
@@ -136,9 +136,13 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
// Save current editor state for the tab we're leaving
// Save current editor state + scroll position for the tab we're leaving
if (prevPath && pathChanged && editorMountedRef.current) {
cache.set(prevPath, editor.document)
const scrollEl = document.querySelector('.editor__blocknote-container')
cache.set(prevPath, {
blocks: editor.document,
scrollTop: scrollEl?.scrollTop ?? 0,
})
}
prevActivePathRef.current = activeTabPath
@@ -148,7 +152,11 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
// the editor already shows the user's edits.
if (!pathChanged) {
if (activeTabPath && editorMountedRef.current) {
cache.set(activeTabPath, editor.document)
const scrollEl = document.querySelector('.editor__blocknote-container')
cache.set(activeTabPath, {
blocks: editor.document,
scrollTop: scrollEl?.scrollTop ?? 0,
})
}
return
}
@@ -159,7 +167,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
if (!tab) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex
const applyBlocks = (blocks: any[]) => {
const applyBlocks = (blocks: any[], scrollTop = 0) => {
suppressChangeRef.current = true
try {
const current = editor.document
@@ -181,6 +189,11 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
// finishes its internal state updates from the content swap
queueMicrotask(() => { suppressChangeRef.current = false })
}
// Restore scroll position after layout updates from the content swap
requestAnimationFrame(() => {
const scrollEl = document.querySelector('.editor__blocknote-container')
if (scrollEl) scrollEl.scrollTop = scrollTop
})
}
const targetPath = activeTabPath
@@ -190,7 +203,8 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
if (prevActivePathRef.current !== targetPath) return
if (cache.has(targetPath)) {
applyBlocks(cache.get(targetPath)!)
const cached = cache.get(targetPath)!
applyBlocks(cached.blocks, cached.scrollTop)
return
}
@@ -202,7 +216,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
// so the editor is immediately interactive.
if (!preprocessed.trim()) {
const emptyDoc = [{ type: 'paragraph', content: [] }]
cache.set(targetPath, emptyDoc)
cache.set(targetPath, { blocks: emptyDoc, scrollTop: 0 })
applyBlocks(emptyDoc)
return
}
@@ -215,7 +229,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] },
{ type: 'paragraph', content: [], children: [] },
]
cache.set(targetPath, h1Doc)
cache.set(targetPath, { blocks: h1Doc, scrollTop: 0 })
applyBlocks(h1Doc)
return
}
@@ -228,7 +242,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const withWikilinks = injectWikilinks(blocks)
// Only cache non-empty results to avoid poisoning the cache
if (withWikilinks.length > 0) {
cache.set(targetPath, withWikilinks)
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
}
applyBlocks(withWikilinks)
}