diff --git a/src/hooks/useEditorSaveWithLinks.test.ts b/src/hooks/useEditorSaveWithLinks.test.ts index d05f5fb0..bd9414a4 100644 --- a/src/hooks/useEditorSaveWithLinks.test.ts +++ b/src/hooks/useEditorSaveWithLinks.test.ts @@ -2,6 +2,18 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useEditorSaveWithLinks } from './useEditorSaveWithLinks' +const { startTransitionMock } = vi.hoisted(() => ({ + startTransitionMock: vi.fn((callback: () => void) => callback()), +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + startTransition: startTransitionMock, + } +}) + const mockHandleContentChange = vi.fn() const mockHandleSave = vi.fn() const mockSavePendingForPath = vi.fn() @@ -25,6 +37,7 @@ describe('useEditorSaveWithLinks', () => { setTabs = vi.fn() setToastMessage = vi.fn() onAfterSave = vi.fn() + startTransitionMock.mockClear() mockHandleContentChange.mockClear() mockHandleSave.mockClear() mockSavePendingForPath.mockClear() @@ -147,6 +160,8 @@ describe('useEditorSaveWithLinks', () => { expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Project', status: 'Active', + }) + expect(updateEntry).toHaveBeenCalledWith('/note.md', { title: 'Note', hasH1: false, }) @@ -172,6 +187,8 @@ describe('useEditorSaveWithLinks', () => { }) expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Essay', + }) + expect(updateEntry).toHaveBeenCalledWith('/note.md', { title: 'Note', hasH1: false, }) @@ -181,6 +198,8 @@ describe('useEditorSaveWithLinks', () => { }) expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Note', + }) + expect(updateEntry).toHaveBeenCalledWith('/note.md', { title: 'Note', hasH1: false, }) @@ -199,6 +218,20 @@ describe('useEditorSaveWithLinks', () => { expect(updateEntry).toHaveBeenCalledWith(path, expected) }) + it('defers H1 title sync updates in a transition so typing stays responsive', () => { + const { result } = renderHookWithLinks() + + act(() => { + result.current.handleContentChange('/old-title.md', '# Renamed Note\n\nBody') + }) + + expect(startTransitionMock).toHaveBeenCalledTimes(1) + expect(updateEntry).toHaveBeenCalledWith('/old-title.md', { + title: 'Renamed Note', + hasH1: true, + }) + }) + it('spreads all properties from useEditorSave onto the return value', () => { const { result } = renderHookWithLinks() diff --git a/src/hooks/useEditorSaveWithLinks.ts b/src/hooks/useEditorSaveWithLinks.ts index 325b4c0d..46796b50 100644 --- a/src/hooks/useEditorSaveWithLinks.ts +++ b/src/hooks/useEditorSaveWithLinks.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react' +import { startTransition, useCallback, useRef } from 'react' import { useEditorSave } from './useEditorSave' import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks' import { contentToEntryPatch } from './frontmatterOps' @@ -35,6 +35,7 @@ export function useEditorSaveWithLinks(config: { const { handleContentChange: rawOnChange } = editor const prevLinksKeyRef = useRef('') const prevFmKeyRef = useRef('') + const prevTitleKeyRef = useRef('') const handleContentChange = useCallback((path: string, content: string) => { rawOnChange(path, content) const links = extractOutgoingLinks(content) @@ -45,19 +46,25 @@ export function useEditorSaveWithLinks(config: { } const frontmatterPatch = contentToEntryPatch(content) const filename = path.split('/').pop() ?? path - const fmPatch = { - ...frontmatterPatch, - ...deriveDisplayTitleState({ - content, - filename, - frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null, - }), - } + const titlePatch = deriveDisplayTitleState({ + content, + filename, + 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 if (Object.keys(fmPatch).length > 0) 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/tests/smoke/h1-title-decoupled.spec.ts b/tests/smoke/h1-title-decoupled.spec.ts index 39750ca4..961e6248 100644 --- a/tests/smoke/h1-title-decoupled.spec.ts +++ b/tests/smoke/h1-title-decoupled.spec.ts @@ -65,6 +65,13 @@ function quickOpenSelectedTitle(page: Page) { return page.getByTestId('quick-open-palette').locator('[class*="bg-accent"] span.truncate').first() } +async function focusHeadingEnd(page: Page, title: string) { + const heading = page.getByRole('heading', { name: title, level: 1 }) + await expect(heading).toBeVisible({ timeout: 5_000 }) + await heading.click() + await page.keyboard.press('End') +} + test('creating an untitled draft hides the legacy title section in the editor', async ({ page }) => { await page.locator('button[title="Create new note"]').click() @@ -139,3 +146,29 @@ test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete const suggestionMenu = page.locator('.wikilink-menu') await expect(suggestionMenu).toContainText(updatedTitle, { timeout: 5_000 }) }) + +test('@smoke rapid H1 typing stays stable while editing an existing note', async ({ page }) => { + const noteList = page.locator('[data-testid="note-list-container"]') + const firstTitle = 'Alpha Project Fast Typing Check' + const finalTitle = 'Alpha Project Fast Typing Flow' + + await openNote(page, 'Alpha Project') + await focusHeadingEnd(page, 'Alpha Project') + await page.keyboard.type(' Fast Typing Check') + + await expect(page.getByRole('heading', { name: firstTitle, level: 1 })).toBeVisible({ timeout: 5_000 }) + + for (let i = 0; i < ' Check'.length; i += 1) { + await page.keyboard.press('Backspace') + } + await page.keyboard.type(' Flow') + await page.keyboard.press('Meta+s') + + await expect(page.getByRole('heading', { name: finalTitle, level: 1 })).toBeVisible({ timeout: 5_000 }) + await expect(noteList.getByText(finalTitle, { exact: true })).toBeVisible({ timeout: 5_000 }) + await expect(noteList.getByText('Alpha Project', { exact: true })).toHaveCount(0) + + await openNote(page, 'Spring 2026') + await openNote(page, finalTitle) + await expect(page.getByRole('heading', { name: finalTitle, level: 1 })).toBeVisible({ timeout: 5_000 }) +})