Skip to content

feat: commentIdBase prop for collision-free collab comment IDs (#257)#942

Closed
samcorcos wants to merge 8 commits into
eigenpal:mainfrom
USTreasury:fix/257-comment-id-base
Closed

feat: commentIdBase prop for collision-free collab comment IDs (#257)#942
samcorcos wants to merge 8 commits into
eigenpal:mainfrom
USTreasury:fix/257-comment-id-base

Conversation

@samcorcos

@samcorcos samcorcos commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

In a collaborative session every peer's createCommentIdAllocator() started at 1, so the first comment from each peer got the same id and replies threaded onto the wrong card. This adds an optional commentIdBase prop (React + Vue) that offsets the allocator — pass ydoc.clientID * 1_000_000 and each peer mints from a disjoint range. Default 0 preserves the single-editor 1, 2, 3, … sequence. Fixes #257.

Before — every peer's first comment is id: 1:

before

After — per-peer base (clientID × 1e6):

after

Repro that now passes (e2e/tests/collab-comment-id.spec.ts): two pages join the same room, each adds a comment, assert the two data-comment-id values are distinct and both peers see both comments. Hermetic via y-webrtc's BroadcastChannel path — no external signaling.

  • React + Vue parity (check:parity-contract)
  • api:extract snapshots updated
  • Unit tests for createCommentIdAllocator(base) (default unchanged, disjoint bases, seedAbove monotone)
  • e2e regression test
  • examples/collaboration wired up; README caveat removed
  • Changeset (minor)

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

@vercel

vercel Bot commented Jun 19, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
docx-editor Ready Ready Preview, Comment Jun 19, 2026 6:01pm

Request Review

@eigenpal-release-pal

eigenpal-release-pal Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅

Posted by the CLA bot.

@samcorcos

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds an optional commentIdBase prop to both React and Vue DocxEditor components, passing it through to createCommentIdAllocator(base) so each collaborating peer mints comment and revision IDs from a disjoint range (ydoc.clientID * 1_000_000). Default 0 preserves the existing 1, 2, 3, … sequence for single-editor use.

  • Core allocator (commentIdAllocator.ts): createCommentIdAllocator now accepts an optional base parameter; nextId starts at base + 1 and seedAbove only ever raises the counter, so loading a peer's own prior IDs is safe.
  • React + Vue parity: both adapters expose commentIdBase?: number with default 0; React reads it once into useRef at mount, Vue reads it at setup() time — both consistent with the documented "read once at mount" contract.
  • Example + e2e: useCollaboration now derives and exposes commentIdBase; a new Playwright test asserts two same-room peers mint distinct IDs and both see both comments after sync.

Confidence Score: 3/5

The base-partitioning approach works well for the primary scenario (concurrent first comments on an empty document), but the seedAbove path can push a lower-base peer's allocator into a higher-base peer's range once tracked-change revision IDs from that peer have synced into the PM state, leaving the "never collide" guarantee unsatisfied for mixed comment+tracked-change collab sessions.

The core allocator change is sound and the React/Vue wiring is correct. The gap is in seedAbove: because seedCommentAllocator computes a global maximum across all revision marks in PM state (which includes Yjs-synced tracked changes from any peer), a peer with a lower clientID can have its counter raised into a higher-clientID peer's range. After that point, the two peers can mint the same ID on the next concurrent allocation. This contradicts the JSDoc's "never collide" claim and is not covered by the new unit tests or the e2e test (which only exercises an empty document with comments, not tracked changes). The e2e helper also has brittleness concerns (CSS-property-based selector, unguarded null from getAttribute) that could produce confusing CI failures.

The most important file to revisit is packages/core/src/prosemirror/commentIdAllocator.ts — specifically the interaction between seedAbove and the new base parameter. The e2e test e2e/tests/collab-comment-id.spec.ts also warrants a closer look for the selector fragility and null-coercion issues.

Important Files Changed

Filename Overview
packages/core/src/prosemirror/commentIdAllocator.ts Adds base parameter to createCommentIdAllocator; seedAbove can push a peer's counter into another peer's range when cross-peer revision IDs are present in PM state
e2e/tests/collab-comment-id.spec.ts New e2e regression test for #257; button selector is fragile (CSS property match) and getAttribute null case is unguarded
packages/react/src/components/DocxEditor.tsx Adds commentIdBase prop (default 0), passes to createCommentIdAllocator via useRef — correctly read once at mount as documented
packages/vue/src/components/DocxEditor.vue Adds commentIdBase prop with default 0, passes to createCommentIdAllocator at setup time; consistent with React behaviour
examples/collaboration/src/useCollaboration.ts Computes commentIdBase = ydoc.clientID * 1_000_000 from useMemo and exposes it on CollaborationState; clean and correct
packages/core/src/prosemirror/tests/commentIdAllocator.test.ts Good unit coverage for base parameter; test for "seedAbove above base" explicitly documents the cross-partition raise behaviour but no test exercises the mixed revision-ID scenario
scripts/parity/parity.contract.json Adds commentIdBase to the parity contract; React + Vue both implement it so the contract check should pass

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App
    participant useCollaboration
    participant DocxEditor
    participant createCommentIdAllocator
    participant seedCommentAllocator

    App->>useCollaboration: mount(roomName)
    useCollaboration->>useCollaboration: new Y.Doc() → clientID
    useCollaboration-->>App: "commentIdBase = clientID * 1_000_000"

    App->>DocxEditor: render(commentIdBase)
    DocxEditor->>createCommentIdAllocator: createCommentIdAllocator(commentIdBase)
    createCommentIdAllocator-->>DocxEditor: "allocator (nextId = base+1)"

    Note over DocxEditor: Document load
    DocxEditor->>seedCommentAllocator: seedAbove(maxId from doc)
    seedCommentAllocator-->>DocxEditor: "nextId raised if maxId >= nextId"

    Note over DocxEditor: Peer A adds comment
    DocxEditor->>DocxEditor: allocator.next() → base_A + 1

    Note over DocxEditor: Peer B (different base) adds comment
    DocxEditor->>DocxEditor: allocator.next() → base_B + 1

    Note over DocxEditor: IDs are disjoint ✓
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant App
    participant useCollaboration
    participant DocxEditor
    participant createCommentIdAllocator
    participant seedCommentAllocator

    App->>useCollaboration: mount(roomName)
    useCollaboration->>useCollaboration: new Y.Doc() → clientID
    useCollaboration-->>App: "commentIdBase = clientID * 1_000_000"

    App->>DocxEditor: render(commentIdBase)
    DocxEditor->>createCommentIdAllocator: createCommentIdAllocator(commentIdBase)
    createCommentIdAllocator-->>DocxEditor: "allocator (nextId = base+1)"

    Note over DocxEditor: Document load
    DocxEditor->>seedCommentAllocator: seedAbove(maxId from doc)
    seedCommentAllocator-->>DocxEditor: "nextId raised if maxId >= nextId"

    Note over DocxEditor: Peer A adds comment
    DocxEditor->>DocxEditor: allocator.next() → base_A + 1

    Note over DocxEditor: Peer B (different base) adds comment
    DocxEditor->>DocxEditor: allocator.next() → base_B + 1

    Note over DocxEditor: IDs are disjoint ✓
Loading

Comments Outside Diff (1)

  1. packages/core/src/prosemirror/commentIdAllocator.ts, line 48-50 (link)

    P1 seedAbove can silently break the base partition in mixed comment+tracked-change collab sessions

    seedCommentAllocator computes a single global maximum across all comment IDs and tracked-change revision marks (revisionId) in the PM state, then passes it to seedAbove. In Vue, this runs before every comment creation (useCommentLifecycle.ts:244); in React it runs at every document load.

    Concretely: peer B has base = 1_000_000 (nextId = 1_000_001). Peer A has base = 5_000_000 and makes a tracked change (revisionId = 5_000_001), which Yjs syncs into peer B's PM state. Peer B next creates a comment: seedCommentAllocator finds max = 5_000_001, fires seedAbove(5_000_001) → peer B's nextId jumps to 5_000_002. Peer A simultaneously creates another tracked change → revisionId = 5_000_002. Both peers mint the same ID.

    The JSDoc's "never collide" guarantee only holds when tracked-change IDs from other peers are absent or below the local base. A targeted fix: in seedAbove (or in seedCommentAllocator), skip IDs that fall strictly above nextId's base epoch — i.e., only seed if maxId < base + stride for some defined stride — or document that commentIdBase does not provide collision safety when tracked changes are also used concurrently.

Reviews (1): Last reviewed commit: "chore: changeset for commentIdBase (#257..." | Re-trigger Greptile

Comment thread e2e/tests/collab-comment-id.spec.ts Outdated
Comment on lines +17 to +32
await page.waitForFunction(() => {
for (const b of document.querySelectorAll('[data-testid="docx-editor"] button')) {
const s = getComputedStyle(b);
if (s.position === 'absolute' && s.zIndex === '50') return true;
}
return false;
});
const handle = await page.evaluateHandle(() => {
for (const b of document.querySelectorAll('[data-testid="docx-editor"] button')) {
const s = getComputedStyle(b);
if (s.position === 'absolute' && s.zIndex === '50') return b;
}
return null;
});
await handle.asElement()!.click();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fragile button selector based on computed CSS values

clickFloatingAddComment identifies the add-comment button by matching position === 'absolute' and zIndex === '50' at runtime. Any unrelated CSS change — a Tailwind utility swap, a z-index token change, or a browser default difference — silently breaks the selector and the test fails with a confusing timeout rather than a useful assertion error. A data-testid="add-comment-btn" (or equivalent stable attribute) on the floating button would make intent explicit and survive styling refactors.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread e2e/tests/collab-comment-id.spec.ts Outdated
Comment on lines +42 to +46
const id = await page
.locator(`${SIDEBAR} .docx-comment-card[data-comment-id]`)
.last()
.getAttribute('data-comment-id');
return Number(id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unguarded null from getAttribute silently coerces to 0

getAttribute('data-comment-id') returns string | null. If the attribute is absent or the locator races against a re-render, Number(null) === 0. The test then hits expect(0).toBeGreaterThan(1_000_000) with a misleading failure instead of a clear "attribute was missing" error. Adding a non-null assertion (e.g., if (id === null) throw new Error('data-comment-id missing')) before Number(id) would produce a diagnostic-friendly failure.

@samcorcos

Copy link
Copy Markdown
Contributor Author

Interesting! I underestimated Greptile. Will update.

seedAbove now ignores IDs outside (base, base+stride] so a peer's synced
tracked-change revisionId can't pull this peer's allocator into another
partition; commentIdStride prop threads the stride through React/Vue and
the collab example. Floating add-comment button gets a data-testid so the
e2e test no longer matches on computed CSS, and getAttribute null is
guarded for a clearer failure.
@jedrazb jedrazb self-requested a review June 19, 2026 18:00
@USTreasury USTreasury closed this by deleting the head repository Jun 19, 2026
@samcorcos

Copy link
Copy Markdown
Contributor Author

Whoops, didn't mean to close this @jedrazb -- still a valid PR. Apologies.

@jedrazb

jedrazb commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

@samcorcos do you have plans to reopen? or should i open a fresh PR from your branch to get this fixed?

EDIT: cherrypicked your work and continuing in #956

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.

Comment IDs can collide between collaborating peers (Comment.id is a module-level scalar)

3 participants