From 9bb0f03112e5d616a08cad81b80aaf953ac7ccc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 19:36:07 +0900 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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), ); From 7fe164e3bc5d99e5f43b8cef9b35acfa6c5e5042 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 12:19:39 -0700 Subject: [PATCH 29/41] fix(editor): preserve legacy initial change signal --- src/components/CwlEditor.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index baceb52c..81c403c5 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 hasPublishedInitialLegacyValueRef = useRef(false); const compositionActiveRef = useRef(false); const compositionSnapshotPendingRef = useRef(false); const editorInstanceRef = useRef(null); @@ -257,7 +258,15 @@ export const CwlEditor = forwardRef( editor.view.dom.dispatchEvent(new EventConstructor('compositionend')); } editor.setEditable(editable, false); - }, [editor, editable]); + if (!hasPublishedInitialLegacyValueRef.current) { + hasPublishedInitialLegacyValueRef.current = true; + // Preserve the historical `onChange` initialization signal without + // misclassifying a non-document transaction as `onDocumentChange`. + onChangeRef.current?.( + editorHtmlToValue(editor.getHTML(), modeRef.current), + ); + } + }, [editor, editable, modeRef, onChangeRef]); useEffect(() => { /* v8 ignore next -- the editor is created after client hydration. */ From 2dcab4382ace4dd3086fcfae3d2a83e5b663e72c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 12:20:58 -0700 Subject: [PATCH 30/41] test(editor): lock composition callback boundaries --- .../CwlEditor.controlledValueComposition.test.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx index 1131f706..5f879e41 100644 --- a/src/components/CwlEditor.controlledValueComposition.test.tsx +++ b/src/components/CwlEditor.controlledValueComposition.test.tsx @@ -62,7 +62,10 @@ describe('CwlEditor controlled value during composition', () => { />, ); await waitFor(() => expect(editor).toBeTruthy()); + await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)); + expect(onChange).toHaveBeenLastCalledWith('Original'); expect(onDocumentChange).not.toHaveBeenCalled(); + onChange.mockClear(); const editable = document.querySelector('.ProseMirror') as HTMLElement; expect(editable).toBe(editor!.view.dom); @@ -80,14 +83,20 @@ describe('CwlEditor controlled value during composition', () => { expect(onDocumentChange).not.toHaveBeenCalled(); fireEvent.compositionEnd(editable, { data: '' }); - await waitFor(() => expect(editor!.view.composing).toBe(false)); + await waitFor(() => { + expect(editor!.view.composing).toBe(false); + expect(onDocumentChange).toHaveBeenCalledTimes(1); + }); + expect(onDocumentChange.mock.calls[0]![0].snapshot.value).toBe( + 'Original composing', + ); act(() => { editor!.chain().focus('end').insertContent(' committed').run(); }); - expect(onDocumentChange).toHaveBeenCalledTimes(1); - expect(onDocumentChange.mock.calls[0]![0].snapshot.value).toBe( + expect(onDocumentChange).toHaveBeenCalledTimes(2); + expect(onDocumentChange.mock.calls[1]![0].snapshot.value).toBe( 'Original composing committed', ); }); From 581b0e6510701d93f2d91d16fab9291337d5e147 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:16:28 -0700 Subject: [PATCH 31/41] test: expose deferred controlled composition snapshot ordering --- ...Editor.controlledValueComposition.test.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx index 5f879e41..f14e7eb7 100644 --- a/src/components/CwlEditor.controlledValueComposition.test.tsx +++ b/src/components/CwlEditor.controlledValueComposition.test.tsx @@ -135,4 +135,51 @@ describe('CwlEditor controlled value during composition', () => { 'Original composing', ); }); + + it('publishes the finalized local composition before applying a deferred controlled value', async () => { + let editor: Editor | undefined; + const onDocumentChange = vi.fn(); + const captureEditor = (instance: Editor) => { + editor = instance; + }; + + const { rerender } = render( + , + ); + await waitFor(() => expect(editor).toBeTruthy()); + + const editable = editor!.view.dom; + fireEvent.compositionStart(editable, { data: '' }); + act(() => { + editor!.chain().focus('end').insertContent(' composing').run(); + }); + expect(editor!.getText()).toBe('Original composing'); + expect(onDocumentChange).not.toHaveBeenCalled(); + + await act(async () => { + rerender( + , + ); + }); + expect(editor!.view.composing).toBe(true); + expect(editor!.getText()).toBe('Original composing'); + + fireEvent.compositionEnd(editable, { data: '' }); + + await waitFor(() => expect(editor!.getText()).toBe('Host replacement')); + await waitFor(() => expect(onDocumentChange).toHaveBeenCalledTimes(1)); + expect(onDocumentChange.mock.calls[0]![0].snapshot.value).toBe( + 'Original composing', + ); + }); }); From 0f7d3acd83cf6e90bb8faf166c70350bb2a39561 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:19:24 -0700 Subject: [PATCH 32/41] fix: preserve finalized composition snapshot ordering --- src/components/CwlEditor.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 81c403c5..5a1fa522 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -295,11 +295,22 @@ export const CwlEditor = forwardRef( return; } - editor.view.dom.addEventListener('compositionend', synchronizeValue, { - once: true, - }); + const synchronizeAfterComposition = () => { + // The composition lifecycle listener queues the committed local + // snapshot first. Defer host replacement to the next microtask so a + // controlled prop cannot overwrite that snapshot before publication. + queueMicrotask(synchronizeValue); + }; + editor.view.dom.addEventListener( + 'compositionend', + synchronizeAfterComposition, + { once: true }, + ); return () => { - editor.view.dom.removeEventListener('compositionend', synchronizeValue); + editor.view.dom.removeEventListener( + 'compositionend', + synchronizeAfterComposition, + ); }; }, [editor, isControlled, value, mode]); From 217b5ad2ad8bb125dbe7016d86a6e70b03b7e2f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 15:11:33 -0700 Subject: [PATCH 33/41] test(reliability): cover composition teardown race --- ...Editor.controlledValueComposition.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx index f14e7eb7..444db000 100644 --- a/src/components/CwlEditor.controlledValueComposition.test.tsx +++ b/src/components/CwlEditor.controlledValueComposition.test.tsx @@ -182,4 +182,40 @@ describe('CwlEditor controlled value during composition', () => { 'Original composing', ); }); + + it('drops a queued composition snapshot when the editor is destroyed first', async () => { + let editor: Editor | undefined; + const onDocumentChange = vi.fn(); + + const { unmount } = render( + { + editor = instance; + }} + />, + ); + await waitFor(() => expect(editor).toBeTruthy()); + + const editable = editor!.view.dom; + fireEvent.compositionStart(editable, { data: '' }); + act(() => { + editor!.chain().focus('end').insertContent(' composing').run(); + }); + expect(onDocumentChange).not.toHaveBeenCalled(); + + act(() => { + editable.dispatchEvent( + new CompositionEvent('compositionend', { bubbles: true, data: '' }), + ); + unmount(); + }); + + await act(async () => { + await Promise.resolve(); + }); + expect(onDocumentChange).not.toHaveBeenCalled(); + }); }); From 0a8fe0e9fb4cac452a818cd207724e25391f10fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 15:16:20 -0700 Subject: [PATCH 34/41] fix(reliability): cancel composition snapshots after teardown --- src/components/CwlEditor.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 5a1fa522..e214b077 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 hasPublishedInitialLegacyValueRef = useRef(false); + const componentActiveRef = useRef(true); const compositionActiveRef = useRef(false); const compositionSnapshotPendingRef = useRef(false); const editorInstanceRef = useRef(null); @@ -107,6 +108,16 @@ export const CwlEditor = forwardRef( const onDestroyRef = useLatestRef(onDestroy); const formResetValueRef = useLatestRef(formResetValue); const onFormResetRef = useLatestRef(onFormReset); + + useEffect(() => { + componentActiveRef.current = true; + return () => { + componentActiveRef.current = false; + compositionActiveRef.current = false; + compositionSnapshotPendingRef.current = false; + }; + }, []); + const reportImageError = useCallback((error: Error) => { onImageErrorRef.current?.(error); }, [onImageErrorRef]); @@ -122,6 +133,7 @@ export const CwlEditor = forwardRef( }, []); const endComposition = useCallback(() => { queueMicrotask(() => { + if (!componentActiveRef.current) return; compositionActiveRef.current = false; if (compositionSnapshotPendingRef.current) { compositionSnapshotPendingRef.current = false; From 9494f584ffc136cf7d7eb616a53dbed25fff1ffa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 15:20:44 -0700 Subject: [PATCH 35/41] test(reliability): cover deferred sync teardown race --- ...Editor.controlledValueComposition.test.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx index 444db000..bd599b59 100644 --- a/src/components/CwlEditor.controlledValueComposition.test.tsx +++ b/src/components/CwlEditor.controlledValueComposition.test.tsx @@ -218,4 +218,57 @@ describe('CwlEditor controlled value during composition', () => { }); expect(onDocumentChange).not.toHaveBeenCalled(); }); + + it('drops a deferred controlled replacement when the editor is destroyed first', async () => { + let editor: Editor | undefined; + const onDocumentChange = vi.fn(); + const captureEditor = (instance: Editor) => { + editor = instance; + }; + + const { rerender, unmount } = render( + , + ); + await waitFor(() => expect(editor).toBeTruthy()); + + const editable = editor!.view.dom; + fireEvent.compositionStart(editable, { data: '' }); + act(() => { + editor!.chain().focus('end').insertContent(' composing').run(); + }); + expect(editor!.getText()).toBe('Original composing'); + expect(onDocumentChange).not.toHaveBeenCalled(); + + await act(async () => { + rerender( + , + ); + }); + expect(editor!.view.composing).toBe(true); + expect(editor!.getText()).toBe('Original composing'); + + act(() => { + editable.dispatchEvent( + new CompositionEvent('compositionend', { bubbles: true, data: '' }), + ); + unmount(); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onDocumentChange).not.toHaveBeenCalled(); + expect(editor!.getText()).toBe('Original composing'); + }); }); From 05bbe0221a51ebee492cd0dd81fc303a31a106ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:13:17 -0700 Subject: [PATCH 36/41] fix(editor): drop deferred sync after unmount --- src/components/CwlEditor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index e214b077..e6e24504 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -295,6 +295,7 @@ export const CwlEditor = forwardRef( if (!editor || !isControlled || emittingRef.current) return; const synchronizeValue = () => { + if (!componentActiveRef.current) return; const current = editorHtmlToValue(editor.getHTML(), mode); if (current !== value) { /* v8 ignore next -- isControlled guarantees value is defined. */ From 4b6d624706b0a7959e07f4ddb4afab9af048db81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:26:47 +0900 Subject: [PATCH 37/41] test(ci): cover event-specific Python matrix Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 7104fd66..209f4845 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,10 +50,14 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) + matrix_match = re.search(r"python-version:\s*(.+)", office_job) assert matrix_match is not None - matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) - assert matrix_versions == SUPPORTED_PYTHON_VERSIONS + pull_request_versions, push_versions = ( + tuple(re.findall(r'"(3\.\d+)"', versions)) + for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + ) + assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) + assert push_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From 306c18910890539d927286380d4cf0819b3ec310 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:32:54 +0900 Subject: [PATCH 38/41] test(ci): bind Python matrix to event Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 209f4845..a52ddec3 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,11 +50,16 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r"python-version:\s*(.+)", office_job) + matrix_match = re.search( + r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" + r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" + r"fromJSON\('(\[[^']+\])'\)\s*\}\}", + office_job, + ) assert matrix_match is not None pull_request_versions, push_versions = ( tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + for versions in matrix_match.groups() ) assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) assert push_versions == SUPPORTED_PYTHON_VERSIONS From e710cf4314c101ed384ef9340ada382fdefd9d35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:07:05 +0900 Subject: [PATCH 39/41] revert(ci): restore Office contract owner Remove the duplicated event-matrix test repair from the controlled-value lane. PR #405 remains the single writer for that shared CI contract. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- office/tests/test_python_support_contract.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index a52ddec3..7104fd66 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,19 +50,10 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search( - r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" - r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" - r"fromJSON\('(\[[^']+\])'\)\s*\}\}", - office_job, - ) + matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) assert matrix_match is not None - pull_request_versions, push_versions = ( - tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in matrix_match.groups() - ) - assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) - assert push_versions == SUPPORTED_PYTHON_VERSIONS + matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) + assert matrix_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From c80e2b3fb6410306f4bf2750bb1ed56d5db9d074 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:28:25 +0900 Subject: [PATCH 40/41] fix(editor): use the public core type in controlled synchronization The packed declaration verifier rejected the inherited helper's Editor import from TipTap React. Import the same public Editor type from TipTap core, matching the shared dependency foundation. Preserve the declaration boundary assertion; focused composition/policy/editability checks, build and full package verification pass. Signed-off-by: Seongho Bae --- src/components/editorControlledValueSync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/editorControlledValueSync.ts b/src/components/editorControlledValueSync.ts index 9659580f..50af8741 100644 --- a/src/components/editorControlledValueSync.ts +++ b/src/components/editorControlledValueSync.ts @@ -1,5 +1,5 @@ import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'; -import type { Editor } from '@tiptap/react'; +import type { Editor } from '@tiptap/core'; import type { EditorMode } from '../types.js'; import { editorValueToHtml } from './editorSerialization.js'; From f9a7879b66ea7c9590caa0d0465c84050791aa6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 16:10:00 +0900 Subject: [PATCH 41/41] test(editor): observe the final live document before teardown Signed-off-by: Seongho Bae --- .../CwlEditor.controlledValueComposition.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx index bd599b59..ecc27d54 100644 --- a/src/components/CwlEditor.controlledValueComposition.test.tsx +++ b/src/components/CwlEditor.controlledValueComposition.test.tsx @@ -222,6 +222,7 @@ describe('CwlEditor controlled value during composition', () => { it('drops a deferred controlled replacement when the editor is destroyed first', async () => { let editor: Editor | undefined; const onDocumentChange = vi.fn(); + const onDestroy = vi.fn((instance: Editor) => instance.getText()); const captureEditor = (instance: Editor) => { editor = instance; }; @@ -231,6 +232,7 @@ describe('CwlEditor controlled value during composition', () => { mode="markdown" value="Original" onDocumentChange={onDocumentChange} + onDestroy={onDestroy} onReady={captureEditor} />, ); @@ -250,6 +252,7 @@ describe('CwlEditor controlled value during composition', () => { mode="markdown" value="Host replacement" onDocumentChange={onDocumentChange} + onDestroy={onDestroy} onReady={captureEditor} />, ); @@ -268,7 +271,9 @@ describe('CwlEditor controlled value during composition', () => { await Promise.resolve(); await Promise.resolve(); }); + await waitFor(() => expect(editor!.isDestroyed).toBe(true)); expect(onDocumentChange).not.toHaveBeenCalled(); - expect(editor!.getText()).toBe('Original composing'); + expect(onDestroy).toHaveBeenCalledTimes(1); + expect(onDestroy).toHaveReturnedWith('Original composing'); }); });