Board theme change does not propagate to other open viewers of the same board until they reload #90

Open
opened 2026-04-28 11:29:10 +00:00 by eslamnawara · 3 comments
Member

Summary

When the same board is open in two windows, changing the board theme in one window does not propagate to the other in real time. The other window continues rendering the previous theme until it is manually reloaded — even though other realtime activity is clearly working between the two clients.

Steps to reproduce

  1. Open a board in tab A.
  2. Open the same board's shared link in tab B.
  3. Confirm both tabs are connected — for example, tab B shows tab A's
    cursor moving on the canvas and new text added reflects in real time.
  4. In tab A, open the Board Theme picker and switch to a different theme
    (e.g. Dark → Light).

Expected

Tab A flips to the new theme immediately (this already works), and tab B
also receives the theme change in real time and updates to match —
consistent with how other live updates (cursors, edits) propagate.

Actual

  • Tab A flips to the new theme immediately.
  • Tab B keeps rendering the old theme indefinitely. It only picks up the
    new theme after a manual page reload, despite the realtime channel
    clearly being active (cursor presence from tab A is visible in tab B).
## Summary When the same board is open in two windows, changing the board theme in one window does not propagate to the other in real time. The other window continues rendering the previous theme until it is manually reloaded — even though other realtime activity is clearly working between the two clients. ## Steps to reproduce 1. Open a board in tab A. 2. Open the same board's shared link in tab B. 3. Confirm both tabs are connected — for example, tab B shows tab A's cursor moving on the canvas and new text added reflects in real time. 4. In tab A, open the Board Theme picker and switch to a different theme (e.g. Dark → Light). ## Expected Tab A flips to the new theme immediately (this already works), and tab B also receives the theme change in real time and updates to match — consistent with how other live updates (cursors, edits) propagate. ## Actual - Tab A flips to the new theme immediately. - Tab B keeps rendering the old theme indefinitely. It only picks up the new theme after a manual page reload, despite the realtime channel clearly being active (cursor presence from tab A is visible in tab B).
Member

Spec — Issue #90: Board theme change must propagate to other open viewers in real time

Objective

When a user changes the board theme in one window/tab, every other client connected to the same board over the existing collaboration WebSocket must apply the same theme immediately, without reloading. The theme must also remain persisted on the server so any future opener of the board gets it on initial load (current behavior — unchanged).

Requirements

  • Reuse the existing per-board WebSocket broadcast channel in crates/hero_whiteboard_ui/src/ws.rs. Do not introduce a new transport, channel, or socket.
  • Reuse the existing message-shape pattern ({ type: '...', sender: ..., data: ... }) used for object.created/object.updated. Add a new type value board.theme.updated.
  • The theme continues to be persisted via board.update RPC into board.data.theme / board.data.customCSS (already implemented in static/web/js/whiteboard/sync.js's saveBoardTheme). No server-side schema change.
  • When a remote board.theme.updated arrives, the client must apply the theme by calling WhiteboardThemes.loadTheme(name, customCSS) so the canvas background, grid, calendars, and theme-dependent renderers refresh — exactly as on first load.
  • Applying a remote theme update must not trigger another board.update RPC or another WebSocket broadcast (no echo loop).
  • The fix must also work on the read-only board view (board_view.html) if it shares sync.js — verify WhiteboardThemes is loaded there; otherwise leave its behavior unchanged (out of scope if themes is editor-only).
  • No new dependencies. No new files unless strictly necessary; prefer modifying existing ones.

Files to Modify

1. crates/hero_whiteboard_ui/static/web/js/whiteboard/themes.js

  • Add a flag/parameter to applyTheme(name, opts)opts._fromSync (default false). When true:
    • Skip the call to WhiteboardSync.saveBoardTheme(...) at the bottom of applyTheme so we don't re-persist or re-broadcast a theme update we just received.
  • After the existing local apply branch (i.e. when _fromSync is false and the theme was successfully applied), broadcast a WebSocket message to all other clients of this board. Use the existing WhiteboardSync._wsSend (already exposed) with payload:
    { type: 'board.theme.updated', data: { name: <themeName>, customCSS: <serializedCurrentTheme> } }
    
    Send it after saveBoardTheme is queued (order does not matter — WS and RPC are independent).
  • Optionally factor the no-side-effect part of applyTheme into an internal applyThemeLocal(name) helper that both the user-driven and sync-driven paths share, to keep the diff minimal and readable. Keep the public surface (applyTheme, loadTheme) backward compatible.

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

  • In handleWsMessage, add a new branch (placed alongside the existing object.* / connector.* / comment.* handlers, before the cursor/join/leave cases for clarity):
    } else if (msg.type === 'board.theme.updated') {
        if (msg.data && msg.data.name && typeof WhiteboardThemes !== 'undefined') {
            WhiteboardThemes.loadTheme(msg.data.name, msg.data.customCSS);
        }
    }
    
    Note: loadTheme already calls applyTheme internally — to suppress re-broadcast/re-persist when applied from sync, either:
    • (Preferred) Have loadTheme accept an opts arg and pass { _fromSync: true } through to applyTheme, or
    • Add a new WhiteboardThemes.applyRemoteTheme(name, customCSS) that wraps the local apply path with _fromSync semantics. Pick whichever produces the smaller diff against themes.js.
  • No other changes to sync.js — the existing echo-skip (msg.sender === localUserId) already prevents the originator from re-applying its own broadcast, because wsSend stamps msg.sender = localUserId before sending.

