For part of the morning of 5 September, anyone signed in to app.tourdash.io saw a cream rectangle and nothing else. The site was up: the HTML, the stylesheet and the bundle all served in a few milliseconds, and a visitor who was signed out got the sign-in page. What broke was the first render for a signed-in account, and it broke in a way that left no message on the screen. This report says what happened, why the checks did not catch it, and what is different now.
The notifications work that morning made the bell honest: the dot now shows only when something is unread. To know that, the header started reading the notifications hook, the same hook the popover under the bell already read. Each copy of the hook opened a live channel to the notifications table under the same name.
The Supabase client does not open a second channel for a duplicate name. It hands back the one that already exists, and a channel that is already connecting refuses to take on new listeners. It refuses by throwing. The second copy of the hook threw inside its effect, nothing above it was built to catch that, and React did what it does when nothing catches a throw: it unmounted the whole tree. What was left was the page's background colour.
The exact error, reproduced from a script against the live project:
cannot add `postgres_changes` callbacks for realtime:notifications-<user id> after `subscribe()`.
Every screenshot that morning came from /demo, which is built to make zero requests to
Supabase. That isolation is a feature: it lets the demo run without an account and without touching
real data. It also means the demo never opens a live channel, never resolves a session, and never
runs any code that sits behind the demo switch. The double subscription only exists on a real
account, so the demo looked perfect while the live app was blank.
The listings hook had hit this same trap months earlier and fixed it locally with a shared, counted channel. The fix was right and it stayed in one file, so the notifications hook did not inherit it.
| Change | What it does |
|---|---|
| One place for live channels | Every table subscription now goes through a single registry. The first caller opens the channel, later callers attach a listener, and the channel closes when the last listener leaves. Two components asking for the same table is the normal case, not a crash. The listings hook uses it too. |
| A crash screen | An error boundary now sits above the router and every provider. If anything throws during a render, the person sees a sentence, the error's own words and a Reload button, on the app's own colours. A blank page from here on means the script never ran at all. |
| A regression test | The registry is bundled on its own against the real client and subscribed twice to one topic. It must not throw, must hold one channel, and must close it when both listeners leave. Six checks, all passing before the deploy. |
The demo stays the place to check layout, copy and theme. It is no longer treated as proof for anything that touches data hooks, the session, the header or the sidebar. Those changes get loaded signed in against the live backend, with the console read, before they ship. After every deploy, the root of app.tourdash.io is loaded headless and must redirect to sign-in with a clean console, which catches a broken boot even without an account.
One thing is still needed for that to be automatic: a test account for signed-in checks. Creating one through the app's own sign-up is blocked for the assistant, so the account and its password have to come from the owner and live outside the repository.