Shape type changes do not propagate to other open viewers of the same board until they reload #91

Open
opened 2026-04-28 12:14:55 +00:00 by eslamnawara · 3 comments
Member

Summary

Changing the Shape type of an existing shape (e.g. Rectangle → Ellipse)
in the Properties panel does not propagate to other clients viewing the same
board in real time. The other client keeps rendering the previous shape type
until the page is reloaded.

Steps to reproduce

  1. Open a board in tab A.
  2. Open the same board in tab B via the shared link.
  3. Confirm both tabs are connected (cursor presence is visible).
  4. In tab A, draw a Rectangle.
  5. In tab A, select the rectangle and change Stroke color via the
    Properties panel — observe that tab B updates immediately.
  6. In tab A, with the same shape selected, change Shape from Rectangle
    to Ellipse (or any other shape type) in the Properties panel.

Expected

Tab B updates the rendered shape to an Ellipse in real time, the same way
it received the stroke/fill color changes.

Actual

  • Tab A: shape becomes an Ellipse immediately.
  • Tab B: shape stays as a Rectangle indefinitely. It only changes to the
    Ellipse after manually reloading tab B.
## Summary Changing the **Shape** type of an existing shape (e.g. Rectangle → Ellipse) in the Properties panel does not propagate to other clients viewing the same board in real time. The other client keeps rendering the previous shape type until the page is reloaded. ## Steps to reproduce 1. Open a board in tab A. 2. Open the same board in tab B via the shared link. 3. Confirm both tabs are connected (cursor presence is visible). 4. In tab A, draw a Rectangle. 5. In tab A, select the rectangle and change `Stroke` color via the Properties panel — observe that tab B updates immediately. 6. In tab A, with the same shape selected, change `Shape` from `Rectangle` to `Ellipse` (or any other shape type) in the Properties panel. ## Expected Tab B updates the rendered shape to an Ellipse in real time, the same way it received the stroke/fill color changes. ## Actual - Tab A: shape becomes an Ellipse immediately. - Tab B: shape stays as a Rectangle indefinitely. It only changes to the Ellipse after manually reloading tab B.
Member

Implementation Spec — Issue #91: Shape type changes must propagate live

Objective

Make Shape-type changes (e.g. Rectangle → Ellipse via the Properties panel) propagate live to all other open viewers of the same board, replacing the underlying Konva node so the rendered shape actually changes — not just its style attributes.

Root Cause

  • Sender path is correct. properties.js:678-686 wires #prop-shape-type to WhiteboardObjects.changeShapeType(...), which (in objects.js:699-783) destroys the old .bg Konva node, creates a new one of the correct class, and calls WhiteboardSync.onUpdate(group). serializeForServer already serializes data.shapeType, so the new type is persisted server-side AND broadcast over WebSocket as object.updated.
  • Receiver path is broken. In sync.js applySyncUpdate the type === 'shape' branch (lines 546-564) only patches style.fill / style.stroke / style.strokeWidth and rerenders text. It never reads data.shapeType and never swaps the underlying Konva node. Because a Konva.Rect cannot morph into a Konva.Ellipse (different classes), the remote viewer keeps rendering the old shape until full reload, where loadSingleObjectWhiteboardApp.renderSingleObject rebuilds the node from shapeType.

Requirements

  • A change to a shape's shapeType must propagate live to all other open viewers within the existing debounce window.
  • The change must continue to persist server-side (already works — do not regress).
  • A third tab opening the board after the change must load the new shape type (already works via loadSingleObject).
  • Use the existing WhiteboardSync / object.updated transport. No new transports, no new dependencies.
  • No infinite-loop risk: applying a remote shape-type swap must not re-broadcast.
  • Local style/text/dimension state on the shape (fill, stroke, strokeWidth, text, fontSize, align, fontFamily, position, rotation) must be preserved across the type swap on the receiver.

