Skip to content

feat(sheet): live presence & live calculation for the collaborative spreadsheet#312

Closed
SamTV12345 wants to merge 10 commits into
mainfrom
feat/sheet-live-presence
Closed

feat(sheet): live presence & live calculation for the collaborative spreadsheet#312
SamTV12345 wants to merge 10 commits into
mainfrom
feat/sheet-live-presence

Conversation

@SamTV12345

Copy link
Copy Markdown
Member

What

Makes the collaborative spreadsheet feel like Google Docs:

  • Cursor presence — every user sees the others' active cell as a colored border + name tag.
  • Live calculation — while someone types a formula, everyone else sees the in‑progress formula text in that cell and dependent cells recompute live, before the editor presses Enter.

Finishes the presence that the original spreadsheet design (docs/superpowers/specs/2026-06-21-collaborative-spreadsheet-design.md §5) envisioned but deferred, and goes one step further with live pre‑commit calculation.

How (ephemeral, minimal)

One new SHEET_PRESENCE websocket frame each direction — never persisted, never routed through the OT serialization goroutine.

  • Server (lib/ws/SheetHandler.go, lib/models/ws/sheetMessages.go, lib/ws/client.go): a direct relay that stamps identity (userId/name/color) server‑side from session.Author — the incoming frame carries no identity fields, so spoofing is structurally impossible. Read‑only sessions have editing/raw stripped.
  • Cleanup reuses existing frames: USER_LEAVE (disconnect) drops a peer's cursor + live edit; NEW_SHEET_OP.author (commit) clears the author's overlay flicker‑free. No new server state, no snapshot/heartbeat in v1.
  • Client (ui/src/js/sheet/): a small SheetPresence reducer + a pure effectiveCells overlay that layers remote in‑progress raws onto the formula engine's grid so dependents recompute deterministically on each viewer. raw stays the single source of truth; value is derived locally. Live‑edit is throttled (~60 ms), selection debounced (~50 ms).

Build fix included

vite build --mode sheet hung indefinitely — vite 8 is rolldown‑based and handles CommonJS natively, but the legacy @rollup/plugin-commonjs plugin deadlocked the rolldown transform while bundling HyperFormula, so the sheet bundle had never built. Skipped that plugin for the sheet entry only; other bundles are unchanged.

Testing

  • Go (lib/ws): relay with server‑stamped identity, sender exclusion, read‑only stripping, unknown‑author best‑effort relay.
  • Frontend (vitest, 30 passing): presence reducer rules; effectiveCells overlay drives a real HyperFormula recompute (C2 = 31) and wins over a committed cell on collision.
  • Playwright E2E (playwright/specs/sheet_presence.spec.ts): two sessions — remote cursor tag, live formula text, dependent C2 = 31 before Enter, B2 = 30 after commit, cursor cleanup on disconnect. ✅ passing.

Deliberately out of scope (v1)

Join‑snapshot of idle cursors, heartbeat/TTL, multi‑sheet‑tab rendering, range selections — all documented with upgrade paths in docs/superpowers/specs/2026-06-25-sheet-live-presence-design.md §8. The text‑pad path is untouched.

🤖 Generated with Claude Code

SamTV12345 and others added 10 commits June 25, 2026 19:50
Spec for Google-Docs-style collaboration on the spreadsheet:
- active-cell cursor presence with author color + name label
- live broadcast of in-progress cell input + live recompute of
  dependent cells before commit

ponytail-trimmed: one new SHEET_PRESENCE frame each direction;
cleanup reuses existing USER_LEAVE and NEW_SHEET_OP.author; no
server-side presence cache / snapshot / heartbeat in v1 (upgrade
paths documented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 TDD tasks: server relay (Go) + presence reducer/overlay (vitest) +
grid decorations + editor wiring + two-session Playwright E2E.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n deadlock)

