diff --git a/README.md b/README.md index 662df2b..bd5b521 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,15 @@ block to the IME; proven-disjoint `remote` updates are queued until settling, while local or same-block commands return `composition_conflict` and must be retried afterward. +`getJsonEditableDocumentHost(editor)` exposes a document-host capability for +delayed or causal changes without widening the established `JsonEditable` +shape. It flushes pending native input before a ready change, defers while the +browser still owns a composition island, and identifies coordinator-owned +native, application, history, and ready publications with a pre-commit +sequence. The demo wires the SHA-pinned `json-document` causal inbox through +that capability; “지연 편집 추적” exercises both positional rebase after a +local insertion and settle-time retry during IME composition. + Enter is handled as a structural intent, independently from DOM ownership. A semantic paragraph event is retained and replayed once composition settles; candidate-confirming `keydown Enter` by itself is never treated as a paragraph. diff --git a/docs/composition-island-architecture.md b/docs/composition-island-architecture.md index 5d33b87..000157c 100644 --- a/docs/composition-island-architecture.md +++ b/docs/composition-island-architecture.md @@ -18,9 +18,11 @@ JSONDocument -> keyed DOM bounded DOM diff -> JSONDocument document transactions wait ``` -This is a short, block-granular lease rather than a CRDT. A CRDT or remote-op -adapter can sit above the coordinator, but it must retry or buffer an operation -that receives `composition_conflict`. +This is a short, block-granular lease rather than a CRDT. A causal or remote-op +adapter can sit above the coordinator through +`getJsonEditableDocumentHost(editor)`. The host flushes pending native input +and defers ready work until the lease is released; the app retries a deferred +inbox from a microtask after observing idle state. ## Ownership Invariants @@ -145,6 +147,70 @@ cannot capture a stale pre-normalization DOM caret. - Direct writes to the supplied `JSONDocument`: unsupported while mounted. The editor reports `out_of_band_document_write` and recovers conservatively. +### Causal document host + +`getJsonEditableDocumentHost(editor)` is the only supported path for an adapter +whose own `doc.commit()` must be reconciled as an editor-owned publication. It +is a small capability rather than a second transaction API, and the separate +getter keeps the established `JsonEditable` structural contract unchanged: + +```ts +const inbox = createCausalPatchInbox(document, { + positionalSchema: EditableDocumentSchema, + host: getJsonEditableDocumentHost(editor), +}); +``` + +Before every coordinator-owned native, application, or history change, +`runDocumentChange` assigns a monotonic sequence. `ownsPublication` returns +that sequence only during the synchronous publication; origin strings are not +used as authority. A ready apply reserves from the same sequence before its +scope-bound closure runs. Raw `document.commit()` calls therefore remain out of +band. +Editor snapshot subscribers are exception-isolated and report +`subscriber_failed`; fault observers are isolated as well. A user callback +therefore cannot abort the document's subscriber loop before a causal inbox +journals an owned publication. + +For a ready causal envelope, `runReady` first drains mutation records. It calls +the supplied `apply()` exactly once only when the editor is idle and has no +pending composition, native intent, structural intent, or queued remote patch. +It refuses immediately, without attempting that drain, while a browser event +handler or another coordinator-owned document publication is still on the +stack. This prevents both pre-native DOM interleaving and nested journal order +from depending on subscriber registration order. + +That condition must hold both before and after the drain: recovery that cancels +a damaged composition still defers the current turn, so a causal render cannot +overlap an OS composition that has not delivered its final event. Otherwise +`runReady` returns `host_not_ready` without calling `apply()`. The first +publication whose `mergeKey` matches the ready envelope id is reconciled as a +remote change. A nested or second publication remains out of band, while the +causal inbox independently detects projection divergence. + +Browser composition activity is tracked separately from the pinned +`CompositionSession`. Losing an ancestor or Text identity may cancel the pin, +but it does not release the OS lease. Ready work remains deferred until +`compositionend`, blur, or a non-composing `insertFromComposition` input clears +that latch. Release is queued after the native event handler returns, so a +synchronous editor subscriber cannot re-enter the inbox before final native +flush and phase handling complete. A generation token prevents an old release +from closing a newly started composition, and a new composing signal cancels +any orphaned settle timer from a damaged session. Clearing the latch emits a +snapshot change so a coalesced retry waiting behind a damaged composition wakes +up. + +If `compositionend` arrives after the pinned session was already lost, the +editor still enters the same 30 ms settling window before exposing idle state; +this leaves room for the browser's late final input. + +Retry is scheduled in a microtask after an idle snapshot, not synchronously +inside an editor subscriber. This lets the current document publication finish +and reach the causal inbox's ownership subscriber before another ingestion +begins. The app-owned `causalDocumentInbox` tracer implements this coalesced +retry and avoids a same-revision microtask loop when another pending input state +still prevents readiness. + The demo's “remote” origin means a queued asynchronous application update, not a full collaborative undo model. True collaboration needs causal operations and origin-selective undo in the CRDT/OT layer instead of this linear queue. @@ -162,6 +228,7 @@ The mount API intentionally stays small: ```ts const editor = mountJsonEditable({ root, document, onFault }); +const host = getJsonEditableDocumentHost(editor); editor.dispatch(action); editor.getSnapshot(); editor.subscribe(listener); @@ -216,6 +283,8 @@ before it removes listeners and restores the host attributes it borrowed. The automated suite verifies: - coordinator transitions and history in jsdom; +- causal host publication ownership, delayed positional rebase, selection + restoration, pending-native flush, composition defer, and microtask retry; - owner-document/cross-realm DOM behavior; - ordinary typing, empty-block deletion, and root-boundary Select All in real Chromium, Firefox, and WebKit; diff --git a/package.json b/package.json index db64b78..e6f607a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ }, "dependencies": { "@interactive-os/json-document": "1.1.0-rc.0", + "@interactive-os/json-document-causal-patch-inbox": "file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz", + "@interactive-os/json-document-id-resolver": "file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz", "@tanstack/react-router": "^1.170.16", "@tanstack/react-start": "^1.168.26", "lucide-react": "^0.545.0", @@ -43,6 +45,10 @@ "vitest": "^4.1.5" }, "pnpm": { + "overrides": { + "@interactive-os/json-document-patch-rebase": "file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz", + "@interactive-os/json-document-stable-id-rebase": "file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz" + }, "onlyBuiltDependencies": [ "esbuild", "lightningcss" diff --git a/packages/editable/browser/editor.ts b/packages/editable/browser/editor.ts index 33f57fa..7e41706 100644 --- a/packages/editable/browser/editor.ts +++ b/packages/editable/browser/editor.ts @@ -1,4 +1,5 @@ import type { + JSONChangeMetadata, JSONDocument, JSONPatchOperation, SelectionSnap, @@ -8,7 +9,10 @@ import type { EditorDocumentCommand, } from "../core"; -export { mountJsonEditable } from "./editorCoordinator"; +export { + getJsonEditableDocumentHost, + mountJsonEditable, +} from "./editorCoordinator"; export type EditorPhase = | "idle" @@ -36,7 +40,8 @@ export type EditorFault = { | "input_state_lost" | "composition_overlap" | "composition_conflict" - | "queued_change_commit_failed"; + | "queued_change_commit_failed" + | "subscriber_failed"; recoverable: boolean; reason: string; }; @@ -70,6 +75,23 @@ export type EditorResult = reason: string; }; +export type JsonEditableDocumentHost = { + ownsPublication(publication: { + operations: ReadonlyArray; + metadata?: JSONChangeMetadata; + }): false | { sequence: number }; + runReady(request: { + id: string; + apply(): void; + }): + | { ok: true } + | { + ok: false; + code: "host_not_ready"; + reason: string; + }; +}; + export type JsonEditable = { dispatch(action: EditorAction): EditorResult; getSnapshot(): EditorSnapshot; diff --git a/packages/editable/browser/editorCoordinator.ts b/packages/editable/browser/editorCoordinator.ts index 183d974..288e9e7 100644 --- a/packages/editable/browser/editorCoordinator.ts +++ b/packages/editable/browser/editorCoordinator.ts @@ -1,5 +1,6 @@ import { applyPatch, + type JSONChangeMetadata, type JSONDocument, type JSONPatchOperation, type SelectionSnap, @@ -27,6 +28,7 @@ import type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, } from "./editor"; import { @@ -116,6 +118,11 @@ type BlockChange = { typeChanged: boolean; }; +type ReadyDocumentChange = { + id: string; + publicationCount: number; +}; + const OWNED_HOST_ATTRIBUTES = [ "contenteditable", "spellcheck", @@ -125,6 +132,17 @@ const OWNED_HOST_ATTRIBUTES = [ ] as const; let editorSequence = 0; +const documentHosts = new WeakMap(); + +export function getJsonEditableDocumentHost( + editor: JsonEditable, +): JsonEditableDocumentHost { + const host = documentHosts.get(editor); + if (host === undefined) { + throw new TypeError("The editor was not created by mountJsonEditable()."); + } + return host; +} export function mountJsonEditable( options: MountJsonEditableOptions, @@ -133,6 +151,14 @@ export function mountJsonEditable( } class JsonEditableCoordinator implements JsonEditable { + private readonly documentHost: JsonEditableDocumentHost = { + ownsPublication: () => { + const sequence = this.activeDocumentPublicationSequence; + return sequence === null ? false : { sequence }; + }, + runReady: (request) => this.runReadyDocumentChange(request), + }; + private readonly root: HTMLElement; private readonly document: JSONDocument; private readonly onFault: ((fault: EditorFault) => void) | undefined; @@ -146,15 +172,21 @@ class JsonEditableCoordinator implements JsonEditable { private phase: EditorPhase = "idle"; private revision = 0; private composition: CompositionSession | null = null; + private browserCompositionActive = false; + private browserCompositionGeneration = 0; private compositionSequence = 0; private blockSequence = 0; private settleTimer: number | null = null; private nativeTurnTimer: number | null = null; private commitSource: ChangeSource | null = null; private commitTextChanges: ReadonlyMap | null = null; + private documentPublicationSequence = 0; + private activeDocumentPublicationSequence: number | null = null; + private readyDocumentChange: ReadyDocumentChange | null = null; private lastNativeCompositionHistoryId: number | null = null; private dispatching = false; private destroyed = false; + private browserEventDepth = 0; private domWriteDepth = 0; private pendingRecords: MutationRecord[] = []; private mutationFlushQueued = false; @@ -205,13 +237,14 @@ class JsonEditableCoordinator implements JsonEditable { this.observe(); this.attachEvents(); - this.stopDocumentSubscription = document.subscribe(() => { - this.onDocumentChange(); + this.stopDocumentSubscription = document.subscribe((_operations, metadata) => { + this.onDocumentChange(metadata); }); this.stopSelectionSubscription = document.selection?.subscribe(() => { this.bump(); }) ?? null; + documentHosts.set(this, this.documentHost); } dispatch(action: EditorAction): EditorResult { @@ -494,6 +527,10 @@ class JsonEditableCoordinator implements JsonEditable { ): T { const previousSource = this.commitSource; const previousTextChanges = this.commitTextChanges; + const previousPublicationSequence = + this.activeDocumentPublicationSequence; + const publicationSequence = this.reserveDocumentPublicationSequence(); + this.activeDocumentPublicationSequence = publicationSequence; this.commitSource = source; this.commitTextChanges = textChanges; try { @@ -501,13 +538,125 @@ class JsonEditableCoordinator implements JsonEditable { } finally { this.commitSource = previousSource; this.commitTextChanges = previousTextChanges; + this.activeDocumentPublicationSequence = previousPublicationSequence; + } + } + + private runReadyDocumentChange( + request: Parameters[0], + ): ReturnType { + if (this.destroyed) { + throw new Error("The editor has been destroyed."); + } + if (this.dispatching) { + return { + ok: false, + code: "host_not_ready", + reason: "The editor is already dispatching a document transaction.", + }; + } + if ( + this.browserEventDepth > 0 || + this.activeDocumentPublicationSequence !== null + ) { + return { + ok: false, + code: "host_not_ready", + reason: + "The editor is still handling a browser event or publishing a document change.", + }; + } + + this.dispatching = true; + try { + const enteredReady = this.canApplyReadyDocumentChange(); + this.flushNativeMutations([], false); + if (this.destroyed) { + throw new Error( + "The editor was destroyed while preparing a ready document change.", + ); + } + if (!enteredReady || !this.canApplyReadyDocumentChange()) { + return { + ok: false, + code: "host_not_ready", + reason: + "The editor must settle native input and composition before applying this document change.", + }; + } + + const readyChange: ReadyDocumentChange = { + id: request.id, + publicationCount: 0, + }; + const previousPublicationSequence = + this.activeDocumentPublicationSequence; + this.activeDocumentPublicationSequence = + this.reserveDocumentPublicationSequence(); + this.readyDocumentChange = readyChange; + const selectionBefore = selectionSnapshotSignature( + this.document.selection?.snapshot() ?? null, + ); + let didThrow = false; + let thrown: unknown; + try { + request.apply(); + } catch (error) { + didThrow = true; + thrown = error; + } finally { + this.readyDocumentChange = null; + this.activeDocumentPublicationSequence = previousPublicationSequence; + } + const selectionChanged = + selectionSnapshotSignature( + this.document.selection?.snapshot() ?? null, + ) !== selectionBefore; + + if ( + !this.destroyed && + this.composition === null && + (readyChange.publicationCount > 0 || selectionChanged) + ) { + try { + restoreDOMSelection( + this.root, + this.document.value, + this.document.selection?.snapshot() ?? null, + ); + } catch (error) { + if (!didThrow) { + throw error; + } + } + } + if (didThrow) { + throw thrown; + } + return { ok: true }; + } finally { + this.dispatching = false; } } - private onDocumentChange(): void { + private canApplyReadyDocumentChange(): boolean { + return ( + this.phase === "idle" && + !this.destroyed && + this.browserEventDepth === 0 && + this.activeDocumentPublicationSequence === null && + !this.browserCompositionActive && + this.composition === null && + this.pendingNativeIntent === null && + this.pendingStructuralIntent === null && + this.queuedRemotePatches.length === 0 + ); + } + + private onDocumentChange(metadata?: JSONChangeMetadata): void { const before = this.lastValue; const after = this.document.value; - const source = this.commitSource ?? "external"; + const source = this.documentChangeSource(metadata); if (source !== "native") { this.lastNativeCompositionHistoryId = null; } @@ -532,6 +681,30 @@ class JsonEditableCoordinator implements JsonEditable { this.bump(); } + private documentChangeSource(metadata?: JSONChangeMetadata): ChangeSource { + if (this.commitSource !== null) { + return this.commitSource; + } + const readyChange = this.readyDocumentChange; + if (readyChange === null) { + return "external"; + } + readyChange.publicationCount += 1; + return readyChange.publicationCount === 1 && + metadata?.mergeKey === readyChange.id + ? "remote" + : "external"; + } + + private reserveDocumentPublicationSequence(): number { + const sequence = this.documentPublicationSequence + 1; + if (!Number.isSafeInteger(sequence)) { + throw new Error("The editor document publication sequence is exhausted."); + } + this.documentPublicationSequence = sequence; + return sequence; + } + private reconcileComposition( after: EditableDocumentValue, changes: ReadonlyArray, @@ -1175,7 +1348,12 @@ class JsonEditableCoordinator implements JsonEditable { private endComposition(): void { if (this.composition === null) { - this.setPhase("idle"); + if (this.browserCompositionActive) { + this.setPhase("settling"); + this.scheduleSettle(); + } else { + this.setPhase("idle"); + } return; } this.flushNativeMutations([], true); @@ -1616,39 +1794,103 @@ class JsonEditableCoordinator implements JsonEditable { } private attachEvents(): void { - this.root.addEventListener("beforeinput", this.onBeforeInput); - this.root.addEventListener("input", this.onInput); - this.root.addEventListener("compositionstart", this.onCompositionStart); - this.root.addEventListener("compositionupdate", this.onCompositionUpdate); - this.root.addEventListener("compositionend", this.onCompositionEnd); - this.root.addEventListener("paste", this.onPaste); - this.root.addEventListener("cut", this.onCut); - this.root.addEventListener("keydown", this.onKeyDown); - this.root.addEventListener("blur", this.onBlur); + this.root.addEventListener("beforeinput", this.guardedOnBeforeInput); + this.root.addEventListener("input", this.guardedOnInput); + this.root.addEventListener( + "compositionstart", + this.guardedOnCompositionStart, + ); + this.root.addEventListener( + "compositionupdate", + this.guardedOnCompositionUpdate, + ); + this.root.addEventListener("compositionend", this.guardedOnCompositionEnd); + this.root.addEventListener("paste", this.guardedOnPaste); + this.root.addEventListener("cut", this.guardedOnCut); + this.root.addEventListener("keydown", this.guardedOnKeyDown); + this.root.addEventListener("blur", this.guardedOnBlur); this.root.ownerDocument.addEventListener( "selectionchange", - this.onSelectionChange, + this.guardedOnSelectionChange, ); } private detachEvents(): void { - this.root.removeEventListener("beforeinput", this.onBeforeInput); - this.root.removeEventListener("input", this.onInput); - this.root.removeEventListener("compositionstart", this.onCompositionStart); - this.root.removeEventListener("compositionupdate", this.onCompositionUpdate); - this.root.removeEventListener("compositionend", this.onCompositionEnd); - this.root.removeEventListener("paste", this.onPaste); - this.root.removeEventListener("cut", this.onCut); - this.root.removeEventListener("keydown", this.onKeyDown); - this.root.removeEventListener("blur", this.onBlur); + this.root.removeEventListener("beforeinput", this.guardedOnBeforeInput); + this.root.removeEventListener("input", this.guardedOnInput); + this.root.removeEventListener( + "compositionstart", + this.guardedOnCompositionStart, + ); + this.root.removeEventListener( + "compositionupdate", + this.guardedOnCompositionUpdate, + ); + this.root.removeEventListener("compositionend", this.guardedOnCompositionEnd); + this.root.removeEventListener("paste", this.guardedOnPaste); + this.root.removeEventListener("cut", this.guardedOnCut); + this.root.removeEventListener("keydown", this.guardedOnKeyDown); + this.root.removeEventListener("blur", this.guardedOnBlur); this.root.ownerDocument.removeEventListener( "selectionchange", - this.onSelectionChange, + this.guardedOnSelectionChange, ); } + private readonly guardedOnBeforeInput = (event: Event): void => { + this.runBrowserEvent(() => this.onBeforeInput(event)); + }; + + private readonly guardedOnInput = (event: Event): void => { + this.runBrowserEvent(() => this.onInput(event)); + }; + + private readonly guardedOnCompositionStart = (): void => { + this.runBrowserEvent(this.onCompositionStart); + }; + + private readonly guardedOnCompositionUpdate = (): void => { + this.runBrowserEvent(this.onCompositionUpdate); + }; + + private readonly guardedOnCompositionEnd = (event: Event): void => { + this.runBrowserEvent(() => this.onCompositionEnd(event)); + }; + + private readonly guardedOnPaste = (event: ClipboardEvent): void => { + this.runBrowserEvent(() => this.onPaste(event)); + }; + + private readonly guardedOnCut = (event: ClipboardEvent): void => { + this.runBrowserEvent(() => this.onCut(event)); + }; + + private readonly guardedOnKeyDown = (event: KeyboardEvent): void => { + this.runBrowserEvent(() => this.onKeyDown(event)); + }; + + private readonly guardedOnBlur = (): void => { + this.runBrowserEvent(this.onBlur); + }; + + private readonly guardedOnSelectionChange = (): void => { + this.runBrowserEvent(this.onSelectionChange); + }; + + private runBrowserEvent(run: () => void): void { + this.browserEventDepth += 1; + try { + run(); + } finally { + this.browserEventDepth -= 1; + } + } + private readonly onBeforeInput = (rawEvent: Event): void => { const event = rawEvent as InputEvent; + if (event.isComposing || isCompositionInputType(event.inputType)) { + this.markBrowserCompositionActive(); + } const targetSelection = isTextMutationInputType(event.inputType) ? this.selectionFromInputTarget(event) : null; @@ -1808,6 +2050,11 @@ class JsonEditableCoordinator implements JsonEditable { private readonly onInput = (rawEvent: Event): void => { const event = rawEvent as InputEvent; + if (event.isComposing) { + this.markBrowserCompositionActive(); + } else if (event.inputType === "insertFromComposition") { + this.scheduleBrowserCompositionRelease(); + } this.inputTargetSelection = null; if (this.pendingNativeIntent !== null) { this.commitPendingNativeIntent(); @@ -1868,6 +2115,7 @@ class JsonEditableCoordinator implements JsonEditable { } private readonly onCompositionStart = (): void => { + this.markBrowserCompositionActive(); this.nativeEvidenceUntil = performance.now() + 100; if (this.pendingNativeIntent !== null) { return; @@ -1876,6 +2124,7 @@ class JsonEditableCoordinator implements JsonEditable { }; private readonly onCompositionUpdate = (): void => { + this.markBrowserCompositionActive(); this.nativeEvidenceUntil = performance.now() + 100; if (this.pendingNativeIntent !== null) { return; @@ -1885,6 +2134,7 @@ class JsonEditableCoordinator implements JsonEditable { private readonly onCompositionEnd = (rawEvent: Event): void => { const event = rawEvent as CompositionEvent; + this.scheduleBrowserCompositionRelease(); this.nativeEvidenceUntil = performance.now() + 100; const hasParagraphEvidence = hasTrailingLineBreak(event.data); let session = this.composition; @@ -1980,6 +2230,7 @@ class JsonEditableCoordinator implements JsonEditable { }; private readonly onBlur = (): void => { + this.scheduleBrowserCompositionRelease(); if (this.composition === null) { this.closeNativeTurn(); } else { @@ -1987,6 +2238,31 @@ class JsonEditableCoordinator implements JsonEditable { } }; + private markBrowserCompositionActive(): void { + this.clearSettleTimer(); + this.browserCompositionGeneration += 1; + this.browserCompositionActive = true; + } + + private scheduleBrowserCompositionRelease(): void { + if (!this.browserCompositionActive) { + return; + } + const generation = this.browserCompositionGeneration; + queueMicrotask(() => { + if ( + !this.browserCompositionActive || + this.browserCompositionGeneration !== generation + ) { + return; + } + this.browserCompositionActive = false; + if (!this.destroyed) { + this.bump(); + } + }); + } + private createBlockId(): string { let id: string; do { @@ -2041,12 +2317,24 @@ class JsonEditableCoordinator implements JsonEditable { this.revision += 1; const snapshot = this.getSnapshot(); for (const listener of [...this.listeners]) { - listener(snapshot); + try { + listener(snapshot); + } catch (error) { + this.reportFault({ + code: "subscriber_failed", + recoverable: true, + reason: callbackFailureReason("Editor subscriber", error), + }); + } } } private reportFault(fault: EditorFault): void { - this.onFault?.(fault); + try { + this.onFault?.(fault); + } catch { + // Fault observers cannot be allowed to interrupt document publication. + } } } @@ -2090,6 +2378,16 @@ function selectionAt(path: string, offset: number): SelectionSnap { return selectionBetween(path, offset, path, offset); } +function selectionSnapshotSignature(selection: SelectionSnap | null): string { + return JSON.stringify(selection); +} + +function callbackFailureReason(label: string, error: unknown): string { + return error instanceof Error + ? `${label} failed: ${error.message}` + : `${label} failed with an unknown value.`; +} + function selectionBetween( anchorPath: string, anchorOffset: number, diff --git a/packages/editable/browser/index.ts b/packages/editable/browser/index.ts index 6a76d99..741484c 100644 --- a/packages/editable/browser/index.ts +++ b/packages/editable/browser/index.ts @@ -15,7 +15,7 @@ export type { EditablePoint, OrderedEditableSelection, } from "../core"; -export { mountJsonEditable } from "./editor"; +export { getJsonEditableDocumentHost, mountJsonEditable } from "./editor"; export type { EditorAction, EditorFault, @@ -23,5 +23,6 @@ export type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, } from "./editor"; diff --git a/packages/editable/causalHost.test.ts b/packages/editable/causalHost.test.ts new file mode 100644 index 0000000..cf7b766 --- /dev/null +++ b/packages/editable/causalHost.test.ts @@ -0,0 +1,736 @@ +// @vitest-environment jsdom + +import { + createCausalPatchInbox, + type CausalPatchInbox, +} from "@interactive-os/json-document-causal-patch-inbox"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createEditableDocument, + EditableDocumentSchema, + type EditorFault, + getJsonEditableDocumentHost, + type JsonEditable, + mountJsonEditable, +} from "./index"; + +type Fixture = ReturnType; + +const mountedEditors: JsonEditable[] = []; +const mountedInboxes: CausalPatchInbox[] = []; + +afterEach(() => { + for (const inbox of mountedInboxes.splice(0)) { + inbox.dispose(); + } + for (const editor of mountedEditors.splice(0)) { + editor.destroy(); + } + window.document.body.replaceChildren(); + vi.useRealTimers(); +}); + +function setupEditor(onFault?: (fault: EditorFault) => void) { + const document = createEditableDocument({ + schema: "interactive-os.editable-document@2", + id: "causal-host-test", + blocks: [ + { id: "alpha", type: "paragraph", text: "abcdef" }, + { id: "beta", type: "paragraph", text: "second" }, + ], + }); + const root = window.document.createElement("div"); + const faults: EditorFault[] = []; + window.document.body.append(root); + const editor = mountJsonEditable({ + root, + document, + onFault: (fault) => { + faults.push(fault); + onFault?.(fault); + }, + }); + mountedEditors.push(editor); + return { document, editor, faults, root }; +} + +function createInbox(fixture: Fixture) { + const inbox = createCausalPatchInbox(fixture.document, { + host: getJsonEditableDocumentHost(fixture.editor), + positionalSchema: EditableDocumentSchema, + }); + mountedInboxes.push(inbox); + return inbox; +} + +function textNode(fixture: Fixture, blockId: string): Text { + const node = fixture.root.querySelector( + `[data-editable-block="${blockId}"] [data-editable-text]`, + )?.firstChild; + if (!(node instanceof Text)) { + throw new Error(`Missing editable Text node for ${blockId}.`); + } + return node; +} + +function setDOMCaret(node: Text, offset: number): void { + const selection = window.getSelection(); + if (selection === null) { + throw new Error("The test DOM does not expose a Selection."); + } + const range = window.document.createRange(); + range.setStart(node, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} + +function inputEvent( + type: "beforeinput" | "input", + inputType: string, + options: { data?: string | null; isComposing?: boolean } = {}, +): InputEvent { + return new InputEvent(type, { + bubbles: true, + cancelable: type === "beforeinput", + data: options.data, + inputType, + isComposing: options.isComposing ?? false, + }); +} + +describe("editable causal document host", () => { + it("rebases a delayed positional edit after an owned local insertion", () => { + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const base = fixture.document.value; + const baseRevision = inbox.current().journalRevision; + + expect(baseRevision).toBe(0); + expect( + fixture.editor.dispatch({ + type: "patch", + patch: [ + { + op: "add", + path: "/blocks/0", + value: { id: "local", type: "paragraph", text: "local" }, + }, + ], + }), + ).toMatchObject({ ok: true, change: "document" }); + expect(inbox.current().journalRevision).toBe(1); + + const result = inbox.ingest({ + id: "delayed-beta", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision, + operations: [ + { + op: "replace", + path: "/blocks/1/text", + value: "Reviewed", + }, + ], + selectionAfter: { path: "/blocks/1/text", offset: 4 }, + }, + }); + + expect(result).toMatchObject({ + ok: true, + applied: ["delayed-beta"], + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + code: "pointer_shifted", + pointer: "/blocks/1/text", + rebasedPointer: "/blocks/2/text", + }), + ]), + }); + expect(fixture.document.value.blocks).toEqual([ + { id: "local", type: "paragraph", text: "local" }, + { id: "alpha", type: "paragraph", text: "abcdef" }, + { id: "beta", type: "paragraph", text: "Reviewed" }, + ]); + expect(fixture.document.selection?.primaryRange).toEqual({ + anchor: { path: "/blocks/2/text", offset: 4 }, + focus: { path: "/blocks/2/text", offset: 4 }, + }); + const beta = textNode(fixture, "beta"); + expect(window.getSelection()?.focusNode).toBe(beta); + expect(window.getSelection()?.focusOffset).toBe(4); + expect(inbox.current()).toMatchObject({ + status: "active", + journalRevision: 2, + frontier: ["delayed-beta"], + }); + expect(fixture.faults).toEqual([]); + }); + + it("lets another causal inbox journal a ready publication as host-owned", () => { + const fixture = setupEditor(); + const applyingInbox = createInbox(fixture); + const observingInbox = createInbox(fixture); + + expect( + applyingInbox.ingest({ + id: "from-first-inbox", + dependsOn: [], + operations: [ + { op: "replace", path: "/blocks/0/text", value: "shared" }, + ], + }), + ).toMatchObject({ ok: true, applied: ["from-first-inbox"] }); + + expect(observingInbox.current()).toMatchObject({ + status: "active", + journalRevision: 1, + frontier: [], + }); + expect(fixture.faults).toEqual([]); + }); + + it("journals pending native input, defers for composition, then retries when idle", async () => { + vi.useFakeTimers(); + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const base = fixture.document.value; + const composingNode = textNode(fixture, "alpha"); + setDOMCaret(composingNode, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + fixture.root.dispatchEvent( + inputEvent("beforeinput", "insertCompositionText", { + data: "한", + isComposing: true, + }), + ); + composingNode.insertData(2, "한"); + setDOMCaret(composingNode, 3); + + let retryResult: ReturnType | undefined; + let retryScheduled = false; + const unsubscribe = fixture.editor.subscribe((snapshot) => { + if ( + retryResult === undefined && + !retryScheduled && + snapshot.phase === "idle" && + snapshot.queuedChanges === 0 + ) { + retryScheduled = true; + queueMicrotask(() => { + retryResult = inbox.ingest([]); + }); + } + }); + + const deferred = inbox.ingest({ + id: "after-composition", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { + op: "replace", + path: "/blocks/1/text", + value: "after IME", + }, + ], + }, + }); + + expect(deferred).toMatchObject({ + ok: false, + code: "host_not_ready", + id: "after-composition", + }); + expect(fixture.document.value.blocks[0]?.text).toBe("ab한cdef"); + expect(inbox.current()).toMatchObject({ + journalRevision: 1, + queued: [{ id: "after-composition", missing: [] }], + }); + expect(textNode(fixture, "alpha")).toBe(composingNode); + + fixture.root.dispatchEvent( + inputEvent("input", "insertCompositionText", { + data: "한", + isComposing: true, + }), + ); + fixture.root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ); + await vi.advanceTimersByTimeAsync(31); + + expect(retryResult).toMatchObject({ + ok: true, + applied: ["after-composition"], + }); + expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ + "ab한cdef", + "after IME", + ]); + expect(inbox.current()).toMatchObject({ + status: "active", + journalRevision: 2, + queued: [], + }); + expect(fixture.faults).toEqual([]); + unsubscribe(); + }); + + it("defers the turn when flushing a damaged composition makes the editor idle", async () => { + vi.useFakeTimers(); + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const base = fixture.document.value; + const pinnedNode = textNode(fixture, "alpha"); + setDOMCaret(pinnedNode, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + const replacement = window.document.createTextNode(pinnedNode.data); + pinnedNode.parentNode?.replaceChild(replacement, pinnedNode); + + const deferred = inbox.ingest({ + id: "after-damaged-composition", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "recovered" }, + ], + }, + }); + + expect(deferred).toMatchObject({ + ok: false, + code: "host_not_ready", + id: "after-damaged-composition", + }); + expect(fixture.editor.getSnapshot()).toMatchObject({ + phase: "idle", + composition: null, + }); + expect(fixture.document.value.blocks[1]?.text).toBe("second"); + + expect(inbox.ingest([])).toMatchObject({ + ok: false, + code: "host_not_ready", + id: "after-damaged-composition", + }); + let releaseRetry: ReturnType | undefined; + let retryOnRelease = true; + fixture.editor.subscribe((snapshot) => { + if ( + retryOnRelease && + releaseRetry === undefined && + snapshot.phase === "idle" + ) { + releaseRetry = inbox.ingest([]); + } + }); + fixture.root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ); + expect(releaseRetry).toBeUndefined(); + expect(fixture.document.value.blocks[1]?.text).toBe("second"); + await Promise.resolve(); + expect(releaseRetry).toBeUndefined(); + expect(fixture.editor.getSnapshot().phase).toBe("settling"); + await vi.advanceTimersByTimeAsync(31); + retryOnRelease = false; + expect(releaseRetry).toMatchObject({ + ok: true, + applied: ["after-damaged-composition"], + }); + expect(fixture.document.value.blocks[1]?.text).toBe("recovered"); + }); + + it("does not apply when the editor is destroyed during the readiness flush", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const pinnedNode = textNode(fixture, "alpha"); + setDOMCaret(pinnedNode, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + let armed = false; + fixture.editor.subscribe((snapshot) => { + if (armed && snapshot.phase === "idle") { + fixture.editor.destroy(); + } + }); + armed = true; + pinnedNode.parentNode?.replaceChild( + window.document.createTextNode(pinnedNode.data), + pinnedNode, + ); + const apply = vi.fn(); + + expect(() => + documentHost.runReady({ id: "destroyed-during-flush", apply }), + ).toThrow("destroyed"); + expect(apply).not.toHaveBeenCalled(); + }); + + it("does not let a damaged-session settle timer end a new composition", async () => { + vi.useFakeTimers(); + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const node = textNode(fixture, "alpha"); + setDOMCaret(node, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + node.parentNode?.replaceChild( + window.document.createTextNode(node.data), + node, + ); + expect( + inbox.ingest({ + id: "wait-for-new-composition", + dependsOn: [], + operations: [ + { op: "replace", path: "/blocks/1/text", value: "later" }, + ], + }), + ).toMatchObject({ ok: false, code: "host_not_ready" }); + fixture.root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ); + + const nextNode = textNode(fixture, "alpha"); + setDOMCaret(nextNode, 2); + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + await vi.advanceTimersByTimeAsync(31); + + expect(fixture.editor.getSnapshot()).toMatchObject({ + phase: "composing", + composition: { blockId: "alpha" }, + }); + expect(textNode(fixture, "alpha")).toBe(nextNode); + expect(fixture.document.value.blocks[1]?.text).toBe("second"); + }); + + it("does not apply reentrantly from a native beforeinput subscriber", () => { + const fixture = setupEditor(); + const inbox = createInbox(fixture); + const base = fixture.document.value; + let armed = false; + let nestedResult: ReturnType | undefined; + fixture.editor.subscribe(() => { + if (!armed || nestedResult !== undefined) { + return; + } + nestedResult = inbox.ingest({ + id: "during-beforeinput", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "delayed" }, + ], + }, + }); + }); + setDOMCaret(textNode(fixture, "alpha"), 1); + armed = true; + + expect( + fixture.root.dispatchEvent( + inputEvent("beforeinput", "insertText", { data: "X" }), + ), + ).toBe(false); + + expect(nestedResult).toMatchObject({ + ok: false, + code: "host_not_ready", + id: "during-beforeinput", + }); + expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ + "aXbcdef", + "second", + ]); + expect(inbox.ingest([])).toMatchObject({ + ok: true, + applied: ["during-beforeinput"], + }); + expect(fixture.document.value.blocks[1]?.text).toBe("delayed"); + }); + + it("assigns owned publication sequences before commit and rejects raw writes", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const ownerships: Array = []; + let reentrantResult: + | ReturnType + | undefined; + const nestedApply = vi.fn(); + fixture.document.subscribe((operations, metadata) => { + ownerships.push( + documentHost.ownsPublication({ operations, metadata }), + ); + reentrantResult ??= documentHost.runReady({ + id: "nested", + apply: nestedApply, + }); + }); + + expect( + fixture.editor.dispatch({ + type: "replaceText", + blockId: "alpha", + from: 0, + to: 1, + text: "A", + }).ok, + ).toBe(true); + expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); + expect(fixture.editor.dispatch({ type: "redo" }).ok).toBe(true); + expect(fixture.editor.dispatch({ type: "reset" }).ok).toBe(true); + + const ownedSequences = ownerships.map((ownership) => { + if (ownership === false) { + throw new Error("Expected a coordinator-owned publication."); + } + return ownership.sequence; + }); + expect(ownedSequences).toHaveLength(4); + expect( + ownedSequences.every( + (sequence, index) => index === 0 || sequence > ownedSequences[index - 1]!, + ), + ).toBe(true); + expect(reentrantResult).toMatchObject({ + ok: false, + code: "host_not_ready", + }); + expect(nestedApply).not.toHaveBeenCalled(); + expect( + documentHost.ownsPublication({ operations: [] }), + ).toBe(false); + + expect( + fixture.document.commit([ + { op: "replace", path: "/blocks/0/text", value: "raw" }, + ]), + ).toEqual({ ok: true }); + expect(ownerships.at(-1)).toBe(false); + expect(fixture.faults).toContainEqual( + expect.objectContaining({ code: "out_of_band_document_write" }), + ); + }); + + it("journals an owned publication even when an editor subscriber throws", () => { + const fixture = setupEditor(() => { + throw new Error("fault observer failed"); + }); + const inbox = createInbox(fixture); + const base = fixture.document.value; + const subscriberFailure = new Error("subscriber failed"); + fixture.editor.subscribe(() => { + throw subscriberFailure; + }); + + expect(() => + fixture.editor.dispatch({ + type: "patch", + patch: [ + { + op: "add", + path: "/blocks/0", + value: { id: "leading", type: "paragraph", text: "leading" }, + }, + ], + }), + ).not.toThrow(); + expect(inbox.current().journalRevision).toBe(1); + + expect( + inbox.ingest({ + id: "after-throwing-subscriber", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "correct" }, + ], + }, + }), + ).toMatchObject({ ok: true, applied: ["after-throwing-subscriber"] }); + expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ + "leading", + "abcdef", + "correct", + ]); + expect(fixture.faults).toContainEqual( + expect.objectContaining({ code: "subscriber_failed" }), + ); + }); + + it("propagates ready apply failures without losing an already published DOM change", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const readyOwnerships: Array = []; + fixture.document.subscribe((operations, metadata) => { + readyOwnerships.push( + documentHost.ownsPublication({ operations, metadata }), + ); + }); + fixture.document.selection?.collapse({ + path: "/blocks/0/text", + offset: 1, + }); + const outside = window.document.createElement("button"); + window.document.body.append(outside); + outside.focus(); + const beforeFailure = new Error("before publication"); + expect(() => + documentHost.runReady({ + id: "before", + apply() { + throw beforeFailure; + }, + }), + ).toThrow(beforeFailure); + expect(fixture.document.value.blocks[0]?.text).toBe("abcdef"); + expect(window.document.activeElement).toBe(outside); + + const afterFailure = new Error("after publication"); + expect(() => + documentHost.runReady({ + id: "after", + apply() { + expect( + fixture.document.commit( + [{ op: "replace", path: "/blocks/0/text", value: "published" }], + { mergeKey: "after", origin: "causal-test" }, + ), + ).toEqual({ ok: true }); + throw afterFailure; + }, + }), + ).toThrow(afterFailure); + expect(fixture.document.value.blocks[0]?.text).toBe("published"); + expect(textNode(fixture, "alpha").data).toBe("published"); + expect(readyOwnerships).toEqual([{ sequence: 2 }]); + expect(fixture.faults).toEqual([]); + + expect( + fixture.editor.dispatch({ + type: "replaceText", + blockId: "beta", + from: 0, + to: 0, + text: "still usable ", + }).ok, + ).toBe(true); + }); + + it("restores DOM selection after a ready test-only commit", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const publications = vi.fn(); + fixture.document.subscribe(publications); + const outside = window.document.createElement("button"); + window.document.body.append(outside); + outside.focus(); + + expect( + documentHost.runReady({ + id: "test-only", + apply() { + expect( + fixture.document.commit( + [{ op: "test", path: "/blocks/1/id", value: "beta" }], + { mergeKey: "test-only", origin: "causal-test" }, + ), + ).toEqual({ ok: true }); + }, + }), + ).toEqual({ ok: true }); + expect(window.document.activeElement).toBe(outside); + + expect( + documentHost.runReady({ + id: "selection-only", + apply() { + expect( + fixture.document.commit( + [{ op: "test", path: "/blocks/1/id", value: "beta" }], + { + mergeKey: "selection-only", + origin: "causal-test", + selectionAfter: { path: "/blocks/1/text", offset: 3 }, + }, + ), + ).toEqual({ ok: true }); + }, + }), + ).toEqual({ ok: true }); + + expect(publications).not.toHaveBeenCalled(); + expect(fixture.document.selection?.primaryRange).toEqual({ + anchor: { path: "/blocks/1/text", offset: 3 }, + focus: { path: "/blocks/1/text", offset: 3 }, + }); + expect(window.getSelection()?.focusNode).toBe(textNode(fixture, "beta")); + expect(window.getSelection()?.focusOffset).toBe(3); + }); + + it("isolates an earlier editor observer and restores selection", () => { + const fixture = setupEditor(); + const documentHost = getJsonEditableDocumentHost(fixture.editor); + const alpha = textNode(fixture, "alpha"); + setDOMCaret(alpha, 1); + fixture.document.selection?.collapse({ + path: "/blocks/0/text", + offset: 1, + }); + const observerFailure = new Error("observer failed"); + fixture.editor.subscribe(() => { + throw observerFailure; + }); + + expect( + documentHost.runReady({ + id: "selection-observer-failure", + apply() { + fixture.document.commit( + [{ op: "test", path: "/blocks/1/id", value: "beta" }], + { + mergeKey: "selection-observer-failure", + origin: "causal-test", + selectionAfter: { path: "/blocks/1/text", offset: 2 }, + }, + ); + }, + }), + ).toEqual({ ok: true }); + + expect(fixture.document.selection?.primaryRange).toEqual({ + anchor: { path: "/blocks/1/text", offset: 2 }, + focus: { path: "/blocks/1/text", offset: 2 }, + }); + expect(window.getSelection()?.focusNode).toBe(textNode(fixture, "beta")); + expect(window.getSelection()?.focusOffset).toBe(2); + expect(fixture.faults).toContainEqual( + expect.objectContaining({ code: "subscriber_failed" }), + ); + }); +}); diff --git a/packages/editable/editor.ts b/packages/editable/editor.ts index eb4f59f..c0283a5 100644 --- a/packages/editable/editor.ts +++ b/packages/editable/editor.ts @@ -1,4 +1,4 @@ -export { mountJsonEditable } from "./browser"; +export { getJsonEditableDocumentHost, mountJsonEditable } from "./browser"; export type { EditorAction, EditorFault, @@ -6,5 +6,6 @@ export type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, } from "./browser"; diff --git a/packages/editable/index.ts b/packages/editable/index.ts index f29492b..49b3545 100644 --- a/packages/editable/index.ts +++ b/packages/editable/index.ts @@ -15,7 +15,7 @@ export type { EditablePoint, OrderedEditableSelection, } from "./browser"; -export { mountJsonEditable } from "./browser"; +export { getJsonEditableDocumentHost, mountJsonEditable } from "./browser"; export type { EditorAction, EditorFault, @@ -23,5 +23,6 @@ export type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, } from "./browser"; diff --git a/packages/editable/publicApi.test.ts b/packages/editable/publicApi.test.ts index 88822f3..0ef40e2 100644 --- a/packages/editable/publicApi.test.ts +++ b/packages/editable/publicApi.test.ts @@ -1,4 +1,5 @@ import type { + JSONChangeMetadata, JSONDocument, JSONPatchOperation, Pointer, @@ -18,9 +19,11 @@ import type { EditorResult, EditorSnapshot, JsonEditable, + JsonEditableDocumentHost, MountJsonEditableOptions, OrderedEditableSelection, } from "./index"; +import { getJsonEditableDocumentHost } from "./index"; import * as PublicAPI from "./index"; type ExpectedBlockType = "paragraph" | "heading" | "quote" | "code"; @@ -55,7 +58,8 @@ type ExpectedFault = { | "input_state_lost" | "composition_overlap" | "composition_conflict" - | "queued_change_commit_failed"; + | "queued_change_commit_failed" + | "subscriber_failed"; recoverable: boolean; reason: string; }; @@ -109,6 +113,22 @@ type ExpectedResult = | "commit_failed"; reason: string; }; +type ExpectedDocumentHost = { + ownsPublication(publication: { + operations: ReadonlyArray; + metadata?: JSONChangeMetadata; + }): false | { sequence: number }; + runReady(request: { + id: string; + apply(): void; + }): + | { ok: true } + | { + ok: false; + code: "host_not_ready"; + reason: string; + }; +}; type ExpectedEditor = { dispatch(action: ExpectedAction): ExpectedResult; getSnapshot(): ExpectedSnapshot; @@ -130,6 +150,7 @@ describe("editable public API", () => { "editableBlockIndexFromTextPath", "editableTextPath", "findEditableBlockIndex", + "getJsonEditableDocumentHost", "mountJsonEditable", "orderedEditableSelection", "primaryEditablePoint", @@ -149,6 +170,7 @@ describe("editable public API", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf< MountJsonEditableOptions @@ -172,6 +194,9 @@ describe("editable public API", () => { expectTypeOf(PublicAPI.findEditableBlockIndex).toEqualTypeOf< (value: ExpectedDocument, blockId: string) => number >(); + expectTypeOf(getJsonEditableDocumentHost).toEqualTypeOf< + (editor: ExpectedEditor) => ExpectedDocumentHost + >(); expectTypeOf(PublicAPI.primaryEditablePoint).toEqualTypeOf< ( value: ExpectedDocument, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13c9e35..2f5afab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@interactive-os/json-document-patch-rebase': file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz + '@interactive-os/json-document-stable-id-rebase': file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz + importers: .: @@ -11,6 +15,12 @@ importers: '@interactive-os/json-document': specifier: 1.1.0-rc.0 version: 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + '@interactive-os/json-document-causal-patch-inbox': + specifier: file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz + version: file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) + '@interactive-os/json-document-id-resolver': + specifier: file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz + version: file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) '@tanstack/react-router': specifier: ^1.170.16 version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -276,6 +286,31 @@ packages: '@noble/hashes': optional: true + '@interactive-os/json-document-causal-patch-inbox@file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz': + resolution: {integrity: sha512-oGkLQJOvcAV1j05+XjKQxatp4ks7aar6LbdKh/g2cdDv3sQFz+kRNSStyJOxsHgqFLypMREV4WhSafGuCR8lUg==, tarball: file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + '@interactive-os/json-document': ^1.0.0 + + '@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz': + resolution: {integrity: sha512-rEDUwGPn7syeNLWDgM+40Klu/5qUWk/GWDieeUZRS+MYVN7lBghRPb1+MRAhdKfBRTUHLWek/R1DKweG+TBLKQ==, tarball: file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + '@interactive-os/json-document': ^1.0.0 + + '@interactive-os/json-document-patch-rebase@file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz': + resolution: {integrity: sha512-6ZZXG/6tB4DZns11lEo5yIkMVnS4m/+SVcb9tHBKCNWbTHP9zX8x9ahlrSL6kzfoEbcFXNzJPj73+tqzPZbY2Q==, tarball: file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + '@interactive-os/json-document': ^1.0.0 + + '@interactive-os/json-document-stable-id-rebase@file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz': + resolution: {integrity: sha512-d7wp2R8AKWyQjVEAjw9EHGePz/Jyj7zmddR3Fy9lCWEA1wgxyzEyBnI5DhAerFeUCNBFFVmghLgi14xaMmD0Bw==, tarball: file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + '@interactive-os/json-document': ^1.0.0 + '@interactive-os/json-document-id-resolver': ^0.1.0 + '@interactive-os/json-document@1.1.0-rc.0': resolution: {integrity: sha512-gC88ujdx95iCQWQBzkcNHRL76pGmNbjgVX5tSyawok+HKYfIp8b2pIQWEJNKm1kERARwX2CQLDo+fg6MgFVPFg==} peerDependencies: @@ -1497,6 +1532,27 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@interactive-os/json-document-causal-patch-inbox@file:vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': + dependencies: + '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + '@interactive-os/json-document-patch-rebase': file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) + '@interactive-os/json-document-stable-id-rebase': file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) + transitivePeerDependencies: + - '@interactive-os/json-document-id-resolver' + + '@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': + dependencies: + '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + + '@interactive-os/json-document-patch-rebase@file:vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': + dependencies: + '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + + '@interactive-os/json-document-stable-id-rebase@file:vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': + dependencies: + '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + '@interactive-os/json-document-id-resolver': file:vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) + '@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)': dependencies: zod: 4.4.3 diff --git a/src/editable-lab/JsonEditableDemo.tsx b/src/editable-lab/JsonEditableDemo.tsx index 594cd1b..62dd80b 100644 --- a/src/editable-lab/JsonEditableDemo.tsx +++ b/src/editable-lab/JsonEditableDemo.tsx @@ -24,14 +24,17 @@ import { type JsonEditable, mountJsonEditable, } from "../../packages/editable"; +import { createEditableCausalInbox } from "./causalDocumentInbox"; type EditableDocument = ReturnType; +type EditableCausalInbox = ReturnType; declare global { interface Window { __jsonEditableLab?: { document: EditableDocument; editor: JsonEditable; + causalInbox: EditableCausalInbox; }; } } @@ -40,6 +43,8 @@ export function JsonEditableDemo() { const document = useMemo(() => createEditableDocument(), []); const rootRef = useRef(null); const editorRef = useRef(null); + const causalInboxRef = useRef(null); + const causalTracerSequenceRef = useRef(0); const [snapshot, setSnapshot] = useState(null); const [lastFault, setLastFault] = useState(null); @@ -54,18 +59,22 @@ export function JsonEditableDemo() { document, onFault: setLastFault, }); + const causalInbox = createEditableCausalInbox(document, editor); editorRef.current = editor; + causalInboxRef.current = causalInbox; setSnapshot(editor.getSnapshot()); const unsubscribe = editor.subscribe(setSnapshot); - window.__jsonEditableLab = { document, editor }; + window.__jsonEditableLab = { document, editor, causalInbox }; return () => { if (window.__jsonEditableLab?.editor === editor) { delete window.__jsonEditableLab; } unsubscribe(); + causalInbox.dispose(); editor.destroy(); editorRef.current = null; + causalInboxRef.current = null; }; }, [document]); @@ -112,6 +121,73 @@ export function JsonEditableDemo() { }); }, []); + const runCausalTracer = useCallback(() => { + const editor = editorRef.current; + const inbox = causalInboxRef.current; + const editorSnapshot = editor?.getSnapshot(); + const inboxSnapshot = inbox?.current(); + const block = [...document.value.blocks] + .reverse() + .find( + (candidate) => candidate.id !== editorSnapshot?.composition?.blockId, + ); + if ( + editor === null || + inbox === null || + block === undefined || + inboxSnapshot?.journalRevision === undefined + ) { + return; + } + + causalTracerSequenceRef.current += 1; + const sequence = causalTracerSequenceRef.current; + const base = document.value; + const blockIndex = base.blocks.findIndex( + (candidate) => candidate.id === block.id, + ); + if (blockIndex < 0) { + return; + } + if (editorSnapshot?.composition === null) { + const local = editor.dispatch({ + type: "patch", + patch: [ + { + op: "add", + path: "/blocks/0", + value: { + id: `causal-local-${sequence}`, + type: "paragraph", + text: `로컬 선행 변경 ${sequence}`, + }, + }, + ], + label: "causal tracer local insertion", + }); + if (!local.ok) { + return; + } + } + + inbox.ingest({ + id: `causal-delayed-${sequence}`, + dependsOn: inboxSnapshot.frontier, + intent: { + kind: "positional", + base, + baseRevision: inboxSnapshot.journalRevision, + operations: [ + { + op: "replace", + path: `/blocks/${blockIndex}/text`, + value: `${block.text} · 지연 변경 ${sequence}`, + }, + ], + }, + }); + }, [document]); + return (
현재 조합과 충돌 + {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: mountJsonEditable assigns the empty host's editing semantics. */} @@ -197,6 +280,10 @@ export function JsonEditableDemo() { }} /> +
diff --git a/src/editable-lab/causalDocumentInbox.test.ts b/src/editable-lab/causalDocumentInbox.test.ts new file mode 100644 index 0000000..55ca8f8 --- /dev/null +++ b/src/editable-lab/causalDocumentInbox.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createEditableDocument, + type JsonEditable, + mountJsonEditable, +} from "../../packages/editable"; +import { createEditableCausalInbox } from "./causalDocumentInbox"; + +let editor: JsonEditable | null = null; + +afterEach(() => { + editor?.destroy(); + editor = null; + window.document.body.replaceChildren(); + vi.useRealTimers(); +}); + +describe("editable lab causal inbox", () => { + it("retries a deferred envelope in a microtask after composition settles", async () => { + vi.useFakeTimers(); + const document = createEditableDocument({ + schema: "interactive-os.editable-document@2", + id: "causal-lab-test", + blocks: [ + { id: "alpha", type: "paragraph", text: "abcdef" }, + { id: "beta", type: "paragraph", text: "second" }, + ], + }); + const root = window.document.createElement("div"); + window.document.body.append(root); + editor = mountJsonEditable({ root, document }); + const inbox = createEditableCausalInbox(document, editor); + const base = document.value; + const node = root.querySelector( + '[data-editable-block="alpha"] [data-editable-text]', + )?.firstChild; + if (!(node instanceof Text)) { + throw new Error("Missing alpha Text node."); + } + setDOMCaret(node, 2); + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + + const result = inbox.ingest({ + id: "delayed", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "settled" }, + ], + }, + }); + expect(result).toMatchObject({ ok: false, code: "host_not_ready" }); + + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ); + await vi.advanceTimersByTimeAsync(31); + + expect(document.value.blocks[1]?.text).toBe("settled"); + expect(inbox.current()).toMatchObject({ + status: "active", + frontier: ["delayed"], + queued: [], + }); + inbox.dispose(); + }); + + it("does not retry a recovered pin until the browser composition ends", async () => { + vi.useFakeTimers(); + const document = createEditableDocument({ + schema: "interactive-os.editable-document@2", + id: "causal-recovery-test", + blocks: [ + { id: "alpha", type: "paragraph", text: "abcdef" }, + { id: "beta", type: "paragraph", text: "second" }, + ], + }); + const root = window.document.createElement("div"); + window.document.body.append(root); + editor = mountJsonEditable({ root, document }); + const inbox = createEditableCausalInbox(document, editor); + const base = document.value; + const node = root.querySelector( + '[data-editable-block="alpha"] [data-editable-text]', + )?.firstChild; + if (!(node instanceof Text)) { + throw new Error("Missing alpha Text node."); + } + setDOMCaret(node, 2); + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + node.parentNode?.replaceChild( + window.document.createTextNode(node.data), + node, + ); + + expect( + inbox.ingest({ + id: "after-recovery", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [ + { op: "replace", path: "/blocks/1/text", value: "released" }, + ], + }, + }), + ).toMatchObject({ ok: false, code: "host_not_ready" }); + await Promise.resolve(); + expect(document.value.blocks[1]?.text).toBe("second"); + + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ); + await Promise.resolve(); + expect(document.value.blocks[1]?.text).toBe("second"); + await vi.advanceTimersByTimeAsync(31); + + expect(document.value.blocks[1]?.text).toBe("released"); + expect(inbox.current()).toMatchObject({ + status: "active", + frontier: ["after-recovery"], + queued: [], + }); + inbox.dispose(); + }); +}); + +function setDOMCaret(node: Text, offset: number): void { + const selection = window.getSelection(); + if (selection === null) { + throw new Error("The test DOM does not expose a Selection."); + } + const range = window.document.createRange(); + range.setStart(node, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} diff --git a/src/editable-lab/causalDocumentInbox.ts b/src/editable-lab/causalDocumentInbox.ts new file mode 100644 index 0000000..33f5861 --- /dev/null +++ b/src/editable-lab/causalDocumentInbox.ts @@ -0,0 +1,103 @@ +import type { JSONDocument } from "@interactive-os/json-document"; +import { + type CausalPatchInbox, + createCausalPatchInbox, +} from "@interactive-os/json-document-causal-patch-inbox"; +import { + EditableDocumentSchema, + type EditableDocumentValue, + getJsonEditableDocumentHost, + type JsonEditable, +} from "../../packages/editable"; + +export function createEditableCausalInbox( + document: JSONDocument, + editor: JsonEditable, +): CausalPatchInbox { + const inbox = createCausalPatchInbox(document, { + host: getJsonEditableDocumentHost(editor), + positionalSchema: EditableDocumentSchema, + stableIdScopes: [ + { + scope: "editable-block", + query: "/blocks/*", + readId: (value) => readBlockId(value), + }, + ], + }); + let disposed = false; + let retryPending = false; + let retryScheduled = false; + let lastAttemptedRevision: number | null = null; + + const scheduleRetry = ( + snapshot: ReturnType, + allowCurrentRevision = false, + ): void => { + if ( + disposed || + !retryPending || + retryScheduled || + snapshot.phase !== "idle" || + snapshot.queuedChanges !== 0 || + (!allowCurrentRevision && lastAttemptedRevision === snapshot.revision) + ) { + return; + } + retryScheduled = true; + queueMicrotask(() => { + retryScheduled = false; + if (disposed || !retryPending) { + return; + } + const latest = editor.getSnapshot(); + if (latest.phase !== "idle" || latest.queuedChanges !== 0) { + return; + } + retryPending = false; + lastAttemptedRevision = latest.revision; + const result = inbox.ingest([]); + if (!result.ok && result.code === "host_not_ready") { + retryPending = true; + } + }); + }; + + const stopEditorSubscription = editor.subscribe((snapshot) => { + scheduleRetry(snapshot); + }); + + return { + ingest(input) { + const result = inbox.ingest(input); + if (!result.ok && result.code === "host_not_ready") { + retryPending = true; + scheduleRetry(editor.getSnapshot(), true); + } else if (result.ok) { + retryPending = false; + } + return result; + }, + current: () => inbox.current(), + dispose() { + if (disposed) { + return; + } + disposed = true; + stopEditorSubscription(); + inbox.dispose(); + }, + }; +} + +function readBlockId(value: unknown): string | null { + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" + ) { + return null; + } + return value.id; +} diff --git a/tests/browser/editable.spec.ts b/tests/browser/editable.spec.ts index 2480a3d..ca0eb9a 100644 --- a/tests/browser/editable.spec.ts +++ b/tests/browser/editable.spec.ts @@ -129,6 +129,29 @@ test("ordinary browser Enter splits the selected block through the model", async await expect(lastFault(page)).toHaveText("null"); }); +test("the delayed-edit tracer rebases across its local leading insertion", async ({ + page, +}) => { + const initialCount = (await readDocumentBlocks(page)).length; + + await page.getByRole("button", { name: "지연 편집 추적" }).click(); + + await expect.poll(() => readDocumentBlocks(page)).toHaveLength(initialCount + 1); + const blocks = await readDocumentBlocks(page); + expect(blocks[0]).toEqual({ + id: "causal-local-1", + type: "paragraph", + text: "로컬 선행 변경 1", + }); + expect(blocks.find((block) => block.id === "render-rule")?.text).toBe( + "renderOutside(compositionIsland) · 지연 변경 1", + ); + await expect(blockSurface(page, "render-rule")).toHaveText( + "renderOutside(compositionIsland) · 지연 변경 1", + ); + await expect(lastFault(page)).toHaveText("null"); +}); + // These tests dispatch untrusted CompositionEvent/InputEvent objects in a real // browser page and manually perform the DOM mutation a browser would perform. // They validate the coordinator protocol, but they do not create an OS IME diff --git a/vendor/json-document-e8d572d4/README.md b/vendor/json-document-e8d572d4/README.md new file mode 100644 index 0000000..405b52e --- /dev/null +++ b/vendor/json-document-e8d572d4/README.md @@ -0,0 +1,28 @@ +# json-document dogfood artifacts + +These packages were built from merged `json-document` commit +`e8d572d4af1d6570dde2783972e40b9f914236c2` (PR #232). They keep this +integration tracer independent from an npm release and make the exact upstream +code under test reviewable. + +## Rebuild + +From a clean checkout of that commit in `../json-document`: + +```sh +npm ci +npm run build +npm pack --pack-destination ../editable/vendor/json-document-e8d572d4 ./labs/extensions/causal-patch-inbox +npm pack --pack-destination ../editable/vendor/json-document-e8d572d4 ./packages/id-resolver +npm pack --pack-destination ../editable/vendor/json-document-e8d572d4 ./labs/extensions/patch-rebase +npm pack --pack-destination ../editable/vendor/json-document-e8d572d4 ./labs/extensions/stable-id-rebase +``` + +## SHA-256 + +```text +7fae978a0449b6d7e705be959703edad17c16b4d67868ee8d18498302daecf04 interactive-os-json-document-causal-patch-inbox-0.1.0.tgz +d0787346d5a566fbbf4847343f519f761ba25e9a79762663167eaba7413ce8bf interactive-os-json-document-id-resolver-0.1.0.tgz +88a20dd7d755b2befb365e74fffce860573abcc392eb74c9aa44976fb9578b64 interactive-os-json-document-patch-rebase-0.1.0.tgz +5e246ee1b82c06598b1a89ac0f85cea9e826d1730577c04f264519aa93d8512a interactive-os-json-document-stable-id-rebase-0.1.0.tgz +``` diff --git a/vendor/json-document-e8d572d4/SHA256SUMS b/vendor/json-document-e8d572d4/SHA256SUMS new file mode 100644 index 0000000..55c6963 --- /dev/null +++ b/vendor/json-document-e8d572d4/SHA256SUMS @@ -0,0 +1,4 @@ +7fae978a0449b6d7e705be959703edad17c16b4d67868ee8d18498302daecf04 interactive-os-json-document-causal-patch-inbox-0.1.0.tgz +d0787346d5a566fbbf4847343f519f761ba25e9a79762663167eaba7413ce8bf interactive-os-json-document-id-resolver-0.1.0.tgz +88a20dd7d755b2befb365e74fffce860573abcc392eb74c9aa44976fb9578b64 interactive-os-json-document-patch-rebase-0.1.0.tgz +5e246ee1b82c06598b1a89ac0f85cea9e826d1730577c04f264519aa93d8512a interactive-os-json-document-stable-id-rebase-0.1.0.tgz diff --git a/vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz b/vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz new file mode 100644 index 0000000..275d1b2 Binary files /dev/null and b/vendor/json-document-e8d572d4/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz differ diff --git a/vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz b/vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz new file mode 100644 index 0000000..440c446 Binary files /dev/null and b/vendor/json-document-e8d572d4/interactive-os-json-document-id-resolver-0.1.0.tgz differ diff --git a/vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz b/vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz new file mode 100644 index 0000000..00ef321 Binary files /dev/null and b/vendor/json-document-e8d572d4/interactive-os-json-document-patch-rebase-0.1.0.tgz differ diff --git a/vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz b/vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz new file mode 100644 index 0000000..a816487 Binary files /dev/null and b/vendor/json-document-e8d572d4/interactive-os-json-document-stable-id-rebase-0.1.0.tgz differ