Files to Modify

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js — extend changeShapeType to accept an opts argument so the receiver can call it without re-broadcasting or pushing history.
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js — in applySyncUpdate's type === 'shape' branch, detect a data.shapeType mismatch and call WhiteboardObjects.changeShapeType with the no-broadcast option before applying style/text patches.

Files to Leave Alone

  • properties.js — sender already works.
  • app.js — initial render path (renderSingleObject) already handles shape kinds correctly.
  • Server crates — payload already includes shapeType in data. No backend change required.
  • All other type branches in applySyncUpdate (sticky, text, document, frame, kanban, mindmap, etc.) — out of scope.

Step-by-Step Plan

Step 1 — Extend changeShapeType in objects.js to support a no-broadcast mode

File: crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js — function changeShapeType(group, newShapeType) at line 699.

  • Change signature to changeShapeType(group, newShapeType, opts). Treat opts as optional {}.
  • Wrap WhiteboardHistory.snapshotBefore(group.id()) so it only runs when !opts._fromSync.
  • Wrap the trailing WhiteboardSync.onUpdate(group) and WhiteboardHistory.commitUpdate(group.id()) (lines 781-782) the same way.
  • Add an early no-op guard at the top: if objData.shapeType === newShapeType, return.

The change is backward-compatible: the existing single-arg call site in properties.js:683 still works.

Dependencies: none.

Step 2 — Handle data.shapeType changes in applySyncUpdate

File: crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js — function applySyncUpdate(obj), branch type === 'shape' (around line 546).

Insert at the very top of the type === 'shape' branch, before the existing style/text patch:

if (data && data.shapeType && existing.shapeType !== data.shapeType) {
    WhiteboardObjects.changeShapeType(node, data.shapeType, { _fromSync: true });
    bg = node.findOne('.bg');
}

Then let the existing style/text patch logic run on the (refreshed) bg.

Notes:

  • existing.shapeType is set in objects.js createShape and updated by changeShapeType, so the comparison is reliable.
  • bg is captured earlier in applySyncUpdate and used by the dimensions block and style block. Reassigning is required because changeShapeType destroys and recreates the .bg node.
  • Order matters: the dimensions patch block runs before the type branches and operates on the old bg. changeShapeType reads dimensions from the (already resized) old bg before destroying it, so the new shape inherits the correct width/height.
  • The existing _applyingSync guard already prevents WhiteboardSync.onUpdate from re-broadcasting, but the explicit _fromSync flag is cleaner and also blocks history pollution.

Dependencies: Step 1.

Acceptance Criteria

  • In tab A, change a Rectangle to an Ellipse via Properties. Tab B (already open) re-renders that object as an Ellipse within ~1 update-debounce window, with its existing fill, stroke, stroke-width, text, position, rotation, and dimensions preserved.
  • Same flow works for every entry in the shape dropdown: rect, rounded_rect, circle, ellipse, diamond, triangle, pentagon, hexagon, star, cloud, callout, cylinder, parallelogram, cross.
  • Opening a third tab C after the change shows the new shape type from the server (regression check on persistence).
  • Changing shape type does not produce duplicate broadcasts or history entries on the originating tab.
  • Changing only stroke color still works (no regression on previously-working path).
  • No console errors in either tab during the type swap.
  • Connectors attached to the shape still anchor to the new bg node after a remote type change.

Notes / Caveats / Out of Scope

  • Do not change the wire format. data.shapeType is already on the wire; the fix is purely receiver-side rendering plus a small refactor of changeShapeType.
  • Konva limitation. Konva.Rect cannot become Konva.Ellipse in place — different classes with different geometry primitives. Destroy + recreate is the correct approach, mirroring what the local changeShapeType already does.
  • History on the receiver. A remote shape-type change must NOT push a local undo step on the receiver — Step 1's _fromSync guard ensures that.
  • Debounce / batching. onUpdate already debounces and batches via flushUpdates — no change needed.
  • Symmetric shapes (Star, RegularPolygon, Circle). changeShapeType already handles aspect-ratio stretching for these.
  • Server side. hero_whiteboard_server already round-trips data opaquely; no schema/migration change required.
