Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 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
f11b7f0
fix(input): bind composition state before editor ready
seonghobae Aug 26, 2026
d0efcb0
fix(input): install composition guard before ready callback
seonghobae Aug 26, 2026
31002b1
test(input): localize composition snapshot regression
seonghobae Aug 26, 2026
9fdef51
fix(editor): suppress synthetic editability updates
seonghobae Aug 26, 2026
6d7d37b
test(input): require committed composition snapshot
seonghobae Aug 26, 2026
29d2136
fix(input): publish finalized composition snapshot
seonghobae Aug 27, 2026
7fe164e
fix(editor): preserve legacy initial change signal
seonghobae Aug 27, 2026
2dcab43
test(editor): lock composition callback boundaries
seonghobae Aug 27, 2026
581b0e6
test: expose deferred controlled composition snapshot ordering
seonghobae Aug 27, 2026
0f7d3ac
fix: preserve finalized composition snapshot ordering
seonghobae Aug 27, 2026
217b5ad
test(reliability): cover composition teardown race
seonghobae Aug 28, 2026
0a8fe0e
fix(reliability): cancel composition snapshots after teardown
seonghobae Aug 28, 2026
9494f58
test(reliability): cover deferred sync teardown race
seonghobae Aug 28, 2026
05bbe02
fix(editor): drop deferred sync after unmount
seonghobae Aug 29, 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
274 changes: 274 additions & 0 deletions src/components/CwlEditor.controlledValueComposition.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
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());
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);
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);
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(2);
expect(onDocumentChange.mock.calls[1]![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(
<CwlEditor
mode="markdown"
defaultValue="Original"
onDocumentChange={onDocumentChange}
onReady={(instance) => {
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',
);
});

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(
<CwlEditor
mode="markdown"
value="Original"
onDocumentChange={onDocumentChange}
onReady={captureEditor}
/>,
);
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(
<CwlEditor
mode="markdown"
value="Host replacement"
onDocumentChange={onDocumentChange}
onReady={captureEditor}
/>,
);
});
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',
);
});

it('drops a queued composition snapshot when the editor is destroyed first', async () => {
let editor: Editor | undefined;
const onDocumentChange = vi.fn();

const { unmount } = render(
<CwlEditor
mode="markdown"
defaultValue="Original"
onDocumentChange={onDocumentChange}
onReady={(instance) => {
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();
});

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(
<CwlEditor
mode="markdown"
value="Original"
onDocumentChange={onDocumentChange}
onReady={captureEditor}
/>,
);
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(
<CwlEditor
mode="markdown"
value="Host replacement"
onDocumentChange={onDocumentChange}
onReady={captureEditor}
/>,
);
});
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');
});
});
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();
});
});
Loading
Loading