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
|
// @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={
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|