3. (No server changes required)

  • crates/hero_whiteboard_server/src/handlers/board.rs already merges params.data into board.data on board.update, so theme and customCSS continue to round-trip through the DB. No edit needed.
  • crates/hero_whiteboard_ui/src/routes.rs and src/ws.rs already provide the per-board broadcast — no edit needed. The relay simply forwards arbitrary text frames between clients of the same board, so adding a new type value is purely a client-side contract.

Step-by-Step Implementation Plan

Step 1 — Add _fromSync opt to themes.js

  • File: crates/hero_whiteboard_ui/static/web/js/whiteboard/themes.js
  • Change applyTheme(name) signature to applyTheme(name, opts); opts = opts || {}.
  • Wrap the existing WhiteboardSync.saveBoardTheme(...) call in if (!opts._fromSync) { ... }.
  • Update loadTheme(name, themeData) to forward _fromSync — change to loadTheme(name, themeData, opts) and pass opts (or { _fromSync: true }) to applyTheme.
  • No behavior change for any existing caller because the new arg is optional and falsy by default.
  • Dependencies: none.

Step 2 — Broadcast on local theme change

  • File: crates/hero_whiteboard_ui/static/web/js/whiteboard/themes.js (same file as Step 1)
  • After the WhiteboardSync.saveBoardTheme(...) block in applyTheme, when !opts._fromSync and WhiteboardSync && WhiteboardSync._wsSend, send:
    WhiteboardSync._wsSend({
        type: 'board.theme.updated',
        data: { name: name, customCSS: JSON.stringify(currentTheme) }
    });
    
  • Rationale for sending the serialized currentTheme as customCSS: matches the existing saveBoardTheme(name, JSON.stringify(currentTheme)) shape and the loadTheme(name, themeData) consumer signature, so receivers can fall back to deserializing the same blob even if a custom theme name is unknown locally.
  • Dependencies: Step 1 (so the _fromSync guard exists; otherwise re-applying remotely would re-broadcast).

Step 3 — Handle remote theme updates in sync.js

  • File: crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js
  • In handleWsMessage, add the board.theme.updated branch as shown above. Call WhiteboardThemes.loadTheme(msg.data.name, msg.data.customCSS, { _fromSync: true }) (or applyRemoteTheme if that variant was chosen).
  • The early if (msg.sender === localUserId) return; already runs before this branch — confirm placement is after that guard so the originator does not re-apply.
  • Dependencies: Steps 1 + 2.