vite 8 is rolldown-based and handles CommonJS natively. The legacy
@rollup/plugin-commonjs plugin deadlocks the rolldown transform when
bundling HyperFormula, so `vite build --mode sheet` hung indefinitely and
the sheet bundle never built. Skip the plugin for the sheet entry only;
other entries are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a Playwright spec covering: remote cursor tag, live formula text on
the viewer, dependent-cell live recompute (C2=31) before commit, result
after commit (B2=30), and cursor cleanup on disconnect.

Fix found by the e2e: render the presence name tag via a CSS ::after
pseudo-element (data-label) instead of a text node, so a peer's name never
leaks into the cell's textContent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final-review findings:
- Honor data.readonly on the client: read-only viewers get non-editable
  cells, so no live-edit/commit frames originate locally (server already
  strips/rejects them). Closes the plan's "enforced on both client and
  server" gap and the type-then-revert UX bug.
- Add a direct unit test for the load-bearing effectiveCells property: a
  remote live raw replaces (not duplicates) a committed cell at the same
  coordinate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add sheet live cursor presence and pre-commit formula recalculation
✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Description

• Broadcast ephemeral cursor + in-progress cell edits so peers see live formulas and recomputed
 dependents.
• Relay SHEET_PRESENCE server-side with stamped identity and read-only edit stripping.
• Add unit/E2E coverage and unhang sheet-mode Vite build by skipping legacy CommonJS plugin.
Diagram

graph TD
  A["DomSheetView"] --> B["sheetEditor"] --> C["WebSocket"] --> D["Go ws/client readPump"] --> E["PadMessageHandler.HandlePresence"] --> C
  B --> F["SheetPresence reducer"] --> G["effectiveCells overlay"] --> H["FormulaEngine"] --> A
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Persist presence as OT ops (or route through OT goroutine)
  • ➕ Guaranteed ordering relative to committed edits
  • ➕ Automatic replay/snapshot on reconnect
  • ➖ Pollutes durable history with ephemeral UI state
  • ➖ Increases OT stream volume and contention for typing activity
  • ➖ Harder to enforce anti-spoofing and read-only constraints cleanly
2. Add server-side presence cache + join snapshot/heartbeat (v2)
  • ➕ New joiners immediately see idle cursors and active edits
  • ➕ TTL/heartbeat prevents stuck overlays on half-open connections
  • ➖ Adds server state and synchronization complexity
  • ➖ Requires more protocol surface (snapshot/TTL frames) and testing
3. Client-side optimistic inference (no live raw broadcast)
  • ➕ Less network traffic; simpler server
  • ➕ Avoids leaking in-progress text
  • ➖ Cannot show exact in-progress formulas or deterministic dependent recompute
  • ➖ Worse collaboration UX than the stated Google-Docs-like goal

Recommendation: The PR’s approach (single ephemeral SHEET_PRESENCE frame relayed server-side with stamped identity, no persistence, and client-side deterministic overlay recompute) is the best v1 trade-off for UX vs. complexity. It preserves the OT/persistence model, prevents identity spoofing structurally, and keeps cleanup cheap by reusing USER_LEAVE and NEW_SHEET_OP.author. A server-side cache/TTL snapshot can be added later if idle-cursor visibility or half-open connection issues become material.

Files changed (12) +1862 / -24

Enhancement (6) +340 / -17
sheetMessages.goIntroduce websocket message types for SHEET_PRESENCE +40/-0

Introduce websocket message types for SHEET_PRESENCE

• Adds SheetPresenceIncoming (client→server) and SheetPresence/SheetPresenceData (server→client) models. Server payload includes stamped identity fields and omits raw when not present.

lib/models/ws/sheetMessages.go

SheetHandler.goRelay SHEET_PRESENCE frames with server-stamped identity +54/-0

Relay SHEET_PRESENCE frames with server-stamped identity

• Implements HandlePresence to broadcast ephemeral cursor/live-edit frames to other sockets in the pad room without routing through the OT goroutine. Stamps userId/name/color from session.Author via authorManager, strips editing/raw for read-only sessions, and logs author lookup failures while still relaying best-effort.

lib/ws/SheetHandler.go

client.goRoute incoming SHEET_PRESENCE frames to HandlePresence +7/-0

Route incoming SHEET_PRESENCE frames to HandlePresence

