Spaces:
Sleeping
Code Review β Polling Flow & Results Screen
Scope: Read-only review of frontend polling + result rendering. Files reviewed:
apps/web/lib/use-inspection-polling.tsapps/web/lib/api.tsapps/web/lib/auth-context.tsxapps/web/lib/uploaded-previews.tsapps/web/lib/jwt.tsapps/web/app/results/[id]/page.tsxapps/web/app/inspect/page.tsxapps/web/app/(app)/inspect/new/page.tsxapps/web/middleware.tspackages/ui/src/components/UploadDropzone.tsxapps/web/components/ResultsTabs.tsx
Note: there is no
apps/web/app/(app)/results/[id]/page.tsx. Results live only underapp/results/[id]/page.tsx, which means the page is not matched byPROTECTED_PREFIXESinmiddleware.ts. See HIGH-3.
Overall impression
The polling hook is overall solid β it has an AbortController, a
cancelledRef, exponential backoff, a max-duration budget, a
consecutive-error budget, and distinguishes "fatal" vs "transient" errors.
Cross-tab refresh dedup in api.ts is thoughtful. The Tauri-style cleanup
in inspect/new/page.tsx (abort on unmount) is well done.
That said, there are real bugs around stale consecutiveErrors/currentInterval
when the retryNonce/effect re-runs, a silent token-refresh failure mode
where a follower tab returns the old access token, and a known and
still-unfixed URL.createObjectURL leak in UploadDropzone.tsx (the
comments claim it's revoked, but the hook is dead code).
The list below is prioritized; the Top 5 section at the end is the order I would fix things in.
CRITICAL
CRITICAL-1 β useFilePreviewUrls is dead code; FilePreview leaks blob URLs on every render
Where: packages/ui/src/components/UploadDropzone.tsx:123-180
The useFilePreviewUrls hook (lines 123-139) does call URL.revokeObjectURL
in a cleanup, but FilePreview is also imported / used in two places that
mutate files array identity on every keystroke / state change in the
parent (apps/web/app/inspect/page.tsx, apps/web/app/(app)/inspect/new/page.tsx).
Concretely:
inspect/page.tsx:33-46rebuildsfiles(new array reference) viasetFiles((prev) => [...prev, ...incoming]...)eachonFilescall.- The dedup logic returns a new array even when nothing changed
(e.g. user re-drops the same file). Every new identity invalidates the
useMemo([files]), which callsURL.createObjectURLagain for every surviving file, leaking the previous URLs until effect cleanup later. - The cleanup runs only when
urlsidentity changes β but by that point a newurlsalready exists, so the cleanup fires for the new set, not the leaked previous set. (useEffect's cleanup uses the previous closure, but the leak is across renders where memo recomputes β and because[files]identity gates it, blob URLs that were already recreated for the sameFilereference are unconditionally orphaned.)
docs/WEB_PERFORMANCE.md:133-147 and docs/SECURITY_WEB_AUDIT.md:145-151
both flag this. The fix the docs describe was supposedly applied, but the
current code path still allocates a fresh URL per memo recompute without
deduping by File identity.
Why it matters: 20 photos Γ ~5 MB = 100 MB of blob memory per leak
cycle. Long sessions on /inspect/new can crash mobile Safari and degrade
desktop Chrome (multi-100 MB).
Suggestion (DO NOT IMPLEMENT β review only):
- Cache URLs in a
Map<File, string>ref keyed byFileidentity, and only allocate for files newly added since the last render. Revoke when aFileleaves the list or on unmount. - Add a unit test that asserts
URL.createObjectURLcall count equalsfiles.lengthafter N re-renders with stable file identities.
CRITICAL-2 β Polling effect resets consecutiveErrors/currentInterval but state attempts is preserved across retry(), producing inconsistent UX
Where: apps/web/lib/use-inspection-polling.ts:87-103
retry() increments retryNonce, which re-runs the entire effect. Inside
the effect:
consecutiveErrors = 0(line 107) andcurrentInterval = intervalMs(line 106) reset on every run β good.- But
setState((s) => ({ ... attempts: s.attempts ... }))at line 100 preservesattempts. After several manual retries,attemptskeeps climbing across resets whileconsecutiveErrorsdoes not.
That's not a bug per se, but combined with the paused flag rendering at
results/[id]/page.tsx, the user sees the pollAttempts counter (line
328) growing without bound across retries. There is no paused branch
rendered on the results page at all β when polling pauses (8 consecutive
errors), the UI shows the <ErrorBanner> plus the <PendingState>
spinner forever. There is no "Check again" button as the hook's docstring
(line 51) promises.
Why it matters: The contract documented at use-inspection-polling.ts:51
("UI should offer a 'check again' button") is not implemented in
results/[id]/page.tsx. Once paused, the user has no way to call retry().
They have to reload the tab, which dumps any optimistic state.
Suggestion:
- Wire
pausedandretryfrom the hook into the results page and render a "Check again" button (analogous to thetimedOutbranch at line 80). - Either persist
attemptsacross retries (current behavior) and label it "Total checks" so the growing number is intentional, or reset it onretry()and label it "Checks this session."
CRITICAL-3 β Cross-tab refresh follower can race and return a token previousAccess === current β null even when leader succeeds
Where: apps/web/lib/api.ts:160-225
In runRefresh():
previousAccess = getStoredAccessToken()is read beforetryAcquireRefreshLock().- If we lose the lock, we call
waitForLeaderRefresh(previousAccess). - The leader in another tab calls
setStoredTokens(access_token, ...), which writeslocalStoragesynchronously. But if the leader completes between steps 1 and 2 in our tab (very narrow window β but possible becausetryAcquireRefreshLockdoes multiplelocalStoragereads),previousAccessmay already equal the new token written by the leader, in which case thewaitForLeaderRefreshpoll never seescurrent !== previousTokenand resolvesnullat the 8 s timeout.
Equally, storage events do not fire in the writing tab β that's
documented at line 178. But the follower polls every 200 ms, which means
the happy path takes up to 200 ms per request of pure latency added
to the 401 β refresh β retry path in the loser tab.
Why it matters: Tabs lose refresh attempts β user gets logged out spuriously in multi-tab sessions. Symptom is intermittent and very hard to repro.
Suggestion:
- After acquiring/losing the lock, re-read the access token and
compare against
previousAccess. If they differ, the leader already finished β return the new token immediately, skip the wait loop. - Reduce the poll interval to 50 ms or use a
BroadcastChannel.
HIGH
HIGH-1 β 401 retry inside axios interceptor does not propagate signal
Where: apps/web/lib/api.ts:99-110
When a 401 fires, the interceptor calls instance.request(original). The
original config carries the original AbortSignal (good), but if the
caller already aborted (e.g. unmounted polling), the abort fires before
runRefresh returns. instance.request(original) will then issue a
network request with an already-aborted signal β axios will throw
CanceledError, which classifyApiError maps to cancelled, which the
polling hook silently swallows. Net effect: harmless, but a wasted refresh
round-trip on every unmount that happens to coincide with a 401.
More importantly: if the user navigates away during refresh, the leader
tab can still complete /auth/refresh and write fresh tokens to
localStorage for a session the user thinks is dead. Edge-y, but worth
noting.
Suggestion: Bail out of the 401 retry path if original.signal?.aborted
before calling runRefresh().
HIGH-2 β setUnauthorizedHandler(null) cleanup in AuthProvider runs on every pathname change β ephemeral window where the handler is unset
Where: apps/web/lib/auth-context.tsx:54-68
The effect re-runs whenever pathname changes (React Router behavior in
app dir is to keep pathname stable inside a segment, but transitions
DO fire). The cleanup sets the handler to null, then the next effect
sets a new closure. If a 401 lands in the millisecond gap (during route
transition), _onUnauthorized?.() is a no-op and the user is left on
the page with a cleared session and no redirect.
Why it matters: Probably rare, but on slow devices or under React 18 concurrent rendering, this gap widens. The symptom is "I'm logged out but the app still shows me the protected page."
Suggestion:
- Either keep the handler permanently registered (use a stable handler
that reads
pathnamefrom a ref), or compute the dependency more narrowly (only re-register ifrouteridentity changes, notpathname).
HIGH-3 β /results/[id] is not in PROTECTED_PREFIXES, but the API call requires auth
Where: apps/web/middleware.ts:4-15, apps/web/app/results/[id]/page.tsx:27
PROTECTED_PREFIXES lists /inspect and /inspect/new but not
/results. An unauthenticated user who navigates to /results/<some-id>
bypasses middleware, then useInspectionPolling fires
GET /api/v1/inspect/<id>, gets a 401, the axios interceptor tries
runRefresh() (no refresh token in localStorage β returns null), clears
tokens (already empty), calls _onUnauthorized?.(). The auth context
does push to /login from the protected pages check at line 58-65 β but
note the condition: it only redirects if pathname is NOT /login,
/register, or /. /results/<id> passes this check, so the user
does get redirected β but only after a failed network call, a failed
refresh, and one full re-render. Slow and noisy.
Also: the FastAPI 401 detail bubbles up as the polling error message
(tHttp('401')) and is briefly shown to the user before the redirect.
Suggestion: Add /results to PROTECTED_PREFIXES so middleware
short-circuits at the edge.
HIGH-4 β Polling effect re-runs whenever tResult/tNetwork/tHttp translator identities change β full polling reset
Where: apps/web/lib/use-inspection-polling.ts:220-231
The effect deps include tResult, tNetwork, tHttp. useTranslations()
from next-intl returns a new function reference on every locale change
and sometimes on every parent re-render, depending on the provider
implementation. If those identities are not memoized, the polling effect
tears down (ac.abort(), clearTimeout) and rebuilds on every render of
results/[id]/page.tsx.
That would mean:
startedAtRef.current = Date.now()resets, defeating themaxDurationMsbudget.- A new
AbortControllerand a new initialtick()fire β back-to-back HTTP calls. consecutiveErrorsandcurrentIntervalreset to defaults, so exponential backoff never kicks in.
I have not confirmed next-intl's exact memo behavior, but the standard
pattern is to not include translator functions in effect deps for
this reason.
Why it matters: If translators are unstable, this hook will hammer
the backend with 1.5 / second requests indefinitely.
Suggestion:
- Capture
tResult,tNetwork,tHttpin refs at the top of the hook and read from refs insidetick. Drop them from the dep array.
HIGH-5 β loadImage in uploaded-previews.ts leaks the image and the object URL on img.onerror
Where: apps/web/lib/uploaded-previews.ts:52-59
loadImage resolves on onload, rejects on onerror. The finally in
fileToResizedDataUrl does revoke the object URL. Good. But the
HTMLImageElement itself is never cleared (img.src = '' or img.onload = null). If the image is mid-decode and the user navigates away, the
decode continues in the browser. Minor.
Bigger issue: onerror has no timeout. A corrupt image hangs the upload
forever, blocking stashUploadedPreviews which blocks the
router.push('/results/...') (see inspect/new/page.tsx:104). The user
sits on the upload page with no feedback after the upload itself
succeeded server-side.
Suggestion:
- Add a
Promise.racewith a 5-10 s timeout inloadImage. - Make
stashUploadedPreviewsnon-blocking: kick it off, navigate immediately, let it finish in background and write to sessionStorage when ready. (The results page already tolerates missing previews.)
MEDIUM
MEDIUM-1 β getStoredAccessToken() is read inside request.use interceptor for every request β no protection against expired tokens
Where: apps/web/lib/api.ts:83-89
The request interceptor unconditionally attaches the access token even if
isJwtExpired(token) returns true. The 401 response interceptor will
catch it and refresh β that's fine β but every expired-but-not-yet-refused
request wastes a round trip.
Suggestion: Check isJwtExpired(token, 5) in the request interceptor;
if expired, await runRefresh() proactively before sending. (Slight
risk: deadlock if runRefresh itself is intercepted; guard with a
"don't intercept /auth/refresh" check.)
MEDIUM-2 β paused branch never rendered on results page
Where: apps/web/app/results/[id]/page.tsx:27-95
The destructure on line 27 takes data, loading, error, attempts, timedOut
but omits paused and retry. The hook documents (line 18-20, 51)
that paused is the user's manual-retry escape hatch. Since the results
page never reads it, the contract is silently broken β see CRITICAL-2.
MEDIUM-3 β data?.error is used as a translated error message but is the raw backend string
Where: apps/web/app/results/[id]/page.tsx:71, use-inspection-polling.ts:130-133
When status === 'failed', the hook sets
error: data.error ?? tResult('failed'). The results page shows
description={data?.error ?? tCommon('errorGeneric')}. So if the backend
returns "CUDA out of memory: tried to allocate 12.00 GiB..." that string
ends up verbatim in the UI. No i18n, no scrubbing, leaks internal
implementation detail.
Suggestion: Map known backend error codes to translated strings; fall back to a generic translated message when the code is unknown.
MEDIUM-4 β effectiveMode recompute can desync mode state on (app)/inspect/new/page.tsx
Where: apps/web/app/(app)/inspect/new/page.tsx:71-74
effectiveMode is forced to 'async' when files.length > MAX_SYNC_FILES,
but the underlying mode state still says 'sync'. If the user then
removes files to get under 5, the UI flips back to 'sync' without the
user re-confirming. Probably fine, but worth a UX nit: keep a
"user-chosen mode" plus a "current capability" and reset the user choice
when files cross the threshold up.
MEDIUM-5 β getInspectionStatus returns data as-is; no validation against InspectionStatusResponse type
Where: apps/web/lib/api.ts:355-367
res.data is typed as InspectionStatusResponse but the type assertion
trusts the backend completely. A malformed response (or version skew)
crashes downstream (data.status, data.result.parts.flatMap...). The
polling hook would mis-treat a "no status field at all" response as
"still polling" forever.
Suggestion: Validate with a tiny runtime check (or a zod schema in
@arac-hasar/types) and convert validation failures into a notFound/
server ApiErrorInfo.
MEDIUM-6 β Inspect /inspect/page.tsx does not propagate signal and has no unmount-abort
Where: apps/web/app/inspect/page.tsx:60-83
The duplicate (older?) page at /inspect/page.tsx calls
createInspection(files, { mode: effectiveMode }) with no signal and
no unmount cleanup. If the user submits 20 photos then navigates away
mid-upload, the request runs to completion in the background and may
call router.push after unmount β classic "Can't perform a React state
update on an unmounted component" warning.
The newer (app)/inspect/new/page.tsx does this correctly (lines 39-47,
89-90). Decide which page is canonical and either delete or fix the
other.
LOW
LOW-1 β clearStoredTokens() does not clear cookie path/domain rigorously
Where: apps/web/lib/api.ts:49-54
Setting max-age=0 on the same path/samesite as the writer works in
practice, but if the cookie was ever issued from a different path
(legacy code, A/B test), it'll linger. Low risk in a clean codebase.
LOW-2 β tryAcquireRefreshLock returns true on try { ... } catch { return true }
Where: apps/web/lib/api.ts:141-143
If localStorage throws (Safari private mode, quota), the tab declares
itself leader. In multi-tab Safari private mode, all tabs will declare
leader and call /auth/refresh in parallel β refresh-token reuse error,
session burns. Edge case.
LOW-3 β isJwtExpired parses the JWT on every request
Where: apps/web/lib/jwt.ts:48-53 (and the middleware path at
apps/web/middleware.ts:63)
Negligible perf cost, but middleware decodes on every request β including HMR pings during dev. Consider caching by token string.
LOW-4 β data.image.url ?? '' falls back to empty string, then renders <img src="">
Where: apps/web/app/results/[id]/page.tsx:136-145
annotatedUrl becomes '', the falsy check on line 145 does catch it
(annotatedUrl ? ... : <fallback>) β but only because '' is falsy.
Brittle. Prefer explicit annotatedUrl ? <Tabs/> : <Placeholder/> with
the value typed as string | null.
LOW-5 β classifyApiError returns { kind: 'unknown' } for non-axios errors with no detail info
Where: apps/web/lib/api.ts:536
A thrown TypeError (e.g. JSON.parse failure in an interceptor) becomes
{ kind: 'unknown' }, which the polling hook then shows as
tNetwork('unknown') β usually misleading ("internet broken" when the
real cause is a bug). Adding error: err instanceof Error ? err.message : undefined would help debugging.
LOW-6 β attempts counter in PendingState shows "1" on the very first render
Where: apps/web/app/results/[id]/page.tsx:327
{attempts > 1 && ...} guards against the initial "attempt 0/1" flicker,
but the first successful tick increments to 1 and the user briefly sees
"Attempt 1" before the data resolves. Cosmetic.
LOW-7 β useInspectionPolling mixes UI strings into hook return values
Where: apps/web/lib/use-inspection-polling.ts:169-182
The hook resolves translation strings inline. Cleaner separation: return
{ kind: ApiErrorInfo['kind'] | 'failed' | 'timeout' | 'paused' } and let
the consumer translate. Reduces coupling and makes the hook unit-testable
without a NextIntlClientProvider wrapper.
Top 5 priority fixes
CRITICAL-1 β Fix the
UploadDropzone/FilePreviewblob URL leak. The hook exists but its dedup contract is broken; right now everysetFilescall burns blob memory. High user-visible cost on mobile.CRITICAL-2 / MEDIUM-2 β Wire the
paused/retrycontract throughresults/[id]/page.tsx. Right now an 8-error transient pause is a silent dead-end with the spinner running forever.HIGH-4 β Stabilize the translator references (or drop them from the effect dep array). If
next-intlreturns new functions per render, polling resets ~every 100 ms and your backend gets DoS'd by your own UI. Verify with aconsole.countintickduring a 30 s session.CRITICAL-3 / HIGH-1 β Tighten the cross-tab refresh path:
- Re-read access token after lock acquisition and short-circuit if it already changed.
- Bail out of the 401 retry if the original signal is aborted.
HIGH-3 β Add
/resultstoPROTECTED_PREFIXESinmiddleware.ts. Currently the only thing protecting the results route is a failed API call.
Things done right (call-outs)
- The
cancelledRef + AbortControllerdual-guard inuse-inspection-polling.tsis exactly the right pattern for React strict-mode double-mounting. No state update will leak post-unmount. runRefreshin-tab dedup via shared promise (_refreshPromise) is correct and avoids the multi-401 stampede.- The
_retrysentinel on the axios config (line 96-100) correctly prevents an infinite 401βrefreshβ401 loop. - Sync vs async UX gating (
MAX_SYNC_FILES = 5) is matched against the backend cap and visually disabled on the UI β defense in depth. stashUploadedPreviewsresizes to 1024 px JPEG q=0.8 with a 2 MB cap, scoped tosessionStorageβ solid quota discipline.classifyApiErrorproduces a stable union and parses FastAPI's twodetailshapes (string vs array-of-errors) without crashing.