# Implementation Spec — Issue #91: Shape type changes must propagate live ## Objective Make Shape-type changes (e.g. Rectangle → Ellipse via the Properties panel) propagate live to all other open viewers of the same board, replacing the underlying Konva node so the rendered shape actually changes — not just its style attributes. ## Root Cause - **Sender path is correct.** `properties.js:678-686` wires `#prop-shape-type` to `WhiteboardObjects.changeShapeType(...)`, which (in `objects.js:699-783`) destroys the old `.bg` Konva node, creates a new one of the correct class, and calls `WhiteboardSync.onUpdate(group)`. `serializeForServer` already serializes `data.shapeType`, so the new type is persisted server-side AND broadcast over WebSocket as `object.updated`. - **Receiver path is broken.** In `sync.js` `applySyncUpdate` the `type === 'shape'` branch (lines 546-564) only patches `style.fill` / `style.stroke` / `style.strokeWidth` and rerenders text. It never reads `data.shapeType` and never swaps the underlying Konva node. Because a `Konva.Rect` cannot morph into a `Konva.Ellipse` (different classes), the remote viewer keeps rendering the old shape until full reload, where `loadSingleObject` → `WhiteboardApp.renderSingleObject` rebuilds the node from `shapeType`. ## Requirements - A change to a shape's `shapeType` must propagate live to all other open viewers within the existing debounce window. - The change must continue to persist server-side (already works — do not regress). - A third tab opening the board after the change must load the new shape type (already works via `loadSingleObject`). - Use the existing `WhiteboardSync` / `object.updated` transport. No new transports, no new dependencies. - No infinite-loop risk: applying a remote shape-type swap must not re-broadcast. - Local style/text/dimension state on the shape (fill, stroke, strokeWidth, text, fontSize, align, fontFamily, position, rotation) must be preserved across the type swap on the receiver. ## Files to Modify - `crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js` — extend `changeShapeType` to accept an `opts` argument so the receiver can call it without re-broadcasting or pushing history. - `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` — in `applySyncUpdate`'s `type === 'shape'` branch, detect a `data.shapeType` mismatch and call `WhiteboardObjects.changeShapeType` with the no-broadcast option before applying style/text patches. ## Files to Leave Alone - `properties.js` — sender already works. - `app.js` — initial render path (`renderSingleObject`) already handles shape kinds correctly. - Server crates — payload already includes `shapeType` in `data`. No backend change required. - All other type branches in `applySyncUpdate` (sticky, text, document, frame, kanban, mindmap, etc.) — out of scope. ## Step-by-Step Plan ### Step 1 — Extend `changeShapeType` in `objects.js` to support a no-broadcast mode File: `crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js` — function `changeShapeType(group, newShapeType)` at line 699. - Change signature to `changeShapeType(group, newShapeType, opts)`. Treat `opts` as optional `{}`. - Wrap `WhiteboardHistory.snapshotBefore(group.id())` so it only runs when `!opts._fromSync`. - Wrap the trailing `WhiteboardSync.onUpdate(group)` and `WhiteboardHistory.commitUpdate(group.id())` (lines 781-782) the same way. - Add an early no-op guard at the top: if `objData.shapeType === newShapeType`, `return`. The change is backward-compatible: the existing single-arg call site in `properties.js:683` still works. Dependencies: none. ### Step 2 — Handle `data.shapeType` changes in `applySyncUpdate` File: `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` — function `applySyncUpdate(obj)`, branch `type === 'shape'` (around line 546). Insert at the **very top** of the `type === 'shape'` branch, before the existing style/text patch: ``` if (data && data.shapeType && existing.shapeType !== data.shapeType) { WhiteboardObjects.changeShapeType(node, data.shapeType, { _fromSync: true }); bg = node.findOne('.bg'); } ``` Then let the existing style/text patch logic run on the (refreshed) `bg`. Notes: - `existing.shapeType` is set in `objects.js` `createShape` and updated by `changeShapeType`, so the comparison is reliable. - `bg` is captured earlier in `applySyncUpdate` and used by the dimensions block and style block. Reassigning is required because `changeShapeType` destroys and recreates the `.bg` node. - Order matters: the dimensions patch block runs *before* the type branches and operates on the *old* bg. `changeShapeType` reads dimensions from the (already resized) old bg before destroying it, so the new shape inherits the correct width/height. - The existing `_applyingSync` guard already prevents `WhiteboardSync.onUpdate` from re-broadcasting, but the explicit `_fromSync` flag is cleaner and also blocks history pollution. Dependencies: Step 1. ## Acceptance Criteria - [ ] In tab A, change a Rectangle to an Ellipse via Properties. Tab B (already open) re-renders that object as an Ellipse within ~1 update-debounce window, with its existing fill, stroke, stroke-width, text, position, rotation, and dimensions preserved. - [ ] Same flow works for every entry in the shape dropdown: `rect`, `rounded_rect`, `circle`, `ellipse`, `diamond`, `triangle`, `pentagon`, `hexagon`, `star`, `cloud`, `callout`, `cylinder`, `parallelogram`, `cross`. - [ ] Opening a third tab C *after* the change shows the new shape type from the server (regression check on persistence). - [ ] Changing shape type does not produce duplicate broadcasts or history entries on the originating tab. - [ ] Changing only stroke color still works (no regression on previously-working path). - [ ] No console errors in either tab during the type swap. - [ ] Connectors attached to the shape still anchor to the new bg node after a remote type change. ## Notes / Caveats / Out of Scope - **Do not change the wire format.** `data.shapeType` is already on the wire; the fix is purely receiver-side rendering plus a small refactor of `changeShapeType`. - **Konva limitation.** `Konva.Rect` cannot become `Konva.Ellipse` in place — different classes with different geometry primitives. Destroy + recreate is the correct approach, mirroring what the local `changeShapeType` already does. - **History on the receiver.** A remote shape-type change must NOT push a local undo step on the receiver — Step 1's `_fromSync` guard ensures that. - **Debounce / batching.** `onUpdate` already debounces and batches via `flushUpdates` — no change needed. - **Symmetric shapes (Star, RegularPolygon, Circle).** `changeShapeType` already handles aspect-ratio stretching for these. - **Server side.** `hero_whiteboard_server` already round-trips `data` opaquely; no schema/migration change required.
Member