• Adds a readPump branch to detect and unmarshal SHEET_PRESENCE frames and call the new handler directly. Keeps SHEET_PRESENCE separate from the existing SHEET_OP enqueue path.

lib/ws/client.go

sheetPresence.tsAdd SheetPresence reducer and effectiveCells overlay helper +86/-0

Add SheetPresence reducer and effectiveCells overlay helper

• Introduces a small presence state manager that tracks remote cursors and live edits by userId, ignoring self frames. Provides effectiveCells() to overlay remote in-progress raws onto the committed grid for deterministic recomputation.

ui/src/js/sheet/sheetPresence.ts

sheetView.tsRender remote cursor/live-edit decorations and expose selection/edit lifecycle hooks +86/-13

Render remote cursor/live-edit decorations and expose selection/edit lifecycle hooks

• Enhances DomSheetView with onSelect/onLiveEdit/onEditEnd callbacks, Escape handling, and read-only non-editable cells. Adds remote cursor/live-edit decoration APIs and rendering that overlays live raw text while keeping name tags out of cell textContent.

ui/src/js/sheet/sheetView.ts

sheetEditor.tsWire presence transport, throttled live typing, and overlay recompute into sheet editor +67/-4

Wire presence transport, throttled live typing, and overlay recompute into sheet editor

• Adds emission and handling of SHEET_PRESENCE frames, debounced selection updates, and throttled live typing broadcasts. Integrates SheetPresence into the editor’s recompute loop by feeding FormulaEngine an effective grid and clearing overlays via NEW_SHEET_OP.author and USER_LEAVE.

ui/src/js/sheet/sheetEditor.ts

Tests (3) +280 / -4
sheet_handler_test.goAdd Go unit tests for presence relay, stamping, and read-only stripping +150/-4

Add Go unit tests for presence relay, stamping, and read-only stripping

• Extends the ws handler test harness with an authorManager and adds helpers to build/decode presence frames. Adds tests for sender exclusion, server-stamped identity, best-effort anonymous relay when author lookup fails, and enforced stripping of live edits for read-only sessions.

lib/ws/sheet_handler_test.go

sheetPresence.test.tsAdd vitest coverage for presence rules and overlay-driven formula recompute +73/-0

Add vitest coverage for presence rules and overlay-driven formula recompute

• Tests reducer behaviors (self-ignore, editing toggles, drop/clear semantics, per-sheet filtering). Validates that effectiveCells drives a real FormulaEngine recompute for dependent cells and that live edits override committed raws on coordinate collision.

ui/src/js/sheet/sheetPresence.test.ts

sheet_presence.spec.tsAdd two-session Playwright E2E for live cursor presence and live calculation +57/-0

Add two-session Playwright E2E for live cursor presence and live calculation

• Creates an end-to-end test with two browser contexts verifying remote cursor tags, live formula text propagation, dependent cell recompute before commit, and cleanup on disconnect via USER_LEAVE.

playwright/specs/sheet_presence.spec.ts

Documentation (2) +1237 / -0
2026-06-25-sheet-live-presence.mdAdd detailed implementation plan for sheet live presence and live calculation +1059/-0

Add detailed implementation plan for sheet live presence and live calculation

• Introduces a step-by-step, TDD-oriented execution plan covering server relay, client reducer/overlay, UI decorations, wiring, and E2E validation. Documents constraints (ephemeral state, server-stamped identity, read-only stripping) and the intended protocol and cleanup behaviors.

docs/superpowers/plans/2026-06-25-sheet-live-presence.md

2026-06-25-sheet-live-presence-design.mdAdd design spec for SHEET_PRESENCE protocol and live recompute semantics +178/-0

Add design spec for SHEET_PRESENCE protocol and live recompute semantics

• Defines the new ephemeral presence wire protocol, server responsibilities (relay + identity stamping + read-only enforcement), and client overlay-driven recomputation model. Explicitly scopes out join snapshots/TTLs and documents upgrade paths.

docs/superpowers/specs/2026-06-25-sheet-live-presence-design.md

