Skip to content

Sheets themselves cross to the other people on the board - #34

Merged
boubou666 merged 2 commits into
mainfrom
sheet-sync
Aug 8, 2026
Merged

boubou666 merged 2 commits into
mainfrom
sheet-sync

Conversation

@boubou666

@boubou666 boubou666 commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

Second of three. #33 addressed ops to a sheet, so two editors on different sheets stopped writing into each other's canvas. It left the sheets themselves local: adding, renaming, duplicating or removing one happened on your screen and nowhere else, and everyone else found out when they next reloaded. This is that half.

The vocabulary

Sheets grow the seam the store has had all along. on says "draw again", which is what a tab strip wants; onChanges says what happened, in three words a wire can carry:

{ t: 'sheet-add',    id, name, index, order, objects }
{ t: 'sheet-rename', id, name }
{ t: 'sheet-del',    id }

sheet-add carries the contents, which makes duplicating a sheet one message rather than a message plus every object on it — and carries the ids, so the copy is the same copy on every screen.

There is deliberately no sheet-order: nothing reorders sheets yet, and a vocabulary with a word nothing says is a word that will be wrong when something finally does.

Its own broadcast event (sheet) rather than more ops, because it is a change to the board rather than to a canvas — stretching the op vocabulary to cover the set of sheets would leave isOp answering for two different kinds of thing. Held in the same queue as the ops before hydration, though, because the order between them is the point: a sheet that is added and then drawn on is two messages, and the second means nothing until the first has landed.

What it refuses to do

  • A sheet somebody else makes does not become the sheet on your screen. Being moved off your work because a colleague pressed + is how a collaborative tool becomes unusable. The one change that does move you is somebody deleting the sheet you are looking at, and only because there is then nothing else to show.
  • The last sheet cannot be deleted by anyone, remote included. A board with no canvas is not a board, and that invariant belongs to the board rather than to whichever client pressed Delete — two people removing the last two sheets at once would otherwise leave one.
  • Nothing is guessed at. A change for a sheet that is not here, a sheet that has arrived twice, a rename to the name it already has: all ignored. A channel resends, and a client can be told about a sheet it already has.

app.js stopped wrapping the sheet commands and subscribed instead: a switch is no longer always this person's doing, and a subscription cannot be forgotten by a command added later.

The test I should have written last time

#33 shipped with a bug that only CI could catch — two clients deriving different ids for the same sheet, so every message crossed addressed to nothing. The two-client suite runs against real Realtime and needs a local Supabase, which this machine cannot run.

So the same claim is pinned where it can be checked anywhere: what one client announces is what another applies builds two sheet models from the same board, feeds one's announcements to the other, and asserts they end up identical — sheets, names, and the ids of a duplicated sheet's objects. I broke id agreement on purpose and watched it fail.

The real-Realtime tests are here too, in sync.test.js: a sheet crossing, a rename and a removal crossing, an edit on one sheet staying off another, and a duplicate arriving with the same object ids. Those first execute in CI.

One flake, fixed

the first look at a workspace marks nothing has been failing about one run in five — twice on #33's CI, and finally on this machine, which is what made it findable.

settle() answers once the shell is up with nothing loading, and the account gate renders inside the shell. So the previous test's list can still be in flight when it returns, and the seen-record that list seeds lands after this test clears storage. The workspace is then not a first look, both planted boards are news, and the run that catches it is whichever one was slow enough.

It reloads before clearing now, which unloads the document that would have written. Three clean full runs since.

Not fixed here, and worth its own change: session.send in the CDP helper has no timeout, so an evaluate that never returns hangs the run for ever. That has happened twice in one sitting, in two different files, and on CI it would burn the job until the workflow timeout.

Tests

658 passing, line coverage 96.0%. core/sheets.js at 100%.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GJ9tdqJPUktg2XR3M7Ncqr

Summary by CodeRabbit

  • New Features

    • Added real-time synchronization for creating, renaming, duplicating, and deleting sheets across connected sessions.
    • Preserved sheet-specific edits and camera positions during synchronization.
    • Added safeguards for invalid, duplicate, or unknown remote sheet changes.
    • Improved handling of sheet changes during workspace loading.
    • Hide sheet actions until sheet data is ready.
  • Bug Fixes

    • Prevented synchronized updates from unexpectedly changing another session’s active sheet.
    • Ensured duplicated sheets retain their content and object identities.
  • Tests

    • Expanded coverage for sheet operations, synchronization, hydration, and edge cases.

