From d5ecd567d0b26d868a1ea9ad3d65cb0e1c26169d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 5 May 2026 13:43:48 +0200 Subject: [PATCH] fix(editor): guard stale wikilink click targets --- .../useEditorLinkActivation.test.tsx | 39 ++++++++---- src/components/useEditorLinkActivation.ts | 22 +++++-- .../smoke/wikilink-cmd-click-history.spec.ts | 60 +++++++++++++++++++ 3 files changed, 106 insertions(+), 15 deletions(-) diff --git a/src/components/useEditorLinkActivation.test.tsx b/src/components/useEditorLinkActivation.test.tsx index 47ae26a5..3c24fce9 100644 --- a/src/components/useEditorLinkActivation.test.tsx +++ b/src/components/useEditorLinkActivation.test.tsx @@ -50,8 +50,8 @@ function appendUrl(container: HTMLElement, href: string) { return link } -function dispatchClick(target: HTMLElement, options: MouseEventInit = {}) { - const event = new MouseEvent('click', { +function dispatchMouseEvent(target: HTMLElement, type: string, options: MouseEventInit = {}) { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...options, @@ -65,18 +65,34 @@ describe('useEditorLinkActivation', () => { mockOpenExternalUrl.mockClear() }) - it('navigates wikilinks only on Cmd+click', () => { + it('navigates wikilinks only on Cmd+click after the native click stack settles', async () => { const { container, onNavigateWikilink } = renderHarness() const wikilink = appendWikilink(container, 'Alpha Project') - fireEvent.click(wikilink) + dispatchMouseEvent(wikilink, 'click') expect(onNavigateWikilink).not.toHaveBeenCalled() - fireEvent.click(wikilink, { metaKey: true }) + const modifiedClick = dispatchMouseEvent(wikilink, 'click', { metaKey: true }) + expect(modifiedClick.defaultPrevented).toBe(true) + expect(onNavigateWikilink).not.toHaveBeenCalled() + + await Promise.resolve() expect(onNavigateWikilink).toHaveBeenCalledWith('Alpha Project') }) - it('blurs an active editor before navigating a Cmd-clicked wikilink', () => { + it('consumes plain wikilink mousedown and click events before editor internals see stale link nodes', () => { + const { container, onNavigateWikilink } = renderHarness() + const wikilink = appendWikilink(container, 'Alpha Project') + + const mouseDown = dispatchMouseEvent(wikilink, 'mousedown') + const click = dispatchMouseEvent(wikilink, 'click') + + expect(mouseDown.defaultPrevented).toBe(true) + expect(click.defaultPrevented).toBe(true) + expect(onNavigateWikilink).not.toHaveBeenCalled() + }) + + it('blurs an active editor before navigating a Cmd-clicked wikilink', async () => { const { container, onNavigateWikilink } = renderHarness() const { editable, wikilink } = appendEditableWikilink(container, 'Alpha Project') @@ -85,19 +101,20 @@ describe('useEditorLinkActivation', () => { fireEvent.click(wikilink, { metaKey: true }) - expect(onNavigateWikilink).toHaveBeenCalledWith('Alpha Project') expect(document.activeElement).not.toBe(editable) + await Promise.resolve() + expect(onNavigateWikilink).toHaveBeenCalledWith('Alpha Project') }) it('opens URLs only on Cmd+click', () => { const { container } = renderHarness() const link = appendUrl(container, 'https://example.com') - const plainClick = dispatchClick(link) + const plainClick = dispatchMouseEvent(link, 'click') expect(mockOpenExternalUrl).not.toHaveBeenCalled() expect(plainClick.defaultPrevented).toBe(true) - const modifiedClick = dispatchClick(link, { metaKey: true }) + const modifiedClick = dispatchMouseEvent(link, 'click', { metaKey: true }) expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://example.com') expect(modifiedClick.defaultPrevented).toBe(true) }) @@ -106,8 +123,8 @@ describe('useEditorLinkActivation', () => { const { container } = renderHarness() const link = appendUrl(container, 'https://exa mple.com') - const plainClick = dispatchClick(link) - const modifiedClick = dispatchClick(link, { metaKey: true }) + const plainClick = dispatchMouseEvent(link, 'click') + const modifiedClick = dispatchMouseEvent(link, 'click', { metaKey: true }) expect(plainClick.defaultPrevented).toBe(true) expect(modifiedClick.defaultPrevented).toBe(true) diff --git a/src/components/useEditorLinkActivation.ts b/src/components/useEditorLinkActivation.ts index ea84e576..535188cf 100644 --- a/src/components/useEditorLinkActivation.ts +++ b/src/components/useEditorLinkActivation.ts @@ -31,26 +31,32 @@ function setFollowLinksActive(container: HTMLElement, active: boolean) { else container.removeAttribute('data-follow-links') } -function consumeEditorLinkClick(event: MouseEvent) { +function consumeEditorLinkEvent(event: MouseEvent) { event.preventDefault() event.stopPropagation() } +function scheduleAfterNativeClick(callback: () => void) { + if (typeof queueMicrotask === 'function') queueMicrotask(callback) + else window.setTimeout(callback, 0) +} + function activateWikilink( event: MouseEvent, container: HTMLElement, target: string, onNavigateWikilink: (target: string) => void, ) { + consumeEditorLinkEvent(event) + if (!hasFollowModifier(event)) return - consumeEditorLinkClick(event) blurActiveEditable(container) - onNavigateWikilink(target) + scheduleAfterNativeClick(() => onNavigateWikilink(target)) } function activateExternalUrl(event: MouseEvent, href: string) { - consumeEditorLinkClick(event) + consumeEditorLinkEvent(event) if (!hasFollowModifier(event)) return @@ -77,6 +83,12 @@ function handleEditorLinkClick( if (href) activateExternalUrl(event, href) } +function handleEditorLinkMouseDown(event: MouseEvent) { + if (!(event.target instanceof HTMLElement) || isInsideCodeContext(event.target)) return + + if (resolveWikilinkTarget(event.target)) consumeEditorLinkEvent(event) +} + export function useEditorLinkActivation( containerRef: RefObject, onNavigateWikilink: (target: string) => void, @@ -96,6 +108,7 @@ export function useEditorLinkActivation( handleEditorLinkClick(event, container, onNavigateWikilink) } + container.addEventListener('mousedown', handleEditorLinkMouseDown, true) container.addEventListener('click', handleClick, true) window.addEventListener('keydown', handleModifierChange) window.addEventListener('keyup', handleModifierChange) @@ -103,6 +116,7 @@ export function useEditorLinkActivation( document.addEventListener('visibilitychange', handleVisibilityChange) return () => { + container.removeEventListener('mousedown', handleEditorLinkMouseDown, true) container.removeEventListener('click', handleClick, true) window.removeEventListener('keydown', handleModifierChange) window.removeEventListener('keyup', handleModifierChange) diff --git a/tests/smoke/wikilink-cmd-click-history.spec.ts b/tests/smoke/wikilink-cmd-click-history.spec.ts index a6873b28..990dab10 100644 --- a/tests/smoke/wikilink-cmd-click-history.spec.ts +++ b/tests/smoke/wikilink-cmd-click-history.spec.ts @@ -1,4 +1,6 @@ import { test, expect, type Page } from '@playwright/test' +import fs from 'fs' +import path from 'path' import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, @@ -16,6 +18,41 @@ async function expectActiveHeading(page: Page, title: string) { await expect(page.locator('.bn-editor h1').first()).toHaveText(title, { timeout: 5_000 }) } +function trackStaleWikilinkClickErrors(page: Page): string[] { + const messages: string[] = [] + page.on('pageerror', (error) => { + if (error.message.includes('dispatchEvent')) messages.push(error.message) + }) + page.on('console', (message) => { + if (message.type() === 'error' && message.text().includes('dispatchEvent')) { + messages.push(message.text()) + } + }) + return messages +} + +async function appendToProjectParagraph(page: Page, marker: string): Promise { + const paragraph = page.locator('.bn-editor p') + .filter({ hasText: 'This is a test project that references other notes.' }) + .first() + await expect(paragraph).toBeVisible({ timeout: 5_000 }) + + const box = await paragraph.boundingBox() + if (!box) throw new Error('Expected editable paragraph bounds') + await paragraph.click({ + position: { + x: Math.max(1, box.width - 2), + y: Math.max(1, box.height / 2), + }, + }) + await page.keyboard.press('End') + await page.keyboard.type(` ${marker}`) +} + +async function expectFileToContain(filePath: string, marker: string): Promise { + await expect.poll(() => fs.readFileSync(filePath, 'utf8'), { timeout: 10_000 }).toContain(marker) +} + test.beforeEach(async ({ page }, testInfo) => { testInfo.setTimeout(60_000) await page.goto('/') @@ -50,3 +87,26 @@ test('@smoke Cmd-clicking an existing wikilink preserves Back/Forward history', await page.keyboard.press('Meta+ArrowLeft') await expectActiveHeading(page, 'Note B') }) + +test('Cmd-clicking a wikilink after rich-edit autosave does not dispatch through stale link nodes', async ({ page }) => { + const staleClickErrors = trackStaleWikilinkClickErrors(page) + const marker = `autosaved wikilink click ${Date.now()}` + const alphaPath = path.join(tempVaultDir, 'project', 'alpha-project.md') + + await openNote(page, 'Alpha Project') + await expectActiveHeading(page, 'Alpha Project') + + await appendToProjectParagraph(page, marker) + await expectFileToContain(alphaPath, marker) + + const wikilink = page.locator('.bn-editor .wikilink').filter({ hasText: 'Note B' }).first() + await expect(wikilink).toBeVisible() + + await wikilink.click() + await expectActiveHeading(page, 'Alpha Project') + expect(staleClickErrors).toEqual([]) + + await wikilink.click({ modifiers: ['Meta'] }) + await expectActiveHeading(page, 'Note B') + expect(staleClickErrors).toEqual([]) +})