From 9bb0f03112e5d616a08cad81b80aaf953ac7ccc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 19:36:07 +0900 Subject: [PATCH 01/28] test: prove controlled value policy mutation is atomic --- .../CwlEditorControlledValuePolicy.test.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/components/CwlEditorControlledValuePolicy.test.tsx diff --git a/src/components/CwlEditorControlledValuePolicy.test.tsx b/src/components/CwlEditorControlledValuePolicy.test.tsx new file mode 100644 index 00000000..0b000f63 --- /dev/null +++ b/src/components/CwlEditorControlledValuePolicy.test.tsx @@ -0,0 +1,59 @@ +import { Plugin } from '@tiptap/pm/state'; +import type { Editor } from '@tiptap/react'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CwlEditor } from './CwlEditor.js'; + +afterEach(cleanup); + +describe('CwlEditor controlled-value transaction policy', () => { + it('keeps the previous document when policy appends a transformed replacement', async () => { + let editor: Editor | undefined; + let transformedReplacementCount = 0; + const { rerender } = render( + { + editor = instance; + }} + />, + ); + + await waitFor(() => expect(editor).toBeTruthy()); + editor!.registerPlugin( + new Plugin({ + appendTransaction(_transactions, _oldState, newState) { + if (newState.doc.textContent !== 'Requested') return null; + transformedReplacementCount += 1; + const paragraph = newState.schema.nodes.paragraph!.create( + null, + newState.schema.text('Policy transformed'), + ); + return newState.tr.replaceWith( + 0, + newState.doc.content.size, + paragraph, + ); + }, + }), + ); + + await act(async () => { + rerender( + { + editor = instance; + }} + />, + ); + }); + + await waitFor(() => { + expect(transformedReplacementCount).toBeGreaterThan(0); + expect(editor!.getText()).toBe('Original'); + }); + }); +}); From ed41233774cb0a7237af20c29b3187ba74293fa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 19:40:25 +0900 Subject: [PATCH 02/28] fix: preflight controlled value transaction policy --- src/components/editorControlledValueSync.ts | 70 +++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/components/editorControlledValueSync.ts diff --git a/src/components/editorControlledValueSync.ts b/src/components/editorControlledValueSync.ts new file mode 100644 index 00000000..86af7f5b --- /dev/null +++ b/src/components/editorControlledValueSync.ts @@ -0,0 +1,70 @@ +import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'; +import type { Editor } from '@tiptap/react'; +import type { EditorMode } from '../types.js'; +import { editorValueToHtml } from './editorSerialization.js'; + +/** + * Apply one controlled host value without allowing policy-driven partial state. + * + * The requested value is parsed once, previewed through the current ProseMirror + * transaction policy, and installed only when that policy produces the exact + * requested document. A live-only divergence is rolled back to the captured + * local state. Policy refusal is local: the caller keeps the previous document + * and does not manufacture an `onChange` success for an unapplied prop value. + */ +export function synchronizeControlledEditorValue( + editor: Editor, + value: string, + mode: EditorMode, +): boolean { + const originalState = editor.state; + const requestedDocument = parseControlledDocument(editor, value, mode); + const previewTransaction = originalState.tr + .replaceWith( + 0, + originalState.doc.content.size, + requestedDocument.content, + ) + .setMeta('preventUpdate', true); + + let previewState; + try { + previewState = originalState.applyTransaction(previewTransaction).state; + } catch { + return false; + } + if (!previewState.doc.eq(requestedDocument)) return false; + + try { + editor.commands.setContent(requestedDocument, false); + } catch { + restoreLocalEditorState(editor, originalState); + return false; + } + if (!editor.state.doc.eq(requestedDocument)) { + restoreLocalEditorState(editor, originalState); + return false; + } + return true; +} + +function parseControlledDocument( + editor: Editor, + value: string, + mode: EditorMode, +) { + const container = document.createElement('div'); + container.innerHTML = editorValueToHtml(value, mode); + return ProseMirrorDOMParser.fromSchema(editor.schema).parse(container); +} + +function restoreLocalEditorState( + editor: Editor, + originalState: Editor['state'], +): void { + try { + editor.view.updateState(originalState); + } catch { + // A broken plugin view must not replace the primary bounded refusal signal. + } +} From 35a101b8a20239dd0a0057cab8fd39b0fa133536 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 19:41:21 +0900 Subject: [PATCH 03/28] fix: keep controlled value synchronization atomic --- src/components/CwlEditor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 598ac948..65d89cc8 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -14,6 +14,7 @@ import { buildEditorAccessibilityAttributes, normalizeEditorPlaceholder, } from './editorAccessibility.js'; +import { synchronizeControlledEditorValue } from './editorControlledValueSync.js'; import { createEditorDocumentSnapshot } from './editorDocumentSnapshot.js'; import { applyEditorFormReset } from './editorFormReset.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; @@ -212,8 +213,7 @@ export const CwlEditor = forwardRef( const current = editorHtmlToValue(editor.getHTML(), mode); if (current !== value) { /* v8 ignore next -- isControlled guarantees value is defined. */ - const next = editorValueToHtml(value ?? '', mode); - editor.commands.setContent(next, false); + synchronizeControlledEditorValue(editor, value ?? '', mode); } }, [editor, isControlled, value, mode]); From 61917a08e78ef85c04a033dcc57975d5bb469d4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 19:46:51 +0900 Subject: [PATCH 04/28] fix: surface controlled-value rollback failures --- src/components/editorControlledValueSync.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/editorControlledValueSync.ts b/src/components/editorControlledValueSync.ts index 86af7f5b..e7d7c802 100644 --- a/src/components/editorControlledValueSync.ts +++ b/src/components/editorControlledValueSync.ts @@ -62,9 +62,5 @@ function restoreLocalEditorState( editor: Editor, originalState: Editor['state'], ): void { - try { - editor.view.updateState(originalState); - } catch { - // A broken plugin view must not replace the primary bounded refusal signal. - } + editor.view.updateState(originalState); } From ebced0234c801c018b236e5615fbfe6872a9d764 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 19:47:55 +0900 Subject: [PATCH 05/28] test: cover controlled-value refusal and rollback paths --- .../CwlEditorControlledValuePolicy.test.tsx | 144 ++++++++++++++---- 1 file changed, 118 insertions(+), 26 deletions(-) diff --git a/src/components/CwlEditorControlledValuePolicy.test.tsx b/src/components/CwlEditorControlledValuePolicy.test.tsx index 0b000f63..f9ad438d 100644 --- a/src/components/CwlEditorControlledValuePolicy.test.tsx +++ b/src/components/CwlEditorControlledValuePolicy.test.tsx @@ -6,22 +6,45 @@ import { CwlEditor } from './CwlEditor.js'; afterEach(cleanup); +async function renderControlledEditor(): Promise<{ + editor: () => Editor; + replaceValue: () => Promise; +}> { + let editor: Editor | undefined; + const { rerender } = render( + { + editor = instance; + }} + />, + ); + + await waitFor(() => expect(editor).toBeTruthy()); + return { + editor: () => editor!, + replaceValue: async () => { + await act(async () => { + rerender( + { + editor = instance; + }} + />, + ); + }); + }, + }; +} + describe('CwlEditor controlled-value transaction policy', () => { - it('keeps the previous document when policy appends a transformed replacement', async () => { - let editor: Editor | undefined; + it('keeps the previous document when preview policy transforms the replacement', async () => { + const controlled = await renderControlledEditor(); let transformedReplacementCount = 0; - const { rerender } = render( - { - editor = instance; - }} - />, - ); - - await waitFor(() => expect(editor).toBeTruthy()); - editor!.registerPlugin( + controlled.editor().registerPlugin( new Plugin({ appendTransaction(_transactions, _oldState, newState) { if (newState.doc.textContent !== 'Requested') return null; @@ -39,21 +62,90 @@ describe('CwlEditor controlled-value transaction policy', () => { }), ); - await act(async () => { - rerender( - { - editor = instance; - }} - />, - ); - }); + await controlled.replaceValue(); await waitFor(() => { expect(transformedReplacementCount).toBeGreaterThan(0); - expect(editor!.getText()).toBe('Original'); + expect(controlled.editor().getText()).toBe('Original'); + }); + }); + + it('rolls back when stateful policy transforms only the live replacement', async () => { + const controlled = await renderControlledEditor(); + let requestedReplacementCount = 0; + controlled.editor().registerPlugin( + new Plugin({ + appendTransaction(_transactions, _oldState, newState) { + if (newState.doc.textContent !== 'Requested') return null; + requestedReplacementCount += 1; + if (requestedReplacementCount === 1) return null; + const paragraph = newState.schema.nodes.paragraph!.create( + null, + newState.schema.text('Live-only transform'), + ); + return newState.tr.replaceWith( + 0, + newState.doc.content.size, + paragraph, + ); + }, + }), + ); + + await controlled.replaceValue(); + + await waitFor(() => { + expect(requestedReplacementCount).toBe(2); + expect(controlled.editor().getText()).toBe('Original'); + }); + }); + + it('rolls back when stateful policy throws only during live replacement', async () => { + const controlled = await renderControlledEditor(); + let requestedReplacementCount = 0; + controlled.editor().registerPlugin( + new Plugin({ + filterTransaction(transaction) { + if (!transaction.docChanged || transaction.doc.textContent !== 'Requested') { + return true; + } + requestedReplacementCount += 1; + if (requestedReplacementCount === 2) { + throw new Error('policy refused live replacement'); + } + return true; + }, + }), + ); + + await controlled.replaceValue(); + + await waitFor(() => { + expect(requestedReplacementCount).toBe(2); + expect(controlled.editor().getText()).toBe('Original'); + }); + }); + + it('keeps the previous document when preview policy throws', async () => { + const controlled = await renderControlledEditor(); + let refusalCount = 0; + controlled.editor().registerPlugin( + new Plugin({ + filterTransaction(transaction) { + if (!transaction.docChanged || transaction.doc.textContent !== 'Requested') { + return true; + } + refusalCount += 1; + throw new Error('policy refused preview'); + }, + }), + ); + + await controlled.replaceValue(); + + await waitFor(() => { + expect(refusalCount).toBe(1); + expect(controlled.editor().getText()).toBe('Original'); }); }); }); From a28bde93b230af732cb5759c64ffbc97b67bc3e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 11:05:14 +0900 Subject: [PATCH 06/28] test(editor): reject invalid runtime editable state --- .../CwlEditor.runtimeEditable.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/components/CwlEditor.runtimeEditable.test.tsx diff --git a/src/components/CwlEditor.runtimeEditable.test.tsx b/src/components/CwlEditor.runtimeEditable.test.tsx new file mode 100644 index 00000000..4907c87d --- /dev/null +++ b/src/components/CwlEditor.runtimeEditable.test.tsx @@ -0,0 +1,23 @@ +// @vitest-environment node + +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { CwlEditor } from './CwlEditor.js'; + +describe('standalone editor editable runtime contract', () => { + it('rejects a non-boolean editable state instead of coercing it into edit authority', () => { + expect(() => + renderToString( + , + ), + ).toThrowError( + new RangeError('editor editable state must be a boolean when provided'), + ); + }); + + it('preserves omitted, explicitly editable, and explicitly read-only states', () => { + expect(() => renderToString()).not.toThrow(); + expect(() => renderToString()).not.toThrow(); + expect(() => renderToString()).not.toThrow(); + }); +}); From fa0d0aa0cce043220adcc635ad9c3bdb7a145b56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:07:58 +0900 Subject: [PATCH 07/28] fix(editor): validate runtime editable state --- src/components/CwlEditor.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 65d89cc8..7b30de0c 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -66,6 +66,10 @@ export const CwlEditor = forwardRef( }, ref, ) { + if (typeof editable !== 'boolean') { + throw new RangeError('editor editable state must be a boolean when provided'); + } + const isControlled = value !== undefined; const selectedDocumentValue = value ?? defaultValue ?? ''; const emittingRef = useRef(false); From 9e0244268a5e8abfca2c0786d00279447215e598 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:11:03 +0900 Subject: [PATCH 08/28] test(data-integrity): reject invalid toolbar visibility state --- ...wlEditor.runtimeToolbarVisibility.test.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/components/CwlEditor.runtimeToolbarVisibility.test.tsx diff --git a/src/components/CwlEditor.runtimeToolbarVisibility.test.tsx b/src/components/CwlEditor.runtimeToolbarVisibility.test.tsx new file mode 100644 index 00000000..bcfbadd1 --- /dev/null +++ b/src/components/CwlEditor.runtimeToolbarVisibility.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment node + +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { CwlEditor } from './CwlEditor.js'; + +describe('standalone editor toolbar visibility runtime contract', () => { + it('rejects a non-boolean toolbar visibility state instead of coercing it', () => { + expect(() => + renderToString( + , + ), + ).toThrowError( + new RangeError( + 'editor toolbar visibility state must be a boolean when provided', + ), + ); + }); + + it('preserves omitted, visible, and hidden toolbar states', () => { + expect(() => renderToString()).not.toThrow(); + expect(() => renderToString()).not.toThrow(); + expect(() => renderToString()).not.toThrow(); + }); +}); From 3c9c16e6c53c41f3872e7f1d28ab33e1e526dc43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 18:17:42 +0900 Subject: [PATCH 09/28] fix(data-integrity): validate toolbar visibility state --- src/components/CwlEditor.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 7b30de0c..ad752ec5 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -69,6 +69,11 @@ export const CwlEditor = forwardRef( if (typeof editable !== 'boolean') { throw new RangeError('editor editable state must be a boolean when provided'); } + if (typeof hideToolbar !== 'boolean') { + throw new RangeError( + 'editor toolbar visibility state must be a boolean when provided', + ); + } const isControlled = value !== undefined; const selectedDocumentValue = value ?? defaultValue ?? ''; From 5fe17e009df6ba253cd7d194cc5069a4c08367fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:45:24 +0900 Subject: [PATCH 10/28] test(editor): define runtime document value RED --- .../editorDocumentValue.runtime.test.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/components/editorDocumentValue.runtime.test.tsx diff --git a/src/components/editorDocumentValue.runtime.test.tsx b/src/components/editorDocumentValue.runtime.test.tsx new file mode 100644 index 00000000..a7ed85f6 --- /dev/null +++ b/src/components/editorDocumentValue.runtime.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment node + +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { CwlEditor } from './CwlEditor.js'; + +describe('standalone editor document value runtime contract', () => { + it('rejects a defined non-string controlled value before serialization', () => { + expect(() => + renderToString(), + ).toThrowError( + new RangeError('editor value must be a string when provided'), + ); + }); + + it('rejects a defined non-string default value before serialization', () => { + expect(() => + renderToString(), + ).toThrowError( + new RangeError('editor default value must be a string when provided'), + ); + }); + + it('preserves controlled precedence and exact empty or Unicode strings', () => { + expect( + renderToString( + , + ), + ).toContain('data-cwl-editor-root=""'); + expect( + renderToString(), + ).toContain('data-cwl-editor-root=""'); + }); +}); From 58531c02178227fa4c2a20a21b9c1010d4f0ab35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:48:22 +0900 Subject: [PATCH 11/28] fix(test): keep document value RED product-specific --- src/components/editorDocumentValue.runtime.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/editorDocumentValue.runtime.test.tsx b/src/components/editorDocumentValue.runtime.test.tsx index a7ed85f6..58f3b7e7 100644 --- a/src/components/editorDocumentValue.runtime.test.tsx +++ b/src/components/editorDocumentValue.runtime.test.tsx @@ -22,13 +22,13 @@ describe('standalone editor document value runtime contract', () => { }); it('preserves controlled precedence and exact empty or Unicode strings', () => { - expect( + expect(() => renderToString( , ), - ).toContain('data-cwl-editor-root=""'); - expect( + ).not.toThrow(); + expect(() => renderToString(), - ).toContain('data-cwl-editor-root=""'); + ).not.toThrow(); }); }); From 16fe78d4ffd4b7c6267ebebacad3956179a1dd18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:53:26 +0900 Subject: [PATCH 12/28] fix(editor): validate runtime document values --- src/components/CwlEditor.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index ad752ec5..ad450c17 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -66,6 +66,14 @@ export const CwlEditor = forwardRef( }, ref, ) { + if (value !== undefined && typeof value !== 'string') { + throw new RangeError('editor value must be a string when provided'); + } + if (defaultValue !== undefined && typeof defaultValue !== 'string') { + throw new RangeError( + 'editor default value must be a string when provided', + ); + } if (typeof editable !== 'boolean') { throw new RangeError('editor editable state must be a boolean when provided'); } From 2fe2ce9ba0071953b879f562c4cffed08e5ff1de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:06:22 +0900 Subject: [PATCH 13/28] test(editor): define runtime reset document RED --- .../editorDocumentValue.runtime.test.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/editorDocumentValue.runtime.test.tsx b/src/components/editorDocumentValue.runtime.test.tsx index 58f3b7e7..62f7503d 100644 --- a/src/components/editorDocumentValue.runtime.test.tsx +++ b/src/components/editorDocumentValue.runtime.test.tsx @@ -21,6 +21,18 @@ describe('standalone editor document value runtime contract', () => { ); }); + it('rejects a defined non-string native-form reset document before wiring', () => { + expect(() => + renderToString( + , + ), + ).toThrowError( + new RangeError( + 'editor form reset value must be a string when provided', + ), + ); + }); + it('preserves controlled precedence and exact empty or Unicode strings', () => { expect(() => renderToString( @@ -28,7 +40,12 @@ describe('standalone editor document value runtime contract', () => { ), ).not.toThrow(); expect(() => - renderToString(), + renderToString( + , + ), ).not.toThrow(); }); }); From 9fd9a281073da390409ed368fcc9311c8d501411 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:13:48 +0900 Subject: [PATCH 14/28] fix(editor): validate native-form reset documents --- src/components/CwlEditor.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index ad450c17..0ec7a81c 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -74,6 +74,11 @@ export const CwlEditor = forwardRef( 'editor default value must be a string when provided', ); } + if (formResetValue !== undefined && typeof formResetValue !== 'string') { + throw new RangeError( + 'editor form reset value must be a string when provided', + ); + } if (typeof editable !== 'boolean') { throw new RangeError('editor editable state must be a boolean when provided'); } From 89d766ec43b5eb1420950b8d6c14fa3e7be7d7c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:17:27 -0700 Subject: [PATCH 15/28] test(editor): reproduce composition state across read-only transition --- .../CwlEditor.editabilityComposition.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/components/CwlEditor.editabilityComposition.test.tsx diff --git a/src/components/CwlEditor.editabilityComposition.test.tsx b/src/components/CwlEditor.editabilityComposition.test.tsx new file mode 100644 index 00000000..be745ef2 --- /dev/null +++ b/src/components/CwlEditor.editabilityComposition.test.tsx @@ -0,0 +1,32 @@ +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'; +import type { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CwlEditor } from './CwlEditor.js'; + +afterEach(cleanup); + +describe('CwlEditor editability transition during composition', () => { + it('clears local composition state before revoking edit authority', async () => { + let editor: Editor | undefined; + const captureEditor = (instance: Editor) => { + editor = instance; + }; + + const { rerender } = render( + , + ); + await waitFor(() => expect(editor).toBeTruthy()); + + const editable = document.querySelector('.ProseMirror') as HTMLElement; + fireEvent.compositionStart(editable, { data: '' }); + expect(editor!.view.composing).toBe(true); + + rerender( + , + ); + + await waitFor(() => expect(editor!.isEditable).toBe(false)); + expect(editor!.view.composing).toBe(false); + expect(editor!.getText()).toBe('기준'); + }); +}); From 343d4132574f4cb20eb561928df034154609bab7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:25:07 -0700 Subject: [PATCH 16/28] fix(editor): end composition before read-only transition --- src/components/CwlEditor.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 0ec7a81c..fdd20cce 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -216,7 +216,17 @@ export const CwlEditor = forwardRef( useEditorHandle(ref, editor, modeRef); useEffect(() => { - editor?.setEditable(editable); + if (!editor) return; + if (!editable && editor.view.composing) { + // ProseMirror treats compositionend as an edit event, so it will stop + // processing that event after editability has already been revoked. + // Drain the active local composition first to avoid stranding its + // internal composing state across the read-only transition. + const EventConstructor = + editor.view.dom.ownerDocument.defaultView!.Event; + editor.view.dom.dispatchEvent(new EventConstructor('compositionend')); + } + editor.setEditable(editable); }, [editor, editable]); useEffect(() => { From 576dc4756e1d380957077e89cd4aae15c6a16363 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:16:10 -0700 Subject: [PATCH 17/28] test(editor): reproduce controlled sync during composition --- ...Editor.controlledValueComposition.test.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/components/CwlEditor.controlledValueComposition.test.tsx diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx new file mode 100644 index 00000000..bc0027d4 --- /dev/null +++ b/src/components/CwlEditor.controlledValueComposition.test.tsx @@ -0,0 +1,47 @@ +import type { Editor } from '@tiptap/react'; +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CwlEditor } from './CwlEditor.js'; + +afterEach(cleanup); + +describe('CwlEditor controlled value during composition', () => { + it('defers host replacement until composition ends and applies the latest value', async () => { + let editor: Editor | undefined; + const captureEditor = (instance: Editor) => { + editor = instance; + }; + + const { rerender } = render( + , + ); + await waitFor(() => expect(editor).toBeTruthy()); + + const editable = document.querySelector('.ProseMirror') as HTMLElement; + fireEvent.compositionStart(editable, { data: '' }); + expect(editor!.view.composing).toBe(true); + + await act(async () => { + rerender( + , + ); + }); + expect(editor!.view.composing).toBe(true); + expect(editor!.getText()).toBe('Original'); + + await act(async () => { + rerender( + , + ); + }); + expect(editor!.view.composing).toBe(true); + expect(editor!.getText()).toBe('Original'); + + fireEvent.compositionEnd(editable, { data: '' }); + + await waitFor(() => { + expect(editor!.view.composing).toBe(false); + expect(editor!.getText()).toBe('Latest host value'); + }); + }); +}); From f582e7cfd423cc2f55b2a2adc36a6201186b31ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:20:00 -0700 Subject: [PATCH 18/28] fix(editor): defer controlled sync during composition --- src/components/CwlEditor.tsx | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index fdd20cce..2846c2b5 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -242,11 +242,26 @@ export const CwlEditor = forwardRef( useEffect(() => { if (!editor || !isControlled || emittingRef.current) return; - const current = editorHtmlToValue(editor.getHTML(), mode); - if (current !== value) { - /* v8 ignore next -- isControlled guarantees value is defined. */ - synchronizeControlledEditorValue(editor, value ?? '', mode); + + const synchronizeValue = () => { + const current = editorHtmlToValue(editor.getHTML(), mode); + if (current !== value) { + /* v8 ignore next -- isControlled guarantees value is defined. */ + synchronizeControlledEditorValue(editor, value ?? '', mode); + } + }; + + if (!editor.view.composing) { + synchronizeValue(); + return; } + + editor.view.dom.addEventListener('compositionend', synchronizeValue, { + once: true, + }); + return () => { + editor.view.dom.removeEventListener('compositionend', synchronizeValue); + }; }, [editor, isControlled, value, mode]); const handleFormReset = useCallback( From ff182079145419159eca4b442f54a882b1d63da1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 10:43:28 -0700 Subject: [PATCH 19/28] test(input): reject intermediate composition snapshots --- ...Editor.controlledValueComposition.test.tsx | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx index bc0027d4..7dbc41f1 100644 --- a/src/components/CwlEditor.controlledValueComposition.test.tsx +++ b/src/components/CwlEditor.controlledValueComposition.test.tsx @@ -1,6 +1,6 @@ import type { Editor } from '@tiptap/react'; import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { CwlEditor } from './CwlEditor.js'; afterEach(cleanup); @@ -44,4 +44,47 @@ describe('CwlEditor controlled value during composition', () => { expect(editor!.getText()).toBe('Latest host value'); }); }); + + it('keeps intermediate composition text out of document snapshot callbacks', async () => { + let editor: Editor | undefined; + const onChange = vi.fn(); + const onDocumentChange = vi.fn(); + + render( + { + editor = instance; + }} + />, + ); + await waitFor(() => expect(editor).toBeTruthy()); + + const editable = document.querySelector('.ProseMirror') as HTMLElement; + fireEvent.compositionStart(editable, { data: '' }); + expect(editor!.view.composing).toBe(true); + + act(() => { + editor!.chain().focus('end').insertContent(' composing').run(); + }); + + expect(editor!.getText()).toBe('Original composing'); + expect(onChange).toHaveBeenLastCalledWith('Original composing'); + expect(onDocumentChange).not.toHaveBeenCalled(); + + fireEvent.compositionEnd(editable, { data: '' }); + await waitFor(() => expect(editor!.view.composing).toBe(false)); + + act(() => { + editor!.chain().focus('end').insertContent(' committed').run(); + }); + + expect(onDocumentChange).toHaveBeenCalledTimes(1); + expect(onDocumentChange.mock.calls[0]![0].snapshot.value).toBe( + 'Original composing committed', + ); + }); }); From d46640d1881a2469a80edcab75cab20eba25dc5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 13:10:32 -0700 Subject: [PATCH 20/28] fix(input): suppress composition snapshots --- src/components/CwlEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 2846c2b5..017c86fd 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -176,7 +176,7 @@ export const CwlEditor = forwardRef( if (!valueListener && !snapshotListener) return; emittingRef.current = true; try { - if (snapshotListener) { + if (snapshotListener && !instance.view.composing) { const snapshot = createEditorDocumentSnapshot( instance, modeRef.current, From 216c0aaaa47f45905b78601f26e116f0ad2efc27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 13:15:10 -0700 Subject: [PATCH 21/28] fix(input): track composition callback boundary --- src/components/CwlEditor.tsx | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 017c86fd..0331a1ad 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -91,6 +91,7 @@ export const CwlEditor = forwardRef( const isControlled = value !== undefined; const selectedDocumentValue = value ?? defaultValue ?? ''; const emittingRef = useRef(false); + const compositionActiveRef = useRef(false); const editorInstanceRef = useRef(null); const modeRef = useLatestRef(mode); const onChangeRef = useLatestRef(onChange); @@ -160,6 +161,18 @@ export const CwlEditor = forwardRef( content: editorValueToHtml(selectedDocumentValue, mode), editorProps: { attributes: editorAttributes, + handleDOMEvents: { + compositionstart: () => { + compositionActiveRef.current = true; + return false; + }, + compositionend: () => { + queueMicrotask(() => { + compositionActiveRef.current = false; + }); + return false; + }, + }, }, onCreate: ({ editor: instance }) => { editorInstanceRef.current = instance; @@ -167,6 +180,7 @@ export const CwlEditor = forwardRef( }, onDestroy: () => { const instance = editorInstanceRef.current!; + compositionActiveRef.current = false; onDestroyRef.current?.(instance); editorInstanceRef.current = null; }, @@ -176,7 +190,11 @@ export const CwlEditor = forwardRef( if (!valueListener && !snapshotListener) return; emittingRef.current = true; try { - if (snapshotListener && !instance.view.composing) { + if ( + snapshotListener && + !compositionActiveRef.current && + !instance.view.composing + ) { const snapshot = createEditorDocumentSnapshot( instance, modeRef.current, From 39eb0dd3c8a184c63b30ec75554170f2215df266 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 13:20:44 -0700 Subject: [PATCH 22/28] fix(input): observe native composition lifecycle --- src/components/CwlEditor.tsx | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 0331a1ad..924a6e57 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -161,18 +161,6 @@ export const CwlEditor = forwardRef( content: editorValueToHtml(selectedDocumentValue, mode), editorProps: { attributes: editorAttributes, - handleDOMEvents: { - compositionstart: () => { - compositionActiveRef.current = true; - return false; - }, - compositionend: () => { - queueMicrotask(() => { - compositionActiveRef.current = false; - }); - return false; - }, - }, }, onCreate: ({ editor: instance }) => { editorInstanceRef.current = instance; @@ -233,6 +221,27 @@ export const CwlEditor = forwardRef( useEditorHandle(ref, editor, modeRef); + useEffect(() => { + if (!editor) return; + const editableElement = editor.view.dom; + const beginComposition = () => { + compositionActiveRef.current = true; + }; + const finishComposition = () => { + queueMicrotask(() => { + compositionActiveRef.current = false; + }); + }; + + editableElement.addEventListener('compositionstart', beginComposition); + editableElement.addEventListener('compositionend', finishComposition); + return () => { + editableElement.removeEventListener('compositionstart', beginComposition); + editableElement.removeEventListener('compositionend', finishComposition); + compositionActiveRef.current = false; + }; + }, [editor]); + useEffect(() => { if (!editor) return; if (!editable && editor.view.composing) { From f11b7f0b9fc1801aad9c1d1f3861d06d9528448e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:08:14 -0700 Subject: [PATCH 23/28] fix(input): bind composition state before editor ready --- src/components/CwlEditor.tsx | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 924a6e57..0331a1ad 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -161,6 +161,18 @@ export const CwlEditor = forwardRef( content: editorValueToHtml(selectedDocumentValue, mode), editorProps: { attributes: editorAttributes, + handleDOMEvents: { + compositionstart: () => { + compositionActiveRef.current = true; + return false; + }, + compositionend: () => { + queueMicrotask(() => { + compositionActiveRef.current = false; + }); + return false; + }, + }, }, onCreate: ({ editor: instance }) => { editorInstanceRef.current = instance; @@ -221,27 +233,6 @@ export const CwlEditor = forwardRef( useEditorHandle(ref, editor, modeRef); - useEffect(() => { - if (!editor) return; - const editableElement = editor.view.dom; - const beginComposition = () => { - compositionActiveRef.current = true; - }; - const finishComposition = () => { - queueMicrotask(() => { - compositionActiveRef.current = false; - }); - }; - - editableElement.addEventListener('compositionstart', beginComposition); - editableElement.addEventListener('compositionend', finishComposition); - return () => { - editableElement.removeEventListener('compositionstart', beginComposition); - editableElement.removeEventListener('compositionend', finishComposition); - compositionActiveRef.current = false; - }; - }, [editor]); - useEffect(() => { if (!editor) return; if (!editable && editor.view.composing) { From d0efcb0b2e34a364401b6178b08b2519b958cdad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:13:40 -0700 Subject: [PATCH 24/28] fix(input): install composition guard before ready callback --- src/components/CwlEditor.tsx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 0331a1ad..0f519e29 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -114,6 +114,14 @@ export const CwlEditor = forwardRef( }, [onClipboardErrorRef], ); + const beginComposition = useCallback(() => { + compositionActiveRef.current = true; + }, []); + const endComposition = useCallback(() => { + queueMicrotask(() => { + compositionActiveRef.current = false; + }); + }, []); const normalizedPlaceholder = useMemo( () => normalizeEditorPlaceholder(placeholder), [placeholder], @@ -161,25 +169,17 @@ export const CwlEditor = forwardRef( content: editorValueToHtml(selectedDocumentValue, mode), editorProps: { attributes: editorAttributes, - handleDOMEvents: { - compositionstart: () => { - compositionActiveRef.current = true; - return false; - }, - compositionend: () => { - queueMicrotask(() => { - compositionActiveRef.current = false; - }); - return false; - }, - }, }, onCreate: ({ editor: instance }) => { editorInstanceRef.current = instance; + instance.view.dom.addEventListener('compositionstart', beginComposition); + instance.view.dom.addEventListener('compositionend', endComposition); onReadyRef.current?.(instance); }, onDestroy: () => { const instance = editorInstanceRef.current!; + instance.view.dom.removeEventListener('compositionstart', beginComposition); + instance.view.dom.removeEventListener('compositionend', endComposition); compositionActiveRef.current = false; onDestroyRef.current?.(instance); editorInstanceRef.current = null; From 31002b1398596f96198c170be43a837f2c7c3dd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:19:14 -0700 Subject: [PATCH 25/28] test(input): localize composition snapshot regression --- src/components/CwlEditor.controlledValueComposition.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx index 7dbc41f1..1042872a 100644 --- a/src/components/CwlEditor.controlledValueComposition.test.tsx +++ b/src/components/CwlEditor.controlledValueComposition.test.tsx @@ -62,15 +62,19 @@ describe('CwlEditor controlled value during composition', () => { />, ); await waitFor(() => expect(editor).toBeTruthy()); + expect(onDocumentChange).not.toHaveBeenCalled(); const editable = document.querySelector('.ProseMirror') as HTMLElement; + expect(editable).toBe(editor!.view.dom); fireEvent.compositionStart(editable, { data: '' }); expect(editor!.view.composing).toBe(true); + expect(onDocumentChange).not.toHaveBeenCalled(); act(() => { editor!.chain().focus('end').insertContent(' composing').run(); }); + expect(editor!.view.composing).toBe(true); expect(editor!.getText()).toBe('Original composing'); expect(onChange).toHaveBeenLastCalledWith('Original composing'); expect(onDocumentChange).not.toHaveBeenCalled(); From 9fdef510acb6798144e2cee8496306bf0720efc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:33:56 -0700 Subject: [PATCH 26/28] fix(editor): suppress synthetic editability updates --- src/components/CwlEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 0f519e29..9913f78a 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -244,7 +244,7 @@ export const CwlEditor = forwardRef( editor.view.dom.ownerDocument.defaultView!.Event; editor.view.dom.dispatchEvent(new EventConstructor('compositionend')); } - editor.setEditable(editable); + editor.setEditable(editable, false); }, [editor, editable]); useEffect(() => { From 6d7d37b2427b5ea1eb93a08aff4552b3fc28e420 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:37:18 -0700 Subject: [PATCH 27/28] test(input): require committed composition snapshot --- ...Editor.controlledValueComposition.test.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx index 1042872a..1131f706 100644 --- a/src/components/CwlEditor.controlledValueComposition.test.tsx +++ b/src/components/CwlEditor.controlledValueComposition.test.tsx @@ -91,4 +91,39 @@ describe('CwlEditor controlled value during composition', () => { 'Original composing committed', ); }); + + it('publishes the finalized composition snapshot when composition ends', async () => { + let editor: Editor | undefined; + const onDocumentChange = vi.fn(); + + render( + { + editor = instance; + }} + />, + ); + await waitFor(() => expect(editor).toBeTruthy()); + expect(onDocumentChange).not.toHaveBeenCalled(); + + const editable = editor!.view.dom; + fireEvent.compositionStart(editable, { data: '' }); + act(() => { + editor!.chain().focus('end').insertContent(' composing').run(); + }); + expect(onDocumentChange).not.toHaveBeenCalled(); + + fireEvent.compositionEnd(editable, { data: '' }); + + await waitFor(() => { + expect(editor!.view.composing).toBe(false); + expect(onDocumentChange).toHaveBeenCalledTimes(1); + }); + expect(onDocumentChange.mock.calls[0]![0].snapshot.value).toBe( + 'Original composing', + ); + }); }); From 29d21363005217026b3f085bfe6fbbd38c24b5ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 17:08:13 -0700 Subject: [PATCH 28/28] fix(input): publish finalized composition snapshot --- src/components/CwlEditor.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 9913f78a..baceb52c 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -92,6 +92,7 @@ export const CwlEditor = forwardRef( const selectedDocumentValue = value ?? defaultValue ?? ''; const emittingRef = useRef(false); const compositionActiveRef = useRef(false); + const compositionSnapshotPendingRef = useRef(false); const editorInstanceRef = useRef(null); const modeRef = useLatestRef(mode); const onChangeRef = useLatestRef(onChange); @@ -116,12 +117,19 @@ export const CwlEditor = forwardRef( ); const beginComposition = useCallback(() => { compositionActiveRef.current = true; + compositionSnapshotPendingRef.current = false; }, []); const endComposition = useCallback(() => { queueMicrotask(() => { compositionActiveRef.current = false; + if (compositionSnapshotPendingRef.current) { + compositionSnapshotPendingRef.current = false; + const instance = editorInstanceRef.current!; + const snapshot = createEditorDocumentSnapshot(instance, modeRef.current); + onDocumentChangeRef.current?.({ editor: instance, snapshot }); + } }); - }, []); + }, [modeRef, onDocumentChangeRef]); const normalizedPlaceholder = useMemo( () => normalizeEditorPlaceholder(placeholder), [placeholder], @@ -181,6 +189,7 @@ export const CwlEditor = forwardRef( instance.view.dom.removeEventListener('compositionstart', beginComposition); instance.view.dom.removeEventListener('compositionend', endComposition); compositionActiveRef.current = false; + compositionSnapshotPendingRef.current = false; onDestroyRef.current?.(instance); editorInstanceRef.current = null; }, @@ -202,6 +211,9 @@ export const CwlEditor = forwardRef( valueListener?.(snapshot.value); snapshotListener({ editor: instance, snapshot }); } else { + if (snapshotListener) { + compositionSnapshotPendingRef.current = true; + } valueListener?.( editorHtmlToValue(instance.getHTML(), modeRef.current), );