PR 1 addressed ops to a sheet so two editors on different sheets would
stop writing into each other's canvas. It left the sheets themselves
local: adding, renaming, duplicating or removing one happened on your
screen and nowhere else, and the other people found out when they next
reloaded.

So sheets grow the seam the store has had all along. `on` says "draw
again", which is what a tab strip wants; `onChanges` says *what happened*,
in three words a wire can carry:

    { t:'sheet-add',    id, name, index, order, objects }
    { t:'sheet-rename', id, name }
    { t:'sheet-del',    id }

`sheet-add` carries the contents, which is what makes duplicating a sheet
one message rather than a message plus every object on it — and carries
the ids, so the copy is the same copy on every screen. There is no
`sheet-order`: nothing reorders sheets yet, and a vocabulary with a word
nothing says is a word that will be wrong when something does.

Everything is written to survive arriving twice or arriving for something
that is not here, because a channel resends and a client can be told
about a sheet it already has. The last sheet cannot be deleted by anyone,
including a remote — that invariant belongs to the board rather than to
whoever pressed Delete.

A sheet somebody else makes does *not* become the sheet on your screen.
Being moved off your work because a colleague pressed `+` is how a
collaborative tool becomes unusable. The one change that does move you is
somebody deleting the sheet you are looking at, and only because there is
then nothing else to show.

That last case is why `app.js` stopped wrapping the sheet commands and
subscribed instead: a switch is no longer always this person's doing, and
a subscription cannot be forgotten by a command added later.

Also here, and not about sheets: `the first look at a workspace marks
nothing` has been failing about one run in five, on CI and now locally.
`settle()` answers once the shell is up with nothing loading, and the
account gate renders inside the shell — so the previous test's list can
still be in flight, and the record it seeds lands after this test clears
storage. Then the workspace is not a first look, both planted boards are
news, and the run that catches it is whichever one was slow. It reloads
before clearing now, which unloads the document that would have written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ9tdqJPUktg2XR3M7Ncqr
@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Sheet changes now use structured local and remote events. The sheet model validates and applies these events after hydration. Synchronization transports sheet events during and after hydration. The application updates viewport state through active-sheet subscriptions.

Changes

Sheet synchronization

Layer / File(s) Summary
Sheet change model
src/core/sheets.js, test/node/sheets.test.js
The sheet model validates structured changes, gates mutations until readiness, emits local changes, and applies valid remote changes idempotently.
Active-sheet viewport handling
src/app.js, src/components/SheetTabs.jsx, test/browser/shell.test.js
Active-sheet subscriptions save and restore cameras, clear selection, and fit or reset the viewport. Sheet controls wait for settled sheet data.
Board sheet-event synchronization
src/platform/sync.js, test/node/sync-hydration.test.js, test/node/sync-cursors.test.js, test/node/sync-writer.test.js
The sync layer broadcasts sheet events, applies incoming changes, queues events during hydration, replays them in order, and removes listeners during cleanup.
Cross-client synchronization validation
test/node/sync.test.js
Integration tests cover sheet creation, renaming, removal, active-sheet preservation, object routing, and duplication across clients.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LocalSheets
  participant BoardSync
  participant SHEET_EVENT
  participant RemoteSheets
  participant AppViewport
  LocalSheets->>BoardSync: emit LOCAL sheet change
  BoardSync->>SHEET_EVENT: broadcast sheet payload
  SHEET_EVENT->>RemoteSheets: deliver sheet change
  RemoteSheets->>RemoteSheets: applyChanges
  RemoteSheets->>AppViewport: notify active-sheet change
  AppViewport->>AppViewport: restore camera or fit viewport
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title describes synchronizing sheets with other board users, which matches the pull request's main change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sheet-sync

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
test/node/sync.test.js (2)

391-395: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the renamed sheet by ID.

The predicate succeeds if a different sheet receives the name Discovery. Match made before accepting the remote rename. The remote change contract identifies the target sheet by ID.