Test Results

  • cargo fmt --all -- --check: pass
  • cargo check --workspace: pass
  • cargo clippy --workspace -- -D warnings: pass
  • cargo test --workspace --lib: pass (0 passed)
  • node --check objects.js: pass
  • node --check sync.js: pass
## Test Results - cargo fmt --all -- --check: pass - cargo check --workspace: pass - cargo clippy --workspace -- -D warnings: pass - cargo test --workspace --lib: pass (0 passed) - node --check objects.js: pass - node --check sync.js: pass
Member

Implementation Summary

Two files changed, JS-only — no server / SDK / OpenRPC / DB changes. Reuses the existing object.updated broadcast; the wire format is unchanged because the sender already serializes data.shapeType.

Root cause

The receiver path in sync.js applySyncUpdate patched style fields on the existing .bg Konva node but never swapped the node when shapeType changed. A Konva.Rect and Konva.Ellipse are different classes, so until a full reload the remote viewer kept rendering the old shape.

crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js

  • changeShapeType(group, newShapeType) is now changeShapeType(group, newShapeType, opts) with opts = opts || {}. The function is unchanged for local callers (opts is optional and falsy means "behave as before").
  • Added an early return if objData.shapeType === newShapeType — no churn when the type already matches.
  • Wrapped WhiteboardHistory.snapshotBefore(group.id()) and the trailing WhiteboardSync.onUpdate(group) + WhiteboardHistory.commitUpdate(group.id()) in if (!opts._fromSync) { ... }, so a sync-driven swap doesn't push a local history entry or re-broadcast.

crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js

  • In applySyncUpdate, at the top of the type === 'shape' branch, before the existing style/text patch:
    if (data && data.shapeType && existing.shapeType !== data.shapeType) {
        WhiteboardObjects.changeShapeType(node, data.shapeType, { _fromSync: true });
        bg = node.findOne('.bg');
    }
    
  • The receiver re-reads bg because changeShapeType destroys the old .bg and creates a new one of the right class. The existing style/text patches then run on the new bg.

Verification

  • cargo fmt --all -- --check: clean
  • cargo check --workspace: clean
  • cargo clippy --workspace -- -D warnings: clean
  • cargo test --workspace --lib: pass
  • node --check on both modified JS modules: clean

Manual smoke test

  1. Open the same board in two windows.
  2. In tab A draw a rectangle, then change Shape from Rectangle to Ellipse via the Properties panel.
  3. Tab B updates within ~1 debounce window. Fill, stroke, dimensions, position, rotation, and any text are preserved.
  4. Open a third tab — it loads the new shape from the server (persistence preserved).
  5. Stroke-color change still propagates as before (no regression).

Notes

  • No server-side changes. data.shapeType was already on the wire; the bug was purely that the receiver never acted on it.
  • The _fromSync guard is defense-in-depth on top of the existing _applyingSync flag — it specifically blocks history pollution and is symmetric with the same pattern used elsewhere in sync.js / themes.js / connectors.js.
## Implementation Summary Two files changed, JS-only — no server / SDK / OpenRPC / DB changes. Reuses the existing `object.updated` broadcast; the wire format is unchanged because the sender already serializes `data.shapeType`. ### Root cause The receiver path in `sync.js` `applySyncUpdate` patched style fields on the existing `.bg` Konva node but never swapped the node when `shapeType` changed. A `Konva.Rect` and `Konva.Ellipse` are different classes, so until a full reload the remote viewer kept rendering the old shape. ### `crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js` - `changeShapeType(group, newShapeType)` is now `changeShapeType(group, newShapeType, opts)` with `opts = opts || {}`. The function is unchanged for local callers (`opts` is optional and falsy means "behave as before"). - Added an early `return` if `objData.shapeType === newShapeType` — no churn when the type already matches. - Wrapped `WhiteboardHistory.snapshotBefore(group.id())` and the trailing `WhiteboardSync.onUpdate(group)` + `WhiteboardHistory.commitUpdate(group.id())` in `if (!opts._fromSync) { ... }`, so a sync-driven swap doesn't push a local history entry or re-broadcast. ### `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` - In `applySyncUpdate`, at the top of the `type === 'shape'` branch, before the existing style/text patch: ``` if (data && data.shapeType && existing.shapeType !== data.shapeType) { WhiteboardObjects.changeShapeType(node, data.shapeType, { _fromSync: true }); bg = node.findOne('.bg'); } ``` - The receiver re-reads `bg` because `changeShapeType` destroys the old `.bg` and creates a new one of the right class. The existing style/text patches then run on the new `bg`. ### Verification - `cargo fmt --all -- --check`: clean - `cargo check --workspace`: clean - `cargo clippy --workspace -- -D warnings`: clean - `cargo test --workspace --lib`: pass - `node --check` on both modified JS modules: clean ### Manual smoke test 1. Open the same board in two windows. 2. In tab A draw a rectangle, then change Shape from Rectangle to Ellipse via the Properties panel. 3. Tab B updates within ~1 debounce window. Fill, stroke, dimensions, position, rotation, and any text are preserved. 4. Open a third tab — it loads the new shape from the server (persistence preserved). 5. Stroke-color change still propagates as before (no regression). ### Notes - No server-side changes. `data.shapeType` was already on the wire; the bug was purely that the receiver never acted on it. - The `_fromSync` guard is defense-in-depth on top of the existing `_applyingSync` flag — it specifically blocks history pollution and is symmetric with the same pattern used elsewhere in `sync.js` / `themes.js` / `connectors.js`.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#91
No description provided.