Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9bb0f03
test: prove controlled value policy mutation is atomic
seonghobae Aug 11, 2026
ed41233
fix: preflight controlled value transaction policy
seonghobae Aug 11, 2026
35a101b
fix: keep controlled value synchronization atomic
seonghobae Aug 11, 2026
61917a0
fix: surface controlled-value rollback failures
seonghobae Aug 11, 2026
ebced02
test: cover controlled-value refusal and rollback paths
seonghobae Aug 11, 2026
a28bde9
test(editor): reject invalid runtime editable state
seonghobae Aug 12, 2026
fa0d0aa
fix(editor): validate runtime editable state
seonghobae Aug 12, 2026
9e02442
test(data-integrity): reject invalid toolbar visibility state
seonghobae Aug 12, 2026
3c9c16e
fix(data-integrity): validate toolbar visibility state
seonghobae Aug 12, 2026
5fe17e0
test(editor): define runtime document value RED
seonghobae Aug 12, 2026
58531c0
fix(test): keep document value RED product-specific
seonghobae Aug 12, 2026
16fe78d
fix(editor): validate runtime document values
seonghobae Aug 12, 2026
2fe2ce9
test(editor): define runtime reset document RED
seonghobae Aug 12, 2026
9fd9a28
fix(editor): validate native-form reset documents
seonghobae Aug 12, 2026
43d4f00
chore: sync controlled-value branch with current protected main
seonghobae Aug 17, 2026
89d766e
test(editor): reproduce composition state across read-only transition
seonghobae Aug 21, 2026
343d413
fix(editor): end composition before read-only transition
seonghobae Aug 21, 2026
576dc47
test(editor): reproduce controlled sync during composition
seonghobae Aug 23, 2026
f582e7c
fix(editor): defer controlled sync during composition
seonghobae Aug 23, 2026
f3b4c1d
chore(sync): integrate protected security baseline
seonghobae Aug 25, 2026
580ac1a
Merge fd75c835a2a7c5d9a1f57c3e080364237d69819a into f3b4c1dbaa48a94a5…
seonghobae Aug 25, 2026
3fd5157
chore(sync): integrate current protected main
seonghobae Aug 26, 2026
ff18207
test(input): reject intermediate composition snapshots
seonghobae Aug 26, 2026
d46640d
fix(input): suppress composition snapshots
seonghobae Aug 26, 2026
216c0aa
fix(input): track composition callback boundary
seonghobae Aug 26, 2026
39eb0dd
fix(input): observe native composition lifecycle
seonghobae Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/components/CwlEditor.controlledValueComposition.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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(
<CwlEditor mode="markdown" value="Original" onReady={captureEditor} />,
);
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(
<CwlEditor mode="markdown" value="First host value" onReady={captureEditor} />,
);
});
expect(editor!.view.composing).toBe(true);
expect(editor!.getText()).toBe('Original');

await act(async () => {
rerender(
<CwlEditor mode="markdown" value="Latest host value" onReady={captureEditor} />,
);
});
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(
<CwlEditor
mode="markdown"
defaultValue="Original"
onChange={onChange}
onDocumentChange={onDocumentChange}
onReady={(instance) => {
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();

Check failure on line 76 in src/components/CwlEditor.controlledValueComposition.test.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

src/components/CwlEditor.controlledValueComposition.test.tsx > CwlEditor controlled value during composition > keeps intermediate composition text out of document snapshot callbacks

AssertionError: expected "spy" to not be called at all, but actually been called 1 times Received: 1st spy call: Array [ Object { "editor": [Editor], "snapshot": [Object], }, ] Number of calls: 1 ❯ src/components/CwlEditor.controlledValueComposition.test.tsx:76:34

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',
);
});
});
32 changes: 32 additions & 0 deletions src/components/CwlEditor.editabilityComposition.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CwlEditor defaultValue="기준" editable onReady={captureEditor} />,
);
await waitFor(() => expect(editor).toBeTruthy());

const editable = document.querySelector('.ProseMirror') as HTMLElement;
fireEvent.compositionStart(editable, { data: '' });
expect(editor!.view.composing).toBe(true);

rerender(
<CwlEditor defaultValue="기준" editable={false} onReady={captureEditor} />,
);

await waitFor(() => expect(editor!.isEditable).toBe(false));
expect(editor!.view.composing).toBe(false);
expect(editor!.getText()).toBe('기준');
});
});
23 changes: 23 additions & 0 deletions src/components/CwlEditor.runtimeEditable.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CwlEditor editable={'false' as unknown as boolean} />,
),
).toThrowError(
new RangeError('editor editable state must be a boolean when provided'),
);
});

it('preserves omitted, explicitly editable, and explicitly read-only states', () => {
expect(() => renderToString(<CwlEditor />)).not.toThrow();
expect(() => renderToString(<CwlEditor editable />)).not.toThrow();
expect(() => renderToString(<CwlEditor editable={false} />)).not.toThrow();
});
});
25 changes: 25 additions & 0 deletions src/components/CwlEditor.runtimeToolbarVisibility.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CwlEditor hideToolbar={'false' as unknown as boolean} />,
),
).toThrowError(
new RangeError(
'editor toolbar visibility state must be a boolean when provided',
),
);
});

it('preserves omitted, visible, and hidden toolbar states', () => {
expect(() => renderToString(<CwlEditor />)).not.toThrow();
expect(() => renderToString(<CwlEditor hideToolbar={false} />)).not.toThrow();
expect(() => renderToString(<CwlEditor hideToolbar />)).not.toThrow();
});
});
88 changes: 81 additions & 7 deletions src/components/CwlEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,9 +66,32 @@ export const CwlEditor = forwardRef<CwlEditorHandle, CwlEditorProps>(
},
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<Editor | null>(null);
const modeRef = useLatestRef(mode);
const onChangeRef = useLatestRef(onChange);
Expand Down Expand Up @@ -144,6 +168,7 @@ export const CwlEditor = forwardRef<CwlEditorHandle, CwlEditorProps>(
},
onDestroy: () => {
const instance = editorInstanceRef.current!;
compositionActiveRef.current = false;
onDestroyRef.current?.(instance);
editorInstanceRef.current = null;
},
Expand All @@ -153,7 +178,11 @@ export const CwlEditor = forwardRef<CwlEditorHandle, CwlEditorProps>(
if (!valueListener && !snapshotListener) return;
emittingRef.current = true;
try {
if (snapshotListener) {
if (
snapshotListener &&
!compositionActiveRef.current &&
!instance.view.composing
) {
const snapshot = createEditorDocumentSnapshot(
instance,
modeRef.current,
Expand Down Expand Up @@ -193,7 +222,38 @@ export const CwlEditor = forwardRef<CwlEditorHandle, CwlEditorProps>(
useEditorHandle(ref, editor, modeRef);

useEffect(() => {
editor?.setEditable(editable);
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) {
// 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(() => {
Expand All @@ -209,12 +269,26 @@ export const CwlEditor = forwardRef<CwlEditorHandle, CwlEditorProps>(

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(
Expand Down
Loading
Loading