feat: commentIdBase prop for collision-free collab comment IDs (#257)#956
feat: commentIdBase prop for collision-free collab comment IDs (#257)#956jedrazb wants to merge 16 commits into
Conversation
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.
- Scope the collaboration-example dev server to Playwright runs that include the chromium project, so project-scoped runs (notably the release publish-path parity smoke gate) don't boot an unrelated server. - Time-box the example's startup GitHub stars fetch (2s) so a restricted network can't hang the webServer startup window. - Document the two preconditions for cross-peer collision-freedom (distinct clientIDs; fewer than `stride` allocations per session). - Use Vue binding syntax in the Vue prop's JSDoc example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
All contributors have signed the CLA ✍️ ✅ Posted by the CLA bot. |
Greptile SummaryAdds optional
Confidence Score: 4/5Safe to merge; the core logic is correct for all documented usage patterns and the backward-compatible defaults preserve existing single-editor behaviour. The allocator correctly filters out-of-partition IDs in seedAbove, both React and Vue wire up the props symmetrically, unit tests cover the key collab edge cases, and the parity contract is updated. Two observations hold the score: next() is unbounded and will silently overflow ceiling if a session mints more than stride IDs (the ceiling variable exists but is unused in next()), and the E2E test's .last() card picker could non-deterministically grab a peer-synced card depending on sidebar sort order under slow Yjs sync. packages/core/src/prosemirror/commentIdAllocator.ts — the next() overflow path; e2e/tests/collab-comment-id.spec.ts — the .last() card-ID picker.
|
| Filename | Overview |
|---|---|
| packages/core/src/prosemirror/commentIdAllocator.ts | Core change: adds base/stride params for ID partitioning; seedAbove correctly clamps to partition, but next() is unbounded and silently overflows ceiling |
| packages/core/src/prosemirror/tests/commentIdAllocator.test.ts | Good unit coverage: default sequence, partition isolation, in-partition seed, out-of-partition seed, and cross-peer contamination all tested |
| packages/react/src/components/DocxEditor.tsx | Correctly threads commentIdBase/commentIdStride into useRef(createCommentIdAllocator(...)); read-once-at-mount behavior is documented and intentional |
| packages/vue/src/components/DocxEditor.vue | Vue component initializes allocator with props at setup time; commentIdStride defaults to undefined (which correctly maps to Infinity via function default) |
| e2e/tests/collab-comment-id.spec.ts | Hermetic two-peer BroadcastChannel test; the .last() card picker could race against Yjs sync delivering the peer's card out of order |
| playwright.config.ts | Conditionally boots the collab dev server only when chromium project is in scope; requestedProjects parsing handles both --project=x and --project x forms correctly |
| examples/collaboration/src/useCollaboration.ts | Derives commentIdBase = ydoc.clientID * 1_000_000 inside the roomName-scoped useMemo and exports both props; clean integration |
| examples/collaboration/vite.config.ts | AbortSignal.timeout(2000) added to the GitHub stars fetch to prevent hanging the Playwright webServer startup window on restricted networks |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["DocxEditor mount\n(commentIdBase, commentIdStride)"] --> B["createCommentIdAllocator(base, stride)\nnextId = base + 1\nceiling = base + stride"]
B --> C["seedCommentAllocator called\non document load"]
C --> D{"For each comment/\nrevision mark ID"}
D --> E["allocator.seedAbove(id)"]
E --> F{"id >= nextId\nAND id <= ceiling?"}
F -- Yes --> G["nextId = id + 1\n(in-partition seed)"]
F -- No --> H["Ignored\n(out-of-partition\nor already passed)"]
G --> I["allocator.next()\nreturns nextId++"]
H --> I
I --> J{"nextId > ceiling?\n(overflow)"}
J -- "No (common case)" --> K["ID minted in own partition\n✓ No collision"]
J -- "Yes (> stride IDs minted)" --> L["ID crosses into\nadjacent partition\n⚠ Silent overflow"]
%%{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"}}}%%
flowchart TD
A["DocxEditor mount\n(commentIdBase, commentIdStride)"] --> B["createCommentIdAllocator(base, stride)\nnextId = base + 1\nceiling = base + stride"]
B --> C["seedCommentAllocator called\non document load"]
C --> D{"For each comment/\nrevision mark ID"}
D --> E["allocator.seedAbove(id)"]
E --> F{"id >= nextId\nAND id <= ceiling?"}
F -- Yes --> G["nextId = id + 1\n(in-partition seed)"]
F -- No --> H["Ignored\n(out-of-partition\nor already passed)"]
G --> I["allocator.next()\nreturns nextId++"]
H --> I
I --> J{"nextId > ceiling?\n(overflow)"}
J -- "No (common case)" --> K["ID minted in own partition\n✓ No collision"]
J -- "Yes (> stride IDs minted)" --> L["ID crosses into\nadjacent partition\n⚠ Silent overflow"]
Reviews (1): Last reviewed commit: "fix(comment-id): address review on colla..." | Re-trigger Greptile
| export function createCommentIdAllocator(base = 0, stride = Infinity): CommentIdAllocator { | ||
| let nextId = base + 1; | ||
| const ceiling = base + stride; | ||
| return { | ||
| next: () => nextId++, | ||
| seedAbove(maxId: number) { | ||
| if (maxId >= nextId) nextId = maxId + 1; | ||
| if (maxId >= nextId && maxId <= ceiling) nextId = maxId + 1; | ||
| }, | ||
| }; |
There was a problem hiding this comment.
next() is unbounded — silent partition overflow
ceiling is computed but only consulted in seedAbove; next() never checks it. Once a peer mints its 1,000,001st ID it silently emits values in the adjacent partition. From that point, the other peer's seedAbove will accept those values (they fall within (adjacentBase, adjacentBase + stride]) and advance the victim's counter, causing the exact collision this feature is meant to prevent — with no error or warning.
Adding a dev-mode guard directly in next() (e.g., if (process.env.NODE_ENV !== 'production' && nextId > ceiling) console.warn(...)) would make the overflow immediately visible during development without any production overhead.
| const card = page.locator(`${SIDEBAR} .docx-comment-card[data-comment-id]`).last(); | ||
| await card.waitFor({ state: 'visible' }); | ||
| const id = await card.getAttribute('data-comment-id'); | ||
| if (id == null) throw new Error('comment card rendered without data-comment-id'); | ||
| return Number(id); |
There was a problem hiding this comment.
.last() card picker is racing against Yjs sync
By the time peer B presses Enter and the assertion resolves, peer A's comment card may have already synced into peer B's sidebar. If the sidebar renders synced cards after the locally-minted card (e.g., by comment ID ascending, where peer B's base is higher than peer A's), .last() returns the correct card. But if the sidebar sorts by document position or Y.Array insertion order, A's card might appear after B's, causing .last() to return A's ID — making idB === idA and tripping the distinctness assertion non-deterministically.
A more resilient approach is to snapshot visible IDs before adding the comment and then wait for a new ID to appear, rather than relying on positional order.
|
Heads up before this lands: the per-peer ID scheme produces comment/revision IDs that Word won't accept, so collaborative saves will come back corrupt. The relevant attributes ( <xsd:simpleType name="ST_DecimalNumber">
<xsd:restriction base="xsd:integer"/>
</xsd:simpleType>The schema doesn't match what Word actually does, though. Word reads these IDs as signed 32-bit ints, so anything above 2,147,483,647 (0x7FFFFFFF) gets rejected: the file opens into "unreadable content" / Document Recovery and the comment or revision is dropped or renumbered. We already depend on this exact limit elsewhere. The scheme here is The partitioning idea itself is fine for keeping IDs unique; the problem is just that There are really two separate requirements:
I'd keep the per-peer partition for the live side, and renumber the comment + revision IDs down to a dense 1..N at serialize time, only when something is actually out of range so normal single-editor saves stay untouched, with a hard 0x7FFFFFFF clamp as a backstop. One thing to watch: the editor uses a single ID space shared across comments and revisions, so the renumber has to draw from one sequence rather than two. This also covers oversized IDs coming from a malformed input file, not just collab. Suggest holding the merge until the save side is handled. The partition on its own isn't safe for Word. |
|
Follow-up patch addressing the int32 What it does:
Verified locally: Patch (apply with
|
…e comments Collab partitions comment/revision IDs per peer (clientID * 1e6), which exceeds Word's signed-int32 w:id limit and makes saved files open into Document Recovery with comments/revisions dropped. Add a save-time pre-pass (decimalIdRemap) that compacts oversized comment + revision IDs to a dense 1..N sequence from one shared sequence; no-op when every ID already fits, so single-editor saves stay byte-identical. Also bounds-warn the allocator in dev and de-races the collab e2e. The renumber runs on detached shallow clones of the comments array, so the open session and collab peers keep their live IDs and stay in sync with the unchanged PM comment marks; only this save's snapshot is renumbered. Co-authored-by: Sam Corcos <2626231+samcorcos@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core): footnote-PM-doc layout seam + data-footnote-id + note-ref marker on serialize Adds getFootnotePmDoc seam (mirrors getHfPmDoc) so the painter can read a live footnote ProseMirror doc; stamps data-footnote-id on .layout-footnote-content for per-footnote position disambiguation; re-inserts the w:footnoteRef/w:endnoteRef marker run when serializing a normal note from content (parser drops it). Groundwork for editable footnotes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(react): lazy hidden footnote ProseMirror view + writeback Mounts one off-screen EditorView for the actively-edited footnote (lazy single slot keyed by activeFootnoteId), mirroring HiddenHeaderFooterPMs. Transactions sync proseDocToBlocks back to Document.package.footnotes and clear verbatimXml; useLayoutPipeline feeds the live doc through getFootnotePmDoc so the painter reflects edits. Footnote-edit wiring lives in a new useFootnoteEditState hook (keeps PagedEditor under the line cap). Click routing/overlay follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(react): route clicks into footnotes for editing Clicking a painted footnote enters footnote-edit mode for that data-footnote-id, places the caret in its hidden view (scoped span-snap so colliding per-footnote PM positions never cross-match), focuses it, and blurs+read-onlys the body; clicking outside exits. activeSurface() now routes body|HF|footnote. Adds e2e spec (run via playwright-runner) and a unit test for the hit-resolver. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(react): restore body focus when a click exits footnote-edit mode Exiting footnote mode via a body click now hands focus + caret back to the body in the same gesture. The restore is deferred to a parent-level effect (exitFootnoteToBody) that runs after HiddenFootnotePM tears its view down, so the teardown's destroy-blur can't steal the focus, and the body PM is already editable by then. Verified by the footnote-editing e2e (click→type→exit→type). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(react): visible caret + selection overlay for footnote editing Draws the caret and range-selection rects over the actively-edited footnote, mirroring the header/footer overlay. New framework-free core helpers (computeFootnoteCaretRectFromView/SelectionRectsFromView) scope the DOM walk to the active .layout-footnote-content[data-footnote-id] so colliding per-footnote PM positions never cross-match; DocxEditorPagedArea portals the overlay and recomputes on painter:painted + resize. e2e asserts the caret renders within the clicked footnote bounds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(vue): editable footnotes at parity with React Mirrors the React footnote-editing feature in the Vue adapter: lazy single hidden footnote EditorView + writeback (useFootnotePM), click routing into footnotes (usePagesPointer), and caret/selection overlay (useFootnoteOverlay + FootnoteOverlay.vue) reusing the framework-free core helpers. getFootnotePmDoc feeds the live doc to the painter. No public API change; parity contract holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: changeset for editable footnotes * chore: update API report for footnote-editing exports * fix(react): exit footnote-edit mode when the edited footnote is gone Reset footnoteEditId when the actively-edited footnote is no longer present in the document (e.g. a new document loaded mid-edit) — otherwise the body PM stays readOnly and the next writeback resolves the stale id against the new document. Keyed on footnote existence, not document identity (history.state gets a fresh object per body edit, so a bare [document] reset would exit mid-edit). Mirrors the Vue adapter's destroyFootnotePM-on-load behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(footnotes): black caret + glyph-accurate click placement The footnote caret rendered blue (HF chrome color) — make it black to match the body caret in both adapters. Footnote clicks resolved position via a coarse estimate that drifted (clicking between letters landed at the word end); React now reuses the body's glyph-accurate findPositionInSpan (exported from core) on the span under the cursor, scoped to the active footnote. Vue keeps the coarse snap for now (precise caret exposes a separate Vue typing issue). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(footnotes): enable click-drag text selection The footnote click handler placed the caret and returned without arming drag state, so click-drag selected nothing. Arm dragAnchorRef + isDraggingRef from the clicked position on mousedown (DOM-resolved, no view needed); the existing move handler already extends the selection through the footnote surface. e2e covers it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jacob Jove <jacobjove@Jacobs-MacBook-Pro.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(core): keep a footnote reference on the same line as its word The line-breaker created a wrap opportunity at every run boundary, so a footnote/endnote reference run (a separate superscript with no space before it, e.g. copyright.¹) could split onto the next line. Fold the width of the unbreakable content that follows a run's last word into the wrap decision so adjacent runs with no whitespace between them wrap as a single cluster, matching Word. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(core): derive footnote-glue maxWidth from measureTextWidth The hardcoded maxWidth assumed this file's 8px/char canvas stub, but the guard only installs it when no global document exists; run after another test that sets one, measureTextWidth returned different widths and the control case failed. Derive the threshold from measureTextWidth so the test is self-consistent under whichever stub is active. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jacob Jove <jacobjove@Jacobs-MacBook-Pro.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
) * fix: superscript footnote/endnote refs only when the style says so (Word parity) A bare anchor run (no FootnoteReference rStyle, e.g. Pandoc output) was force-raised by the layout bridge, diverging from Word, which renders an unstyled anchor at the baseline. Drop the implicit superscript; it now flows solely from the resolved character-style chain or the run's own vertAlign. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(core): note basedOn limitation + endnote-mark intent in footnote-ref parity test Addresses review feedback on PR #994 — clarify that getRunStyleOwnProperties resolves only own (not basedOn-inherited) vertAlign, and that endnoteRef content maps to the footnoteRef mark. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(core): correct basedOn note — styleParser pre-merges the chain The prior comment (and a PR #994 review reply) wrongly claimed a basedOn-inherited vertAlign renders at the baseline. styleParser.resolveStyleInheritance merges the parent rPr into the child before getRunStyleOwnProperties reads it, so production honors inherited superscript; the own-only behavior is the test mock's, not the real resolver's. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jacob Jove <jacobjove@Jacobs-MacBook-Pro.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
) * fix(core): grow a section's footer/header band per its own margins extendMarginsForHeaderFooter decided whether to grow the header/footer band once, from the body section's margins, then applied that decision to every section. A section whose own margins are thin — e.g. a landscape table section with a 0.5in bottom margin (≈ the footer distance) embedded in a 1in-margin portrait body — never grew its footer band, so the footer overlapped the footnote area and the page number rode up beside the last footnote instead of sitting below it. Decide the overflow per margin set, using that set's own top/bottom and header/footer distances. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: changeset for header/footer band fix --------- Co-authored-by: Jacob Jove <jacobjove@Jacobs-MacBook-Pro.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Merging main tipped two files over their max-lines caps: - DocxEditor.vue (1252 > 1250): the collab comment-id props add inline withDefaults + allocator wiring; bumped to 1260. - useDocxEditor.ts (1003 > 1000): editable footnotes (#995) on main pushed it over with no override; added a 1050 cap, matching the other host-file ceilings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes #942
In a collaborative session every peer's
createCommentIdAllocator()started at1, so the first comment from each peer got the sameidand replies threaded onto the wrong card. This adds optionalcommentIdBase/commentIdStrideprops (React + Vue) that partition the ID space: passydoc.clientID * 1_000_000withcommentIdStride={1_000_000}and each peer mints from a disjoint range. Default0/Infinitypreserves the single-editor1, 2, 3, …sequence. Fixes #257.Review fixes applied on top of the original work:
chromiumproject, so project-scoped runs (notably the release publish-path parity smoke gate) don't boot an unrelated server.clientIDs; fewer thanstrideallocations per session).Known follow-up (not addressed here):
DocxEditor.tsxcrossed 2000 lines so themax-linescap was nudged to 2020. The real fix is the planned file split, tracked separately.Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.