Step 4 — Manual smoke test

  • Open the same board in two windows. In tab A, switch theme. Verify tab B updates within ~1 frame, no reload required. Verify cursors/edits still sync (confirm the WS pipeline is healthy in the same session).
  • Open a third tab on the same board after Step 3. Confirm it loads with the most recently selected theme (tests persistence still works).

Acceptance Criteria

  • Switching theme in tab A causes tab B (already connected) to render the new theme within ~1 frame, no reload.
  • A third tab opened after a theme change loads with the new theme on initial render (persistence preserved).
  • Switching theme in tab A does not cause tab A itself to receive its own broadcast and re-apply (no flash, no echo).
  • Receiving a remote theme update does not trigger a second board.update RPC from the receiver (verify in network tab — only the originator hits board.update).
  • Cursors, object create/update/delete, and connectors continue to sync as before — no regression in handleWsMessage.
  • No new dependencies, no new files (other than potentially trivial helpers within existing JS modules), no new RPC methods, no new WebSocket routes.

Notes / Caveats / Out of Scope

  • Echo skip is provided by sync.js's existing if (msg.sender === localUserId) return; because wsSend stamps msg.sender = localUserId. We rely on this; do not introduce a separate dedup. The _fromSync flag is a defense-in-depth guard that also prevents re-broadcast/re-persist if anything else (e.g. syncFromServer) ever calls loadTheme programmatically.
  • Read-only viewer (board_view.html): the read-only template should still receive and apply the theme so its rendering stays consistent with the editor. If WhiteboardThemes and WhiteboardSync are both loaded there, no extra work is needed. If WhiteboardThemes is only loaded in the editor template, do not add it now — that's a separate scope.
  • Custom themes: when a user has a custom theme that is not in the receiver's BUILTIN_THEMES, loadTheme(name, customCSS) already populates customThemes[name] from customCSS and applies it. This path is exercised by the broadcast.
  • Do NOT change any RPC method, the DB schema, the WebSocket route, the broadcast channel structure, or the RPC proxy sniffing logic in routes.rs. The relay is intentionally generic — adding a new client-side type is sufficient.
  • Do NOT add server-side broadcast for board.update (similar to how board.delete does it). Theme changes are routed directly client→client over the same WS the originator already has open; piggybacking on the RPC proxy is unnecessary complexity.
  • Throttling: theme picks are user-driven (one click per change), so no throttling is needed beyond what the WS layer already provides. Do not add a debounce.
