Skip to content

Commit 0cbb7d8

Browse files
add sse for user re-fetch
1 parent 88db517 commit 0cbb7d8

File tree

5 files changed

+185
-27
lines changed

5 files changed

+185
-27
lines changed

package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
1010
"preview": "vite preview",
1111
"prod": "serve -s dist",
12-
"test": "vitest",
12+
"test": "vitest --run",
1313
"test:ui": "vitest --ui",
1414
"coverage": "vitest run --coverage",
1515
"generate:graphql": "graphql-codegen --config codegen.ts"
@@ -47,21 +47,21 @@
4747
"zod": "^3.24.1"
4848
},
4949
"devDependencies": {
50+
"@graphql-codegen/cli": "^5.0.7",
51+
"@graphql-codegen/client-preset": "^4.5.1",
52+
"@graphql-codegen/typescript-operations": "^4.3.1",
53+
"@graphql-codegen/typescript-react-apollo": "^4.3.3",
5054
"@testing-library/jest-dom": "^6.6.3",
5155
"@testing-library/react": "^16.1.0",
5256
"@testing-library/user-event": "^14.5.2",
5357
"@types/jest": "^29.5.14",
58+
"@types/keycloak-js": "^3.4.1",
5459
"@types/node": "^22.10.2",
5560
"@types/react": "^18.3.13",
5661
"@types/react-dom": "^18.3.1",
57-
"@types/keycloak-js": "^3.4.1",
5862
"@types/react-i18next": "^8.1.0",
5963
"@types/react-router-dom": "^5.3.3",
6064
"@types/redux-persist": "^4.3.1",
61-
"@graphql-codegen/cli": "^5.0.7",
62-
"@graphql-codegen/client-preset": "^4.5.1",
63-
"@graphql-codegen/typescript-operations": "^4.3.1",
64-
"@graphql-codegen/typescript-react-apollo": "^4.3.3",
6565
"@typescript-eslint/eslint-plugin": "^8.18.2",
6666
"@typescript-eslint/parser": "^8.18.2",
6767
"@vitejs/plugin-react-swc": "^3.7.2",
@@ -75,14 +75,14 @@
7575
"eslint-plugin-react-hooks": "^5.0.0",
7676
"eslint-plugin-react-refresh": "^0.4.16",
7777
"jsdom": "^25.0.1",
78+
"msw": "^2.7.0",
7879
"postcss": "^8.4.49",
7980
"prettier": "^3.4.2",
8081
"prettier-plugin-tailwindcss": "^0.6.9",
8182
"tailwindcss": "^3.4.17",
8283
"typescript": "^5.7.2",
8384
"vite": "^5.4.11",
8485
"vite-tsconfig-paths": "^5.1.4",
85-
"vitest": "^2.1.8",
86-
"msw": "^2.7.0"
86+
"vitest": "^2.1.8"
8787
}
8888
}

