Skip to content

fix: three production bugs the fixture work surfaced - #2820

Open
kushagrasarathe wants to merge 4 commits into
feat/design-systemfrom
qa/prod-bugs
Open

fix: three production bugs the fixture work surfaced#2820
kushagrasarathe wants to merge 4 commits into
feat/design-systemfrom
qa/prod-bugs

Conversation

@kushagrasarathe

Copy link
Copy Markdown
Contributor

Summary

Three production bugs, found while building the fixture harness in #2819. None was caught by a test, by Sentry, or by review — each surfaced from looking at a real screen or a real log.

Kept apart from #2819 on purpose: that PR is tooling with no production behaviour, this one is ~130 lines that change what users see. If one of these is wrong during launch week, reverting it should not mean reverting a harness.

Base: feat/design-system (PR #2813).

1. Internal dev tooling answered 200 on peanut.me

The intent to block /dev on production has been in the tree since February (6830d7f3e). It was written in the wrong place, so it never worked.

(mobile-ui)/dev/layout.tsx called notFound() for everything outside a small allowlist. Inside a route group that renders the not-found UI and still answers 200, so peanut.me served the page and its chunks to anyone who asked.

Worse, the pages under src/app/dev/ sit outside that group and had no gate at all. /dev/kyc-flows rendered internal KYC flow diagrams to anonymous visitors on production.

The check now runs in the proxy, which sees both route groups, returns a real status, and covers routes nobody has written yet. Measured on a production build: 17 dev routes went 200 → 404; full-graph, payment-graph and safe-area stayed 200. The allowlist is the one the layout already had — nothing added, nothing removed.

The host decides, not the build. Blocking on the dev-and-preview flag alone would have taken /dev off staging, which 6830d7f3e enabled deliberately. peanut.me is the only host that blocks, and a build with no base URL reads as peanut.me, so the gate fails closed. The layout keeps its check because the native build is a static export with no proxy.

A test walks src/app for every page.tsx, so a dev page added tomorrow is covered without anyone remembering.

2. A backend outage sent signed-in users to the signup flow

(mobile-ui)/layout.tsx renders BackendErrorScreen when the user fetch fails. An effect earlier in the same file matched the same state with no error guard, called router.replace('/setup'), and armed a 3-second hard-nav fallback behind it.

Instrumented first paints, before:

+3128ms   error screen paints
+7054ms   → /setup        ← the fallback fires

During an outage that reads as "you have been logged out", which is the worst thing to tell someone whose money is behind the login.

"No user" has two causes and the data already told them apart: the user query returns null for a 401 and throws for a 5xx. One condition. After: the screen holds past 11 seconds and the URL never moves. A logged-out visitor still reaches /setup on identical timing.

Jest could not import the layout at all — no CSS mapper — so this had never been testable. One line added; jest-transform-stub was already a dependency.

3. A failed points fetch spun forever

/rewards sat on the mascot loader with no way out when GET /points failed. The error branch below the loader was unreachable for that path.

pointsApi.getTierInfo catches its own failure and resolves with data: null, so react-query never reports an error and never retries. The loading guard read !tierInfo?.data, which stays true forever once the call has failed. The invites call throws properly, which is why the error branch looked live in review.

The guards now split three states instead of two. A disabled query reports isLoading false while both queries wait on user, so isLoading cannot tell "not started" from "settled and empty". isPending can.

The screen was only correct before because a parent covers for it, using a different predicate than the queries use. It is now correct on its own terms.

Verified

  • typecheck clean · npx jest 304 suites, 3639 tests · prettier clean · ds-lint --check no metric increased
  • Each fix has one focused spec, and each spec was proven to fail against the unfixed code before it passed — including against the original buggy version, not only against a strawman
  • Production builds served and probed for 1 and 2; first-paint instrumentation for 2, because a screenshot after settle cannot see a redirect that fires at 7 seconds

Found and deliberately not fixed

  • Points failures never reach Sentry. History's error branch calls captureException; rewards only console.errors, and getTierInfo swallows before even that. This is why bug 3 survived
  • A failed invites call still blanks the whole rewards screen instead of dropping one section
  • isRedirecting.current never resets, so a later legitimate redirect in the same mount is suppressed
  • The /dev chunks still reach production bundles. The proxy blocks the routes, not the JavaScript — that needs a build rule over every /dev route

The intent to block internal tooling on production has been in the tree since
February. It was written in the wrong place, so it never worked.

`(mobile-ui)/dev/layout.tsx` called notFound() for every route outside a small
allowlist. Inside a route group that renders the not-found UI and still
answers 200, so peanut.me served the page and its chunks to anyone who asked.
The pages under `src/app/dev/` sat outside that group and had no gate at all:
`/dev/kyc-flows` rendered internal KYC flow diagrams to anonymous visitors.

The check now runs in the proxy, which sees both route groups, returns a real
status, and covers routes nobody has written yet. Measured on a production
build: 17 dev routes went 200 to 404, and full-graph, payment-graph and
safe-area stayed 200. The allowlist is the one the layout already had —
nothing added, nothing removed.

The host, not the build, decides. Blocking on the dev-and-preview flag alone
would have taken /dev off staging, which 6830d7f turned on deliberately.
peanut.me is the only host that blocks, and a build with no base URL reads as
peanut.me, so the gate fails closed.

The layout keeps its check. The native build is a static export with no
proxy, so there it is the only gate; both now read the same predicate.

The test walks src/app for every page.tsx, so a dev page added tomorrow is
covered without anyone remembering to add it.

Still open, out of scope here: the /dev chunks stay in the production bundle.
The proxy blocks the routes, not the JavaScript.
…sers to signup

When the user fetch failed, the error screen painted and then the app threw
the person at /setup — the signup flow. During an outage that reads as "you
have been logged out", which is the worst thing to tell someone whose money
is behind the login.

The redirect effect matched on "no user" and stopped there. No user has two
causes, and the data already told them apart: the user query returns null for
a 401 and throws for a 5xx or a network failure. So an error means the backend
is down, not that the person is signed out. Reading the two apart is one
condition.

The 3 second hard-nav fallback made it worse. The soft redirect ran, the error
screen rendered, and three seconds later the fallback replaced the location
anyway. React clears that timer when the dependency changes; the spec asserts
no timer survives.

Measured before: error screen at 3128ms, gone to /setup at 7054ms. After: the
screen holds past 11s, the URL never moves. A logged-out visitor still reaches
/setup on the same timing as before, and a healthy load is unchanged.

Jest could not import the layout at all, because it pulls in globals.css and
there was no css mapper. Added one line; jest-transform-stub was already a
dependency.

Left alone: `isRedirecting.current` never resets, so a later legitimate
redirect in the same mount is suppressed. Pre-existing, not reachable from
this path.
…orever

`/rewards` waited on the mascot loader with no way out when `GET /points`
failed. The error branch below the loader was unreachable for that path.

Two mistakes met. `pointsApi.getTierInfo` catches its own failure and resolves
with `data: null`, so react-query never reports an error and never retries.
The loading guard then read `!tierInfo?.data`, which stays true forever once
the call has failed. The invites call throws properly, which is why the error
branch looked live in review.

The guards now split three states instead of two. A disabled query reports
`isLoading` false while both queries wait on `user`, so `isLoading` cannot
tell "not started" from "settled and empty". `isPending` can, and past that
guard the request has settled, so missing data means it failed.

The screen was only correct before because a parent covers for it: the
(mobile-ui) layout holds every protected route on its own loader until `user`
is truthy, using a different predicate than the queries use. The component is
now correct on its own terms, and the spec pins it.

The spec keys its EmptyState stub on the title. That component is shared — the
page uses it for this failure and, further down, for "no invites yet" — so a
bare test id matches both and the success case reads as a failure.

Left alone, both worth their own change: a failed invites call still blanks the
whole screen instead of dropping one section, and points failures never reach
Sentry, which is why this survived.
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 26, 2026 11:35am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9d6b6e1d-2aa7-41ce-b655-11ad26a86ef1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

The dev-route lockdown missed src/app/dev/* on the production native
build — a static export where proxy.ts never runs, so kyc-flows and
loading-words answered in the app. Both route groups now share one
gate: shouldBlockDevRoute keeps the flag and the predicate together so
a future call site cannot use half of it. Also from review: the gate
test now routes ALLOWED pages through the proxy instead of assuming
they answer; the rewards error branch logs the settled response
instead of a contentless null; the routes.ts comment describes the
mechanism this PR actually shipped.
@kushagrasarathe

Copy link
Copy Markdown
Contributor Author

/code-review pass — 2026-08-26

Fixed in 213588f (typecheck + 3,639 unit tests green):

  • The one finding that mattered: the dev-route lockdown missed src/app/dev/* on the production NATIVE build — a static export where proxy.ts never runs, so /dev/kyc-flows and /dev/loading-words answered in the app. Both route groups now share one gate via shouldBlockDevRoute(), which keeps the flag and predicate together so a future call site cannot use half of it.
  • The gate test now routes ALLOWED pages through the proxy too — before, a change that over-blocked /dev/full-graph on prod would have passed the suite.
  • The rewards error branch logged a contentless null in exactly the swallowed-error path it was built for; it now logs the settled response.
  • routes.ts comment described the pre-PR mechanism; updated.

Declined with reason: DEV_ROUTES_ENABLED inlines its own prod-domain literal (a 5th copy in the repo) because general.consts pulls viem into the edge bundle — consolidating means moving BASE_URL out of general.consts, out of scope here. The review's encoded-path bypass note (%64ev) rests on Next normalizing before matching, which current releases do; an integration probe on a prod build would pin it, also out of scope.

The three original fixes themselves verified clean — no regression found in their happy/error paths.

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.

1 participant