Connector updates do not propagate to other open viewers — line style and endpoint-follow-on-move are not synced live #92
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#92
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Two related issues when the same board is open in two windows:
Line Style(e.g. Straight → Curved / Elbow) in tab A does not update the connector in tab B. Tab B keeps rendering the previous line style until reload.Both changes reflects as soon as tab B is manually reloaded.
Steps to reproduce
Line Stylein theProperties panel (e.g. set it to
Curved).Expected
Actual
Implementation Spec — Issue #92: Connector updates must propagate live
Objective
Make connector updates propagate live across all open viewers of the same board, fixing two related sync gaps:
dragmove.Requirements
connector.created/connector.deleted). No new transport, no new RPC method, no new DB column.opts._fromSyncflag through the local mutation function so receivers can suppress re-broadcast and history.optsargument is optional and additive.Root cause
Bug 1 — Line Style not synced.
connectors.cycleLineStyle()(called fromproperties.js:1160) does the local destroy + recreate, persists viarpcCall('connector.update', ...), but never broadcasts aconnector.updatedmessage over the WebSocket.sync.js#handleWsMessagealso has no branch forconnector.updated. Tab B therefore never learns. Same issue applies topersistStyle()(stroke color / stroke width) — it persists but doesn't broadcast.Bug 2 — Endpoint does not follow on receiver.
connectors.createConnector()re-anchors a connector via Konva listeners:fromGroup.on('dragmove.connector_<id>', updateFn). On the sender, the user drag firesdragmove. On the receiver,sync.js#applySyncUpdaterepositions the attached object withnode.x(obj.x); node.y(obj.y)— programmatic setters do not firedragmove, so the connector'supdateFnnever runs.Architecturally, the smaller and less risky fix is for the receiver to recompute connector endpoints after applying a position update, instead of broadcasting redundant waypoints — the geometry is fully derived from the from/to anchor points.
Files to Modify
crates/hero_whiteboard_ui/static/web/js/whiteboard/connectors.jscrates/hero_whiteboard_ui/static/web/js/whiteboard/sync.jsFiles to Leave Alone
properties.js(itschange/inputhandlers already callcycleLineStyleandpersistStyle— once those broadcast, properties.js gets sync for free).objects.js,tools.js(the local-drag re-anchor path already works via Konva events).connector.updatealready exists and is called.Step-by-Step Plan
Step 1 —
connectors.js: broadcast on style/line-style change + add receiver hooksFile:
crates/hero_whiteboard_ui/static/web/js/whiteboard/connectors.jsIntroduce a private helper
broadcastUpdate(id)that builds the same payloadpersistStyle/cycleLineStylealready build forrpcCall('connector.update', ...), then callsws_sendConnector({ type: 'connector.updated', data: payload })ifws_sendConnectoris set andidis not a temp id (matches the temp-id guard already used inpersistStyleand the delete path).connector.createpayload shape thatloadFromSyncalready understands:id,board_id,from_id,to_id,line_type,style(JSON-stringified{ stroke, strokeWidth }).Accept an optional
optsargument oncycleLineStyle(id, opts)andpersistStyle(id, opts)and skip both the RPC call and the WS broadcast whenopts && opts._fromSyncis true. Pattern matches the existingdeleteConnector(id, opts)guard.After the existing
rpcCall('connector.update', ...)call insidecycleLineStyle, callbroadcastUpdate(id). Do the same after the existingrpcCall('connector.update', ...)insidepersistStyle. Place the broadcast outside the promise chain so the WS message goes out immediately (matchespersistStyle's fire-and-forget style).Add a public
applyUpdate(connData, opts)on the module's return object. It looks upconnectors[String(connData.id)]. If found, andconnData.line_typediffers from the currentlineStyle, callcycleLineStyle(id, { _fromSync: true })(or the equivalent — the spec accepts looping through up to 3 cycles to land on the requested type, matching whatproperties.jsdoes today). Then, ifconnData.stylecarries newstroke/strokeWidth, set them onc.arrowand updatec.baseStroke/c.baseStrokeWidth, thenWhiteboardCanvas.getConnectorLayer().batchDraw(). If the connector is not present locally, fall back toloadFromSync(connData)(covers the case where the receiver missed the originalconnector.created).Add a public
reAnchorByObject(objectId)on the module's return object. It iterates overconnectorsand, for any connector whosefromIdortoId(compared as strings) equalsobjectId, looks up the from/to groups inWhiteboardCanvas.getObjectLayer()and runs the same body thatupdateFnalready runs increateConnector(recomputeclosestAnchor/getLinePoints, including the curved special case, writec.arrow.points(pts), thenbatchDraw). This is the receiver-side equivalent of the Konvadragmovelistener.Dependencies: none.
Step 2 —
sync.js: routeconnector.updatedand re-anchor connectors after object movesFile:
crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.jsIn
handleWsMessage, alongsideconnector.created/connector.deleted, add:In
applySyncUpdate, after the position writenode.x(obj.x); node.y(obj.y)and after rotation/dimension updates that could move the bounding box, callWhiteboardConnectors.reAnchorByObject(strId). Place it once, near the end ofapplySyncUpdatejust before the redraw block, so it runs after every geometry mutation. Guard withtypeof WhiteboardConnectors !== 'undefined' && WhiteboardConnectors.reAnchorByObject.Dependencies: Step 1.
Acceptance Criteria
connector.updated(existingnumCId <= 0/isNaNguard frompersistStyleis reused).Notes
connector.updatedfromobjects.js'sdragmovepath. The receiver derives endpoints from anchor positions, so emitting redundant waypoints would only widen the wire payload and create races with the position update itself.cycleLineStylecurrently destroys and recreates the local Konva arrow. When called with_fromSync: true, the recreate path inside it must not re-callrpcCall('connector.create', ...)—createConnectoralready guards on_fromServer, so the recursivecreateConnector(... { _fromServer: true, ... })call insidecycleLineStyleis preserved. Only the outerrpcCall('connector.update', ...)and the new broadcast are skipped under_fromSync.reAnchorByObjectdoes not fire Konva events — it directly recomputes endpoints. This avoids interfering with the sender path's listeners and is why the function lives inconnectors.js(it owns theconnectorsmap).persistStylesignature change adds an optionaloptsonly — call sites inproperties.jskeep working unchanged.Test Results
Implementation Summary
Two files changed, JS-only — no server / SDK / OpenRPC / DB changes. Reuses the existing per-board WebSocket relay; the only new wire-level addition is a
connector.updatedmessage routed alongside the already-handledconnector.createdandconnector.deleted.Root cause
connectors.cycleLineStyleandconnectors.persistStylepersisted viarpcCall('connector.update', ...)but never broadcast over the WebSocket, andsync.js#handleWsMessagehad noconnector.updatedbranch.dragmove.connector_<id>listeners. On the receiver,applySyncUpdaterepositions the attached object withnode.x(...); node.y(...). Programmatic setters do not firedragmove, so the receiver's connector listeners never ran.crates/hero_whiteboard_ui/static/web/js/whiteboard/connectors.jscycleLineStyle(id)andpersistStyle(id)now accept an optionaloptsand skip both therpcCall('connector.update', ...)and the new WebSocket broadcast whenopts._fromSyncis true. The recursivecreateConnector(..., { _fromServer: true, ... })call insidecycleLineStyleis preserved unchanged so the local Konva arrow is still recreated correctly under sync-driven cycling.broadcastUpdate(id)sends{ type: 'connector.updated', data }via the existingws_sendConnectorcallback. Payload mirrors theconnector.updateRPC field shape (id,board_id,from_id,to_id,line_type, JSON-stringifiedstyle { stroke, strokeWidth }). Uses the existing temp-id guard so connectors with not-yet-assigned ids are not broadcast.applyUpdate(connData, opts)— applies a remoteconnector.updatedpayload locally. CycleslineStyleto the requested type viacycleLineStyle(id, { _fromSync: true })(capped at 4 iterations to avoid infinite loops on unknown types), parsesconnData.style, applies stroke / strokeWidth toc.arrow, updatesbaseStroke/baseStrokeWidth, and redraws. Falls back toloadFromSync(connData)if the connector is not yet present locally.reAnchorByObject(objectId)— iterates connectors and, for any whosefromIdortoIdmatches, recomputes anchor + line points (including the curved-style midpoint offset block, copied from the localdragmoveupdateFn) and writes them ontoc.arrow.points(pts). SinglebatchDrawafter the loop.crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.jshandleWsMessagenext to the existing connector branches:applySyncUpdate, a single call toWhiteboardConnectors.reAnchorByObject(strId)near the end of the function (after every position / rotation / dimension mutation, just before therequestAnimationFrameredraw block) re-runs the same anchor-recompute logic the localdragmovelistener runs, but driven by sync state.Verification
cargo fmt --all -- --check: cleancargo check --workspace: cleancargo clippy --workspace -- -D warnings: cleancargo test --workspace --lib: passnode --checkon both modified JS modules: cleanManual smoke test
Notes
connector.updatedfrom the object-drag path. Endpoint geometry is fully derived from the from/to anchors, so the receiver recomputes it locally viareAnchorByObjectafter applying the position update — same approach the sender already uses through Konva's localdragmovelistener.numCId <= 0 / isNaNguard frompersistStyleis reused inbroadcastUpdate._fromSyncguard pattern is consistent with the prior issue #90 / #91 fixes (themes + shape type) and with the existingdeleteConnector(id, opts)guard.