src/pages/users/Users.test.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeAll, afterAll, afterEach, describe, expect, it } from 'vitest'
1+
import { beforeAll, afterAll, afterEach, describe, expect, it, vitest } from 'vitest'
22
import { render, screen, waitFor } from '@testing-library/react'
33
import React, { act } from 'react'
44
import { BrowserRouter } from 'react-router-dom'
@@ -43,6 +43,7 @@ const mockedUsers = [
4343
],
4444
},
4545
]
46+
4647
export const handlers = [
4748
http.get(import.meta.env.VITE_APP_BACKEND_URL + '/coroutine/users', () => {
4849
return HttpResponse.json(mockedUsers)
@@ -60,6 +61,17 @@ server.events.on('request:start', ({ request }) => {
6061

6162
describe('user component tests', () => {
6263
beforeAll(() => {
64+
;(global as any).EventSource = vitest.fn().mockImplementation((url: string) => ({
65+
url,
66+
readyState: 1,
67+
addEventListener: vitest.fn(),
68+
removeEventListener: vitest.fn(),
69+
close: vitest.fn(),
70+
dispatchEvent: vitest.fn(),
71+
onopen: null,
72+
onmessage: null,
73+
onerror: null,
74+
}))
6375
server.listen()
6476
act(() => store.dispatch(usersApi.util.resetApiState()))
6577
})
@@ -70,6 +82,7 @@ describe('user component tests', () => {
7082
})
7183

7284
afterAll(() => {
85+
delete (global as any).EventSource
7386
server.close()
7487
})
7588

src/pages/users/Users.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useUserColumns } from '@/pages/users/hooks/useColumns'
1212
import { LoadingScreenMemo } from '@/components/app/loading-screen'
1313
import { usersApi } from '@/pages/users/api/usersApi.ts'
1414
import { useSearchParams } from 'react-router-dom'
15+
import { useSimpleSSE } from '@/pages/users/hooks/useSseUsers.tsx'
1516

1617
type UsersSearchParams = {
1718
page: number
@@ -96,12 +97,45 @@ export const Users: React.FC<UsersProps> = (/* props: UsersProps */) => {
9697
)
9798
}, [pagination, sorting])
9899

100+
const { connectionStatus, lastMessage } = useSimpleSSE(import.meta.env.VITE_APP_BACKEND_URL + '/update/user', {
101+
onUpdate: (message) => {
102+
console.log('User update received:', message)
103+
console.log('Refetching users...')
104+
users.refetch()
105+
},
106+
onConnect: () => {
107+
console.log('Connected to SSE stream')
108+
},
109+
onError: (error) => {
110+
console.error('SSE error:', error)
111+
},
112+
})
113+
99114
if (users.isLoading) {
100115
return <LoadingScreenMemo />
101116
}
102117

118+
if (users.isError) {
119+
return (
120+
<>
121+
{/* todo: refactor by creating a custom control component */}
122+
<div className="sse-bar">
123+
<span className={`status ${connectionStatus}`}>Status: {connectionStatus} </span>
124+
{lastMessage && <span>Last update: {lastMessage.content}</span>}
125+
</div>
126+
<div>Error</div>
127+
</>
128+
)
129+
}
130+
103131
return (
104132
<>
133+
{/* todo: refactor by creating a custom control component */}
134+
<div className="sse-bar">
135+
<span className={`status ${connectionStatus}`}>Status: {connectionStatus} </span>
136+
{lastMessage && <span>Last update: {lastMessage.content}</span>}
137+
</div>
138+
105139
<Card>
106140
<CardHeader>
107141
<CardTitle>{t(`users.t1`, { ns: ['main'] })}</CardTitle>

src/pages/users/hooks/useRolesGraphql.tsx

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,28 @@ export const useRolesGraphql = () => {
1414
})
1515

1616
useEffect(() => {
17-
if (!roles) {
17+
if (!roles && !loading && !error && data) {
1818
getRoles()
1919
}
20-
}, [data, loading, error])
21-
22-
const getRoles = () => {
23-
if (error) {
24-
console.error(`apollo error: ` + error.message)
25-
}
2620

27-
if (loading) {
28-
// ...
21+
return () => {
22+
setRoles(undefined)
2923
}
24+
}, [data, loading, error])
3025

31-
if (data) {
32-
const permissions: Role[] | undefined = data.getAllPermissions?.map((e) => {
33-
return {
34-
_id: e?.id,
35-
name: e?.name,
36-
} as Role
37-
})
38-
setRoles([...permissions!])
39-
}
26+
const getRoles = () => {
27+
const permissions: Role[] | undefined = data?.getAllPermissions?.map((e) => {
28+
return {
29+
_id: e?.id,
30+
name: e?.name,
31+
} as Role
32+
})
33+
setRoles([...permissions!])
4034
}
4135

4236
return {
4337
roles,
38+
loading,
39+
error,
4440
}
4541
}

src/pages/users/hooks/useSseUsers.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { useEffect, useState, useRef } from 'react'
2+
3+
interface SseMessage {
4+
from: string
5+
to: string
6+
content: string
7+
}
8+
9+
interface SSEOptions {
10+
onUpdate?: (message: SseMessage) => void
11+
onConnect?: () => void
12+
onError?: (error: Event) => void
13+
autoReconnect?: boolean
14+
maxRetries?: number
15+
}
16+
17+
export const useSimpleSSE = (url: string, options: SSEOptions = {}) => {
18+
const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting')
19+
const [lastMessage, setLastMessage] = useState<SseMessage | null>(null)
20+
const [retryCount, setRetryCount] = useState(0)
21+
22+
const eventSourceRef = useRef<EventSource | null>(null)
23+
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null)
24+
const optionsRef = useRef(options)
25+
const retryCountRef = useRef(0)
26+
const maxAttempts = optionsRef.current.maxRetries || 5
27+
28+
// Update options ref
29+
useEffect(() => {
30+
optionsRef.current = options
31+
}, [options])
32+
33+
// Update ref whenever state changes
34+
useEffect(() => {
35+
retryCountRef.current = retryCount
36+
}, [retryCount])
37+
38+
const connect = () => {
39+
// Clear any existing retry timeout
40+
if (retryTimeoutRef.current) {
41+
clearTimeout(retryTimeoutRef.current)
42+
}
43+
44+
// Close existing connection
45+
if (eventSourceRef.current) {
46+
eventSourceRef.current.close()
47+
eventSourceRef.current = null
48+
}
49+
50+
if (retryCountRef.current < maxAttempts) {
51+
eventSourceRef.current = new EventSource(url)
52+
53+
eventSourceRef.current.onopen = () => {
54+
setConnectionStatus('connected')
55+
setRetryCount(0) // Reset retry count on successful connection
56+
optionsRef.current.onConnect?.()
57+
}
58+
59+
eventSourceRef.current.addEventListener('connected', (event) => {
60+
const message: SseMessage = JSON.parse(event.data)
61+
setLastMessage(message)
62+
})
63+
64+
eventSourceRef.current.addEventListener('update', (event) => {
65+
const message: SseMessage = JSON.parse(event.data)
66+
setLastMessage(message)
67+
optionsRef.current.onUpdate?.(message)
68+
})
69+
70+
eventSourceRef.current.addEventListener('heartbeat', () => {})
71+
72+
eventSourceRef.current.onerror = (event) => {
73+
setConnectionStatus('disconnected')
74+
optionsRef.current.onError?.(event)
75+
76+
// Auto-reconnect logic
77+
if (optionsRef.current.autoReconnect !== false) {
78+
console.error('SSE error:', event)
79+
80+
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000)
81+
console.warn(`Reconnecting in ${delay}ms (attempt ${retryCountRef.current + 1})`)
82+
setRetryCount((prev) => prev + 1)
83+
84+
retryTimeoutRef.current = setTimeout(() => {
85+
connect()
86+
}, delay)
87+
}
88+
}
89+
}
90+
}
91+
92+
useEffect(() => {
93+
setConnectionStatus('connecting')
94+
connect()
95+
96+
return () => {
97+
if (retryTimeoutRef.current) {
98+
clearTimeout(retryTimeoutRef.current)
99+
}
100+
if (eventSourceRef.current) {
101+
eventSourceRef.current.close()
102+
eventSourceRef.current = null
103+
}
104+
setConnectionStatus('disconnected')
105+
}
106+
}, [url]) // Only reconnect when URL
107+
108+
return {
109+
connectionStatus,
110+
lastMessage,
111+
retryCount,
112+
maxAttempts,
113+
reconnect: connect,
114+
}
115+
}

0 commit comments

Comments
 (0)