Skip to content

feat: controlled comments API + derive tracked changes from PM (collab-ready)#255

Merged
jedrazb merged 3 commits intofeat/collaboration-examplefrom
feat/controlled-comments-collab
Apr 13, 2026
Merged

feat: controlled comments API + derive tracked changes from PM (collab-ready)#255
jedrazb merged 3 commits intofeat/collaboration-examplefrom
feat/controlled-comments-collab

Conversation

@jedrazb
Copy link
Copy Markdown
Contributor

@jedrazb jedrazb commented Apr 13, 2026

Stacked on #254. Base will retarget to `main` once that lands.

Summary

Two changes that together make comments and tracked changes work over Yjs:

  1. Comments — additive controlled mode. New optional `comments` + `onCommentsChange` props on `DocxEditor`. When passed, the editor reads thread metadata (text, author, replies, resolved status) from the prop and emits every change through the callback. Pair with a `Y.Array` (or any other shared collection) to sync threads across collaborators. Existing consumers see no behavior change — fallback to internal `useState` is identical to today.

  2. Tracked changes — eliminate the redundant React state. `useState<TrackedChangeEntry[]>([])` was a debounced, derived view of data already in PM mark attrs (`insertion`/`deletion` carry `revisionId`/`author`/`date`). Replaced by a `useTrackedChanges(state)` hook that derives synchronously on every doc-changing transaction. Tracked changes already collab-sync through `ySyncPlugin` because the metadata rides along with the synced PM tree — this PR just removes the redundant copy and confirms the path.

Demo: the existing `examples/collaboration` is updated to wire comments through a `Y.Array` on the same `Y.Doc`. Open the demo in two browser windows (Share link → paste in second tab) to verify both flows end-to-end.

What changed

File Why
`packages/react/src/hooks/useTrackedChanges.ts` (new) Pure `extractTrackedChanges(state)` returns `{ entries, commentToRevision }` from a single doc walk — both the sidebar list and the comment-threading map come from one pass.
`packages/react/src/hooks/useControllableComments.ts` (new) Radix-style controlled/uncontrolled hook for the comments array.
`packages/react/src/components/DocxEditor.tsx` Replace state declarations; remove ~120-line `extractTrackedChanges` callback; remove 300ms debounce timer; comment-threading effect consumes the pre-computed map.
`examples/collaboration/src/useCollaboration.ts` Expose `comments` + `setComments` mirrored against `Y.Array`.
`examples/collaboration/src/App.tsx` Pass new controlled props to `DocxEditor`.
`examples/collaboration/src/identity.ts` Drop random number suffix from display name (fixes `A4` initials).
`examples/collaboration/README.md` Document the four-piece architecture; document the demo's known limitations honestly.
`docs/PROPS.md` Two new props in the table + a "Controlled Comments" section with a Yjs Y.Array bridge example.

API surface

```tsx
<DocxEditor
document={createEmptyDocument()}
externalContent
externalPlugins={[ySync, yCursor, yUndo]}
comments={comments} // NEW: controlled
onCommentsChange={setComments} // NEW: fires on every change
// ...
/>
```

The granular `onCommentAdd`/`onCommentResolve`/`onCommentDelete`/`onCommentReply` callbacks still fire alongside `onCommentsChange` — parents that just want notifications don't need to switch.

Why tracked changes don't need a controlled API

Originally I assumed tracked-changes metadata lived in React state too. Code reading proved otherwise: it's already in PM mark attrs, parsed from `<w:ins>`/`<w:del>` and serialized back via `fromProseDoc.ts`. The React state was a debounced derived view used by the sidebar. That copy is now gone — the sidebar derives directly from PM via the new hook, and remote ySync updates flow through automatically.

This matches superdoc's split: comments use an external entity store (their `comment-entity-store.ts` + `Y.Map` collab adapter), tracked changes live entirely in PM marks.