Proposed test change
 await waitFor(
-  () => his.sheets.list().some((s) => s.name === 'Discovery'),
+  () => his.sheets.list().find((s) => s.id === made)?.name === 'Discovery',
   'the rename to reach Bob',
 );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sync.test.js` around lines 391 - 395, Update the waitFor predicate
in the rename synchronization test to require both the expected sheet ID from
made and the name Discovery, rather than matching any sheet by name alone.
Preserve the existing remote rename assertion and wait message.

397-399: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test deletion of the active sheet.

Bob remains on the initial sheet here. This only tests removal of a non-active sheet. Select made before the remote deletion, then assert that activeId changes to the remaining sheet. The PR objective requires this fallback behavior.

Proposed test change
+his.sheets.select(made);
 hers.sheets.remove(made);
 await waitFor(() => !his.sheets.has(made), 'the removal to reach Bob');
 assert.equal(his.sheets.size, 1);
+assert.equal(his.sheets.activeId, his.sheets.list()[0].id);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sync.test.js` around lines 397 - 399, Update the deletion scenario
around hers.sheets.remove(made) to have Bob select made before Alice removes it,
then wait for the remote removal and assert Bob’s activeId falls back to the
remaining sheet. Retain the existing sheet-count assertion while verifying the
active-sheet fallback behavior.
src/core/sheets.js (1)

424-432: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the local at binding to avoid shadowing the at(id) helper.

Line 426 declares const at as a number inside the sheet-add block. The closure also defines at = (id) => entries.findIndex(...) at line 242. The current code is correct, because drop resolves the helper from the outer scope. A future edit that calls at(change.id) inside this block would throw. Use a distinct name.

