canopy-ui 0.4.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.
@@ -0,0 +1,199 @@
1
+ // @vitest-environment jsdom
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { usePresence } from './usePresence'
5
+
6
+ class FakeSocket {
7
+ static last: FakeSocket | null = null
8
+ static created = 0
9
+ static OPEN = 1
10
+ readyState = 0
11
+ sent: string[] = []
12
+ onopen: (() => void) | null = null
13
+ onmessage: ((e: { data: string }) => void) | null = null
14
+ onclose: (() => void) | null = null
15
+ constructor(public url: string) {
16
+ FakeSocket.last = this
17
+ FakeSocket.created += 1
18
+ }
19
+ send(frame: string) {
20
+ this.sent.push(frame)
21
+ }
22
+ close() {
23
+ this.readyState = 3
24
+ }
25
+ open() {
26
+ this.readyState = FakeSocket.OPEN
27
+ this.onopen?.()
28
+ }
29
+ deliver(payload: unknown) {
30
+ this.onmessage?.({ data: JSON.stringify(payload) })
31
+ }
32
+ }
33
+
34
+ beforeEach(() => {
35
+ vi.stubGlobal('WebSocket', FakeSocket as unknown as typeof WebSocket)
36
+ vi.useFakeTimers()
37
+ FakeSocket.created = 0
38
+ })
39
+ afterEach(() => {
40
+ vi.useRealTimers()
41
+ vi.unstubAllGlobals()
42
+ FakeSocket.last = null
43
+ })
44
+
45
+ const LOC = { pageKey: 'ace:ws:opp:a/run-001', subLocation: 'run overview' }
46
+
47
+ describe('usePresence', () => {
48
+ it('sends presence.enter once the socket opens', () => {
49
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
50
+ act(() => FakeSocket.last!.open())
51
+ expect(JSON.parse(FakeSocket.last!.sent[0])).toEqual({
52
+ type: 'presence.enter',
53
+ page_key: LOC.pageKey,
54
+ sub_location: LOC.subLocation,
55
+ })
56
+ })
57
+
58
+ it('exposes the roster the server broadcasts', () => {
59
+ const { result } = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
60
+ act(() => FakeSocket.last!.open())
61
+ act(() =>
62
+ FakeSocket.last!.deliver({
63
+ event: 'presence.roster',
64
+ data: {
65
+ page_key: LOC.pageKey,
66
+ viewers: [{ email: 'a@x.com', name: 'A', sub_location: 'idea-to-pdd', idle: false, self: true }],
67
+ },
68
+ }),
69
+ )
70
+ expect(result.current.viewers).toEqual([
71
+ { email: 'a@x.com', name: 'A', subLocation: 'idea-to-pdd', idle: false, self: true },
72
+ ])
73
+ })
74
+
75
+ it('ignores a roster for a page key it is no longer on', () => {
76
+ const { result } = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
77
+ act(() => FakeSocket.last!.open())
78
+ act(() =>
79
+ FakeSocket.last!.deliver({
80
+ event: 'presence.roster',
81
+ data: { page_key: 'ace:ws:opp:STALE/run-999', viewers: [
82
+ { email: 'z@x.com', name: 'Z', sub_location: '', idle: false, self: false }] },
83
+ }),
84
+ )
85
+ expect(result.current.viewers).toEqual([])
86
+ })
87
+
88
+ it('re-enters without reconnecting when the location changes', () => {
89
+ const { rerender } = renderHook(
90
+ ({ location }) => usePresence({ url: 'ws://x/ws/presence/', location }),
91
+ { initialProps: { location: LOC } },
92
+ )
93
+ act(() => FakeSocket.last!.open())
94
+ const socket = FakeSocket.last!
95
+ rerender({ location: { pageKey: 'ace:ws:activity', subLocation: 'Activity' } })
96
+ expect(FakeSocket.last).toBe(socket) // same socket, no reconnect
97
+ expect(JSON.parse(socket.sent[socket.sent.length - 1])).toEqual({
98
+ type: 'presence.enter',
99
+ page_key: 'ace:ws:activity',
100
+ sub_location: 'Activity',
101
+ })
102
+ })
103
+
104
+ it('heartbeats every 20 seconds', () => {
105
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
106
+ act(() => FakeSocket.last!.open())
107
+ act(() => void vi.advanceTimersByTime(20_000))
108
+ expect(JSON.parse(FakeSocket.last!.sent[1])).toEqual({ type: 'presence.heartbeat', idle: false })
109
+ })
110
+
111
+ it('clears the roster and sends nothing when there is no location', () => {
112
+ const { result } = renderHook(() =>
113
+ usePresence({ url: 'ws://x/ws/presence/', location: null }),
114
+ )
115
+ expect(result.current.viewers).toEqual([])
116
+ expect(FakeSocket.last).toBeNull()
117
+ })
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
+
185
+ it('swallows a synchronous WebSocket constructor throw and stays on an empty roster', () => {
186
+ class ThrowingSocket {
187
+ constructor() {
188
+ throw new Error('mixed content: refused to connect to insecure WebSocket')
189
+ }
190
+ }
191
+ vi.stubGlobal('WebSocket', ThrowingSocket as unknown as typeof WebSocket)
192
+
193
+ let hook!: ReturnType<typeof renderHook<{ viewers: unknown[] }, void>>
194
+ expect(() => {
195
+ hook = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
196
+ }).not.toThrow()
197
+ expect(hook.result.current.viewers).toEqual([])
198
+ })
199
+ })
@@ -0,0 +1,188 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { PageLocation } from './pageKey'
3
+
4
+ export interface Viewer {
5
+ email: string
6
+ name: string
7
+ subLocation: string
8
+ idle: boolean
9
+ self: boolean
10
+ }
11
+
12
+ export interface UsePresenceOptions {
13
+ url: string
14
+ location: PageLocation | null
15
+ }
16
+
17
+ const HEARTBEAT_MS = 20_000
18
+ const IDLE_AFTER_MS = 120_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
25
+
26
+ /**
27
+ * One presence socket per tab.
28
+ *
29
+ * Navigation re-keys the existing connection with a fresh `presence.enter`
30
+ * rather than reconnecting — a socket per page would churn handshakes on
31
+ * every click.
32
+ *
33
+ * Every failure path degrades to an empty roster. Presence is an
34
+ * enhancement; it must never surface an error to the user.
35
+ */
36
+ export function usePresence({ url, location }: UsePresenceOptions): { viewers: Viewer[] } {
37
+ const [viewers, setViewers] = useState<Viewer[]>([])
38
+ const wsRef = useRef<WebSocket | null>(null)
39
+ const locationRef = useRef(location)
40
+ const idleRef = useRef(false)
41
+ locationRef.current = location
42
+
43
+ // Socket lifecycle. Deliberately NOT keyed on `location` — the socket
44
+ // outlives navigation.
45
+ useEffect(() => {
46
+ if (!location) {
47
+ setViewers([])
48
+ return
49
+ }
50
+ let closedByCleanup = false
51
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
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
+ }
60
+
61
+ const send = (frame: unknown) => {
62
+ const ws = wsRef.current
63
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame))
64
+ }
65
+
66
+ const enter = () => {
67
+ const loc = locationRef.current
68
+ if (loc) send({ type: 'presence.enter', page_key: loc.pageKey, sub_location: loc.subLocation })
69
+ }
70
+
71
+ function open() {
72
+ let sock: WebSocket
73
+ try {
74
+ sock = new WebSocket(url)
75
+ } catch {
76
+ // Synchronous construction failure (malformed URL, mixed-content
77
+ // scheme mismatch, …): treat exactly like any other connection
78
+ // failure — stay on the empty roster and retry on the normal
79
+ // reconnect cadence, never propagate.
80
+ setViewers([])
81
+ scheduleReconnect()
82
+ return
83
+ }
84
+ wsRef.current = sock
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
89
+ enter()
90
+ heartbeat = setInterval(
91
+ () => send({ type: 'presence.heartbeat', idle: idleRef.current }),
92
+ HEARTBEAT_MS,
93
+ )
94
+ }
95
+ sock.onmessage = (e) => {
96
+ try {
97
+ const msg = JSON.parse(e.data)
98
+ if (msg.event !== 'presence.roster') return
99
+ // Drop rosters for a page we have already navigated away from —
100
+ // an in-flight broadcast can land after the re-key.
101
+ if (msg.data?.page_key !== locationRef.current?.pageKey) return
102
+ setViewers(
103
+ (msg.data.viewers ?? []).map((v: Record<string, unknown>) => ({
104
+ email: String(v.email ?? ''),
105
+ name: String(v.name ?? ''),
106
+ subLocation: String(v.sub_location ?? ''),
107
+ idle: Boolean(v.idle),
108
+ self: Boolean(v.self),
109
+ })),
110
+ )
111
+ } catch {
112
+ // Malformed frame: ignore. Never surface.
113
+ }
114
+ }
115
+ sock.onclose = () => {
116
+ if (wsRef.current === sock) wsRef.current = null
117
+ if (heartbeat) clearInterval(heartbeat)
118
+ setViewers([])
119
+ scheduleReconnect()
120
+ }
121
+ }
122
+ open()
123
+
124
+ return () => {
125
+ closedByCleanup = true
126
+ if (reconnectTimer) clearTimeout(reconnectTimer)
127
+ if (heartbeat) clearInterval(heartbeat)
128
+ wsRef.current?.close()
129
+ wsRef.current = null
130
+ }
131
+ // eslint-disable-next-line react-hooks/exhaustive-deps
132
+ }, [url, location === null])
133
+
134
+ // Re-key on navigation. Deps are the primitive fields, not `location`
135
+ // itself — callers (e.g. pageKeyFor) may return a fresh object each
136
+ // render, and keying on identity would re-send presence.enter on every
137
+ // render instead of only on an actual location change.
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([])
144
+ const ws = wsRef.current
145
+ if (!location || !ws || ws.readyState !== WebSocket.OPEN) return
146
+ ws.send(
147
+ JSON.stringify({
148
+ type: 'presence.enter',
149
+ page_key: location.pageKey,
150
+ sub_location: location.subLocation,
151
+ }),
152
+ )
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
154
+ }, [location?.pageKey, location?.subLocation])
155
+
156
+ // Idle tracking: hidden for longer than IDLE_AFTER_MS. Reports an
157
+ // observable fact (the tab is not frontmost), never an attention claim.
158
+ useEffect(() => {
159
+ let timer: ReturnType<typeof setTimeout> | null = null
160
+ const flush = () => {
161
+ const ws = wsRef.current
162
+ if (ws && ws.readyState === WebSocket.OPEN) {
163
+ ws.send(JSON.stringify({ type: 'presence.heartbeat', idle: idleRef.current }))
164
+ }
165
+ }
166
+ const onVisibility = () => {
167
+ if (document.hidden) {
168
+ timer = setTimeout(() => {
169
+ idleRef.current = true
170
+ flush()
171
+ }, IDLE_AFTER_MS)
172
+ } else {
173
+ if (timer) clearTimeout(timer)
174
+ if (idleRef.current) {
175
+ idleRef.current = false
176
+ flush()
177
+ }
178
+ }
179
+ }
180
+ document.addEventListener('visibilitychange', onVisibility)
181
+ return () => {
182
+ if (timer) clearTimeout(timer)
183
+ document.removeEventListener('visibilitychange', onVisibility)
184
+ }
185
+ }, [])
186
+
187
+ return { viewers }
188
+ }