From 97224cedbb6ff64d1eaac595974f4ae42c5dcd2e Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 31 Aug 2026 03:15:46 +1200 Subject: [PATCH 1/4] fix(core): guard code blocks against leaked native Enter on WebKit --- .../extensions/code-block-enter-guard.test.ts | 79 +++++++++++++++++++ .../src/extensions/code-block-enter-guard.ts | 56 +++++++++++++ packages/core/src/extensions/extension.ts | 2 + packages/core/src/utils/browser.ts | 4 + .../src/components/code-block-enter.test.tsx | 67 ++++++++++++++++ 5 files changed, 208 insertions(+) create mode 100644 packages/core/src/extensions/code-block-enter-guard.test.ts create mode 100644 packages/core/src/extensions/code-block-enter-guard.ts create mode 100644 packages/core/src/utils/browser.ts create mode 100644 packages/react/src/components/code-block-enter.test.tsx diff --git a/packages/core/src/extensions/code-block-enter-guard.test.ts b/packages/core/src/extensions/code-block-enter-guard.test.ts new file mode 100644 index 00000000..33aaa73e --- /dev/null +++ b/packages/core/src/extensions/code-block-enter-guard.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { page, userEvent } from 'vitest/browser' + +import { setupFixture, type Fixture } from '../testing/index.ts' + +const pmRoot = page.locate('.ProseMirror') +const rogueBreak = pmRoot.locate('br:not(.ProseMirror-trailingBreak)') + +function setupEditor() { + const fixture = setupFixture() + fixture.view.focus() + return fixture +} + +// WebKit fires `compositionend` before the keydown that commits an IME +// composition, and prosemirror-view swallows the first keydown within 500ms +// after `compositionend` without `preventDefault`, letting the browser's +// native editing run. Synthetic composition events arm that swallow because +// prosemirror-view's handlers are plain event listeners, while the following +// Enter stays a real key press whose native default action is exercised. +function fakeCompositionCommit(fixture: Fixture) { + fixture.dom.dispatchEvent(new CompositionEvent('compositionstart')) + fixture.dom.dispatchEvent(new CompositionEvent('compositionend')) +} + +describe('enter after a composition commit', () => { + it('inserts a newline at the start of a code block', async () => { + using fixture = setupEditor() + const { n } = fixture + fixture.set(n.doc(n.codeBlock({ language: 'js' }, 'foobar'))) + fakeCompositionCommit(fixture) + await userEvent.keyboard('{Enter}') + await vi.waitFor(() => { + expect(fixture.doc.eq(n.doc(n.codeBlock({ language: 'js' }, '\nfoobar')))).toBe(true) + }) + expect(pmRoot.locate('pre').all()).toHaveLength(1) + await expect.element(rogueBreak).not.toBeInTheDocument() + }) + + it('keeps the text before the caret in the middle of a code block', async () => { + using fixture = setupEditor() + const { n } = fixture + fixture.set(n.doc(n.codeBlock({ language: 'js' }, 'foobar'))) + fakeCompositionCommit(fixture) + await userEvent.keyboard('{Enter}') + await vi.waitFor(() => { + expect(fixture.doc.eq(n.doc(n.codeBlock({ language: 'js' }, 'foo\nbar')))).toBe(true) + }) + expect(pmRoot.locate('pre').all()).toHaveLength(1) + await expect.element(rogueBreak).not.toBeInTheDocument() + }) + + it('inserts a newline on shift-enter in a code block', async () => { + using fixture = setupEditor() + const { n } = fixture + fixture.set(n.doc(n.codeBlock({ language: 'js' }, 'foobar'))) + // Hold Shift before the commit: a Shift press after `compositionend` + // would itself consume prosemirror-view's one-shot swallow window. + await userEvent.keyboard('{Shift>}') + fakeCompositionCommit(fixture) + await userEvent.keyboard('{Enter}{/Shift}') + await vi.waitFor(() => { + expect(fixture.doc.eq(n.doc(n.codeBlock({ language: 'js' }, 'foo\nbar')))).toBe(true) + }) + await expect.element(rogueBreak).not.toBeInTheDocument() + }) + + it('splits a paragraph', async () => { + using fixture = setupEditor() + const { n } = fixture + fixture.set(n.doc(n.paragraph('foobar'))) + fakeCompositionCommit(fixture) + await userEvent.keyboard('{Enter}') + await vi.waitFor(() => { + expect(fixture.doc.eq(n.doc(n.paragraph('foo'), n.paragraph('bar')))).toBe(true) + }) + await expect.element(rogueBreak).not.toBeInTheDocument() + }) +}) diff --git a/packages/core/src/extensions/code-block-enter-guard.ts b/packages/core/src/extensions/code-block-enter-guard.ts new file mode 100644 index 00000000..2fef71e6 --- /dev/null +++ b/packages/core/src/extensions/code-block-enter-guard.ts @@ -0,0 +1,56 @@ +import { definePlugin, Priority, withPriority, type PlainExtension } from '@prosekit/core' +import { Plugin } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' + +import { isWebKit } from '../utils/browser.ts' + +/** + * Convert WebKit's native Enter inside a code block back into the keymap + * pipeline. prosemirror-view deliberately skips `preventDefault` on some + * Safari Enter keydowns: every plain Enter on iOS, and on desktop the first + * keydown within 500ms after `compositionend` (WebKit fires `compositionend` + * before the keydown that commits an IME composition, so that keydown is + * swallowed as a likely IME confirmation). The browser's native + * `insertParagraph` then clone-splits the `
`, and the node view wrapper
+ * DOM defeats prosemirror-view's DOM-change repair: a rogue `
` stays + * behind, or the text before the caret is silently deleted. ProseMirror + * cancels every Enter keydown a command handles, so an uncanceled native + * `insertParagraph` or `insertLineBreak` in a code block is always such a + * leak; cancel it and run the key through the handlers instead. + */ +export function defineCodeBlockEnterGuard(): PlainExtension { + const plugin = new Plugin({ + props: { + handleDOMEvents: { + beforeinput: (view, event) => { + if (!isWebKit || !event.cancelable) return false + if (event.inputType !== 'insertParagraph' && event.inputType !== 'insertLineBreak') { + return false + } + const { $from } = view.state.selection + if (!$from.parent.type.spec.code) return false + + event.preventDefault() + + // After an iOS Enter keydown, prosemirror-view schedules a synthetic + // Enter 200ms later as a fallback for the native edit this guard just + // canceled; clear the flag so the fallback cannot insert a second + // newline. + const input = (view as EditorView & { input?: { lastIOSEnter: number } }).input + if (input != null) input.lastIOSEnter = 0 + + const keydown = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + shiftKey: event.inputType === 'insertLineBreak', + }) + view.someProp('handleKeyDown', (handler) => handler(view, keydown)) + return true + }, + }, + }, + }) + + return withPriority(definePlugin(plugin), Priority.highest) +} diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index f7be5a78..8b354753 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -14,6 +14,7 @@ import { defineVirtualSelection } from '@prosekit/extensions/virtual-selection' import { defineAtomMarkNavigation } from './atom-mark-navigation.ts' import { defineClipboard } from './clipboard/clipboard.ts' +import { defineCodeBlockEnterGuard } from './code-block-enter-guard.ts' import { defineCodeBlockSyntaxHighlight } from './code-block-highlight.ts' import { defineCodeBlock } from './code-block.ts' import { defineEditorCommands } from './commands.ts' @@ -81,6 +82,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) { defineScrollToSelection(), defineHiddenRunCaret(), defineSystemSubstitutionGuard(), + defineCodeBlockEnterGuard(), defineAtomMarkNavigation({ marks: ATOM_SOURCE_MARK_NAMES.map((name) => ({ name, modes: ['hide', 'focus', 'show'] })), }), diff --git a/packages/core/src/utils/browser.ts b/packages/core/src/utils/browser.ts new file mode 100644 index 00000000..7e8e8759 --- /dev/null +++ b/packages/core/src/utils/browser.ts @@ -0,0 +1,4 @@ +// Apple's WebKit engine: Safari on any platform, every iOS browser, and +// WKWebView hosts. Blink reports a "Google Inc." vendor. +export const isWebKit: boolean = + typeof navigator !== 'undefined' && navigator.vendor.includes('Apple Computer') diff --git a/packages/react/src/components/code-block-enter.test.tsx b/packages/react/src/components/code-block-enter.test.tsx new file mode 100644 index 00000000..6167826d --- /dev/null +++ b/packages/react/src/components/code-block-enter.test.tsx @@ -0,0 +1,67 @@ +import '../testing/index.ts' + +import { TextSelection } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-react' +import { page, userEvent } from 'vitest/browser' + +import { ProseKitEditor } from './prosekit-editor.tsx' +import type { EditorHandle } from './types.ts' + +const pmRoot = page.locate('.ProseMirror') +const tokens = pmRoot.locate('pre code [class*="tok-"]') +const rogueBreak = pmRoot.locate('br:not(.ProseMirror-trailingBreak)') + +const CODE_BLOCK_MD = '```js\nfoobar\n```' + +describe('enter after a composition commit', () => { + async function setupCodeBlockEditor() { + const ref = createRef() + await render() + // WebKit's clone-split only takes its production shape once highlight + // token spans wrap the code text. + await expect.element(tokens.first(), { timeout: 15000 }).toBeInTheDocument() + const view = ref.current?.editor?.view + if (!view) throw new Error('editor not mounted') + return { ref, view } + } + + function placeCaret(view: EditorView, position: number) { + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, position))) + view.focus() + } + + // See `code-block-enter-guard.test.ts` in the core package: synthetic + // composition events arm prosemirror-view's post-composition keydown + // swallow, so the following real Enter reaches WebKit's native editing. + function fakeCompositionCommit(view: EditorView) { + view.dom.dispatchEvent(new CompositionEvent('compositionstart')) + view.dom.dispatchEvent(new CompositionEvent('compositionend')) + } + + it('keeps one pre and no rogue br at the start of the code text', async () => { + const { ref, view } = await setupCodeBlockEditor() + placeCaret(view, 1) + fakeCompositionCommit(view) + await userEvent.keyboard('{Enter}') + await vi.waitFor(() => { + expect(ref.current?.getMarkdown()).toContain('```js\n\nfoobar\n```') + }) + expect(pmRoot.locate('pre').all()).toHaveLength(1) + await expect.element(rogueBreak).not.toBeInTheDocument() + }) + + it('keeps the text before the caret in the middle of the code text', async () => { + const { ref, view } = await setupCodeBlockEditor() + placeCaret(view, 4) + fakeCompositionCommit(view) + await userEvent.keyboard('{Enter}') + await vi.waitFor(() => { + expect(ref.current?.getMarkdown()).toContain('```js\nfoo\nbar\n```') + }) + expect(pmRoot.locate('pre').all()).toHaveLength(1) + await expect.element(rogueBreak).not.toBeInTheDocument() + }) +}) From 3d03390821bb05beb3a05be65024824e5b50471c Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 31 Aug 2026 03:23:10 +1200 Subject: [PATCH 2/4] fix(core): handle vendor-less `navigator` in `isWebKit` --- packages/core/src/utils/browser.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/browser.ts b/packages/core/src/utils/browser.ts index 7e8e8759..eaff81e9 100644 --- a/packages/core/src/utils/browser.ts +++ b/packages/core/src/utils/browser.ts @@ -1,4 +1,7 @@ // Apple's WebKit engine: Safari on any platform, every iOS browser, and -// WKWebView hosts. Blink reports a "Google Inc." vendor. +// WKWebView hosts. Blink reports a "Google Inc." vendor, and Node's global +// `navigator` has no `vendor` at all. export const isWebKit: boolean = - typeof navigator !== 'undefined' && navigator.vendor.includes('Apple Computer') + typeof navigator !== 'undefined' && + navigator.vendor != null && + navigator.vendor.includes('Apple Computer') From 221ff4841ce3154b7144655b9a93b66671fc4238 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 31 Aug 2026 04:01:33 +1200 Subject: [PATCH 3/4] test(react): drive the code block Enter regression with real key presses --- .../extensions/code-block-enter-guard.test.ts | 79 ------------------- .../src/components/code-block-enter.test.tsx | 21 +++-- 2 files changed, 13 insertions(+), 87 deletions(-) delete mode 100644 packages/core/src/extensions/code-block-enter-guard.test.ts diff --git a/packages/core/src/extensions/code-block-enter-guard.test.ts b/packages/core/src/extensions/code-block-enter-guard.test.ts deleted file mode 100644 index 33aaa73e..00000000 --- a/packages/core/src/extensions/code-block-enter-guard.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { page, userEvent } from 'vitest/browser' - -import { setupFixture, type Fixture } from '../testing/index.ts' - -const pmRoot = page.locate('.ProseMirror') -const rogueBreak = pmRoot.locate('br:not(.ProseMirror-trailingBreak)') - -function setupEditor() { - const fixture = setupFixture() - fixture.view.focus() - return fixture -} - -// WebKit fires `compositionend` before the keydown that commits an IME -// composition, and prosemirror-view swallows the first keydown within 500ms -// after `compositionend` without `preventDefault`, letting the browser's -// native editing run. Synthetic composition events arm that swallow because -// prosemirror-view's handlers are plain event listeners, while the following -// Enter stays a real key press whose native default action is exercised. -function fakeCompositionCommit(fixture: Fixture) { - fixture.dom.dispatchEvent(new CompositionEvent('compositionstart')) - fixture.dom.dispatchEvent(new CompositionEvent('compositionend')) -} - -describe('enter after a composition commit', () => { - it('inserts a newline at the start of a code block', async () => { - using fixture = setupEditor() - const { n } = fixture - fixture.set(n.doc(n.codeBlock({ language: 'js' }, '
foobar'))) - fakeCompositionCommit(fixture) - await userEvent.keyboard('{Enter}') - await vi.waitFor(() => { - expect(fixture.doc.eq(n.doc(n.codeBlock({ language: 'js' }, '\nfoobar')))).toBe(true) - }) - expect(pmRoot.locate('pre').all()).toHaveLength(1) - await expect.element(rogueBreak).not.toBeInTheDocument() - }) - - it('keeps the text before the caret in the middle of a code block', async () => { - using fixture = setupEditor() - const { n } = fixture - fixture.set(n.doc(n.codeBlock({ language: 'js' }, 'foobar'))) - fakeCompositionCommit(fixture) - await userEvent.keyboard('{Enter}') - await vi.waitFor(() => { - expect(fixture.doc.eq(n.doc(n.codeBlock({ language: 'js' }, 'foo\nbar')))).toBe(true) - }) - expect(pmRoot.locate('pre').all()).toHaveLength(1) - await expect.element(rogueBreak).not.toBeInTheDocument() - }) - - it('inserts a newline on shift-enter in a code block', async () => { - using fixture = setupEditor() - const { n } = fixture - fixture.set(n.doc(n.codeBlock({ language: 'js' }, 'foobar'))) - // Hold Shift before the commit: a Shift press after `compositionend` - // would itself consume prosemirror-view's one-shot swallow window. - await userEvent.keyboard('{Shift>}') - fakeCompositionCommit(fixture) - await userEvent.keyboard('{Enter}{/Shift}') - await vi.waitFor(() => { - expect(fixture.doc.eq(n.doc(n.codeBlock({ language: 'js' }, 'foo\nbar')))).toBe(true) - }) - await expect.element(rogueBreak).not.toBeInTheDocument() - }) - - it('splits a paragraph', async () => { - using fixture = setupEditor() - const { n } = fixture - fixture.set(n.doc(n.paragraph('foobar'))) - fakeCompositionCommit(fixture) - await userEvent.keyboard('{Enter}') - await vi.waitFor(() => { - expect(fixture.doc.eq(n.doc(n.paragraph('foo'), n.paragraph('bar')))).toBe(true) - }) - await expect.element(rogueBreak).not.toBeInTheDocument() - }) -}) diff --git a/packages/react/src/components/code-block-enter.test.tsx b/packages/react/src/components/code-block-enter.test.tsx index 6167826d..a3e89ea0 100644 --- a/packages/react/src/components/code-block-enter.test.tsx +++ b/packages/react/src/components/code-block-enter.test.tsx @@ -33,18 +33,23 @@ describe('enter after a composition commit', () => { view.focus() } - // See `code-block-enter-guard.test.ts` in the core package: synthetic - // composition events arm prosemirror-view's post-composition keydown - // swallow, so the following real Enter reaches WebKit's native editing. - function fakeCompositionCommit(view: EditorView) { - view.dom.dispatchEvent(new CompositionEvent('compositionstart')) - view.dom.dispatchEvent(new CompositionEvent('compositionend')) + // prosemirror-view swallows the first Safari keydown within 500ms after + // `compositionend` without `preventDefault`, handing the key to the + // browser's native editing (WebKit fires `compositionend` before the keydown + // that commits an IME composition, so that keydown reads as a likely IME + // confirmation). No automation driver can run a real IME, so stamp the + // timestamp prosemirror-view records from that `compositionend`; the Enter + // pressed afterwards stays a real key whose native default action runs. + function armPostCompositionWindow(view: EditorView) { + const input = (view as EditorView & { input?: { compositionEndedAt: number } }).input + if (input == null) throw new Error('prosemirror-view no longer exposes view.input') + input.compositionEndedAt = Date.now() } it('keeps one pre and no rogue br at the start of the code text', async () => { const { ref, view } = await setupCodeBlockEditor() placeCaret(view, 1) - fakeCompositionCommit(view) + armPostCompositionWindow(view) await userEvent.keyboard('{Enter}') await vi.waitFor(() => { expect(ref.current?.getMarkdown()).toContain('```js\n\nfoobar\n```') @@ -56,7 +61,7 @@ describe('enter after a composition commit', () => { it('keeps the text before the caret in the middle of the code text', async () => { const { ref, view } = await setupCodeBlockEditor() placeCaret(view, 4) - fakeCompositionCommit(view) + armPostCompositionWindow(view) await userEvent.keyboard('{Enter}') await vi.waitFor(() => { expect(ref.current?.getMarkdown()).toContain('```js\nfoo\nbar\n```') From 0f79bfeaa413943dce902059c0784470d99a1328 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 31 Aug 2026 04:01:34 +1200 Subject: [PATCH 4/4] chore(core): add `isWebKit` TODO and link the iOS Enter fallback source --- packages/core/src/extensions/code-block-enter-guard.ts | 5 +++-- packages/core/src/utils/browser.ts | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/extensions/code-block-enter-guard.ts b/packages/core/src/extensions/code-block-enter-guard.ts index 2fef71e6..ed4887e6 100644 --- a/packages/core/src/extensions/code-block-enter-guard.ts +++ b/packages/core/src/extensions/code-block-enter-guard.ts @@ -34,8 +34,9 @@ export function defineCodeBlockEnterGuard(): PlainExtension { // After an iOS Enter keydown, prosemirror-view schedules a synthetic // Enter 200ms later as a fallback for the native edit this guard just - // canceled; clear the flag so the fallback cannot insert a second - // newline. + // canceled (`lastIOSEnterFallbackTimeout` in + // https://github.com/ProseMirror/prosemirror-view/blob/1.42.2/src/input.ts#L123-L131); + // clear the flag so the fallback cannot insert a second newline. const input = (view as EditorView & { input?: { lastIOSEnter: number } }).input if (input != null) input.lastIOSEnter = 0 diff --git a/packages/core/src/utils/browser.ts b/packages/core/src/utils/browser.ts index eaff81e9..b80ed62e 100644 --- a/packages/core/src/utils/browser.ts +++ b/packages/core/src/utils/browser.ts @@ -1,3 +1,6 @@ +// TODO: import `isWebKit` from `@prosekit/core` once a released version ships +// it, and delete this file. +// // Apple's WebKit engine: Safari on any platform, every iOS browser, and // WKWebView hosts. Blink reports a "Google Inc." vendor, and Node's global // `navigator` has no `vendor` at all.