diff --git a/src/components/tolariaEditorFormatting.behavior.test.tsx b/src/components/tolariaEditorFormatting.behavior.test.tsx index bd3d6f3d..01f0d64b 100644 --- a/src/components/tolariaEditorFormatting.behavior.test.tsx +++ b/src/components/tolariaEditorFormatting.behavior.test.tsx @@ -305,6 +305,30 @@ describe('tolariaEditorFormatting behavior', () => { expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false) }) + it('deduplicates floating toolbar store writes during close races', () => { + const editor = createMockEditor('paragraph') + useBlockNoteEditorMock.mockReturnValue(editor) + + render( + } + />, + ) + + const toolbarWrapper = screen.getByTestId('toolbar-action').parentElement as HTMLElement + const floatingOptions = positionPopoverState.lastProps?.useFloatingOptions as { + onOpenChange: (open: boolean, event: unknown, reason?: string) => void + } + + floatingOptions.onOpenChange(true, undefined) + floatingOptions.onOpenChange(false, undefined) + floatingOptions.onOpenChange(false, undefined) + fireEvent.blur(toolbarWrapper, { relatedTarget: document.body }) + + expect(formattingToolbarStore.setState).toHaveBeenCalledTimes(1) + expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false) + }) + it('does not open the floating toolbar when the editor anchor element is unavailable', () => { const editor = createMockEditor() editor.domElement = document.createElement('div') diff --git a/src/components/tolariaEditorFormatting.tsx b/src/components/tolariaEditorFormatting.tsx index 27bb5674..b9b391e8 100644 --- a/src/components/tolariaEditorFormatting.tsx +++ b/src/components/tolariaEditorFormatting.tsx @@ -130,6 +130,27 @@ function useFormattingToolbarCloseGrace({ return { closeGraceActive, clearCloseGrace } } +type FormattingToolbarStore = { + setState(open: boolean): void +} + +function useDeduplicatedFormattingToolbarStore( + store: FormattingToolbarStore, + show: boolean, +) { + const openRef = useRef(show) + + useEffect(() => { + openRef.current = show + }, [show]) + + return useCallback((open: boolean) => { + if (openRef.current === open) return + openRef.current = open + store.setState(open) + }, [store]) +} + const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = { bold: { label: 'Bold', @@ -503,6 +524,10 @@ export function TolariaFormattingToolbarController(props: { toolbarHasFocus, toolbarHovered, }) + const setFormattingToolbarOpen = useDeduplicatedFormattingToolbarStore( + formattingToolbar.store, + show, + ) const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive const hasFloatingToolbarAnchor = getFormattingToolbarAnchorElement(editor) !== null @@ -556,7 +581,7 @@ export function TolariaFormattingToolbarController(props: { useFloatingOptions: { open: shouldRenderFloatingToolbar, onOpenChange: (open, _event, reason) => { - formattingToolbar.store.setState(open) + setFormattingToolbarOpen(open) if (!open) { setToolbarHasFocus(false) setToolbarHovered(false) @@ -579,9 +604,9 @@ export function TolariaFormattingToolbarController(props: { [ clearCloseGrace, editor, - formattingToolbar.store, placement, props.floatingUIOptions, + setFormattingToolbarOpen, shouldRenderFloatingToolbar, ], ) @@ -611,7 +636,7 @@ export function TolariaFormattingToolbarController(props: { } setToolbarHasFocus(false) - formattingToolbar.store.setState(false) + setFormattingToolbarOpen(false) }} > diff --git a/tests/smoke/editor-save-tooltip-stability.spec.ts b/tests/smoke/editor-save-tooltip-stability.spec.ts new file mode 100644 index 00000000..83b9d4c9 --- /dev/null +++ b/tests/smoke/editor-save-tooltip-stability.spec.ts @@ -0,0 +1,79 @@ +import { test, expect, type Page } from '@playwright/test' +import { + createFixtureVaultCopy, + openFixtureVault, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' +import { sendShortcut } from './helpers' + +let tempVaultDir: string + +function isCrashError(message: string): boolean { + return ( + message.includes('Maximum update depth') || + message.includes('React error #185') || + message.includes('#185') + ) +} + +function collectCrashErrors(page: Page): string[] { + const errors: string[] = [] + page.on('pageerror', (error) => { + if (isCrashError(error.message)) errors.push(error.message) + }) + page.on('console', (message) => { + if (message.type() === 'error' && isCrashError(message.text())) { + errors.push(message.text()) + } + }) + return errors +} + +async function openNote(page: Page, title: string) { + await page.getByTestId('note-list-container').getByText(title, { exact: true }).click() +} + +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.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVault(page, tempVaultDir) + await page.setViewportSize({ width: 1180, height: 760 }) +}) + +test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) +}) + +test('editing and saving stays stable when truncated labels and tooltips are clicked', async ({ page }) => { + const errors = collectCrashErrors(page) + const initialTitle = 'Alpha Project' + const titleSuffix = ` Loop Guard ${Date.now()}` + const updatedTitle = `${initialTitle}${titleSuffix}` + const noteList = page.getByTestId('note-list-container') + + await openNote(page, initialTitle) + await focusHeadingEnd(page, initialTitle) + await page.keyboard.type(titleSuffix) + await sendShortcut(page, 's', ['Control']) + + await expect(page.getByRole('heading', { name: updatedTitle, level: 1 })).toBeVisible({ timeout: 5_000 }) + await expect(noteList.getByText(updatedTitle, { exact: true })).toBeVisible({ timeout: 5_000 }) + + await page.getByTestId('breadcrumb-filename-trigger').locator('span.truncate').click() + await noteList.getByText(updatedTitle, { exact: true }).click() + + const syncButton = page.getByTestId('breadcrumb-sync-button') + await expect(syncButton).toBeVisible({ timeout: 5_000 }) + await syncButton.hover() + await syncButton.click() + + await page.getByTestId('breadcrumb-filename-trigger').locator('span.truncate').click() + expect(errors).toHaveLength(0) +})