canopy-ui 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { cleanup, fireEvent, render, screen } from '@testing-library/react'
3
- import { afterEach, describe, expect, it } from 'vitest'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
4
  import { PresenceBadge } from './PresenceBadge'
5
5
  import type { Viewer } from './usePresence'
6
6
 
@@ -50,6 +50,32 @@ describe('PresenceBadge', () => {
50
50
  expect(screen.getByText('User 1')).toBeTruthy()
51
51
  })
52
52
 
53
+ it('renders two viewers with no email without colliding their React keys', () => {
54
+ // Email is not guaranteed non-empty (the server sends "" for a user with
55
+ // none), so it cannot be the row key. A collision here shows up as a
56
+ // React duplicate-key error, not as a visibly missing row.
57
+ const errors: unknown[][] = []
58
+ const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
59
+ errors.push(args)
60
+ })
61
+ try {
62
+ render(
63
+ <PresenceBadge
64
+ viewers={[
65
+ viewer(1, { email: '', name: 'Ana' }),
66
+ viewer(2, { email: '', name: 'Bo' }),
67
+ ]}
68
+ />,
69
+ )
70
+ fireEvent.click(screen.getByRole('button'))
71
+ expect(screen.getByText('Ana')).toBeTruthy()
72
+ expect(screen.getByText('Bo')).toBeTruthy()
73
+ } finally {
74
+ spy.mockRestore()
75
+ }
76
+ expect(errors.flat().join(' ')).not.toMatch(/same key/i)
77
+ })
78
+
53
79
  it('marks idle viewers in the expanded list', () => {
54
80
  render(<PresenceBadge viewers={[viewer(1, { idle: true }), viewer(2)]} />)
55
81
  fireEvent.click(screen.getByRole('button'))
@@ -33,6 +33,12 @@ export function PresenceBadge({ viewers }: { viewers: Viewer[] }) {
33
33
  if (viewers.length < 2) return null
34
34
 
35
35
  // You first, then everyone else in roster order.
36
+ //
37
+ // Rows are keyed by POSITION, not by email: email is the only plausible
38
+ // identity field on a Viewer and it is not guaranteed non-empty (the
39
+ // server sends "" for a user with no email), so two such viewers would
40
+ // collide on one key. The list is short, server-ordered, and re-rendered
41
+ // wholesale on every roster broadcast, so index keys cost nothing here.
36
42
  const ordered = [...viewers].sort((a, b) => Number(b.self) - Number(a.self))
37
43
  const shown = ordered.slice(0, MAX_AVATARS)
38
44
  const overflow = ordered.length - shown.length
@@ -46,11 +52,11 @@ export function PresenceBadge({ viewers }: { viewers: Viewer[] }) {
46
52
  aria-label={`${viewers.length} people viewing this page`}
47
53
  className="flex items-center -space-x-2 rounded-full p-0.5 hover:opacity-90"
48
54
  >
49
- {shown.map((v) => {
55
+ {shown.map((v, i) => {
50
56
  const { initials, colorClass } = avatarFor(v.email, v.name)
51
57
  return (
52
58
  <span
53
- key={v.email}
59
+ key={i}
54
60
  className={`inline-flex h-6 w-6 items-center justify-center rounded-full
55
61
  ring-2 ring-card text-[10px] font-semibold text-white ${colorClass}
56
62
  ${v.idle ? 'opacity-45' : ''}`}
@@ -74,10 +80,10 @@ export function PresenceBadge({ viewers }: { viewers: Viewer[] }) {
74
80
  className="absolute right-0 z-50 mt-2 w-64 rounded-md border border-border
75
81
  bg-card p-1 shadow-md"
76
82
  >
77
- {ordered.map((v) => {
83
+ {ordered.map((v, i) => {
78
84
  const { initials, colorClass } = avatarFor(v.email, v.name)
79
85
  return (
80
- <div key={v.email} className="flex items-center gap-2 rounded px-2 py-1.5">
86
+ <div key={i} className="flex items-center gap-2 rounded px-2 py-1.5">
81
87
  <span
82
88
  className={`inline-flex h-6 w-6 shrink-0 items-center justify-center
83
89
  rounded-full text-[10px] font-semibold text-white ${colorClass}
@@ -5,6 +5,7 @@ import { usePresence } from './usePresence'
5
5
 
6
6
  class FakeSocket {
7
7
  static last: FakeSocket | null = null
8
+ static created = 0
8
9
  static OPEN = 1
9
10
  readyState = 0
10
11
  sent: string[] = []
@@ -13,6 +14,7 @@ class FakeSocket {
13
14
  onclose: (() => void) | null = null
14
15
  constructor(public url: string) {
15
16
  FakeSocket.last = this
17
+ FakeSocket.created += 1
16
18
  }
17
19
  send(frame: string) {
18
20
  this.sent.push(frame)
@@ -32,6 +34,7 @@ class FakeSocket {
32
34
  beforeEach(() => {
33
35
  vi.stubGlobal('WebSocket', FakeSocket as unknown as typeof WebSocket)
34
36
  vi.useFakeTimers()
37
+ FakeSocket.created = 0
35
38
  })
36
39
  afterEach(() => {
37
40
  vi.useRealTimers()
@@ -113,6 +116,72 @@ describe('usePresence', () => {
113
116
  expect(FakeSocket.last).toBeNull()
114
117
  })
115
118
 
119
+ it('backs off exponentially while reconnects keep failing, up to a ceiling', () => {
120
+ // A tab left open past session expiry is closed with 4001 on every
121
+ // attempt. A flat retry hammers a handshake every 2s forever; the delay
122
+ // must grow and then cap.
123
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
124
+ expect(FakeSocket.created).toBe(1)
125
+
126
+ const failOnce = (expectedDelay: number) => {
127
+ const before = FakeSocket.created
128
+ act(() => FakeSocket.last!.onclose!())
129
+ // Nothing reconnects a millisecond early...
130
+ act(() => void vi.advanceTimersByTime(expectedDelay - 1))
131
+ expect(FakeSocket.created).toBe(before)
132
+ // ...and exactly one reconnect lands on the deadline.
133
+ act(() => void vi.advanceTimersByTime(1))
134
+ expect(FakeSocket.created).toBe(before + 1)
135
+ }
136
+
137
+ failOnce(2_000)
138
+ failOnce(4_000)
139
+ failOnce(8_000)
140
+ failOnce(16_000)
141
+ failOnce(32_000)
142
+ failOnce(60_000) // ceiling, not 64_000
143
+ failOnce(60_000) // and it stays there
144
+ })
145
+
146
+ it('resets the backoff once a socket actually opens', () => {
147
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
148
+ act(() => FakeSocket.last!.onclose!())
149
+ act(() => void vi.advanceTimersByTime(2_000))
150
+ act(() => FakeSocket.last!.onclose!())
151
+ act(() => void vi.advanceTimersByTime(4_000)) // now at a 4s delay
152
+
153
+ act(() => FakeSocket.last!.open()) // a good connection clears the streak
154
+
155
+ const before = FakeSocket.created
156
+ act(() => FakeSocket.last!.onclose!())
157
+ act(() => void vi.advanceTimersByTime(2_000))
158
+ expect(FakeSocket.created).toBe(before + 1)
159
+ })
160
+
161
+ it('clears the previous page\'s viewers on navigation even when the socket is not open', () => {
162
+ // Otherwise the old page's avatars linger on the new page until a roster
163
+ // for it arrives — which, on a dead socket, may be never.
164
+ const { result, rerender } = renderHook(
165
+ ({ location }) => usePresence({ url: 'ws://x/ws/presence/', location }),
166
+ { initialProps: { location: LOC } },
167
+ )
168
+ act(() => FakeSocket.last!.open())
169
+ act(() =>
170
+ FakeSocket.last!.deliver({
171
+ event: 'presence.roster',
172
+ data: {
173
+ page_key: LOC.pageKey,
174
+ viewers: [{ email: 'a@x.com', name: 'A', sub_location: '', idle: false, self: false }],
175
+ },
176
+ }),
177
+ )
178
+ expect(result.current.viewers).toHaveLength(1)
179
+
180
+ FakeSocket.last!.readyState = 3 // socket dropped, reconnect pending
181
+ rerender({ location: { pageKey: 'ace:ws:activity', subLocation: 'Activity' } })
182
+ expect(result.current.viewers).toEqual([])
183
+ })
184
+
116
185
  it('swallows a synchronous WebSocket constructor throw and stays on an empty roster', () => {
117
186
  class ThrowingSocket {
118
187
  constructor() {
@@ -16,7 +16,12 @@ export interface UsePresenceOptions {
16
16
 
17
17
  const HEARTBEAT_MS = 20_000
18
18
  const IDLE_AFTER_MS = 120_000
19
- const RECONNECT_MS = 2_000
19
+ // Reconnect backoff. A flat retry is wrong for the permanent-failure case:
20
+ // a tab left open past session expiry is closed with 4001 on every attempt,
21
+ // so a flat 2s retry hammers a handshake forever. Double up to a ceiling and
22
+ // reset on a socket that actually opens.
23
+ const RECONNECT_BASE_MS = 2_000
24
+ const RECONNECT_MAX_MS = 60_000
20
25
 
21
26
  /**
22
27
  * One presence socket per tab.
@@ -45,6 +50,13 @@ export function usePresence({ url, location }: UsePresenceOptions): { viewers: V
45
50
  let closedByCleanup = false
46
51
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null
47
52
  let heartbeat: ReturnType<typeof setInterval> | null = null
53
+ let reconnectDelay = RECONNECT_BASE_MS
54
+
55
+ const scheduleReconnect = () => {
56
+ if (closedByCleanup) return
57
+ reconnectTimer = setTimeout(open, reconnectDelay)
58
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS)
59
+ }
48
60
 
49
61
  const send = (frame: unknown) => {
50
62
  const ws = wsRef.current
@@ -66,11 +78,14 @@ export function usePresence({ url, location }: UsePresenceOptions): { viewers: V
66
78
  // failure — stay on the empty roster and retry on the normal
67
79
  // reconnect cadence, never propagate.
68
80
  setViewers([])
69
- if (!closedByCleanup) reconnectTimer = setTimeout(open, RECONNECT_MS)
81
+ scheduleReconnect()
70
82
  return
71
83
  }
72
84
  wsRef.current = sock
73
85
  sock.onopen = () => {
86
+ // A socket that actually opened means the failure that got us here
87
+ // is over; start the next backoff series from the bottom.
88
+ reconnectDelay = RECONNECT_BASE_MS
74
89
  enter()
75
90
  heartbeat = setInterval(
76
91
  () => send({ type: 'presence.heartbeat', idle: idleRef.current }),
@@ -101,8 +116,7 @@ export function usePresence({ url, location }: UsePresenceOptions): { viewers: V
101
116
  if (wsRef.current === sock) wsRef.current = null
102
117
  if (heartbeat) clearInterval(heartbeat)
103
118
  setViewers([])
104
- if (closedByCleanup) return
105
- reconnectTimer = setTimeout(open, RECONNECT_MS)
119
+ scheduleReconnect()
106
120
  }
107
121
  }
108
122
  open()
@@ -122,9 +136,13 @@ export function usePresence({ url, location }: UsePresenceOptions): { viewers: V
122
136
  // render, and keying on identity would re-send presence.enter on every
123
137
  // render instead of only on an actual location change.
124
138
  useEffect(() => {
139
+ // Clear FIRST, unconditionally. If the socket is not open yet the old
140
+ // page's avatars are already wrong — leaving them up until a roster for
141
+ // the new page arrives (which, on a dead socket, may be never) shows
142
+ // strangers as viewing the page you just navigated to.
143
+ setViewers([])
125
144
  const ws = wsRef.current
126
145
  if (!location || !ws || ws.readyState !== WebSocket.OPEN) return
127
- setViewers([])
128
146
  ws.send(
129
147
  JSON.stringify({
130
148
  type: 'presence.enter',