fix(editor): guard stale wikilink click targets

This commit is contained in:
lucaronin
2026-05-05 13:43:48 +02:00
parent f88b359bb5
commit d5ecd567d0
3 changed files with 106 additions and 15 deletions

View File

@@ -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)

View File

@@ -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<HTMLDivElement | null>,
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)

View File

@@ -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<void> {
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<void> {
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([])
})