# Spec — Issue #90: Board theme change must propagate to other open viewers in real time ## Objective When a user changes the board theme in one window/tab, every other client connected to the same board over the existing collaboration WebSocket must apply the same theme immediately, without reloading. The theme must also remain persisted on the server so any future opener of the board gets it on initial load (current behavior — unchanged). ## Requirements - Reuse the existing per-board WebSocket broadcast channel in `crates/hero_whiteboard_ui/src/ws.rs`. Do **not** introduce a new transport, channel, or socket. - Reuse the existing message-shape pattern (`{ type: '...', sender: ..., data: ... }`) used for `object.created`/`object.updated`. Add a new `type` value `board.theme.updated`. - The theme continues to be persisted via `board.update` RPC into `board.data.theme` / `board.data.customCSS` (already implemented in `static/web/js/whiteboard/sync.js`'s `saveBoardTheme`). No server-side schema change. - When a remote `board.theme.updated` arrives, the client must apply the theme by calling `WhiteboardThemes.loadTheme(name, customCSS)` so the canvas background, grid, calendars, and theme-dependent renderers refresh — exactly as on first load. - Applying a remote theme update must **not** trigger another `board.update` RPC or another WebSocket broadcast (no echo loop). - The fix must also work on the read-only board view (`board_view.html`) if it shares `sync.js` — verify `WhiteboardThemes` is loaded there; otherwise leave its behavior unchanged (out of scope if themes is editor-only). - No new dependencies. No new files unless strictly necessary; prefer modifying existing ones. ## Files to Modify ### 1. `crates/hero_whiteboard_ui/static/web/js/whiteboard/themes.js` - Add a flag/parameter to `applyTheme(name, opts)` — `opts._fromSync` (default false). When true: - Skip the call to `WhiteboardSync.saveBoardTheme(...)` at the bottom of `applyTheme` so we don't re-persist or re-broadcast a theme update we just received. - After the existing local apply branch (i.e. when `_fromSync` is false and the theme was successfully applied), broadcast a WebSocket message to all other clients of this board. Use the existing `WhiteboardSync._wsSend` (already exposed) with payload: ``` { type: 'board.theme.updated', data: { name: <themeName>, customCSS: <serializedCurrentTheme> } } ``` Send it after `saveBoardTheme` is queued (order does not matter — WS and RPC are independent). - Optionally factor the no-side-effect part of `applyTheme` into an internal `applyThemeLocal(name)` helper that both the user-driven and sync-driven paths share, to keep the diff minimal and readable. Keep the public surface (`applyTheme`, `loadTheme`) backward compatible. ### 2. `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` - In `handleWsMessage`, add a new branch (placed alongside the existing `object.*` / `connector.*` / `comment.*` handlers, before the `cursor`/`join`/`leave` cases for clarity): ``` } else if (msg.type === 'board.theme.updated') { if (msg.data && msg.data.name && typeof WhiteboardThemes !== 'undefined') { WhiteboardThemes.loadTheme(msg.data.name, msg.data.customCSS); } } ``` Note: `loadTheme` already calls `applyTheme` internally — to suppress re-broadcast/re-persist when applied from sync, either: - (Preferred) Have `loadTheme` accept an `opts` arg and pass `{ _fromSync: true }` through to `applyTheme`, **or** - Add a new `WhiteboardThemes.applyRemoteTheme(name, customCSS)` that wraps the local apply path with `_fromSync` semantics. Pick whichever produces the smaller diff against `themes.js`. - No other changes to `sync.js` — the existing echo-skip (`msg.sender === localUserId`) already prevents the originator from re-applying its own broadcast, because `wsSend` stamps `msg.sender = localUserId` before sending. ### 3. (No server changes required) - `crates/hero_whiteboard_server/src/handlers/board.rs` already merges `params.data` into `board.data` on `board.update`, so `theme` and `customCSS` continue to round-trip through the DB. No edit needed. - `crates/hero_whiteboard_ui/src/routes.rs` and `src/ws.rs` already provide the per-board broadcast — no edit needed. The relay simply forwards arbitrary text frames between clients of the same board, so adding a new `type` value is purely a client-side contract. ## Step-by-Step Implementation Plan ### Step 1 — Add `_fromSync` opt to `themes.js` - File: `crates/hero_whiteboard_ui/static/web/js/whiteboard/themes.js` - Change `applyTheme(name)` signature to `applyTheme(name, opts)`; `opts = opts || {}`. - Wrap the existing `WhiteboardSync.saveBoardTheme(...)` call in `if (!opts._fromSync) { ... }`. - Update `loadTheme(name, themeData)` to forward `_fromSync` — change to `loadTheme(name, themeData, opts)` and pass `opts` (or `{ _fromSync: true }`) to `applyTheme`. - No behavior change for any existing caller because the new arg is optional and falsy by default. - Dependencies: none. ### Step 2 — Broadcast on local theme change - File: `crates/hero_whiteboard_ui/static/web/js/whiteboard/themes.js` (same file as Step 1) - After the `WhiteboardSync.saveBoardTheme(...)` block in `applyTheme`, when `!opts._fromSync` and `WhiteboardSync && WhiteboardSync._wsSend`, send: ``` WhiteboardSync._wsSend({ type: 'board.theme.updated', data: { name: name, customCSS: JSON.stringify(currentTheme) } }); ``` - Rationale for sending the serialized `currentTheme` as `customCSS`: matches the existing `saveBoardTheme(name, JSON.stringify(currentTheme))` shape and the `loadTheme(name, themeData)` consumer signature, so receivers can fall back to deserializing the same blob even if a custom theme name is unknown locally. - Dependencies: Step 1 (so the `_fromSync` guard exists; otherwise re-applying remotely would re-broadcast). ### Step 3 — Handle remote theme updates in `sync.js` - File: `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` - In `handleWsMessage`, add the `board.theme.updated` branch as shown above. Call `WhiteboardThemes.loadTheme(msg.data.name, msg.data.customCSS, { _fromSync: true })` (or `applyRemoteTheme` if that variant was chosen). - The early `if (msg.sender === localUserId) return;` already runs before this branch — confirm placement is **after** that guard so the originator does not re-apply. - Dependencies: Steps 1 + 2. ### Step 4 — Manual smoke test - Open the same board in two windows. In tab A, switch theme. Verify tab B updates within ~1 frame, no reload required. Verify cursors/edits still sync (confirm the WS pipeline is healthy in the same session). - Open a third tab on the same board after Step 3. Confirm it loads with the most recently selected theme (tests persistence still works). ## Acceptance Criteria - [ ] Switching theme in tab A causes tab B (already connected) to render the new theme within ~1 frame, no reload. - [ ] A third tab opened after a theme change loads with the new theme on initial render (persistence preserved). - [ ] Switching theme in tab A does **not** cause tab A itself to receive its own broadcast and re-apply (no flash, no echo). - [ ] Receiving a remote theme update does **not** trigger a second `board.update` RPC from the receiver (verify in network tab — only the originator hits `board.update`). - [ ] Cursors, object create/update/delete, and connectors continue to sync as before — no regression in `handleWsMessage`. - [ ] No new dependencies, no new files (other than potentially trivial helpers within existing JS modules), no new RPC methods, no new WebSocket routes. ## Notes / Caveats / Out of Scope - **Echo skip** is provided by `sync.js`'s existing `if (msg.sender === localUserId) return;` because `wsSend` stamps `msg.sender = localUserId`. We rely on this; do not introduce a separate dedup. The `_fromSync` flag is a defense-in-depth guard that also prevents re-broadcast/re-persist if anything else (e.g. `syncFromServer`) ever calls `loadTheme` programmatically. - **Read-only viewer (`board_view.html`)**: the read-only template should still receive and apply the theme so its rendering stays consistent with the editor. If `WhiteboardThemes` and `WhiteboardSync` are both loaded there, no extra work is needed. If `WhiteboardThemes` is only loaded in the editor template, do **not** add it now — that's a separate scope. - **Custom themes**: when a user has a custom theme that is not in the receiver's `BUILTIN_THEMES`, `loadTheme(name, customCSS)` already populates `customThemes[name]` from `customCSS` and applies it. This path is exercised by the broadcast. - **Do NOT change** any RPC method, the DB schema, the WebSocket route, the broadcast channel structure, or the RPC proxy sniffing logic in `routes.rs`. The relay is intentionally generic — adding a new client-side `type` is sufficient. - **Do NOT** add server-side broadcast for `board.update` (similar to how `board.delete` does it). Theme changes are routed directly client→client over the same WS the originator already has open; piggybacking on the RPC proxy is unnecessary complexity. - **Throttling**: theme picks are user-driven (one click per change), so no throttling is needed beyond what the WS layer already provides. Do not add a debounce.
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 themes.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 themes.js: pass - node --check sync.js: pass
Member

