Skip to content

Commit ffa2b94

Browse files
authored
🐛 Improve swallowed hook error handling
Improves Stellar network error propagation so init, batch refresh, and auto-solve failures update hook state, and keeps malformed localStorage cache handling non-fatal with a logged fallback.\n\nFixes #19300
1 parent 19ce873 commit ffa2b94

4 files changed

Lines changed: 93 additions & 9 deletions

File tree

web/src/hooks/__tests__/useStackDiscovery.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,13 +541,19 @@ describe('useStackDiscovery', () => {
541541
})
542542

543543
it('handles malformed localStorage data without crashing', () => {
544+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
544545
localStorage.setItem(CACHE_KEY, 'not-valid-json{{')
545546

546547
const { result, unmount } = renderHook(() => useStackDiscovery([]))
547548

548549
expect(result.current.stacks).toEqual([])
549550
expect(result.current.isLoading).toBe(true)
551+
expect(warnSpy).toHaveBeenCalledWith(
552+
'[useStackDiscovery] Ignoring malformed JSON for stack cache:',
553+
expect.any(SyntaxError),
554+
)
550555
unmount()
556+
warnSpy.mockRestore()
551557
})
552558

553559
it('persists discovered stacks to localStorage', async () => {

web/src/hooks/__tests__/useStellarSource.test.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,16 @@ describe('useStellarSource — Initial state and SSE messages', () => {
324324
expect(result.current.solves).toEqual([])
325325
})
326326

327+
it('stores an error message when the initial refresh fails', async () => {
328+
mockStellarApi.getState.mockRejectedValueOnce(new Error('Initial refresh failed'))
329+
330+
const { result } = renderHook(() => useStellarSource())
331+
332+
await waitFor(() => {
333+
expect(result.current.connectionError).toBe('Initial refresh failed')
334+
})
335+
})
336+
327337
it('parses incoming SSE notification event and appends to state', async () => {
328338
const { result } = renderHook(() => useStellarSource())
329339

@@ -706,6 +716,22 @@ describe('useStellarSource — Batch refresh', () => {
706716
expect(spyClearTimeout).toHaveBeenCalled()
707717
spyClearTimeout.mockRestore()
708718
})
719+
720+
it('stores an error message when a batch refresh request fails', async () => {
721+
const { result } = renderHook(() => useStellarSource())
722+
723+
await waitFor(() => {
724+
expect(eventSourceInstances).toHaveLength(1)
725+
})
726+
727+
mockStellarApi.getNotifications.mockRejectedValueOnce(new Error('Batch refresh failed'))
728+
729+
await act(async () => {
730+
await result.current.runBatchNow()
731+
})
732+
733+
expect(result.current.connectionError).toBe('Batch refresh failed')
734+
})
709735
})
710736

711737
describe('useStellarSource — Auto-solve trigger', () => {
@@ -788,4 +814,32 @@ describe('useStellarSource — Auto-solve trigger', () => {
788814
expect(mockStellarApi.startSolve).not.toHaveBeenCalled()
789815
expect(result.current.solveProgress['n-warning']).toBeUndefined()
790816
})
817+
818+
it('stores an error message when auto-solve fails for a critical notification', async () => {
819+
const { result } = renderHook(() => useStellarSource())
820+
821+
await waitFor(() => {
822+
expect(eventSourceInstances).toHaveLength(1)
823+
})
824+
825+
mockStellarApi.startSolve.mockRejectedValueOnce(new Error('Auto-solve failed'))
826+
827+
await act(async () => {
828+
eventSourceInstances[0]._triggerEvent('notification', {
829+
id: 'n-critical-fail',
830+
type: 'event',
831+
severity: 'critical',
832+
title: 'Critical Alert',
833+
body: 'Emergency!',
834+
read: false,
835+
createdAt: new Date().toISOString(),
836+
})
837+
await Promise.resolve()
838+
})
839+
840+
await waitFor(() => {
841+
expect(result.current.connectionError).toBe('Auto-solve failed')
842+
})
843+
expect(result.current.solveProgress['n-critical-fail']).toBeUndefined()
844+
})
791845
})

web/src/hooks/useStackDiscovery.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,12 +326,12 @@ function loadCachedStacks(): { stacks: LLMdStack[]; timestamp: number } | null {
326326
try {
327327
const cached = localStorage.getItem(CACHE_KEY)
328328
if (!cached) return null
329-
const parsed = JSON.parse(cached)
330-
if (parsed.timestamp && parsed.stacks) {
331-
return parsed
329+
const parsed = safeJsonParse<{ stacks?: LLMdStack[]; timestamp?: number } | null>(cached, null, 'stack cache')
330+
if (parsed?.timestamp && parsed.stacks) {
331+
return { stacks: parsed.stacks, timestamp: parsed.timestamp }
332332
}
333333
} catch {
334-
// Ignore parse errors
334+
// Ignore storage errors
335335
}
336336
return null
337337
}

web/src/hooks/useStellarSource.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const STELLAR_RECONNECT_MAX_MS = 30000
1212
export const STELLAR_TOKEN_POLL_INTERVAL_MS = 100
1313
export const STELLAR_TOKEN_POLL_MAX_ATTEMPTS = 30
1414
export const STELLAR_MISSION_TRIGGER_EVENT = 'stellar:mission_trigger'
15+
const STELLAR_REFRESH_REQUEST_COUNT = 7
1516

1617
export interface StellarMissionTriggerPayload {
1718
solveId: string
@@ -53,6 +54,12 @@ function getStoredStellarBatchIntervalMs(): number {
5354
return resolveStellarBatchIntervalMs(safeGetItem(STORAGE_KEY_STELLAR_BATCH_INTERVAL_MS))
5455
}
5556

57+
function getErrorMessage(error: unknown, fallback: string): string {
58+
if (error instanceof Error && error.message.trim()) return error.message
59+
if (typeof error === 'string' && error.trim()) return error
60+
return fallback
61+
}
62+
5663
export function useStellarSource() {
5764
const [isConnected, setIsConnected] = useState(false)
5865
const [connectionError, setConnectionError] = useState<string | null>(null)
@@ -92,6 +99,9 @@ export function useStellarSource() {
9299
const [nextBatchAtMs, setNextBatchAtMs] = useState(() => getNextBatchTime(batchIntervalMs))
93100
const [isBatchRefreshing, setIsBatchRefreshing] = useState(false)
94101
const batchRefreshInFlightRef = useRef(false)
102+
const setOperationalError = useCallback((error: unknown, fallback: string) => {
103+
setConnectionError(getErrorMessage(error, fallback))
104+
}, [])
95105

96106
useEffect(() => {
97107
const handleStorage = (event: StorageEvent) => {
@@ -131,8 +141,19 @@ export function useStellarSource() {
131141
if (results[5].status === 'fulfilled') setSolves(results[5].value || [])
132142
if (results[6].status === 'fulfilled') setActivity(results[6].value || [])
133143
const failures = results.filter(result => result.status === 'rejected')
134-
if (failures.length > 0) console.warn('stellar: refreshState partial failure —', failures.length, 'of 7 calls failed')
135-
}, [setNotifications])
144+
if (failures.length === 0) {
145+
setConnectionError(null)
146+
return
147+
}
148+
console.warn('stellar: refreshState partial failure —', failures.length, 'of', STELLAR_REFRESH_REQUEST_COUNT, 'calls failed')
149+
const firstFailure = failures[0]
150+
setOperationalError(
151+
firstFailure.status === 'rejected' ? firstFailure.reason : null,
152+
failures.length === 1
153+
? 'Failed to refresh Stellar state'
154+
: `Failed to refresh Stellar state (${failures.length}/${STELLAR_REFRESH_REQUEST_COUNT} requests failed)`,
155+
)
156+
}, [setNotifications, setOperationalError])
136157
const scheduleNextBatch = useCallback((intervalMs = batchIntervalMsRef.current) => {
137158
setNextBatchAtMs(getNextBatchTime(intervalMs))
138159
}, [])
@@ -144,12 +165,13 @@ export function useStellarSource() {
144165
await refreshState()
145166
} catch (err) {
146167
console.warn('stellar: batch refresh failed:', err)
168+
setOperationalError(err, 'Failed to refresh Stellar state')
147169
} finally {
148170
batchRefreshInFlightRef.current = false
149171
setIsBatchRefreshing(false)
150172
scheduleNextBatch()
151173
}
152-
}, [refreshState, scheduleNextBatch])
174+
}, [refreshState, scheduleNextBatch, setOperationalError])
153175
const setBatchIntervalMs = useCallback((intervalMs: number) => {
154176
const nextIntervalMs = resolveStellarBatchIntervalMs(intervalMs)
155177
batchIntervalMsRef.current = nextIntervalMs
@@ -193,6 +215,7 @@ export function useStellarSource() {
193215
})
194216
stellarApi.startSolve(notif.id).catch(err => {
195217
console.warn('stellar: auto-solve for critical event failed:', notif.id, err)
218+
setOperationalError(err, 'Failed to auto-solve critical event')
196219
setSolveProgress(prev => {
197220
const copy = { ...prev }
198221
delete copy[notif.id]
@@ -267,7 +290,7 @@ export function useStellarSource() {
267290
on<StellarMissionTriggerPayload>('mission_trigger', payload => {
268291
window.dispatchEvent(new CustomEvent(STELLAR_MISSION_TRIGGER_EVENT, { detail: payload }))
269292
})
270-
}, [setNotifications])
293+
}, [setNotifications, setOperationalError])
271294

272295
useEffect(() => {
273296
reconnectRef.current = connectSSE
@@ -308,6 +331,7 @@ export function useStellarSource() {
308331
await refreshState()
309332
} catch (err) {
310333
console.warn('stellar: init failed:', err)
334+
setOperationalError(err, 'Failed to initialize Stellar state')
311335
}
312336
scheduleNextBatch()
313337
connectSSE()
@@ -328,7 +352,7 @@ export function useStellarSource() {
328352
}
329353
esRef.current?.close()
330354
}
331-
}, [connectSSE, refreshState, scheduleNextBatch])
355+
}, [connectSSE, refreshState, scheduleNextBatch, setOperationalError])
332356

333357
const unreadCount = notifications.filter(item => !item.read).length
334358
const acknowledgeNotification = useCallback(async (id: string) => {

0 commit comments

Comments
 (0)