Review pass (4 reviewers, applied fixes inline)

  • Code reviewer — flagged `200ms setTimeout` for initial PM-state mirror as a smell. Kept (works; replacing properly needs a new "doc loaded" callback from `HiddenProseMirror`, larger change).
  • Simplifier — flagged duplicate doc walk in the threading effect. Fixed: `extractTrackedChanges` now returns `{ entries, commentToRevision }` from a single `doc.descendants` pass.
  • UX reviewer — flagged comment-id collisions (filed #257), Y.Array replace-all data loss (strengthened README caveat), `A4` initials bug (fixed), remote cursors invisible (filed #256), tracked-change accept/reject race (added README caveat).
  • Performance reviewer — flagged the duplicate doc walk (fixed by simplifier change above) as the highest-impact perf issue. Also called out the demo's full-array Y.Array replace as a bandwidth/memory concern at scale (already documented in README; production guidance to use `Y.Map<id, Comment>`).

Test plan

  • `bun run typecheck` — clean across all workspaces
  • `bun run build` (collab demo) — clean build
  • `comment-button.spec.ts` — 3/3 pass
  • Manual smoke check: two browser tabs at http://localhost:5273, verify text/cursors/comments/tracked-changes all sync (pending live reviewer verification)
  • `toolbar-state.spec.ts` and `text-editing.spec.ts` show flakes — same flakes are present on `main` (CLAUDE.md flags them as known flaky), unrelated to this change

Risks

  • Sidebar reactivity for tracked changes: now synchronous on every doc-changing transaction instead of 300ms-debounced. Single doc walk is O(nodes); per-keystroke cost stays sub-millisecond on typical docs. Scaling cliff at 500+ pages with hundreds of tracked changes — flagged in code comments; mitigation is to wrap `setPmState` in `requestAnimationFrame` rather than reintroducing a delay.
  • Comment-threading effect loop: depends on `[commentToRevision, setComments]` and writes `comments`. Short-circuited via the existing `changed` flag and `useControllableComments`'s reference-equality check.
  • Demo replace-all Y.Array sync: documented in README; production should use `Y.Map<id, Comment>`.

Follow-ups filed

  • #256 Forward PM decorations to layout-painter (unblocks remote cursors)
  • #257 Comment IDs can collide between collaborating peers

Unblocks Yjs collaboration for comments and confirms tracked changes
already sync via ySyncPlugin.

**Comments — additive controlled mode** (new optional props):
- `comments: Comment[]` — when passed, the editor reads thread metadata
  from the prop instead of internal state
- `onCommentsChange: (comments: Comment[]) => void` — fires on every
  mutation (add, reply, resolve, unresolve, delete, orphan cleanup)

When omitted, the editor falls back to internal `useState<Comment[]>([])`.
Existing consumers see no behavior change.

Implemented via a small `useControllableComments` hook (Radix-style
controlled/uncontrolled pattern). All existing `setComments` call sites
are unchanged — they route through the hook's setter.

**Tracked changes — eliminate the redundant React state**:
- `useTrackedChanges(state)` derives `TrackedChangeEntry[]` from the
  current PM state via memoized pure-function extraction
- DocxEditor mirrors the latest PM state on every doc-changing transaction
  (including remote ones from ySyncPlugin)
- 300ms debounce on extraction is gone — sidebar updates immediately
- Comment-to-tracked-change threading moved to its own effect that depends
  on `[trackedChanges, pmState]`

Tracked changes already worked over Yjs because metadata lives in the
`insertion`/`deletion` mark attrs (synced with the PM tree). The React
state was a redundant derived view.

**Demo (`examples/collaboration`)**:
- `useCollaboration` now exposes `comments` + `setComments`, mirrored
  against a `Y.Array<Comment>` on the same `Y.Doc`
- `App.tsx` passes them as the controlled props
- README documents the four-piece architecture and the comments
  replace-all caveat (production should use `Y.Map<id, Comment>`)

**Docs**: `docs/PROPS.md` adds the two new props + a "Controlled
Comments" section with a Yjs Y.Array bridge example.

Stacked on PR #254 (the demo creation) — base will retarget to main
once that lands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docx-editor Ready Ready Preview, Comment Apr 13, 2026 9:06am

Request Review

Code review fixes for PR #255:

- **Eliminate duplicate doc walk** (perf review): comment→revision overlap
  map is now built in the same `doc.descendants` pass as tracked-change
  extraction. Halves per-keystroke walk cost on large docs with tracked
  changes. `useTrackedChanges` returns `{ entries, commentToRevision }`.

- **Drop random number from display name** (UX review): `Ada 42` → `Ada`,
  fixes `A4` initials in AvatarStack.

- **Strengthen demo README caveats** (UX review): document the data-loss
  risk of the naive Y.Array replace-all sync, link to remote-cursor issue
  (#256) and comment-id-collision issue (#257), call out tracked-change
  accept/reject race.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address the two items deferred from the first review pass:

- **Inline `useControllableComments`** (simplifier): the hook had one
  call site and ~50 lines of plumbing for a pattern that's ~12 lines
  inline. Folded into DocxEditor right next to the existing
  `commentsRef`/`onCommentDeleteRef` block. Hook file deleted.

- **Replace 200ms initial-load setTimeout** (code review): the timer
  existed because there was no deterministic signal that the document
  had been loaded into PM. Replaced with two synchronous paths:
  - `PagedEditor.onReady` callback (line ~4019) now also seeds `pmState`
    once on mount, before any external consumer's `onEditorViewReady`
    fires.
  - The `[state.isLoading, history.state]` effect runs synchronously
    (no timer) — by React's child-first effect ordering, `view.state`
    already reflects the new doc by the time the effect runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jedrazb jedrazb merged commit 8080be1 into feat/collaboration-example Apr 13, 2026
2 checks passed
@jedrazb jedrazb deleted the feat/controlled-comments-collab branch April 13, 2026 09:14
jedrazb added a commit that referenced this pull request Apr 13, 2026
* feat(examples): add realtime collaboration example with Yjs + y-webrtc

Demonstrates multi-user collaborative editing on top of the new
externalContent prop using y-prosemirror over WebRTC. No backend
required — peers connect via the public Yjs signaling servers.

- src/useCollaboration.ts: Y.Doc, WebrtcProvider, awareness, plugins
- src/AvatarStack.tsx: Google-Docs-style overlapping avatars in title bar
- src/identity.ts: per-tab user identity + room from URL hash

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(examples/collaboration): dev script + dedupe + room status placement

- run vite from monorepo root so Tailwind's relative content paths resolve
- dedupe react/react-dom (workspace + example were loading separate copies,
  causing "Cannot read properties of null (reading 'useMemo')")
- move room status next to Share link button on the right; logo area is
  just the GitHub badge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: controlled comments API + derive tracked changes from PM (collab-ready) (#255)

* feat: controlled comments API + derive tracked changes from PM

Unblocks Yjs collaboration for comments and confirms tracked changes
already sync via ySyncPlugin.

**Comments — additive controlled mode** (new optional props):
- `comments: Comment[]` — when passed, the editor reads thread metadata
  from the prop instead of internal state
- `onCommentsChange: (comments: Comment[]) => void` — fires on every
  mutation (add, reply, resolve, unresolve, delete, orphan cleanup)

When omitted, the editor falls back to internal `useState<Comment[]>([])`.
Existing consumers see no behavior change.

Implemented via a small `useControllableComments` hook (Radix-style
controlled/uncontrolled pattern). All existing `setComments` call sites
are unchanged — they route through the hook's setter.

**Tracked changes — eliminate the redundant React state**:
- `useTrackedChanges(state)` derives `TrackedChangeEntry[]` from the
  current PM state via memoized pure-function extraction
- DocxEditor mirrors the latest PM state on every doc-changing transaction
  (including remote ones from ySyncPlugin)
- 300ms debounce on extraction is gone — sidebar updates immediately
- Comment-to-tracked-change threading moved to its own effect that depends
  on `[trackedChanges, pmState]`

Tracked changes already worked over Yjs because metadata lives in the
`insertion`/`deletion` mark attrs (synced with the PM tree). The React
state was a redundant derived view.

**Demo (`examples/collaboration`)**:
- `useCollaboration` now exposes `comments` + `setComments`, mirrored
  against a `Y.Array<Comment>` on the same `Y.Doc`
- `App.tsx` passes them as the controlled props
- README documents the four-piece architecture and the comments
  replace-all caveat (production should use `Y.Map<id, Comment>`)

**Docs**: `docs/PROPS.md` adds the two new props + a "Controlled
Comments" section with a Yjs Y.Array bridge example.

Stacked on PR #254 (the demo creation) — base will retarget to main
once that lands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* review fixes: single-walk derivation + demo polish

Code review fixes for PR #255:

- **Eliminate duplicate doc walk** (perf review): comment→revision overlap
  map is now built in the same `doc.descendants` pass as tracked-change
  extraction. Halves per-keystroke walk cost on large docs with tracked
  changes. `useTrackedChanges` returns `{ entries, commentToRevision }`.

- **Drop random number from display name** (UX review): `Ada 42` → `Ada`,
  fixes `A4` initials in AvatarStack.

- **Strengthen demo README caveats** (UX review): document the data-loss
  risk of the naive Y.Array replace-all sync, link to remote-cursor issue
  (#256) and comment-id-collision issue (#257), call out tracked-change
  accept/reject race.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* review fixes round 2: inline controlled comments + drop 200ms timer

Address the two items deferred from the first review pass:

- **Inline `useControllableComments`** (simplifier): the hook had one
  call site and ~50 lines of plumbing for a pattern that's ~12 lines
  inline. Folded into DocxEditor right next to the existing
  `commentsRef`/`onCommentDeleteRef` block. Hook file deleted.

- **Replace 200ms initial-load setTimeout** (code review): the timer
  existed because there was no deterministic signal that the document
  had been loaded into PM. Replaced with two synchronous paths:
  - `PagedEditor.onReady` callback (line ~4019) now also seeds `pmState`
    once on mount, before any external consumer's `onEditorViewReady`
    fires.
  - The `[state.isLoading, history.state]` effect runs synchronously
    (no timer) — by React's child-first effect ordering, `view.state`
    already reflects the new doc by the time the effect runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant