fix: stop an error response from crashing the page (#53) - #54
Conversation
A user hit a broken SGA Spaces page. /api/spaces returned a transient 401 and
the page died with "l.find is not a function"; a reload cleared it.
fetch('/api/spaces')
.then(r => r.json())
.then((data: Space[]) => setSpaces(data))
The annotation is a promise the code cannot keep. An error response is still
valid JSON, so r.json() resolved happily with { error: 'Unauthorized' }, that
object went into state typed as Space[], and the page died on the next
spaces.find(). `data.length > 0` had already passed silently -- undefined > 0 is
just false -- so nothing failed until the render. TypeScript cannot catch this:
the cast is asserted at a boundary where the shape is only known at runtime.
Eight call sites shared the pattern. Four degraded harmlessly on their own
(data.groups || [], (requests ?? [])), but two more could crash the same way and
one was worse than a crash:
sga-spaces/page.tsx the reported failure
sga-spaces-tab.tsx .then(setSpaces) -- identical shape, admin view
booking-settings-tab every field falls back to a hardcoded default, so an
error body silently populated the form with 0/0/24 and
an admin pressing Save would have written those over
the real booking limits
settings-modal error body cast to Settings, offered back for saving
archive-tab no .catch at all, so a rejected fetch left
setLoading(false) unreached and the tab hung on its
skeleton
getJson/getJsonArray take the fallback as an argument, which makes the failure
case impossible to leave out and keeps the return type honest.
The Spaces page handles its response directly instead, because there an empty
list is a meaningful answer -- "no rooms configured" -- and quietly substituting
one for a failed request would state something untrue. It now shows a short
error with a Try again button, which is the same remedy the reporter found.
Not fixed here: why /api/spaces 401s in the first place. Both endpoints in the
report use the plain getAuthedUser path, no user is missing a profile row and no
sessions are revoked, so this is a transient auth failure rather than a
regression. What is fixed is that it can no longer take the page down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
signOut() defaults to scope 'global' -- confirmed in auth-js:
async _signOut({ scope } = { scope: 'global' })
which revokes every refresh token the user holds anywhere. So the 44-minute
idle timer firing on someone's laptop also ended the session on their phone, in
their other tab, and the copy the Edge middleware refreshes. Each of those then
failed its next refresh with a 400 and began serving 401s, which is how a page
that was working a moment ago starts returning Unauthorized.
Supabase's logs over 24h: of 28 refresh_token grants, 9 returned 400 -- 5 from
browsers and 4 from the Edge middleware. Alongside 28 logouts across ~27 users,
and only 3 of 24 refresh tokens ever having a parent (so rotation is barely
occurring, meaning a 400 is a revoked token rather than a rotation race).
The four sign-outs in dashboard-shell -- the Sign Out button and the three idle
paths -- now use scope 'local'. That is what signing out means on a shared
dashboard: this browser, not every device I own.
The four that mean "this account may not be used" keep the global scope, and
say so in a comment: deactivation in force-sign-out.tsx, LoginCard and
onboarding, plus an expired invite. Ending every session is the point there.
This is a user-facing fix regardless of the 401s -- signing out at a library
machine should not log you out on your phone.
Not claimed: that this accounts for every 401. Vercel's runtime logs are not
readable with the token I have, so the reported failure could not be tied to a
specific revocation. Some 400s also fall in hours with no logouts at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Added: sign out this device only (
|
| scope | where | why |
|---|---|---|
local |
dashboard-shell: Sign Out button + 3 idle paths | this browser, not every device you own |
global |
deactivation (force-sign-out, LoginCard, onboarding) + expired invite | the account may not be used at all — ending every session is the point |
The global ones now carry a comment saying why, so they don't get "unified" later for consistency.
This is worth having regardless of the 401s: signing out at a library machine shouldn't log you out on your phone.
What I'm not claiming
That this accounts for every 401. Vercel's runtime logs return 403 for my token, so I couldn't tie the reported failure to a specific revocation, and some 400s fall in hours with no logouts at all. Treat it as the leading cause with good evidence behind it, not a closed case — worth re-checking the refresh 400 rate after this ships.
Build and typecheck pass; lint is 3 warnings better than dev.
Fixes #53.
What happened
That annotation is a promise the code can't keep. An error response is still valid JSON, so on a 401
r.json()resolves happily with{ error: 'Unauthorized' }, that object lands in state typedSpace[], and the page dies on the nextspaces.find(...)— the reportedl.find is not a function.The intermediate
data.length > 0had already passed silently, becauseundefined > 0is justfalse. So nothing failed until the render, well away from the actual cause.TypeScript can't catch this. The cast is asserted at a boundary where the real shape is only knowable at runtime.
It wasn't only that page
Eight call sites shared the pattern. Four degrade harmlessly on their own (
data.groups || [],(requests ?? [])), but the rest didn't:sga-spaces/page.tsxsga-spaces-tab.tsx.then(setSpaces)— identical shape, admin viewbooking-settings-tab.tsxsettings-modal.tsxSettings, offered back for savingarchive-tab.tsx.catchat all, sosetLoading(false)was unreachable and the tab hung on its skeleton foreverbooking-settings-tabis the one that worried me most. Every field falls back to a hardcoded default (?? 0,?? 24), so an error body silently populated the form with0 / 0 / 24— and an admin who then pressed Save would have written those over the real booking limits. A crash is loud; that is silent data loss.The fix
getJson/getJsonArraytake the fallback as an argument, so the failure case can't be left out and the return type stays honest.The Spaces page handles its response directly instead, because there an empty list is a meaningful answer — "no rooms configured" — and quietly substituting one for a failed request would state something untrue. It now shows a short error with a Try again button, which is the same remedy the reporter found by reloading.
Verified
Ran the real helper against the exact failure (Node 24 strips types, so this exercises
lib/fetch-json.tsitself, not a copy):Build passes, typecheck clean, and lint comes out 3 warnings better than
dev(14 errors / 99 warnings vs 14 / 102) — the removed empty.catch(() => {})blocks.Not fixed here
Why
/api/spacesreturns a 401 in the first place. I checked whether it was a regression from the recent auth work and it isn't: both endpoints in the report use the plaingetAuthedUserpath that wasn't touched, no user is missing a profile row, no sessions are revoked, and no users are inactive. Workbox'sNetworkFirstonly caches 200s, so the service worker isn't replaying it either.That leaves a transient auth failure — most likely an expired access token racing the cookie refresh, which fits "a reload fixed it". Worth chasing separately; what this PR guarantees is that it can no longer take a page down. I'd suggest keeping #53 open, or opening a follow-up, to track the 401 itself.