Skip to content

Let the app admit when something goes wrong - #45

Merged
DanialBeg merged 1 commit into
mainfrom
fix/surface-failures
Sep 21, 2026
Merged

DanialBeg merged 1 commit into
mainfrom
fix/surface-failures

Conversation

@DanialBeg

Copy link
Copy Markdown
Member

Items 9, 11 and 13 from PRODUCTION-TODO.md, which are all the same shape: the app failed quietly and the student was left to guess what happened.

#9 — nine of the thirteen swallows should stay

The audit counted thirteen catch(() => {}) sites. They are not one problem, and wrapping them all in toasts would make the app noisier without making it clearer:

  • Seven are markIntroSeen. If that write fails a tour replays. Telling a student their tour-seen flag did not save is noise about nothing they can act on.
  • One is a hover prefetch in CollegeDiscoverTab — the real fetch happens on click.
  • One is moduleProgress's write chain, which deliberately keeps the chain alive after a rejection and hands the error to its caller.

Those stay silent. Four were real:

useModuleChecklist swallowed both of its failure paths. A failed load rendered the checklist empty, which reads as "none of my work saved" and invites redoing it. A failed write rolled the tick back with no reason given, so the box simply refused to stay ticked.

Financial Aid swallowed three loads — tracked scholarships, the college list, NPC runs. Each failing alone leaves a different part of the module quietly wrong: a tracked scholarship showing "+ Track", a saved college missing from the aid comparison, a cost estimate gone. They share one message, because three toasts for one dropped connection is noise again.

Not changed: useModuleData and useModuleValue already expose a loadFailed flag, and saveData already returns false on failure. Those are contracts, not swallows.

#11 — the profile editor

It reopened on a failed save and said nothing, which reads as the app rejecting what was typed rather than failing to store it. It now says which.

#13 — error tracking, through PostHog

Deliberately minimal about what leaves the browser:

person_profiles: 'never'      // and no identify() anywhere
autocapture: false
capture_pageview: false
capture_pageleave: false
disable_session_recording: true

This app holds a minor's zip code, school, GPA, household income, race, religion and immigration status. The cheapest way to guarantee none of that reaches a third-party error tool is to never send anything that could be joined back to a person. An error needs to say a crash happened and where — not who.

It also registers window.onerror and unhandledrejection handlers. Nothing was listening for either, so those failures were invisible even to the console-only module boundary, which only sees React render errors.

Without VITE_POSTHOG_KEY the module is inert: init does nothing and reportError falls back to the console, so development and any unconfigured deploy behave exactly as today.

A bug this turned up

Putting toast in the load effects' dependency arrays ties a data fetch to a context object's identity. It is stable today only because ToastProvider memoises its value — which is not a promise these hooks should be relying on. Had that memo ever gone, every module would refetch on every render and clobber its own optimistic writes.

The toast is held in a ref and the effects depend on what they actually care about: whether the module is open, and which module it is.

A failing test is what exposed it, and the same failure showed that my first rollback test had been passing vacuously — available was both the starting value and the rolled-back one. The success path is asserted separately now, so the rollback assertion means something.

Testing

377 tests, 27 files. New: the checklist's load and write failure paths including the quiet cases, and the error tracker — that it stays inert without a key, that it never builds a person profile, that it catches unhandled rejections, that a non-Error rejection reason is wrapped rather than dropped, and that the tracker failing does not become a second error on the way out.

tsc -b, eslint ., vitest run and npm run build clean locally.

Needs from you

A PostHog project key as VITE_POSTHOG_KEY in the host's environment, and VITE_POSTHOG_HOST if you are not on US cloud. .env.example documents both. Until the key is set this ships dark and nothing is reported.

Adds one dependency: posthog-js.

Three findings from PRODUCTION-TODO, all the same shape: the app failed
quietly and the student was left to guess.

Thirteen catches swallowed errors, but they are not one problem. Seven are
markIntroSeen — if that fails a tour replays, and telling a student their
tour-seen flag did not save is noise. One is a hover prefetch whose real
fetch happens on click. One is moduleProgress's write chain, which
deliberately keeps the chain alive and hands the error to its caller. Those
nine stay silent.

Four were real. useModuleChecklist swallowed both its load and its write: a
failed load rendered the checklist empty, which reads as "none of my work
saved" and invites redoing it, and a failed tick rolled back with no reason
given, so the box simply refused to stay ticked. Financial Aid swallowed
three loads — tracked scholarships, the college list, NPC runs — each of
which leaves a different part of the module quietly wrong. They share one
message, since three toasts for one dropped connection is noise.

The profile editor reopened on a failed save and said nothing, which reads as
the app rejecting what was typed rather than failing to store it.

Error tracking goes through PostHog, and deliberately sends as little as it
can: person_profiles 'never', no identify, no autocapture, no pageviews, no
session recording. This app holds a minor's zip code, school, GPA, household
income, race, religion and immigration status, and the cheapest way to keep
that out of a third-party tool is to never send anything joinable to a
person. An error says a crash happened and where, not who. Without
VITE_POSTHOG_KEY nothing leaves the browser and errors go to the console
exactly as before.

It also listens for window errors and unhandled rejections, which nothing
listened for at all — those were invisible even in the console's eyes, since
no boundary saw them.

One thing surfaced while testing: putting `toast` in the load effects'
dependencies ties a data fetch to a context object's identity. It is stable
only because ToastProvider memoises its value, which is not a promise these
hooks should rely on — if that memo ever went, every module would refetch on
every render and clobber its own optimistic writes. The toast is held in a
ref and the effects depend on what they actually care about.
@vercel

vercel Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
webapp Ready Ready Preview Sep 21, 2026 1:47am UTC

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying timeline-prototype with  Cloudflare Pages  Cloudflare Pages

Latest commit: f885e11
Status: ✅  Deploy successful!
Preview URL: https://37ba2c50.timeline-prototype.pages.dev
Branch Preview URL: https://fix-surface-failures.timeline-prototype.pages.dev

View logs

@DanialBeg
DanialBeg merged commit f002cae into main Sep 21, 2026
5 checks passed
@DanialBeg
DanialBeg deleted the fix/surface-failures branch September 21, 2026 02:19

This branch was successfully deployed

1 active deployment
Preview — f885e11f Deployed Sep 21, 2026 by vercel[bot]
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