Skip to content

fix: stop an error response from crashing the page (#53) - #54

Merged
pataniaeli merged 2 commits into
devfrom
fix/issue-53-api-error-crash
Aug 28, 2026
Merged

fix: stop an error response from crashing the page (#53)#54
pataniaeli merged 2 commits into
devfrom
fix/issue-53-api-error-crash

Conversation

@pataniaeli

Copy link
Copy Markdown
Collaborator

Fixes #53.

What happened

fetch('/api/spaces')
  .then(r => r.json())
  .then((data: Space[]) => setSpaces(data))

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 typed Space[], and the page dies on the next spaces.find(...) — the reported l.find is not a function.

The intermediate data.length > 0 had already passed silently, because undefined > 0 is just false. 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:

site what happened on a non-200
sga-spaces/page.tsx the reported crash
sga-spaces-tab.tsx .then(setSpaces) — identical shape, admin view
booking-settings-tab.tsx worse than a crash — see below
settings-modal.tsx error body cast to Settings, offered back for saving
archive-tab.tsx no .catch at all, so setLoading(false) was unreachable and the tab hung on its skeleton forever

booking-settings-tab is the one that worried me most. Every field falls back to a hardcoded default (?? 0, ?? 24), so an error body silently populated the form with 0 / 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 / getJsonArray take 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.ts itself, not a copy):

PASS  401 -> array fallback, not the error object
PASS  401 -> object fallback
PASS  200 list passes through
PASS  200 non-array -> []
PASS  500 -> fallback
PASS  network reject -> fallback
PASS  non-JSON body -> fallback
PASS  spaces.find() is callable after a 401   ← the crash itself

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/spaces returns 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 plain getAuthedUser path that wasn't touched, no user is missing a profile row, no sessions are revoked, and no users are inactive. Workbox's NetworkFirst only 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.

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>
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chambers Ready Ready Preview Aug 28, 2026 9:40pm

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>
@pataniaeli

Copy link
Copy Markdown
Collaborator Author

Added: sign out this device only (ec9ccc6)

signOut() defaults to scope 'global' — confirmed in auth-js:

async _signOut({ scope } = { scope: 'global' })

That 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 worked a moment ago starts returning Unauthorized.

Supabase logs over 24h support it: of 28 refresh_token grants, 9 returned 400 (5 browser, 4 Edge middleware), alongside 28 logouts across ~27 users. And only 3 of 24 refresh tokens have ever had a parent, so rotation is barely occurring — meaning a 400 is a revoked token rather than a rotation race.

The split

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.

@pataniaeli pataniaeli self-assigned this Aug 28, 2026
@pataniaeli pataniaeli linked an issue Aug 28, 2026 that may be closed by this pull request
@pataniaeli
pataniaeli merged commit 69c6cd3 into dev Aug 28, 2026
4 checks passed
@pataniaeli
pataniaeli deleted the fix/issue-53-api-error-crash branch August 28, 2026 21:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API endpoint error

1 participant