diff --git a/src/components/CwlEditor.controlledValueComposition.test.tsx b/src/components/CwlEditor.controlledValueComposition.test.tsx
new file mode 100644
index 00000000..1131f706
--- /dev/null
+++ b/src/components/CwlEditor.controlledValueComposition.test.tsx
@@ -0,0 +1,129 @@
+import type { Editor } from '@tiptap/react';
+import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } 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');
+ });
+ });
+
+ 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());
+ 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();
+
+ 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',
+ );
+ });
+
+ 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',
+ );
+ });
+});
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('기준');
+ });
+});
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();
+ });
+});
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();
+ });
+});
diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx
index 598ac948..9913f78a 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';
@@ -65,9 +66,32 @@ 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 (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');
+ }
+ 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 ?? '';
const emittingRef = useRef(false);
+ const compositionActiveRef = useRef(false);
const editorInstanceRef = useRef(null);
const modeRef = useLatestRef(mode);
const onChangeRef = useLatestRef(onChange);
@@ -90,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],
@@ -140,10 +172,15 @@ export const CwlEditor = forwardRef(
},
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;
},
@@ -153,7 +190,11 @@ export const CwlEditor = forwardRef(
if (!valueListener && !snapshotListener) return;
emittingRef.current = true;
try {
- if (snapshotListener) {
+ if (
+ snapshotListener &&
+ !compositionActiveRef.current &&
+ !instance.view.composing
+ ) {
const snapshot = createEditorDocumentSnapshot(
instance,
modeRef.current,
@@ -193,7 +234,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, false);
}, [editor, editable]);
useEffect(() => {
@@ -209,12 +260,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. */
- const next = editorValueToHtml(value ?? '', mode);
- editor.commands.setContent(next, false);
+
+ 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(
diff --git a/src/components/CwlEditorControlledValuePolicy.test.tsx b/src/components/CwlEditorControlledValuePolicy.test.tsx
new file mode 100644
index 00000000..f9ad438d
--- /dev/null
+++ b/src/components/CwlEditorControlledValuePolicy.test.tsx
@@ -0,0 +1,151 @@
+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);
+
+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 preview policy transforms the replacement', async () => {
+ const controlled = await renderControlledEditor();
+ let transformedReplacementCount = 0;
+ controlled.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 controlled.replaceValue();
+
+ await waitFor(() => {
+ expect(transformedReplacementCount).toBeGreaterThan(0);
+ 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');
+ });
+ });
+});
diff --git a/src/components/editorControlledValueSync.ts b/src/components/editorControlledValueSync.ts
new file mode 100644
index 00000000..e7d7c802
--- /dev/null
+++ b/src/components/editorControlledValueSync.ts
@@ -0,0 +1,66 @@
+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 {
+ editor.view.updateState(originalState);
+}
diff --git a/src/components/editorDocumentValue.runtime.test.tsx b/src/components/editorDocumentValue.runtime.test.tsx
new file mode 100644
index 00000000..62f7503d
--- /dev/null
+++ b/src/components/editorDocumentValue.runtime.test.tsx
@@ -0,0 +1,51 @@
+// @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('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(
+ ,
+ ),
+ ).not.toThrow();
+ expect(() =>
+ renderToString(
+ ,
+ ),
+ ).not.toThrow();
+ });
+});