canopy-ui 0.4.0 → 0.6.0

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