Implementation Summary

Two files changed, JS-only — no server / SDK / OpenRPC / DB changes. Reuses the existing per-board WebSocket relay; no new transport.

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

  • applyTheme(name) is now applyTheme(name, opts) with opts = opts || {}. The persistence call (WhiteboardSync.saveBoardTheme) and a new live-broadcast call are both gated on !opts._fromSync, so a sync-driven apply does not re-persist or re-echo.
  • After saveBoardTheme, when not from sync, the function broadcasts:
    WhiteboardSync._wsSend({
        type: 'board.theme.updated',
        data: { name: name, customCSS: JSON.stringify(currentTheme) }
    });
    
  • loadTheme(name, themeData) is now loadTheme(name, themeData, opts) and forwards opts into applyTheme. Existing callers pass no opts, so the new arg is fully backward-compatible.

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

  • Added a board.theme.updated branch in handleWsMessage, alongside the existing object.* / connector.* / comment.* branches and after the msg.sender === localUserId echo-skip:
    } else if (msg.type === 'board.theme.updated') {
        if (msg.data && msg.data.name && typeof WhiteboardThemes !== 'undefined') {
            WhiteboardThemes.loadTheme(msg.data.name, msg.data.customCSS, { _fromSync: true });
        }
    }
    

Verification

  • cargo fmt --all -- --check: clean
  • cargo check --workspace: clean
  • cargo clippy --workspace -- -D warnings: clean
  • cargo test --workspace --lib: pass (no lib tests in scope for this change)
  • node --check on both modified JS modules: clean
  1. Open the same board in two windows.
  2. In tab A, change the theme. Tab B should switch within ~1 frame.
  3. Open a third tab — it should load with the most recent theme (persistence preserved).
  4. Confirm cursors and object edits still sync (no regression).

