From 23543f11cf0ada8e4353e2c8485cdb2ecc41baeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 13 Jul 2026 13:59:14 +0900 Subject: [PATCH] feat(canvas): coordinate DOM input with React commits --- CONTEXT.md | 3 + ...6-canonical-design-document-causal-host.md | 10 +- .../adr/0007-dom-input-render-coordination.md | 115 +++++ e2e/figjam-react-cutover.e2e.ts | 39 ++ e2e/fixtures/react-design-dom-settle.html | 12 + e2e/fixtures/react-design-dom-settle.tsx | 211 ++++++++ e2e/react-design-dom-settle.e2e.ts | 34 ++ packages/figjam-clone/src/FigJamCloneApp.tsx | 9 +- .../src/FigJamTextEditor.test.tsx | 218 ++++++++ .../figjam-clone/src/FigJamTextEditor.tsx | 127 ++++- packages/figma-clone/src/FigmaCloneApp.tsx | 9 +- .../src/FigmaCloneDevtoolsIntegration.test.ts | 2 +- ...nvasDomReactEditorArchitecturePlan.test.ts | 24 + .../CanvasPackagePublicSurface.test.ts.snap | 3 + .../ReactDesignEditorExternalChanges.ts | 232 +++++++++ .../ReactDesignEditorRenderer.tsx | 32 ++ .../ReactDesignEditorRuntime.test.tsx | 464 +++++++++++++++++- .../react-design/ReactDesignEditorRuntime.ts | 4 + .../ReactDesignTextSelection.test.ts | 286 +++++++++++ .../react-design/ReactDesignTextSelection.ts | 247 ++++++++++ src/canvas/react-design/index.ts | 17 + 21 files changed, 2063 insertions(+), 35 deletions(-) create mode 100644 docs/adr/0007-dom-input-render-coordination.md create mode 100644 e2e/fixtures/react-design-dom-settle.html create mode 100644 e2e/fixtures/react-design-dom-settle.tsx create mode 100644 e2e/react-design-dom-settle.e2e.ts create mode 100644 packages/figjam-clone/src/FigJamTextEditor.test.tsx create mode 100644 src/canvas/react-design/ReactDesignEditorExternalChanges.ts create mode 100644 src/canvas/react-design/ReactDesignEditorRenderer.tsx create mode 100644 src/canvas/react-design/ReactDesignTextSelection.test.ts create mode 100644 src/canvas/react-design/ReactDesignTextSelection.ts diff --git a/CONTEXT.md b/CONTEXT.md index 6d6d67f3..bfe2e1bc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,6 +12,9 @@ - Authored Content: DesignDocument가 표현하고 ReactDesignRenderer가 실제 browser DOM으로 투영하는 사용자 디자인 내용. live DOM은 derived runtime output이지 document state가 아니다. - Editor Runtime State: selection, camera, active tool/mode, focus, hover, DOM registration/measurement, overlay, guide, marquee, pointer draft와 live preview처럼 편집 세션 동안만 유효한 상태. DesignDocument, authored history payload, document sync에 들어가지 않는다. - ReactDesignRenderer: DesignDocument node를 intrinsic element 또는 registered component/widget definition으로 해석해 direct React DOM을 만들고 stable node identity를 DomProjection에 등록하는 renderer. document mutation과 editing policy는 소유하지 않는다. +- ReactDesignEditorRenderer: ReactDesignEditorRuntime의 document, editor, registry, projection을 ReactDesignRenderer에 연결하고 immutable DesignDocument/EditorEngine snapshot identity를 mounted subtree layout commit에서 acknowledge하는 runtime-owned renderer. 이 신호는 DOM mutation과 ref commit 완료를 뜻하며 paint, font, image, async layout settle을 뜻하지 않는다. +- ReactDesign Editor External Change Host: EditorEngineDocumentHost의 preview/mutation gate에 정확히 하나인 canonical ReactDesignEditorRenderer의 mounted render lease와 snapshot commit acknowledgement를 결합하는 browser coordination Module. ready external change는 현재 document/editor state가 React DOM에 commit된 뒤에만 실행되고, renderer 중복 mount가 감지된 runtime은 projection ownership을 증명할 수 없어 남은 lifetime 동안 fail-closed하며, 다음 retry 알림은 generation과 snapshot identity를 다시 확인한다. +- ReactDesign Text Selection: raw DOM Range 대신 stable design node id, UTF-16 anchor/focus offset과 direction을 저장하고 ownership/focus generation이 유지될 때만 현재 text control에 복원하는 ephemeral browser selection Module. 같은 node의 text patch에 대한 offset rebase는 하지 않고 현재 value 길이에 clamp한다. - DomProjection: stable design node id와 HTMLElement 사이의 ephemeral runtime 관계, measurement, coordinate projection, observation, hit target을 소유하는 Module. DOM element와 측정값은 DesignDocument에 저장하지 않는다. - EditorEngine: Canvas Foundation/Affordance grammar를 DesignDocument read/command Interface와 DomProjection에 적용해 Figma식 selection, transform, text/layout, keyboard effect를 계획하는 Module. committed edit는 하나의 atomic document command로만 만든다. - React Component Definition: stable definition id에 React renderer, JSON prop validation/default, creation/edit capability, optional inspector contribution을 결합하는 registered definition. DesignDocument는 definition function 대신 id와 JSON data만 참조한다. diff --git a/docs/adr/0006-canonical-design-document-causal-host.md b/docs/adr/0006-canonical-design-document-causal-host.md index 84c6318a..abea8867 100644 --- a/docs/adr/0006-canonical-design-document-causal-host.md +++ b/docs/adr/0006-canonical-design-document-causal-host.md @@ -77,16 +77,16 @@ validation and widen the public mutation surface. - Canvas now has the headless seam needed to test delayed stable-id editing without exposing the internal JSONDocument or adding a runtime dependency on - private labs. A FigJam browser test remains follow-up evidence. + private labs. ADR 0007 adds separate browser evidence for React commit, + composition blur, DOM remount, and stable text selection restoration. - Structural and positional delayed edits need granular DesignDocument command patches instead of a root replacement. - A concrete remote publication discards pre-remote local undo and redo. A future selective-undo design must rebase owned inverses explicitly before it can preserve that history safely. -- DOM caret handoff needs a separate selection adapter plus a render-settle - signal; it must not be inferred from the authored graph. -- FigJam composition/blur behavior and a real ReactDesignRenderer commit must - be covered before claiming IME-safe browser coordination. +- DOM caret handoff now has a text-control selection adapter and explicit React + commit acknowledgement. Contenteditable affinity and native cross-browser IME + evidence remain follow-up work. - Large documents still pay whole-snapshot clone/freeze, validation, and index rebuild costs. Incremental validation and indexing remain separate performance work. diff --git a/docs/adr/0007-dom-input-render-coordination.md b/docs/adr/0007-dom-input-render-coordination.md new file mode 100644 index 00000000..8c41ff09 --- /dev/null +++ b/docs/adr/0007-dom-input-render-coordination.md @@ -0,0 +1,115 @@ +# ADR 0007: DOM Input and Render Coordination + +## Status + +Provisional — accepted for browser dogfood, not a native IME or convergence claim. + +## Context + +ADR 0005 makes `DesignDocument` the authored source of truth and direct React +DOM its derived browser surface. ADR 0006 prevents a ready external document +change from running while `EditorEngine` preview owns uncommitted input. The +headless causal tracer intentionally retries in a microtask and does not prove +that React has committed either the local or external snapshot. + +That gap matters for browser input. A canonical document publication can finish +synchronously while React is still rendering the previous snapshot. A raw DOM +selection can also point at an element that React is about to replace. Using +`DomProjection.revision()`, `EditorEngineSnapshot.revision`, a timer, animation +frame, or `ResizeObserver` as a render acknowledgement would mix unrelated +invalidation with actual React commit. + +FigJam additionally committed `blur` during an active composition. That could +end the preview and unmount its textarea before the final composition input was +observed. + +## Decision + +1. `ReactDesignEditorRenderer` is the runtime-owned canonical render surface. + It keeps the existing `ReactDesignRenderer` interface intact and + acknowledges the immutable `DesignDocument.snapshot` and + `EditorEngineSnapshot` identities from a parent layout effect after the + renderer subtree DOM mutation and callback refs have committed. The editor + snapshot identity invalidates old evidence; its revision number is not + itself render evidence. The acknowledgement is a mounted render lease and + is revoked by layout-effect cleanup. +2. `getReactDesignEditorExternalChangeHost(runtime)` exposes the browser-aware + external change host without changing the existing `ReactDesignEditorRuntime` + shape. It delegates publication ownership and the headless preview/mutation + gate to `EditorEngineDocumentHost`, and additionally returns `host_not_ready` + until the one canonical renderer lease acknowledges the current document + and editor snapshot identities. A runtime has exactly one canonical render + surface because its `DomProjection` registration is singular/latest-wins; + a runtime that observes overlapping renderer mounts remains fail closed for + the rest of its lifetime. Runtimes backed by a document without patch + coordination remain usable when this additive capability is not requested. +3. A ready notification is delivered in a generation-checked microtask after + the explicit layout-commit acknowledgement. The microtask is delivery + scheduling, not the evidence of render completion. If the document snapshot + changes while one listener applies an external change, remaining listeners + do not run for the obsolete snapshot. +4. The required ordering is: + + ```text + input preview owns browser edits + -> local canonical commit + -> local React DOM commit acknowledgement + -> ready external change apply + -> external React DOM commit acknowledgement + -> selection correction + ``` + +5. `ReactDesignTextSelection` stores text-control selection as stable design + node id, UTF-16 anchor/focus offsets, and direction. It does not + retain a DOM `Range` or element reference in the bookmark. Restore resolves + the current element and succeeds only for the active ownership generation, + unchanged focus generation, connected element, and current logical node. + A body-focus gap is accepted only when the captured control was disconnected + and the resolver now returns a different connected replacement. Focusing is + revalidated before the range is written. +6. A React remount may focus the replacement control before its ref callback is + visible to the resolver. That focus transition is reconciled only when the + previously focused element becomes disconnected, no later focus event has + intervened, and the resolver identifies the new target as the same active + logical input. +7. FigJam composition blur is deferred in explicit `composing` and `settling` + phases. `compositionend` starts a generation-checked 30 ms settling window; + a later final input updates the existing `EditorEnginePreviewSession` first + and restarts that window. New composition, preview-session replacement, or + unmount invalidates the timer. This is a conservative browser event window, + not render acknowledgement. +8. The browser causal tracer lives under `e2e/fixtures`. It may compose the + SHA-pinned private causal inbox, but production `src/canvas/**` keeps no + runtime dependency on unpublished json-document labs. + +## Boundaries + +- A React layout commit acknowledgement does not mean browser paint, font load, + image decode, network completion, or asynchronous layout has settled. +- `DesignDocument` remains independent from DOM, React, focus, composition, and + selection. +- `DomProjection` remains measurement and node-to-element runtime state. Its + revision and subscription channel are not reused for render acknowledgement. +- The external change host does not choose CRDT, OT, transport, retry backoff, + acknowledgement, persistence, or conflict policy. +- Text selection bookmarks currently cover native text controls. They preserve + and clamp UTF-16 offsets across remount; they do not rebase offsets through a + same-node text patch. A contenteditable adapter or patch-aware correction + layer must define its own position mapping and boundary affinity semantics. +- Synthetic Chromium composition evidence verifies browser event-handler + ordering, not real Korean/Japanese IME behavior across operating systems or + Safari. Native IME verification remains required before an IME-safe claim. + +## Consequences + +- Figma and FigJam canonical routes use the runtime-owned renderer, so future + external-change adapters can consume one render-aware host instead of wiring + document, engine, projection, and React timing independently. +- A delayed change cannot run merely because a preview ended; the locally + committed or reverted DOM must first be observed in a React commit. +- Selection correction cannot steal focus after the user moves to another + control, deliberately blurs a still-connected editor, or a newer edit + generation takes ownership. +- The headless causal tracer remains useful for deterministic document + coordination, while the Chromium tracer provides separate evidence for DOM + focus, remount, and selection behavior. diff --git a/e2e/figjam-react-cutover.e2e.ts b/e2e/figjam-react-cutover.e2e.ts index 4342270c..02c7bf79 100644 --- a/e2e/figjam-react-cutover.e2e.ts +++ b/e2e/figjam-react-cutover.e2e.ts @@ -194,6 +194,45 @@ test('previews, cancels, commits, and restores direct text edits', async ({ await expect(sticky).toContainText('Ship the canonical DOM board') }) +test('keeps a blurred composition preview until its final input commits', async ({ + page, +}) => { + await page.goto('/figjam') + + const app = figJamApp(page) + const sticky = designNode(page, 'figjam-sticky') + + await sticky.dblclick({ force: true }) + const editor = page.getByRole('textbox', { + name: 'Edit sticky note text', + }) + + await editor.focus() + await editor.dispatchEvent('compositionstart', { data: '한' }) + await editor.evaluate((element) => element.blur()) + + await expect(editor).toBeVisible() + await expect(app).toHaveAttribute('data-preview-node-id', 'figjam-sticky') + + await editor.evaluate((element) => { + element.dispatchEvent(new CompositionEvent('compositionend', { + bubbles: true, + data: '한', + })) + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )?.set + + setter?.call(element, '한글 입력') + element.dispatchEvent(new Event('input', { bubbles: true })) + }) + + await expect(editor).toHaveCount(0) + await expect(sticky).toContainText('한글 입력') + await expect.poll(() => app.getAttribute('data-preview-node-id')).toBeNull() +}) + test('honors widget move and resize capabilities in DOM controls', async ({ page, }) => { diff --git a/e2e/fixtures/react-design-dom-settle.html b/e2e/fixtures/react-design-dom-settle.html new file mode 100644 index 00000000..6d2cc7b9 --- /dev/null +++ b/e2e/fixtures/react-design-dom-settle.html @@ -0,0 +1,12 @@ + + + + + + React design DOM settle tracer + + +
+ + + diff --git a/e2e/fixtures/react-design-dom-settle.tsx b/e2e/fixtures/react-design-dom-settle.tsx new file mode 100644 index 00000000..97830897 --- /dev/null +++ b/e2e/fixtures/react-design-dom-settle.tsx @@ -0,0 +1,211 @@ +import { useEffect, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { createCausalPatchInbox } from '@interactive-os/json-document-causal-patch-inbox' +import type { EditorEnginePreviewSession } from '@interactive-os/canvas/editor' +import { + ReactDesignEditorRenderer, + createDesignDocument, + createReactDesignDefinitionRegistry, + createReactDesignTextSelection, + getDesignDocumentPatchPort, + getReactDesignEditorExternalChangeHost, + useReactDesignEditorRuntime, + type DesignNodeId, + type ReactDesignTextSelectionBookmark, + type ReactDesignTextSelectionOwnership, +} from '@interactive-os/canvas/react-design' + +const STABLE_ID_SCOPES = [{ + scope: 'design-node', + query: '$.nodes[*]', + readId(value: unknown) { + if (!value || typeof value !== 'object' || !('id' in value)) { + return undefined + } + + return typeof value.id === 'string' ? value.id : undefined + }, +}] + +type ActiveEdit = { + readonly draft: string + readonly session: EditorEnginePreviewSession | null +} + +export function DomSettleTracer() { + const runtime = useReactDesignEditorRuntime({ + createDocument: createTracerDocument, + createRegistry: () => createReactDesignDefinitionRegistry({ + intrinsics: ['div'], + }), + viewport: { initial: { scale: 1, x: 0, y: 0 } }, + }) + const externalChanges = getReactDesignEditorExternalChangeHost(runtime) + const [inbox] = useState(() => createCausalPatchInbox( + getDesignDocumentPatchPort(runtime.document), + { + host: externalChanges, + stableIdScopes: STABLE_ID_SCOPES, + }, + )) + const [selection] = useState(() => + createReactDesignTextSelection({ document })) + const [edit, setEdit] = useState(null) + const [status, setStatus] = useState('idle') + const editorRef = useRef(null) + const ownershipRef = useRef(null) + const bookmarkRef = useRef(null) + const noteA = runtime.editor.read.node('note-a')?.text ?? '' + const noteB = runtime.editor.read.node('note-b')?.text ?? '' + + useEffect(() => externalChanges.subscribeReady(() => { + const hasReadyRemote = inbox.current().queued.some((entry) => + entry.missing.length === 0) + + if (hasReadyRemote) { + const result = inbox.ingest([]) + + setStatus(result.ok ? 'remote-applied' : result.code) + return + } + + const ownership = ownershipRef.current + const bookmark = bookmarkRef.current + + if (!ownership || !bookmark) { + return + } + + bookmarkRef.current = null + setStatus(ownership.restore(bookmark) + ? 'selection-restored' + : 'selection-stale') + }), [externalChanges, inbox]) + + useEffect(() => () => { + ownershipRef.current?.release() + selection.dispose() + inbox.dispose() + }, [inbox, selection]) + + const beginEdit = () => { + const session = runtime.editor.commands.beginPreview({ + label: 'Edit note A', + nodeId: 'note-a', + }) + + if (!session) { + setStatus('preview-unavailable') + return + } + + ownershipRef.current?.release() + ownershipRef.current = selection.claim({ + nodeId: 'note-a', + readElement: () => editorRef.current, + }) + setEdit({ draft: noteA, session }) + setStatus('editing') + } + + const updateEdit = (value: string) => { + if (!edit?.session) { + return + } + + const result = edit.session.update([{ target: 'text', value }]) + + setStatus(result.ok ? 'previewing' : result.code) + if (result.ok) { + setEdit({ ...edit, draft: value }) + } + } + + const commitEdit = () => { + if (!edit?.session) { + return + } + + bookmarkRef.current = ownershipRef.current?.capture() ?? null + const result = edit.session.commit() + + setStatus(result.ok ? 'local-committed' : result.code) + if (result.ok) { + setEdit({ ...edit, session: null }) + } + } + + const queueRemote = () => { + const result = inbox.ingest({ + id: 'remote-note-b', + dependsOn: [], + intent: { + kind: 'stable-id-replace', + target: { scope: 'design-node', id: 'note-b' }, + relativePath: '/text', + expected: 'Draft B', + value: 'Remote B', + }, + }) + + setStatus(result.ok ? 'remote-applied' : result.code) + } + + return ( +
+ + + + {edit ? ( +