Other (1) +5 / -3
vite.config.tsFix sheet-mode build hang by skipping legacy CommonJS plugin +5/-3

Fix sheet-mode build hang by skipping legacy CommonJS plugin

• Conditionally disables @rollup/plugin-commonjs when building in sheet mode to avoid a rolldown-vite deadlock while bundling HyperFormula. Leaves other build modes unchanged.

ui/vite.config.ts

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Presence hits rate limiter 🐞 Bug ☼ Reliability
Description
Client.readPump applies CommitRateLimiting to all websocket messages, but the new live-edit
presence loop can emit ~16 msgs/sec (60ms throttle), exceeding the default 10/sec and causing
SHEET_PRESENCE and potentially nearby SHEET_OP messages to be dropped. This can intermittently
break live presence fidelity and (in timing windows) reject a commit sent shortly after a burst of
presence frames.
Code

ui/src/js/sheet/sheetEditor.ts[R49-58]

+  // Live-edit throttle (trailing, ~60ms) so typing does not flood the socket.
+  let liveTimer: ReturnType<typeof setTimeout> | null = null;
+  let lastLive: { row: number; col: number; raw: string } | null = null;
+  const sendLiveEdit = (row: number, col: number, raw: string): void => {
+    lastLive = { row, col, raw };
+    if (liveTimer) return;
+    liveTimer = setTimeout(() => {
+      liveTimer = null;
+      if (lastLive) sendPresence(lastLive.row, lastLive.col, true, lastLive.raw);
+    }, 60);
Evidence
The server rate-limits every inbound websocket message (before routing) to 10 points per 1 second by
default; the new client presence typing loop can send every ~60ms, exceeding that limit and
triggering continue (drop).

lib/ws/client.go[121-191]
lib/settings/configRegistry.go[241-250]
ui/src/js/sheet/sheetEditor.ts[42-73]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The websocket `CommitRateLimiting` check is applied before message decoding, so `SHEET_PRESENCE` frames count toward the same limiter as commits. With the new 60ms trailing live-edit throttle, a normal typing burst can exceed the default 10 events/sec, causing presence and potentially subsequent sheet ops to be silently dropped.

### Issue Context
`SHEET_PRESENCE` is intentionally high-frequency/ephemeral traffic; it should not compete with commit reliability.

### Fix Focus Areas
- lib/ws/client.go[132-191]
- lib/settings/configRegistry.go[241-250]
- ui/src/js/sheet/sheetEditor.ts[42-73]

### Suggested fix
1. Parse enough of the incoming JSON envelope to identify `SHEET_PRESENCE` early.
2. Apply a separate limiter for presence (higher points/sec) or skip `CommitRateLimiting` for `SHEET_PRESENCE` and instead apply:
  - a presence-specific msg/sec limit, and
  - a payload-size cap (see other finding) to prevent abuse.
3. Keep `CommitRateLimiting` for write-bearing messages (`USER_CHANGES`, `SHEET_OP`, etc.).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Read-only cursor broken 🐞 Bug ≡ Correctness
Description
Read-only mode sets td.contentEditable = 'false', but cursor presence is driven by the cell
focus event; non-focusable <td> elements will not reliably receive focus on click, so onSelect
(and thus cursor presence) won’t fire for read-only users. This contradicts the server behavior that
explicitly allows read-only sessions to relay cursor moves while stripping live edits.
Code

ui/src/js/sheet/sheetView.ts[R88-92]

+        // Read-only viewers get non-editable cells: with no typing, no live-edit
+        // or commit frames originate from the client (the server strips them too).
+        td.contentEditable = opts.readOnly ? 'false' : 'true';
        this.attach(td, r, c);
        tr.appendChild(td);
Evidence
The view disables contenteditable in read-only mode yet relies on focus to trigger onSelect,
while the server explicitly supports read-only cursor relay by stripping only edit content.

ui/src/js/sheet/sheetView.ts[79-123]
ui/src/js/sheet/sheetEditor.ts[114-138]
lib/ws/SheetHandler.go[148-165]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
In read-only mode, cells are made non-editable via `contentEditable='false'`, but selection/cursor presence is wired to `focus` on the `<td>`. If the cell cannot be focused, read-only users cannot move their cursor (no `onSelect`), even though the server supports read-only cursor movement.

### Issue Context
The server-side presence relay strips `editing/raw` for read-only but keeps row/col, meaning read-only cursor movement is a supported behavior.

### Fix Focus Areas
- ui/src/js/sheet/sheetView.ts[86-148]
- ui/src/js/sheet/sheetEditor.ts[114-138]
- lib/ws/SheetHandler.go[159-165]

### Suggested fix
Implement read-only as “selectable but not editable”:
- Keep the cell focusable (e.g., `td.tabIndex = 0` and/or handle `click` to select) and continue emitting `onSelect`.
- Prevent edits without relying solely on `contentEditable=false`:
 - Use `beforeinput` handler to `preventDefault()` when `opts.readOnly`,
 - Ignore/suppress `onLiveEdit`/`onEdit` when read-only.
This preserves cursor presence while preventing typing/commits from the client.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Substring routing misroutes frames 🐞 Bug ≡ Correctness
Description
Websocket message routing uses strings.Contains(decodedMessage, ...) on the full JSON, but
SHEET_PRESENCE includes user-controlled raw; if raw contains tokens like SHEET_OP or
CLIENT_READY, the message can be routed to the wrong branch and dropped or handled incorrectly.
This makes presence unreliable for certain inputs and is abusable by crafted clients.
Code

lib/ws/client.go[R184-190]

+		} else if strings.Contains(decodedMessage, "SHEET_PRESENCE") {
+			var presence ws.SheetPresenceIncoming
+			if err := json.Unmarshal(message, &presence); err != nil {
+				logger.Error("Error unmarshalling SHEET_PRESENCE: ", err)
+				continue
+			}
+			c.Handler.HandlePresence(c, presence)
Evidence
readPump applies substring checks in order on the full JSON string, and presence messages carry
arbitrary raw strings (part of the same JSON), making accidental or malicious substring matches
possible.

lib/ws/client.go[132-191]
lib/models/ws/sheetMessages.go[61-79]
lib/models/ws/clientReady.go[1-22]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`Client.readPump` routes by substring search over the entire JSON payload. Because presence frames embed arbitrary user text (`raw`), normal cell contents can contain routing substrings and trigger the wrong handler.

### Issue Context
Presence frames are high-frequency and carry arbitrary `raw` strings. Substring routing is therefore not safe.

### Fix Focus Areas
- lib/ws/client.go[132-210]
- lib/models/ws/clientReady.go[1-27]
- lib/models/ws/sheetMessages.go[1-99]

### Suggested fix
Replace `strings.Contains` routing with JSON field-based routing:
1. Unmarshal into a small envelope:
  - `event`, and
  - `data.type` + `data.component` + `data.data` as `json.RawMessage`.
2. If `data.type == 'CLIENT_READY'` handle client-ready.
3. Else if `data.type == 'COLLABROOM'`, unmarshal inner `data.data` just enough to read `type` (`SHEET_OP`, `SHEET_PRESENCE`, etc.) and switch on that.
This avoids misrouting due to user-controlled content inside other fields.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Unbounded raw relayed 🐞 Bug ⛨ Security
Description
HandlePresence broadcasts the incoming raw string to all other room sockets without validating
or capping its length, allowing amplification of large payloads to every peer in the pad. The
websocket read limit caps the maximum frame size, but a near-limit payload (default 50KB) can still
be disruptive and is not necessary for live-edit UX.
Code

lib/ws/SheetHandler.go[R159-188]

+	editing := msg.Data.Data.Editing
+	raw := msg.Data.Data.Raw
+	if session.ReadOnly {
+		editing = false
+		raw = ""
+	}
+
+	var name, color string
+	if a, err := p.authorManager.GetAuthor(session.Author); err == nil && a != nil {
+		if a.Name != nil {
+			name = *a.Name
+		}
+		color = a.ColorId
+	} else if err != nil {
+		p.Logger.Warn("SHEET_PRESENCE: author lookup failed for ", session.Author, ": ", err)
+		// relay continues with empty name/color — presence is best-effort
+	}
+
+	out := ws.SheetPresence{Type: "COLLABROOM"}
+	out.Data = ws.SheetPresenceData{
+		Type:    "SHEET_PRESENCE",
+		UserId:  session.Author,
+		Name:    name,
+		Color:   color,
+		Sheet:   msg.Data.Data.Sheet,
+		Row:     msg.Data.Data.Row,
+		Col:     msg.Data.Data.Col,
+		Editing: editing,
+		Raw:     raw,
+	}
Evidence
The server copies msg.Data.Data.Raw into the broadcast payload without any length checks; the max
frame size is bounded by socketIo.maxHttpBufferSize (default 50,000 bytes), which is still large
enough to amplify to all peers.

lib/ws/SheetHandler.go[153-199]
lib/models/ws/sheetMessages.go[61-99]
lib/settings/configRegistry.go[48-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Presence `raw` is forwarded verbatim to all other clients. Without a small size cap, a client can send very large strings and cause bandwidth/memory pressure for every connected peer.

### Issue Context
Socket.IO max buffer is 50KB by default, so worst-case payloads are still non-trivial, and presence is high-frequency.

### Fix Focus Areas
- lib/ws/SheetHandler.go[153-199]
- lib/models/ws/sheetMessages.go[61-99]
- lib/settings/configRegistry.go[44-52]

### Suggested fix
1. Add a server-side maximum `raw` length for presence (e.g., 1–4KB):
  - If `len(raw)` exceeds the limit, truncate or drop the `editing/raw` portion (set `editing=false`, `raw=""`) while still relaying cursor row/col.
2. Optionally log at debug-level with truncation info (avoid noisy warn logs on repeated frames).
3. Consider applying the cap only when `editing==true` (cursor-only frames don’t need raw).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +49 to +58
// Live-edit throttle (trailing, ~60ms) so typing does not flood the socket.
let liveTimer: ReturnType<typeof setTimeout> | null = null;
let lastLive: { row: number; col: number; raw: string } | null = null;
const sendLiveEdit = (row: number, col: number, raw: string): void => {
lastLive = { row, col, raw };
if (liveTimer) return;
liveTimer = setTimeout(() => {
liveTimer = null;
if (lastLive) sendPresence(lastLive.row, lastLive.col, true, lastLive.raw);
}, 60);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Presence hits rate limiter 🐞 Bug ☼ Reliability

Client.readPump applies CommitRateLimiting to all websocket messages, but the new live-edit
presence loop can emit ~16 msgs/sec (60ms throttle), exceeding the default 10/sec and causing
SHEET_PRESENCE and potentially nearby SHEET_OP messages to be dropped. This can intermittently
break live presence fidelity and (in timing windows) reject a commit sent shortly after a burst of
presence frames.
Agent Prompt
### Issue description
The websocket `CommitRateLimiting` check is applied before message decoding, so `SHEET_PRESENCE` frames count toward the same limiter as commits. With the new 60ms trailing live-edit throttle, a normal typing burst can exceed the default 10 events/sec, causing presence and potentially subsequent sheet ops to be silently dropped.

### Issue Context
`SHEET_PRESENCE` is intentionally high-frequency/ephemeral traffic; it should not compete with commit reliability.

### Fix Focus Areas
- lib/ws/client.go[132-191]
- lib/settings/configRegistry.go[241-250]
- ui/src/js/sheet/sheetEditor.ts[42-73]

### Suggested fix
1. Parse enough of the incoming JSON envelope to identify `SHEET_PRESENCE` early.
2. Apply a separate limiter for presence (higher points/sec) or skip `CommitRateLimiting` for `SHEET_PRESENCE` and instead apply:
   - a presence-specific msg/sec limit, and
   - a payload-size cap (see other finding) to prevent abuse.
3. Keep `CommitRateLimiting` for write-bearing messages (`USER_CHANGES`, `SHEET_OP`, etc.).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +88 to 92
// Read-only viewers get non-editable cells: with no typing, no live-edit
// or commit frames originate from the client (the server strips them too).
td.contentEditable = opts.readOnly ? 'false' : 'true';
this.attach(td, r, c);
tr.appendChild(td);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Read-only cursor broken 🐞 Bug ≡ Correctness

Read-only mode sets td.contentEditable = 'false', but cursor presence is driven by the cell
focus event; non-focusable <td> elements will not reliably receive focus on click, so onSelect
(and thus cursor presence) won’t fire for read-only users. This contradicts the server behavior that
explicitly allows read-only sessions to relay cursor moves while stripping live edits.
Agent Prompt
### Issue description
In read-only mode, cells are made non-editable via `contentEditable='false'`, but selection/cursor presence is wired to `focus` on the `<td>`. If the cell cannot be focused, read-only users cannot move their cursor (no `onSelect`), even though the server supports read-only cursor movement.

### Issue Context
The server-side presence relay strips `editing/raw` for read-only but keeps row/col, meaning read-only cursor movement is a supported behavior.

### Fix Focus Areas
- ui/src/js/sheet/sheetView.ts[86-148]
- ui/src/js/sheet/sheetEditor.ts[114-138]
- lib/ws/SheetHandler.go[159-165]

### Suggested fix
Implement read-only as “selectable but not editable”:
- Keep the cell focusable (e.g., `td.tabIndex = 0` and/or handle `click` to select) and continue emitting `onSelect`.
- Prevent edits without relying solely on `contentEditable=false`:
  - Use `beforeinput` handler to `preventDefault()` when `opts.readOnly`,
  - Ignore/suppress `onLiveEdit`/`onEdit` when read-only.
This preserves cursor presence while preventing typing/commits from the client.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread lib/ws/client.go
Comment on lines +184 to +190
} else if strings.Contains(decodedMessage, "SHEET_PRESENCE") {
var presence ws.SheetPresenceIncoming
if err := json.Unmarshal(message, &presence); err != nil {
logger.Error("Error unmarshalling SHEET_PRESENCE: ", err)
continue
}
c.Handler.HandlePresence(c, presence)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. Substring routing misroutes frames 🐞 Bug ≡ Correctness

Websocket message routing uses strings.Contains(decodedMessage, ...) on the full JSON, but
SHEET_PRESENCE includes user-controlled raw; if raw contains tokens like SHEET_OP or
CLIENT_READY, the message can be routed to the wrong branch and dropped or handled incorrectly.
This makes presence unreliable for certain inputs and is abusable by crafted clients.
Agent Prompt
### Issue description
`Client.readPump` routes by substring search over the entire JSON payload. Because presence frames embed arbitrary user text (`raw`), normal cell contents can contain routing substrings and trigger the wrong handler.

### Issue Context
Presence frames are high-frequency and carry arbitrary `raw` strings. Substring routing is therefore not safe.

### Fix Focus Areas
- lib/ws/client.go[132-210]
- lib/models/ws/clientReady.go[1-27]
- lib/models/ws/sheetMessages.go[1-99]

### Suggested fix
Replace `strings.Contains` routing with JSON field-based routing:
1. Unmarshal into a small envelope:
   - `event`, and
   - `data.type` + `data.component` + `data.data` as `json.RawMessage`.
2. If `data.type == 'CLIENT_READY'` handle client-ready.
3. Else if `data.type == 'COLLABROOM'`, unmarshal inner `data.data` just enough to read `type` (`SHEET_OP`, `SHEET_PRESENCE`, etc.) and switch on that.
This avoids misrouting due to user-controlled content inside other fields.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@SamTV12345

Copy link
Copy Markdown
Member Author

Superseded: the feature already landed in main via #311 (auto-merged), so this branch conflicts. The remaining fixes (rolldown build fix, tag-pollution ::after fix, client-side read-only, E2E + collision test) are delivered cleanly on top of main in the follow-up PR.

@SamTV12345 SamTV12345 closed this Jun 25, 2026
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