Notes

  • No server-side change. board.update already merges data.theme / data.customCSS, so persistence is unchanged.
  • The receiver path in sync.js is gated on WhiteboardThemes being defined, so templates that do not load the themes module will simply ignore the message.
  • The _fromSync guard is defense-in-depth on top of the existing sender === localUserId echo-skip — the originator does not re-apply, and even if some other code path called loadTheme with the sync flag in the future, it still would not re-persist or re-broadcast.
## Implementation Summary Two files changed, JS-only — no server / SDK / OpenRPC / DB changes. Reuses the existing per-board WebSocket relay; no new transport. ### `crates/hero_whiteboard_ui/static/web/js/whiteboard/themes.js` - `applyTheme(name)` is now `applyTheme(name, opts)` with `opts = opts || {}`. The persistence call (`WhiteboardSync.saveBoardTheme`) and a new live-broadcast call are both gated on `!opts._fromSync`, so a sync-driven apply does not re-persist or re-echo. - After `saveBoardTheme`, when not from sync, the function broadcasts: ``` WhiteboardSync._wsSend({ type: 'board.theme.updated', data: { name: name, customCSS: JSON.stringify(currentTheme) } }); ``` - `loadTheme(name, themeData)` is now `loadTheme(name, themeData, opts)` and forwards `opts` into `applyTheme`. Existing callers pass no `opts`, so the new arg is fully backward-compatible. ### `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` - Added a `board.theme.updated` branch in `handleWsMessage`, alongside the existing `object.*` / `connector.*` / `comment.*` branches and after the `msg.sender === localUserId` echo-skip: ``` } else if (msg.type === 'board.theme.updated') { if (msg.data && msg.data.name && typeof WhiteboardThemes !== 'undefined') { WhiteboardThemes.loadTheme(msg.data.name, msg.data.customCSS, { _fromSync: true }); } } ``` ### Verification - `cargo fmt --all -- --check`: clean - `cargo check --workspace`: clean - `cargo clippy --workspace -- -D warnings`: clean - `cargo test --workspace --lib`: pass (no lib tests in scope for this change) - `node --check` on both modified JS modules: clean ### Manual smoke test (recommended on the deployed instance) 1. Open the same board in two windows. 2. In tab A, change the theme. Tab B should switch within ~1 frame. 3. Open a third tab — it should load with the most recent theme (persistence preserved). 4. Confirm cursors and object edits still sync (no regression). ### Notes - No server-side change. `board.update` already merges `data.theme` / `data.customCSS`, so persistence is unchanged. - The receiver path in `sync.js` is gated on `WhiteboardThemes` being defined, so templates that do not load the themes module will simply ignore the message. - The `_fromSync` guard is defense-in-depth on top of the existing `sender === localUserId` echo-skip — the originator does not re-apply, and even if some other code path called `loadTheme` with the sync flag in the future, it still would not re-persist or re-broadcast.
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#90
No description provided.