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(); + }); +});