Skip to content

ui: use unique id per connection notification so hearts always fire#364

Merged
myleshorton merged 1 commit into
mainfrom
fisk/heart-notification-id-collision
May 15, 2026
Merged

ui: use unique id per connection notification so hearts always fire#364
myleshorton merged 1 commit into
mainfrom
fisk/heart-notification-id-collision

Conversation

@myleshorton

Copy link
Copy Markdown
Contributor

Summary

Only ~50% of successful peer connections produced a heart/explosion animation on unbounded.lantern.io. Discovered via a live Chrome DevTools MCP debugging session: a 20-minute capture with 6 confirmed peer connections generated only 3 play explosion log lines.

Root cause is a notification id collision. The fix gives each per-connection notification a unique id.

How the bug manifests

sequenceDiagram
    autonumber
    participant W as Broflake WASM<br/>wasmInterface.ts:225
    participant E as connectionsEmitter
    participant G as useGeoFuture<br/>useGeoFuture.ts:232
    participant Q as notificationQueue<br/>notification/index.tsx:21
    participant N as Notification effect<br/>notification/index.tsx:41
    participant X as Explosion useEffect<br/>explosion.tsx:9

    Note over W,X: Slot N connects, gets shown, then slot N is reused while the queue still holds id=N

    W->>E: handleConnection({wi:3, state:1})
    E->>G: updateArcs sees state=1 for wi=3
    G->>Q: pushNotification({id:3, heart:true})
    Q->>N: queue=[A:3]
    N->>X: render Explosion(id=3)
    X-->>X: useEffect fires — "play explosion" ✓

    Note over W: ~1s later, slot 3 disconnects then reconnects

    W->>E: handleConnection({wi:3, state:-1})
    W->>E: handleConnection({wi:3, state:1})
    E->>G: updateArcs sees state=1 for wi=3 again
    G->>Q: notification/index.tsx:22<br/>pushNotification({id:3, heart:true}) ⚠️
    rect rgba(255, 200, 200, 0.3)
        Note over Q: notification/index.tsx:23-25<br/>findIndex(n.id === 3) → REPLACES existing<br/>queue entry instead of appending 🐛
        Note over N: notification/index.tsx:52<br/>notification.id === notifications[0].id<br/>(both 3) → early return 🐛
    end
    N--xX: Explosion stays mounted with id=3 unchanged
    X--xX: memo prevents re-render — useEffect never re-fires
    Note over X: no "play explosion", no animation
Loading

Why workerIdx looked like a reasonable id

It looks unique per connection at a glance — but Broflake reuses 8 worker slots (Starting WorkerFSM... [8 times] in the WASM init logs). So the same workerIdx value cycles through many connections over a session. The pushNotification dedup (which is needed for id: -1 "waiting" notifications) silently kills connection notifications.

Fix

useGeoFuture.ts:

+ // Monotonic id for connection notifications. Using workerIdx as the
+ // notification id silently dropped most hearts: Broflake reuses 8
+ // worker slots, so a slot-3 reconnect would re-push id=3 while the
+ // previous id=3 was still in notificationQueue. […]
+ const nextNotificationId = useRef(0)

  pushNotification({
-     id: geo.workerIdx,
+     id: ++nextNotificationId.current,
      ...
  })

The id: -1 waiting notification at useGeoFuture.ts:264 is left alone — it wants dedup so multiple "waiting" pushes don't stack. Only the per-connection callsite needed a unique id.

Test plan

  • yarn build:web succeeds, main.js produced with publicPath=https://embed.lantern.io/ baked in.
  • Open unbounded.lantern.io in Chrome with the new bundle, toggle sharing, wait for ≥3 successful peer connections. Each connection should produce a "play explosion" console log and a visible heart animation overlaying the globe.
  • Specifically reproduce the slot-recycle case: wait for slot N to connect, drop, then reconnect within a few seconds. The second connection should fire a heart — before this PR, it would not.

Discovered via live Chrome DevTools session: only ~50% of successful
peer connections produced a heart/explosion animation. The pattern
held across a 20-minute session with 6 confirmed connections and 3
"play explosion" log lines.

Root cause is the notification id collision:

- pushNotification dedups by id: if a queue entry with the same id
  already exists, it REPLACES that entry in place rather than
  appending.
- useGeoFuture's connection notifications were passing id = geo.workerIdx.
- Broflake reuses 8 worker slots. When slot N reconnects while the
  previous id=N notification is still in the queue (or even being
  displayed), the dedup silently replaces it.
- The Notification effect's `notification.id === notifications[0].id`
  short-circuit then keeps the Explosion mounted with an unchanged
  id prop, so the memoed Explosion does not re-render and its
  [id]-keyed useEffect does not re-fire — no animation, no
  "play explosion" log.

The waiting notification at id=-1 still wants the dedup behavior
(don't stack identical "waiting" pushes), so only the per-connection
callsite changes. nextNotificationId is a useRef'd monotonic
counter, scoped to useGeo so it persists across renders without
allocating module state.
Copilot AI review requested due to automatic review settings May 15, 2026 20:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes missed heart/explosion animations for repeated peer connections by ensuring per-connection notifications use unique IDs instead of recycled Broflake worker slot IDs.

Changes:

  • Adds a monotonic notification ID ref in useGeo.
  • Uses that unique ID for successful connection notifications while preserving id: -1 dedup behavior for waiting notifications.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@myleshorton myleshorton merged commit fba838b into main May 15, 2026
1 check passed
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