♻️ Proposed rename
         if (change.t === 'sheet-add') {
           if (find(change.id)) continue;
-          const at = Math.min(Math.max(change.index, 0), entries.length);
+          const index = Math.min(Math.max(change.index, 0), entries.length);
           entries.splice(at, 0, {
             id: change.id,
-            name: asName(change.name, `Sheet ${at + 1}`),
+            name: asName(change.name, `Sheet ${index + 1}`),
             state: asState({ order: change.order, objects: change.objects }),
           });

Also update the entries.splice(at, ...) call to entries.splice(index, ...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/sheets.js` around lines 424 - 432, Rename the local numeric binding
`at` in the `sheet-add` branch to `index`, and update the corresponding
`entries.splice(at, ...)` call to use `index`; leave the outer `at(id)` helper
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/platform/sync.js`:
- Around line 190-205: Update the sheets.onChanges handler in
src/platform/sync.js (lines 190-205) to queue local changes while holding
instead of discarding them, and ensure release() replays the queued SHEET_EVENT
through the existing outgoing path. Strengthen test/node/sync-hydration.test.js
(lines 381-397) after landed() and settle() to assert that the SHEET_EVENT for
the Early sheet was sent.

---

Nitpick comments:
In `@src/core/sheets.js`:
- Around line 424-432: Rename the local numeric binding `at` in the `sheet-add`
branch to `index`, and update the corresponding `entries.splice(at, ...)` call
to use `index`; leave the outer `at(id)` helper unchanged.

In `@test/node/sync.test.js`:
- Around line 391-395: Update the waitFor predicate in the rename
synchronization test to require both the expected sheet ID from made and the
name Discovery, rather than matching any sheet by name alone. Preserve the
existing remote rename assertion and wait message.
- Around line 397-399: Update the deletion scenario around
hers.sheets.remove(made) to have Bob select made before Alice removes it, then
wait for the remote removal and assert Bob’s activeId falls back to the
remaining sheet. Retain the existing sheet-count assertion while verifying the
active-sheet fallback behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1549d7f6-ad68-47d9-b486-729c6d23965b

📥 Commits

Reviewing files that changed from the base of the PR and between 4f85a59 and 2e50a4b.

📒 Files selected for processing (9)
  • src/app.js
  • src/core/sheets.js
  • src/platform/sync.js
  • test/browser/shell.test.js
  • test/node/sheets.test.js
  • test/node/sync-cursors.test.js
  • test/node/sync-hydration.test.js
  • test/node/sync-writer.test.js
  • test/node/sync.test.js

Comment thread src/platform/sync.js
Review found that a sheet made during hydration was never sent: the
`holding` guard dropped the change instead of queueing it, and the test
asserted only that nothing went out before the board landed — which a
permanent discard satisfies just as well. Another test that could not
fail.

The half it did not find is worse. `sheets.load` replaces every entry, so
that sheet was wiped locally when the board arrived. Sending it would
have announced a sheet this client no longer had, which is why queueing
the change on its own would have made things worse rather than better.

Both come from the same place: the sheets on screen before the board
lands are a placeholder, and a sheet added to a placeholder is a sheet
added to nothing. So the set of sheets is fixed until `hydrate` says the
board is here — `sheets.ready()`, called on both branches, the loaded one
and the seeded one. The strip offers no `+` and no menu until then, which
on any normal load nobody ever sees.

What can still be done in that window is what could always be done in it:
draw on the canvas. Those ops are replayed onto the board when it
arrives, which works precisely because there is exactly one canvas to
replay them onto — the case this now guarantees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ9tdqJPUktg2XR3M7Ncqr

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/components/SheetTabs.jsx (1)

129-151: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Gate the double-click rename path as well.

The new condition hides the menu until sheets.settled, but the tab’s onDoubleClick handler at Line 124 still opens the rename dialog before readiness. commands.renameSheet then returns false because src/core/sheets.js rejects unsettled renames. The user can open a dialog that cannot save.

Apply the same readiness gate to the double-click handler or disable tab actions until the board is ready. The PR objective requires sheet controls to remain unavailable during hydration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/SheetTabs.jsx` around lines 129 - 151, Gate the tab’s
onDoubleClick rename handler with the same sheets.settled readiness condition
used by the “more” menu, preventing the rename dialog from opening during
hydration. Keep rename behavior unchanged once the board is settled, and ensure
sheet controls remain unavailable until readiness.
src/core/sheets.js (1)

229-240: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make concurrent sheet changes converge.

applyChanges does not commute concurrent changes. Concurrent deletes can leave each client with a different surviving sheet. Concurrent adds at index 1 can leave clients with different sheet orders. Because broadcast.self is false, transport ordering cannot repair these optimistic states. Add deterministic conflict resolution or authoritative reconciliation, with model tests for concurrent sheet-add and sheet-del events.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/sheets.js` around lines 229 - 240, Update the sheet-change
application flow, especially applyChanges and the sheet-add/sheet-del handling
around drop, so concurrent operations converge deterministically on every client
rather than relying on transport order. Use a stable conflict-resolution or
authoritative-reconciliation strategy for concurrent deletes and same-index
adds, preserve active-sheet behavior, and add model tests covering concurrent
sheet-add and sheet-del events.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/components/SheetTabs.jsx`:
- Around line 129-151: Gate the tab’s onDoubleClick rename handler with the same
sheets.settled readiness condition used by the “more” menu, preventing the
rename dialog from opening during hydration. Keep rename behavior unchanged once
the board is settled, and ensure sheet controls remain unavailable until
readiness.

In `@src/core/sheets.js`:
- Around line 229-240: Update the sheet-change application flow, especially
applyChanges and the sheet-add/sheet-del handling around drop, so concurrent
operations converge deterministically on every client rather than relying on
transport order. Use a stable conflict-resolution or
authoritative-reconciliation strategy for concurrent deletes and same-index
adds, preserve active-sheet behavior, and add model tests covering concurrent
sheet-add and sheet-del events.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a9f2cbc7-11d8-4e68-8fbe-2c9b66730b3a

📥 Commits

Reviewing files that changed from the base of the PR and between 2e50a4b and 1b2aa82.

📒 Files selected for processing (9)
  • src/app.js
  • src/components/SheetTabs.jsx
  • src/core/sheets.js
  • src/platform/sync.js
  • test/node/sheets.test.js
  • test/node/sync-cursors.test.js
  • test/node/sync-hydration.test.js
  • test/node/sync-writer.test.js
  • test/node/sync.test.js
🚧 Files skipped from review as they are similar to previous changes (7)
  • test/node/sync-cursors.test.js
  • src/app.js
  • test/node/sync-writer.test.js
  • test/node/sheets.test.js
  • src/platform/sync.js
  • test/node/sync.test.js
  • test/node/sync-hydration.test.js

@boubou666
boubou666 merged commit 11594ca into main Aug 8, 2026
3 checks passed
@boubou666
boubou666 deleted the sheet-sync branch August 8, 2026 21:04
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.

2 participants