From 1d08f70210e5abdd68e2d83f4096117eab389973 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 23:46:43 +0900 Subject: [PATCH 01/22] test(editor): define imperative history handle RED --- .../CwlEditorHistoryHandle.test.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/components/CwlEditorHistoryHandle.test.tsx diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx new file mode 100644 index 0000000..b432304 --- /dev/null +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -0,0 +1,65 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { forwardRef, createRef, useRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import type { CwlEditorHandle, EditorMode } from '../types.js'; +import { CwlEditor } from './CwlEditor.js'; +import { useEditorHandle } from './useEditorHandle.js'; + +type HistoryHandle = CwlEditorHandle & { + canUndo?: () => boolean; + undo?: () => boolean; + canRedo?: () => boolean; + redo?: () => boolean; +}; + +const NullEditorHandleHarness = forwardRef((_, ref) => { + const modeRef = useRef('markdown'); + useEditorHandle(ref, null, modeRef); + return null; +}); + +describe('CwlEditor imperative history control', () => { + it('exposes host-safe undo/redo capability and execution on a real editor', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + + const handle = editorRef.current as HistoryHandle; + expect(typeof handle.canUndo).toBe('function'); + expect(typeof handle.undo).toBe('function'); + expect(typeof handle.canRedo).toBe('function'); + expect(typeof handle.redo).toBe('function'); + + await act(async () => { + handle.getEditor()!.chain().focus('end').insertContent(' updated').run(); + }); + expect(handle.getMarkdown()).toBe('Original updated'); + expect(handle.canUndo!()).toBe(true); + + await act(async () => { + expect(handle.undo!()).toBe(true); + }); + expect(handle.getMarkdown()).toBe('Original'); + expect(handle.canRedo!()).toBe(true); + + await act(async () => { + expect(handle.redo!()).toBe(true); + }); + expect(handle.getMarkdown()).toBe('Original updated'); + }); + + it('fails closed when the shared handle has no active editor instance', () => { + const editorRef = createRef(); + render(); + + const handle = editorRef.current as HistoryHandle; + expect(typeof handle.canUndo).toBe('function'); + expect(typeof handle.undo).toBe('function'); + expect(typeof handle.canRedo).toBe('function'); + expect(typeof handle.redo).toBe('function'); + expect(handle.canUndo!()).toBe(false); + expect(handle.undo!()).toBe(false); + expect(handle.canRedo!()).toBe(false); + expect(handle.redo!()).toBe(false); + }); +}); From ce8ba581204af75522687dcb77199fea2ac1aca6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 23:54:39 +0900 Subject: [PATCH 02/22] feat(editor): expose imperative history commands --- src/components/useEditorHandle.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 710fe88..a9bf7bf 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -58,6 +58,10 @@ export function useEditorHandle( blur: () => { editor?.commands.blur(); }, + canUndo: () => editor?.can().undo() ?? false, + undo: () => editor?.chain().focus().undo().run() ?? false, + canRedo: () => editor?.can().redo() ?? false, + redo: () => editor?.chain().focus().redo().run() ?? false, getValue: () => { if (!editor) return ''; return editorHtmlToValue(editor.getHTML(), modeRef.current); From d2539b9247b81dc542d21e114135453cf97f7432 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 23:56:00 +0900 Subject: [PATCH 03/22] feat(editor): type imperative history controls --- src/types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/types.ts b/src/types.ts index 0292d4a..66c9916 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,20 @@ export interface CwlEditorHandle { focus(): void; /** Blur the editable surface. */ blur(): void; + /** Whether the active editor's registered history can currently undo. */ + canUndo(): boolean; + /** + * Run the active editor's registered undo command and return whether it ran. + * Returns `false` before editor creation and does not imply durable persistence. + */ + undo(): boolean; + /** Whether the active editor's registered history can currently redo. */ + canRedo(): boolean; + /** + * Run the active editor's registered redo command and return whether it ran. + * Returns `false` before editor creation and does not imply durable persistence. + */ + redo(): boolean; /** Serialized document in the active `mode` (`markdown` or `html`). */ getValue(): string; /** Always HTML (ProseMirror document dump). */ From fed2fc1c764210705219ca6e1cf5c1fb48e31a6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 00:08:04 +0900 Subject: [PATCH 04/22] test(editor): bind history handle to collaborative undo manager --- src/collaboration/CollaborativeCwlEditor.test.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/collaboration/CollaborativeCwlEditor.test.tsx b/src/collaboration/CollaborativeCwlEditor.test.tsx index 5f4e3b4..a3e2d1d 100644 --- a/src/collaboration/CollaborativeCwlEditor.test.tsx +++ b/src/collaboration/CollaborativeCwlEditor.test.tsx @@ -262,7 +262,7 @@ describe('CollaborativeCwlEditor contract', () => { }); describe('CollaborativeCwlEditor convergence and lifecycle', () => { - it('converges rich text, shares undo, merges concurrent edits, and preserves host-owned documents across remount', async () => { + it('converges rich text, shares imperative undo/redo, merges concurrent edits, and preserves host-owned documents across remount', async () => { const leftDocument = new Y.Doc(); const rightDocument = new Y.Doc(); let disconnect = connectDocuments(leftDocument, rightDocument); @@ -307,7 +307,18 @@ describe('CollaborativeCwlEditor convergence and lifecycle', () => { await waitFor(() => expect(rightRef.current!.getHTML()).toContain('Undo this sentence'), ); - act(() => leftRef.current!.getEditor()!.commands.undo()); + expect(leftRef.current!.canUndo()).toBe(true); + act(() => expect(leftRef.current!.undo()).toBe(true)); + await waitFor(() => + expect(rightRef.current!.getHTML()).not.toContain('Undo this sentence'), + ); + expect(leftRef.current!.canRedo()).toBe(true); + act(() => expect(leftRef.current!.redo()).toBe(true)); + await waitFor(() => + expect(rightRef.current!.getHTML()).toContain('Undo this sentence'), + ); + expect(leftRef.current!.canUndo()).toBe(true); + act(() => expect(leftRef.current!.undo()).toBe(true)); await waitFor(() => expect(rightRef.current!.getHTML()).not.toContain('Undo this sentence'), ); From 45e468628e3f32cfd430f9c3f01a4d3842de453c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 00:22:51 +0900 Subject: [PATCH 05/22] test(editor): bind history proof to public handle type --- .../CwlEditorHistoryHandle.test.tsx | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx index b432304..083dd22 100644 --- a/src/components/CwlEditorHistoryHandle.test.tsx +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -5,13 +5,6 @@ import type { CwlEditorHandle, EditorMode } from '../types.js'; import { CwlEditor } from './CwlEditor.js'; import { useEditorHandle } from './useEditorHandle.js'; -type HistoryHandle = CwlEditorHandle & { - canUndo?: () => boolean; - undo?: () => boolean; - canRedo?: () => boolean; - redo?: () => boolean; -}; - const NullEditorHandleHarness = forwardRef((_, ref) => { const modeRef = useRef('markdown'); useEditorHandle(ref, null, modeRef); @@ -24,7 +17,7 @@ describe('CwlEditor imperative history control', () => { render(); await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); - const handle = editorRef.current as HistoryHandle; + const handle = editorRef.current!; expect(typeof handle.canUndo).toBe('function'); expect(typeof handle.undo).toBe('function'); expect(typeof handle.canRedo).toBe('function'); @@ -34,16 +27,16 @@ describe('CwlEditor imperative history control', () => { handle.getEditor()!.chain().focus('end').insertContent(' updated').run(); }); expect(handle.getMarkdown()).toBe('Original updated'); - expect(handle.canUndo!()).toBe(true); + expect(handle.canUndo()).toBe(true); await act(async () => { - expect(handle.undo!()).toBe(true); + expect(handle.undo()).toBe(true); }); expect(handle.getMarkdown()).toBe('Original'); - expect(handle.canRedo!()).toBe(true); + expect(handle.canRedo()).toBe(true); await act(async () => { - expect(handle.redo!()).toBe(true); + expect(handle.redo()).toBe(true); }); expect(handle.getMarkdown()).toBe('Original updated'); }); @@ -52,14 +45,14 @@ describe('CwlEditor imperative history control', () => { const editorRef = createRef(); render(); - const handle = editorRef.current as HistoryHandle; + const handle = editorRef.current!; expect(typeof handle.canUndo).toBe('function'); expect(typeof handle.undo).toBe('function'); expect(typeof handle.canRedo).toBe('function'); expect(typeof handle.redo).toBe('function'); - expect(handle.canUndo!()).toBe(false); - expect(handle.undo!()).toBe(false); - expect(handle.canRedo!()).toBe(false); - expect(handle.redo!()).toBe(false); + expect(handle.canUndo()).toBe(false); + expect(handle.undo()).toBe(false); + expect(handle.canRedo()).toBe(false); + expect(handle.redo()).toBe(false); }); }); From 6c6229f5eaaa88fc72d04d1a4e4537d65136823f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 00:25:16 +0900 Subject: [PATCH 06/22] test(editor): fail closed after editor destruction --- .../CwlEditorHistoryHandle.test.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx index 083dd22..5814dc2 100644 --- a/src/components/CwlEditorHistoryHandle.test.tsx +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -55,4 +55,24 @@ describe('CwlEditor imperative history control', () => { expect(handle.canRedo()).toBe(false); expect(handle.redo()).toBe(false); }); + + it('fails closed on a retained handle after its editor is destroyed', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + + const handle = editorRef.current!; + const editor = handle.getEditor()!; + await act(async () => { + editor.chain().focus('end').insertContent(' updated').run(); + }); + expect(handle.canUndo()).toBe(true); + + act(() => editor.destroy()); + expect(editor.isDestroyed).toBe(true); + expect(handle.canUndo()).toBe(false); + expect(handle.undo()).toBe(false); + expect(handle.canRedo()).toBe(false); + expect(handle.redo()).toBe(false); + }); }); From fba20b7decc66965479e465b85df5cbb1e3ddffb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:07:40 +0900 Subject: [PATCH 07/22] fix(editor): fail closed history after destruction --- src/components/useEditorHandle.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index a9bf7bf..6553461 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -58,10 +58,18 @@ export function useEditorHandle( blur: () => { editor?.commands.blur(); }, - canUndo: () => editor?.can().undo() ?? false, - undo: () => editor?.chain().focus().undo().run() ?? false, - canRedo: () => editor?.can().redo() ?? false, - redo: () => editor?.chain().focus().redo().run() ?? false, + canUndo: () => + editor && !editor.isDestroyed ? editor.can().undo() : false, + undo: () => + editor && !editor.isDestroyed + ? editor.chain().focus().undo().run() + : false, + canRedo: () => + editor && !editor.isDestroyed ? editor.can().redo() : false, + redo: () => + editor && !editor.isDestroyed + ? editor.chain().focus().redo().run() + : false, getValue: () => { if (!editor) return ''; return editorHtmlToValue(editor.getHTML(), modeRef.current); From a178de32952173b866501683dcdd243880ef05e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:11:29 +0900 Subject: [PATCH 08/22] test(editor): require null editor after destruction --- src/components/CwlEditorHistoryHandle.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx index 5814dc2..48ab093 100644 --- a/src/components/CwlEditorHistoryHandle.test.tsx +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -70,6 +70,7 @@ describe('CwlEditor imperative history control', () => { act(() => editor.destroy()); expect(editor.isDestroyed).toBe(true); + expect(handle.getEditor()).toBeNull(); expect(handle.canUndo()).toBe(false); expect(handle.undo()).toBe(false); expect(handle.canRedo()).toBe(false); From ced6b143265d56b4d340a35c77e180f7efcf4245 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:16:19 +0900 Subject: [PATCH 09/22] fix(editor): hide destroyed editor from host handle --- src/components/useEditorHandle.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 6553461..ec69b55 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -51,7 +51,8 @@ export function useEditorHandle( useImperativeHandle( ref, (): CwlEditorHandle => ({ - getEditor: () => editor, + getEditor: () => + editor && !editor.isDestroyed ? editor : null, focus: () => { editor?.chain().focus().run(); }, From 2fc4d9f83c1405697b23402689546d4a48e880c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:32:44 +0900 Subject: [PATCH 10/22] test(editor): fail closed after handle editor destruction --- .../CwlEditorHistoryHandle.test.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx index 48ab093..ba0ed46 100644 --- a/src/components/CwlEditorHistoryHandle.test.tsx +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -75,5 +75,29 @@ describe('CwlEditor imperative history control', () => { expect(handle.undo()).toBe(false); expect(handle.canRedo()).toBe(false); expect(handle.redo()).toBe(false); + + expect(handle.getValue()).toBe(''); + expect(handle.getHTML()).toBe(''); + expect(handle.getMarkdown()).toBe(''); + expect(handle.getSnapshot()).toEqual({ + mode: 'markdown', + value: '', + html: '', + markdown: '', + plainText: '', + documentJson: null, + isEmpty: true, + }); + expect(handle.getDocumentEnvelope()).toBeNull(); + expect(handle.getDocumentEnvelopeJson()).toBe(''); + expect(handle.getDocumentEnvelopeBytes()).toEqual(new Uint8Array()); + expect(handle.validateDocumentEnvelope({})).toBe(false); + expect(handle.validateDocumentEnvelopeBytes(new Uint8Array())).toBe(false); + expect(handle.validateDocumentJson({ type: 'doc', content: [] })).toBe(false); + expect(handle.isEmpty()).toBe(true); + expect(() => handle.focus()).not.toThrow(); + expect(() => handle.blur()).not.toThrow(); + expect(() => handle.setValue('ignored')).not.toThrow(); + expect(() => handle.clear()).not.toThrow(); }); }); From 023841d62a5e67741be020eace8d32c784cb54cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:36:52 +0900 Subject: [PATCH 11/22] fix(editor): make retained handle reads lifecycle-safe --- src/components/useEditorHandle.ts | 105 +++++++++++++++++------------- 1 file changed, 58 insertions(+), 47 deletions(-) diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index ec69b55..a598015 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -42,6 +42,11 @@ function createCurrentDocumentEnvelope( return createDocumentEnvelope(editor.getJSON(), limits); } +/** Return the current usable editor instance, if one still exists. */ +function activeEditor(editor: Editor | null): Editor | null { + return editor && !editor.isDestroyed ? editor : null; +} + /** Expose the stable host-control contract shared by editor surfaces. */ export function useEditorHandle( ref: ForwardedRef, @@ -51,51 +56,50 @@ export function useEditorHandle( useImperativeHandle( ref, (): CwlEditorHandle => ({ - getEditor: () => - editor && !editor.isDestroyed ? editor : null, + getEditor: () => activeEditor(editor), focus: () => { - editor?.chain().focus().run(); + activeEditor(editor)?.chain().focus().run(); }, blur: () => { - editor?.commands.blur(); - }, - canUndo: () => - editor && !editor.isDestroyed ? editor.can().undo() : false, - undo: () => - editor && !editor.isDestroyed - ? editor.chain().focus().undo().run() - : false, - canRedo: () => - editor && !editor.isDestroyed ? editor.can().redo() : false, - redo: () => - editor && !editor.isDestroyed - ? editor.chain().focus().redo().run() - : false, + activeEditor(editor)?.commands.blur(); + }, + canUndo: () => activeEditor(editor)?.can().undo() ?? false, + undo: () => activeEditor(editor)?.chain().focus().undo().run() ?? false, + canRedo: () => activeEditor(editor)?.can().redo() ?? false, + redo: () => activeEditor(editor)?.chain().focus().redo().run() ?? false, getValue: () => { - if (!editor) return ''; - return editorHtmlToValue(editor.getHTML(), modeRef.current); + const current = activeEditor(editor); + if (!current) return ''; + return editorHtmlToValue(current.getHTML(), modeRef.current); }, - getHTML: () => editor?.getHTML() ?? '', + getHTML: () => activeEditor(editor)?.getHTML() ?? '', getMarkdown: () => { - if (!editor) return ''; - return editorHtmlToValue(editor.getHTML(), 'markdown'); + const current = activeEditor(editor); + if (!current) return ''; + return editorHtmlToValue(current.getHTML(), 'markdown'); }, getSnapshot: () => - createEditorDocumentSnapshot(editor, modeRef.current), - getDocumentEnvelope: (limits) => - editor ? createCurrentDocumentEnvelope(editor, limits) : null, - getDocumentEnvelopeJson: (limits) => - editor + createEditorDocumentSnapshot(activeEditor(editor), modeRef.current), + getDocumentEnvelope: (limits) => { + const current = activeEditor(editor); + return current ? createCurrentDocumentEnvelope(current, limits) : null; + }, + getDocumentEnvelopeJson: (limits) => { + const current = activeEditor(editor); + return current ? serializeValidatedDocumentEnvelope( - createCurrentDocumentEnvelope(editor, limits), + createCurrentDocumentEnvelope(current, limits), ) - : '', - getDocumentEnvelopeBytes: (limits) => - editor + : ''; + }, + getDocumentEnvelopeBytes: (limits) => { + const current = activeEditor(editor); + return current ? encodeValidatedDocumentEnvelope( - createCurrentDocumentEnvelope(editor, limits), + createCurrentDocumentEnvelope(current, limits), ) - : new Uint8Array(), + : new Uint8Array(); + }, getDocumentEnvelopeRevision: (limits, digestProvider) => editor ? createValidatedDocumentEnvelopeRevision( @@ -142,20 +146,25 @@ export function useEditorHandle( return Object.freeze({ revision, selector, textProjection }); }, setValue: (next: string) => { - if (!editor) return; - editor.commands.setContent( + const current = activeEditor(editor); + if (!current) return; + current.commands.setContent( editorValueToHtml(next, modeRef.current), false, ); }, - validateDocumentEnvelope: (source, limits) => - editor - ? validateDocumentEnvelopeForEditor(editor, source, limits) - : false, - validateDocumentEnvelopeBytes: (source, limits) => - editor - ? validateDocumentEnvelopeBytesForEditor(editor, source, limits) - : false, + validateDocumentEnvelope: (source, limits) => { + const current = activeEditor(editor); + return current + ? validateDocumentEnvelopeForEditor(current, source, limits) + : false; + }, + validateDocumentEnvelopeBytes: (source, limits) => { + const current = activeEditor(editor); + return current + ? validateDocumentEnvelopeBytesForEditor(current, source, limits) + : false; + }, restoreDocumentEnvelope: (source, limits) => editor ? restoreDocumentEnvelope(editor, source, limits) : null, restoreDocumentEnvelopeBytes: (source, limits) => @@ -190,8 +199,10 @@ export function useEditorHandle( digestProvider, ) : Promise.resolve(null), - validateDocumentJson: (documentJson) => - editor ? validateDocumentJson(editor, documentJson) : false, + validateDocumentJson: (documentJson) => { + const current = activeEditor(editor); + return current ? validateDocumentJson(current, documentJson) : false; + }, setDocumentJson: (documentJson) => { if (!editor) return; const documentNode = parseDocumentJsonForEditor(editor, documentJson); @@ -210,9 +221,9 @@ export function useEditorHandle( editor.chain().focus().insertContent(documentJson).run(); }, clear: () => { - editor?.commands.clearContent(true); + activeEditor(editor)?.commands.clearContent(true); }, - isEmpty: () => editor?.isEmpty ?? true, + isEmpty: () => activeEditor(editor)?.isEmpty ?? true, }), [editor, modeRef], ); From 554140d40035d5af4f006ad123d3bcdb5b55d261 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:40:37 +0900 Subject: [PATCH 12/22] test(editor): cover full retained-handle lifecycle boundary --- .../CwlEditorHistoryHandle.test.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx index ba0ed46..293fa3b 100644 --- a/src/components/CwlEditorHistoryHandle.test.tsx +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -91,13 +91,37 @@ describe('CwlEditor imperative history control', () => { expect(handle.getDocumentEnvelope()).toBeNull(); expect(handle.getDocumentEnvelopeJson()).toBe(''); expect(handle.getDocumentEnvelopeBytes()).toEqual(new Uint8Array()); + await expect(handle.getDocumentEnvelopeRevision()).resolves.toBeNull(); + await expect( + handle.getDocumentEnvelopeRevisionEvidence(), + ).resolves.toBeNull(); + await expect(handle.getSelectionRevisionEvidence()).resolves.toBeNull(); + await expect(handle.getTextPositionSelectorEvidence()).resolves.toBeNull(); expect(handle.validateDocumentEnvelope({})).toBe(false); expect(handle.validateDocumentEnvelopeBytes(new Uint8Array())).toBe(false); + expect(handle.restoreDocumentEnvelope({})).toBeNull(); + expect(handle.restoreDocumentEnvelopeBytes(new Uint8Array())).toBeNull(); + await expect( + handle.restoreDocumentEnvelopeIfMatch('"sha256-deadbeef"', {}), + ).resolves.toBeNull(); + await expect( + handle.restoreDocumentEnvelopeBytesIfMatch( + '"sha256-deadbeef"', + new Uint8Array(), + ), + ).resolves.toBeNull(); expect(handle.validateDocumentJson({ type: 'doc', content: [] })).toBe(false); expect(handle.isEmpty()).toBe(true); expect(() => handle.focus()).not.toThrow(); expect(() => handle.blur()).not.toThrow(); expect(() => handle.setValue('ignored')).not.toThrow(); + expect(() => + handle.setDocumentJson({ type: 'doc', content: [] }), + ).not.toThrow(); + expect(() => handle.insertValue('ignored')).not.toThrow(); + expect(() => + handle.insertDocumentJson({ type: 'paragraph' }), + ).not.toThrow(); expect(() => handle.clear()).not.toThrow(); }); }); From 895d2074f5b7ca148d4ea58595bfd394cf36ffd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:44:06 +0900 Subject: [PATCH 13/22] fix(editor): isolate retained handles after editor destruction --- src/components/useEditorHandle.ts | 81 +++++++++++++++++++------------ 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index a598015..91703fd 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -100,23 +100,28 @@ export function useEditorHandle( ) : new Uint8Array(); }, - getDocumentEnvelopeRevision: (limits, digestProvider) => - editor + getDocumentEnvelopeRevision: (limits, digestProvider) => { + const current = activeEditor(editor); + return current ? createValidatedDocumentEnvelopeRevision( - createCurrentDocumentEnvelope(editor, limits), + createCurrentDocumentEnvelope(current, limits), digestProvider, ) - : Promise.resolve(null), - getDocumentEnvelopeRevisionEvidence: (limits, digestProvider) => - editor + : Promise.resolve(null); + }, + getDocumentEnvelopeRevisionEvidence: (limits, digestProvider) => { + const current = activeEditor(editor); + return current ? createValidatedDocumentEnvelopeRevisionEvidence( - createCurrentDocumentEnvelope(editor, limits), + createCurrentDocumentEnvelope(current, limits), digestProvider, ) - : Promise.resolve(null), + : Promise.resolve(null); + }, getSelectionRevisionEvidence: async (limits, digestProvider) => { - if (!editor) return null; - const state = editor.state; + const current = activeEditor(editor); + if (!current) return null; + const state = current.state; const selection = Object.freeze({ anchor: state.selection.anchor, head: state.selection.head, @@ -132,8 +137,9 @@ export function useEditorHandle( return Object.freeze({ revision, selection }); }, getTextPositionSelectorEvidence: async (limits, digestProvider) => { - if (!editor) return null; - const state = editor.state; + const current = activeEditor(editor); + if (!current) return null; + const state = current.state; const { selector, textProjection } = createTextPositionSelector( state.doc, state.selection, @@ -165,60 +171,73 @@ export function useEditorHandle( ? validateDocumentEnvelopeBytesForEditor(current, source, limits) : false; }, - restoreDocumentEnvelope: (source, limits) => - editor ? restoreDocumentEnvelope(editor, source, limits) : null, - restoreDocumentEnvelopeBytes: (source, limits) => - editor ? restoreDocumentEnvelopeBytes(editor, source, limits) : null, + restoreDocumentEnvelope: (source, limits) => { + const current = activeEditor(editor); + return current ? restoreDocumentEnvelope(current, source, limits) : null; + }, + restoreDocumentEnvelopeBytes: (source, limits) => { + const current = activeEditor(editor); + return current + ? restoreDocumentEnvelopeBytes(current, source, limits) + : null; + }, restoreDocumentEnvelopeIfMatch: ( expectedStrongEntityTag, source, limits, digestProvider, - ) => - editor + ) => { + const current = activeEditor(editor); + return current ? restoreDocumentEnvelopeIfMatch( - editor, + current, expectedStrongEntityTag, source, limits, digestProvider, ) - : Promise.resolve(null), + : Promise.resolve(null); + }, restoreDocumentEnvelopeBytesIfMatch: ( expectedStrongEntityTag, source, limits, digestProvider, - ) => - editor + ) => { + const current = activeEditor(editor); + return current ? restoreDocumentEnvelopeBytesIfMatch( - editor, + current, expectedStrongEntityTag, source, limits, digestProvider, ) - : Promise.resolve(null), + : Promise.resolve(null); + }, validateDocumentJson: (documentJson) => { const current = activeEditor(editor); return current ? validateDocumentJson(current, documentJson) : false; }, setDocumentJson: (documentJson) => { - if (!editor) return; - const documentNode = parseDocumentJsonForEditor(editor, documentJson); - editor.commands.setContent(documentNode, false); + const current = activeEditor(editor); + if (!current) return; + const documentNode = parseDocumentJsonForEditor(current, documentJson); + current.commands.setContent(documentNode, false); }, insertValue: (next: string) => { - if (!editor) return; - editor + const current = activeEditor(editor); + if (!current) return; + current .chain() .focus() .insertContent(editorValueToHtml(next, modeRef.current)) .run(); }, insertDocumentJson: (documentJson) => { - if (!editor) return; - editor.chain().focus().insertContent(documentJson).run(); + const current = activeEditor(editor); + if (!current) return; + current.chain().focus().insertContent(documentJson).run(); }, clear: () => { activeEditor(editor)?.commands.clearContent(true); From 8715af18fb78deaa0c6b86d600876f2506e584f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:12:39 +0900 Subject: [PATCH 14/22] test(editor): prove history capability transitions --- src/components/CwlEditorHistoryHandle.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx index 293fa3b..256fe72 100644 --- a/src/components/CwlEditorHistoryHandle.test.tsx +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -22,23 +22,29 @@ describe('CwlEditor imperative history control', () => { expect(typeof handle.undo).toBe('function'); expect(typeof handle.canRedo).toBe('function'); expect(typeof handle.redo).toBe('function'); + expect(handle.canUndo()).toBe(false); + expect(handle.canRedo()).toBe(false); await act(async () => { handle.getEditor()!.chain().focus('end').insertContent(' updated').run(); }); expect(handle.getMarkdown()).toBe('Original updated'); expect(handle.canUndo()).toBe(true); + expect(handle.canRedo()).toBe(false); await act(async () => { expect(handle.undo()).toBe(true); }); expect(handle.getMarkdown()).toBe('Original'); + expect(handle.canUndo()).toBe(false); expect(handle.canRedo()).toBe(true); await act(async () => { expect(handle.redo()).toBe(true); }); expect(handle.getMarkdown()).toBe('Original updated'); + expect(handle.canUndo()).toBe(true); + expect(handle.canRedo()).toBe(false); }); it('fails closed when the shared handle has no active editor instance', () => { From a5732c7656777881034ba1e2f50de8421a549234 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:19:23 +0900 Subject: [PATCH 15/22] docs(editor): document bounded imperative history control --- docs/imperative-history-control.md | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/imperative-history-control.md diff --git a/docs/imperative-history-control.md b/docs/imperative-history-control.md new file mode 100644 index 0000000..ab482e9 --- /dev/null +++ b/docs/imperative-history-control.md @@ -0,0 +1,57 @@ +# Imperative undo and redo control + +Status: `implemented_on_active_pr` + +Inkspan exposes a narrow history-control surface on `CwlEditorHandle` for hosts that replace the built-in toolbar, provide an application command palette, or map desktop menu actions to the editor. This active-PR contract is not protected-main authority until the implementing pull request is integrated. + +## Public handle contract + +`CwlEditorHandle` provides four boolean-returning operations: + +```ts +const handle = editorRef.current; + +if (handle?.canUndo()) { + handle.undo(); +} + +if (handle?.canRedo()) { + handle.redo(); +} +``` + +- `canUndo()` reports whether the currently registered editor history can undo. +- `undo()` asks that history implementation to undo and returns whether the command ran. +- `canRedo()` reports whether the currently registered editor history can redo. +- `redo()` asks that history implementation to redo and returns whether the command ran. + +A newly created editor with no history reports both capabilities as `false`. After a document-changing edit, undo capability becomes available. A successful undo exposes redo capability, and a successful redo restores undo capability when history remains. + +## Lifecycle safety + +A retained host handle must not keep a destroyed TipTap editor authoritative. Before an editor exists or after its editor has been destroyed, all four history methods return `false` without throwing. The same active-editor guard is used by the rest of the retained-handle read, mutation, envelope, revision, selection, and restore surface so stale destroyed-editor state cannot be presented as current Inkspan state. + +Hosts should still dispose of retained refs normally; fail-closed behavior is a defensive boundary, not a replacement for application lifecycle management. + +## Standalone and collaborative history + +Standalone `CwlEditor` delegates to the history commands already registered by Inkspan's TipTap/StarterKit configuration. `CollaborativeCwlEditor` delegates through the same public handle to the collaboration-aware history commands registered with Yjs. Inkspan does not re-enable a second StarterKit local history engine in collaboration mode. + +This means a host can wire one stable command surface without reaching into TipTap internals while preserving the history semantics selected by the active Inkspan editor configuration. + +## Authority boundary + +The four methods are local editor commands only. A successful `undo()` or `redo()` does **not** prove a durable save, actor identity, authorization, timestamp, audit event, persistence result, collaboration-provider acknowledgement, or server-side revision transition. Normal document-change callbacks and host persistence workflows remain responsible for whatever durable consequences the embedding application requires. + +Inkspan does not acquire transport, authentication, authorization, tenancy, durable persistence, retention, provider-room, model-use, or audit authority through this API. + +## Acceptance evidence + +The implementing branch is expected to prove: + +- initial, post-edit, post-undo, and post-redo capability transitions on a real editor; +- no-active-editor and destroyed-editor fail-closed behavior; +- collaborative convergence through two in-memory `Y.Doc` editors without duplicate local history; +- unchanged package consumers, accessibility/browser evidence, Office evidence, security scans, and exact 100% owned-production statement/branch/function/line coverage. + +Until the branch reaches protected `main`, downstream products must treat this document as active-PR guidance rather than a shipped-version guarantee. From 275efd202276e35c3050728a869f20f6478e3145 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:19:59 +0900 Subject: [PATCH 16/22] test(package): require history handle in packed consumer --- src/imperativeHistoryPackageContract.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/imperativeHistoryPackageContract.test.ts diff --git a/src/imperativeHistoryPackageContract.test.ts b/src/imperativeHistoryPackageContract.test.ts new file mode 100644 index 0000000..fa9a06a --- /dev/null +++ b/src/imperativeHistoryPackageContract.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const verifier = readFileSync( + resolve(process.cwd(), 'tests/package/verify-package.mjs'), + 'utf8', +); + +describe('imperative history packed-package contract', () => { + it('compiles every public history operation through the strict packed consumer', () => { + expect(verifier).toContain('editorHandle.canUndo()'); + expect(verifier).toContain('editorHandle.undo()'); + expect(verifier).toContain('editorHandle.canRedo()'); + expect(verifier).toContain('editorHandle.redo()'); + }); +}); From 836a72220ca66f390f660fb3eb71eebfaa84b621 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:21:03 +0900 Subject: [PATCH 17/22] test(package): compile imperative history handle consumer --- .../verify-imperative-history-package.mjs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/package/verify-imperative-history-package.mjs diff --git a/tests/package/verify-imperative-history-package.mjs b/tests/package/verify-imperative-history-package.mjs new file mode 100644 index 0000000..281c5fa --- /dev/null +++ b/tests/package/verify-imperative-history-package.mjs @@ -0,0 +1,61 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const verificationDirectory = mkdtempSync( + join(repositoryRoot, '.history-package-verification-'), +); + +try { + const consumerPath = join(verificationDirectory, 'consumer.ts'); + writeFileSync( + consumerPath, + `import type { CwlEditorHandle } from '@contextualwisdomlab/cwl-editor'; + +declare const editorHandle: CwlEditorHandle; + +const undoAvailable: boolean = editorHandle.canUndo(); +const undoExecuted: boolean = editorHandle.undo(); +const redoAvailable: boolean = editorHandle.canRedo(); +const redoExecuted: boolean = editorHandle.redo(); + +void [undoAvailable, undoExecuted, redoAvailable, redoExecuted]; +`, + 'utf8', + ); + + execFileSync( + 'pnpm', + [ + 'exec', + 'tsc', + '--noEmit', + '--strict', + '--skipLibCheck', + 'false', + '--module', + 'NodeNext', + '--moduleResolution', + 'NodeNext', + '--target', + 'ES2022', + '--lib', + 'ES2022,DOM,DOM.Iterable', + consumerPath, + ], + { + cwd: repositoryRoot, + stdio: 'inherit', + }, + ); + + console.log('Verified imperative history methods through public package declarations.'); +} finally { + rmSync(verificationDirectory, { recursive: true, force: true }); +} From 08d3708220a7f0210f2e11f5c977e22d02fb4c03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:41:27 +0900 Subject: [PATCH 18/22] test(package): verify public history handle declarations --- tests/package/verify-package.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/package/verify-package.mjs b/tests/package/verify-package.mjs index 82a03e3..8e41b22 100644 --- a/tests/package/verify-package.mjs +++ b/tests/package/verify-package.mjs @@ -200,6 +200,10 @@ const expectedStrongEntityTag = '"sha256-' + '0'.repeat(64) + '"'; const currentSnapshot: CwlEditorDocumentSnapshot = editorHandle.getSnapshot(); const currentRevision: Promise = editorHandle.getDocumentEnvelopeRevision(undefined, digestProvider); +const canUndo: boolean = editorHandle.canUndo(); +const undoResult: boolean = editorHandle.undo(); +const canRedo: boolean = editorHandle.canRedo(); +const redoResult: boolean = editorHandle.redo(); const conditionalRestore: Promise = editorHandle.restoreDocumentEnvelopeIfMatch( expectedStrongEntityTag, @@ -272,6 +276,10 @@ void [ documentSnapshot, currentSnapshot, currentRevision, + canUndo, + undoResult, + canRedo, + redoResult, conditionalRestore, conditionalEvidence, conditionalByteRestoreResult, From d7ead2540d91cfbb0b64319946e554eab1119b44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:02:49 +0900 Subject: [PATCH 19/22] test(editor): keep imperative history inert when read-only --- .../CwlEditorHistoryHandle.test.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx index 256fe72..80554a4 100644 --- a/src/components/CwlEditorHistoryHandle.test.tsx +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -47,6 +47,30 @@ describe('CwlEditor imperative history control', () => { expect(handle.canRedo()).toBe(false); }); + it('keeps imperative history inert when the editor becomes read-only', async () => { + const editorRef = createRef(); + const { rerender } = render( + , + ); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + + const handle = editorRef.current!; + await act(async () => { + handle.getEditor()!.chain().focus('end').insertContent(' updated').run(); + }); + expect(handle.getMarkdown()).toBe('Original updated'); + expect(handle.canUndo()).toBe(true); + + rerender(); + await waitFor(() => expect(handle.getEditor()?.isEditable).toBe(false)); + + expect(handle.canUndo()).toBe(false); + expect(handle.undo()).toBe(false); + expect(handle.canRedo()).toBe(false); + expect(handle.redo()).toBe(false); + expect(handle.getMarkdown()).toBe('Original updated'); + }); + it('fails closed when the shared handle has no active editor instance', () => { const editorRef = createRef(); render(); From e752891647cfd2341f362a70dcfbc029f8294e4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:06:11 +0900 Subject: [PATCH 20/22] fix(editor): keep imperative history inert when read-only --- src/components/useEditorHandle.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 91703fd..440f657 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -47,6 +47,12 @@ function activeEditor(editor: Editor | null): Editor | null { return editor && !editor.isDestroyed ? editor : null; } +/** Return an editor that currently permits user-facing history commands. */ +function editableHistoryEditor(editor: Editor | null): Editor | null { + const current = activeEditor(editor); + return current?.isEditable ? current : null; +} + /** Expose the stable host-control contract shared by editor surfaces. */ export function useEditorHandle( ref: ForwardedRef, @@ -63,10 +69,12 @@ export function useEditorHandle( blur: () => { activeEditor(editor)?.commands.blur(); }, - canUndo: () => activeEditor(editor)?.can().undo() ?? false, - undo: () => activeEditor(editor)?.chain().focus().undo().run() ?? false, - canRedo: () => activeEditor(editor)?.can().redo() ?? false, - redo: () => activeEditor(editor)?.chain().focus().redo().run() ?? false, + canUndo: () => editableHistoryEditor(editor)?.can().undo() ?? false, + undo: () => + editableHistoryEditor(editor)?.chain().focus().undo().run() ?? false, + canRedo: () => editableHistoryEditor(editor)?.can().redo() ?? false, + redo: () => + editableHistoryEditor(editor)?.chain().focus().redo().run() ?? false, getValue: () => { const current = activeEditor(editor); if (!current) return ''; From 819415422e1bc67edbf75ece767d3b39f737d7d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:08:40 +0900 Subject: [PATCH 21/22] docs(editor): define read-only imperative history semantics --- docs/imperative-history-control.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/imperative-history-control.md b/docs/imperative-history-control.md index ab482e9..428b88a 100644 --- a/docs/imperative-history-control.md +++ b/docs/imperative-history-control.md @@ -27,6 +27,8 @@ if (handle?.canRedo()) { A newly created editor with no history reports both capabilities as `false`. After a document-changing edit, undo capability becomes available. A successful undo exposes redo capability, and a successful redo restores undo capability when history remains. +When the editor surface is read-only (`editable={false}`), all four history operations return `false` and no history entry is consumed. Restoring `editable={true}` exposes the same still-available history again. This read-only guard applies specifically to user-facing history commands; explicit host-control mutators such as `setValue()` and `restoreDocumentEnvelope()` retain their existing programmatic authority so a host can update a read-only presentation from its own trusted state transition. + ## Lifecycle safety A retained host handle must not keep a destroyed TipTap editor authoritative. Before an editor exists or after its editor has been destroyed, all four history methods return `false` without throwing. The same active-editor guard is used by the rest of the retained-handle read, mutation, envelope, revision, selection, and restore surface so stale destroyed-editor state cannot be presented as current Inkspan state. @@ -50,6 +52,7 @@ Inkspan does not acquire transport, authentication, authorization, tenancy, dura The implementing branch is expected to prove: - initial, post-edit, post-undo, and post-redo capability transitions on a real editor; +- read-only history commands are inert without consuming history and become available again when editability is restored; - no-active-editor and destroyed-editor fail-closed behavior; - collaborative convergence through two in-memory `Y.Doc` editors without duplicate local history; - unchanged package consumers, accessibility/browser evidence, Office evidence, security scans, and exact 100% owned-production statement/branch/function/line coverage. From e6278289c47a54f97d450e7dad857f1397ebbe7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:09:12 +0900 Subject: [PATCH 22/22] test(editor): preserve history across read-only transitions --- src/components/CwlEditorHistoryHandle.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx index 80554a4..78304f3 100644 --- a/src/components/CwlEditorHistoryHandle.test.tsx +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -47,7 +47,7 @@ describe('CwlEditor imperative history control', () => { expect(handle.canRedo()).toBe(false); }); - it('keeps imperative history inert when the editor becomes read-only', async () => { + it('keeps imperative history inert while read-only without consuming it', async () => { const editorRef = createRef(); const { rerender } = render( , @@ -69,6 +69,16 @@ describe('CwlEditor imperative history control', () => { expect(handle.canRedo()).toBe(false); expect(handle.redo()).toBe(false); expect(handle.getMarkdown()).toBe('Original updated'); + + rerender(); + await waitFor(() => expect(handle.getEditor()?.isEditable).toBe(true)); + + expect(handle.canUndo()).toBe(true); + await act(async () => { + expect(handle.undo()).toBe(true); + }); + expect(handle.getMarkdown()).toBe('Original'); + expect(handle.canRedo()).toBe(true); }); it('fails closed when the shared handle has no active editor instance', () => {