fix: reliable generated-image display + stop client remount storm (#927) (#934)

* test: reproduce issue #927

* fix(hub): cache generated images + raise socket buffer cap (closes #927)

Generated-image display was slow and could silently fail:

- The /generated-images route sent `Cache-Control: no-store`, so every
  card remount (session switch, scroll, reload) re-ran the full HTTP ->
  socket.io RPC -> base64 round-trip. The bytes for an imageId are
  immutable, so serve them `private, max-age=31536000, immutable` + ETag.
- socket.io / bun-engine `maxHttpBufferSize` was left at the 1 MB default,
  while the MCP tool accepts images up to 25 MB. The base64 CLI -> hub ack
  frame for anything above ~750 KB raw exceeded the cap and was dropped.
  Raise the buffer to comfortably carry the largest allowed image.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(hub): short-circuit generated-image revalidation with a 304 (#927)

The imageId is an immutable content fingerprint, so use it as the ETag and
answer If-None-Match with 304 before issuing the readGeneratedImage RPC.
This makes the ETag actually useful: revalidation now skips the CLI socket
round-trip entirely, and still serves correctly even after the image was
evicted from the CLI's in-memory store.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): stabilize ApiClient identity across token refresh (#927)

The ApiClient was rebuilt whenever the token value changed (useMemo dep),
even though every request already reads the live token via getToken/tokenRef.
On a flaky/remote connection, repeated 401s -> onUnauthorized -> forced
refresh churned `api`'s identity, which remounts everything keyed on it:
VoiceBackendSession ([props.api]) -> Voice re-register spam, and
GeneratedImageCard ([ctx.api]) -> per-image refetch storm, feeding a
render avalanche. Depend on auth presence (hasToken) instead.

Reproduced with a useAuth hook test (red->green); full web suite stays green.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
SSU-WEI HUANG
2026-06-18 10:11:31 +08:00
committed by GitHub
co-authored by HAPI
parent 4bc3393904
commit fc8c32e07a
7 changed files with 208 additions and 5 deletions
+79
View File
@@ -0,0 +1,79 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
// Mock the network layer so we can drive token refreshes deterministically.
// The real ApiClient reads the live token via `getToken`, so the mock records the
// constructor options (including onUnauthorized) and hands out incrementing tokens.
const h = vi.hoisted(() => {
let idSeq = 0
let authCount = 0
class MockApiClient {
token: string
options: { getToken?: () => string | null; onUnauthorized?: () => unknown; baseUrl?: string } | undefined
readonly id: number
constructor(token: string, options?: MockApiClient['options']) {
this.token = token
this.options = options
this.id = ++idSeq
}
async authenticate(): Promise<{ token: string; user: { id: string } }> {
authCount += 1
return { token: `token-${authCount}`, user: { id: 'u1' } }
}
}
class MockApiError extends Error {
status: number
code?: string
constructor(message: string, status = 401, code?: string) {
super(message)
this.status = status
this.code = code
}
}
return { MockApiClient, MockApiError }
})
vi.mock('@/api/client', () => ({ ApiClient: h.MockApiClient, ApiError: h.MockApiError }))
// Imported after the mock is registered (vi.mock is hoisted).
import { useAuth } from '@/hooks/useAuth'
type ApiWithOptions = {
id: number
options?: { getToken?: () => string | null; onUnauthorized?: () => unknown }
}
describe('useAuth — api identity stability across token refresh (issue #927)', () => {
it('keeps the same ApiClient instance when the token refreshes', async () => {
// Stable authSource reference, exactly like the real caller (useAuthSource holds it in
// useState). This isolates the bug under test: a *token* refresh, not a source change.
const authSource = { type: 'accessToken' as const, token: 'seed' }
const { result } = renderHook(() => useAuth(authSource, 'http://hub.test'))
// Initial authenticate resolves and sets the first token.
await waitFor(() => expect(result.current.api).not.toBeNull())
const api1 = result.current.api as unknown as ApiWithOptions
const token1 = result.current.token
expect(token1).toBe('token-1')
// Drive the exact real-world trigger: a 401 invokes onUnauthorized,
// which force-refreshes the token (this is what the flaky remote network does).
await act(async () => {
await api1.options?.onUnauthorized?.()
})
// The token did advance...
expect(result.current.token).toBe('token-2')
expect(result.current.token).not.toBe(token1)
// ...but recreating the client was unnecessary: the OLD instance already serves
// the fresh token via getToken, so nothing downstream needed a new `api` reference.
expect(api1.options?.getToken?.()).toBe(result.current.token)
// DESIRED: `api` stays referentially stable across a refresh, so effects keyed on
// `api` (VoiceBackendSession `[props.api]`, GeneratedImageCard `[ctx.api, ...]`) do
// NOT re-run / remount. On current code `api` is rebuilt because `token` is a useMemo
// dep, which drives the Voice-remount spam + per-image refetch storm. This fails today.
expect(result.current.api).toBe(api1 as unknown as typeof result.current.api)
})
})
+9 -3
View File
@@ -157,15 +157,21 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): {
}
}, [baseUrl])
// Keep the ApiClient referentially stable across token *refreshes*: the client always reads
// the live token via getToken (tokenRef), so it never needs rebuilding when the token value
// changes — only when auth presence toggles (login/logout). Rebuilding on every refresh churns
// `api`'s identity, which remounts everything keyed on it (VoiceBackendSession `[props.api]`,
// GeneratedImageCard `[ctx.api, ...]`) and drives the remount/refetch storm. Issue #927.
const hasToken = token !== null
const api = useMemo(() => (
token
? new ApiClient(token, {
hasToken
? new ApiClient(tokenRef.current ?? '', {
baseUrl,
getToken: () => tokenRef.current,
onUnauthorized: () => refreshAuth({ force: true })
})
: null
), [baseUrl, refreshAuth, token])
), [baseUrl, refreshAuth, hasToken])
useEffect(() => {
let isCancelled = false