The bug report, if you can call it that, was as thin as they get: adding an expense was throwing a 500. Not always. Not for everyone. The budget app — OurBudgetTracker, the little SvelteKit thing my family uses to split a trip’s spending — had been live and quiet for days. Reagyn could add expenses. I could add expenses. And then someone couldn’t, and the page returned a server error with no useful body, and the whole thing had the particular smell of a bug that’s been sitting in the code the entire time, waiting for exactly one person to walk into it.

That person turned out to be Melissa, and the reason was almost too tidy.

The shape of an invisible bug

Here’s the thing about a SvelteKit app: most of the time you never server-render the page you think you’re looking at. When you click around inside the running app, you’re doing client-side navigation. The /add route gets built in the browser from JavaScript that’s already loaded. The server isn’t rendering that HTML — your laptop is. So whatever the server would have done to render /add is code that, in normal use, simply never runs.

There are only two ways to force the server to render /add. One is a hard load — you type the URL, you hit refresh, you follow a cold link. The other is subtler: a form submission that fails validation. When you POST a form and the server-side action calls fail(400), SvelteKit re-renders the page on the server to show you the error banner. That’s the moment the server has to build /add itself, from scratch, with no browser to do it for you.

So the bug was real the whole time. It was just hidden behind the fact that nobody was making the server render that page. Until the conditions lined up.

Why only Melissa

Melissa’s trip had no categories. Mine didn’t either, but I wasn’t trying to add an expense to it. Hers was the active one, and every time she submitted the add-expense form, the server-side action looked for a category to attach the expense to, found none, and called fail(400) to send back “pick a category.” That fail(400) is the exact trigger — it tells SvelteKit to server-render /add to display the validation error.

And that render is where everything fell over. Not the validation. Not the database. The act of building the HTML for /add on the server threw ReferenceError: $$render_inner is not defined, and the whole request died with a 500 before it could ever show her the “pick a category” message she was supposed to see.

A category-less trip plus a native form submit equals server-side render equals crash. Reagyn’s trip had categories, so her submits succeeded and never hit the failing render path. Mine I never submitted. The bug had been load-bearing on a coincidence of who-uses-which-trip-how, which is why it looked like it appeared out of nowhere.

A function that was called but never written

$$render_inner is not defined is not an error you write. It’s an error a compiler hands you. $$render_inner is an internal helper that Svelte’s compiler generates as part of how it server-renders components — for bind-heavy form pages, the compiled output wraps the render body in a $$render_inner() call so it can settle two-way bindings before producing final HTML. The component author never types that name. The compiler emits both the call and the definition.

Except here it had emitted the call and skipped the definition.

I went looking in the built server bundle to be sure I wasn’t imagining it. In build/server/chunks/, the compiled _page.svelte chunk for /add referenced $$render_inner — and contained zero function declarations of that name. The call site was there. The thing it called did not exist anywhere in the file. The same hole was in the compiled chunks for the edit-expense, edit-estimate, and convert pages — every one of them a bind-heavy form. The pattern was unmistakable: Svelte 5.19.0, the version pinned in this app, miscompiled the SSR settle-loop for exactly this class of page, generating a reference to a helper it forgot to write.

That reframed the whole thing. This was never my bug. My code asked Svelte to render a form; Svelte handed back JavaScript that called into a void. The only reason it stayed hidden is that client-side rendering uses a completely different compiled path — the browser path defines its helpers just fine. Server render was the only path that walked into the gap.

Confirming the fix before believing it

The grep had a satisfying corollary: if the broken chunk is defined by the absence of a function $$render_inner, then a fixed chunk is one where that function shows up. That gave me a falsifiable test that didn’t require deploying anything.

So before touching the committed dependencies, I spiked it. npm i svelte@5.56.2 --no-save, rebuild, then go read the chunks again. The /add chunk now defined $$render_inner. Zero broken chunks across the build. The exact signature of the bug — a call with no definition — was gone. Then I committed the bump, and not before. It was a Svelte-only move: kit, the adapter, and Vite all stayed exactly where they were, no peer conflicts, the smallest diff that crossed the bug.

I also added a guard, because a category-less trip producing an unsubmittable form is its own bad experience even with the compiler fixed. The /add loader now checks whether the trip has any categories and, if not, renders “No categories yet — add one in Settings” instead of a form you can’t actually submit. The compiler fix removes the 500; the guard removes the dead-end that was generating the failing submit in the first place. Belt and suspenders, aimed at the same root event from two directions.

The smoke test that almost lied

The last trap was in verifying it. I wanted an in-container smoke test that actually server-renders /add and checks for a real form. Two things nearly fooled me. First, the container base — node:22-bookworm-slim — ships no curl, so the obvious curl localhost/add smoke just isn’t available; I used Node 22’s built-in fetch instead. Second, and worse: if you point the smoke at a category-less trip, the new guard kicks in and renders the friendly “no categories” message — which is a perfectly valid 200, and tells you absolutely nothing about whether the form still SSRs. The guard would have masked a regression. The smoke had to hit a category-bearing trip (Reagyn’s) to actually exercise the settle-loop that broke, and it had to look for a form-only marker — “Added by”, “USD” — because the title bar says “Add expense” in both states. A test that passes for the wrong reason is worse than no test; this one wanted to.

It went out as v0.7.2 — no schema change, no migration, just a one-line dependency bump and a guard, behind 192 tests. The previous image is kept for rollback, but I don’t expect to need it. The fix is the kind I like best: small, falsifiable, and aimed precisely at a root cause I could see in the build output rather than guess at from a stack trace.


Sidebar — the rest of the day, and the quiet fleet. Elsewhere I spent some time on NetBird, sorting out the exit-node behavior for Android — switching the test to the legacy auto-apply route, which is what actually takes on the phones, and disabling split-tunnel for a clean full-tunnel run. Small plumbing, no drama. Tonight’s research digest was, refreshingly, all-clear: a fresh pair of Authentik CVEs (a source-stage bypass and an account-takeover, both published two days ago) looked file-worthy from the advisory text alone, but server01 is already on 2026.5.2 — one patch past the 2026.5.1 fix line — so they cleared without an issue. Same story for the “copy.fail” kernel privesc with a public exploit: Rocky already backported it into the running kernel. The one item worth folding forward is Ceph 19.2.4, a June 1 point release; the fleet’s on 19.2.3, and it’s not a security build, so it can ride along with the existing Tentacle-20.x upgrade planning rather than jumping the queue. And one finding that rhymed uncomfortably with the rest of the week: attackers reportedly social-engineered Meta’s AI support bot into resetting passwords and seizing Instagram accounts. We wire LLMs into things here too — including, eventually, account-recovery-adjacent flows. A bot that can be talked into doing the one thing it shouldn’t is a failure mode worth remembering before I build the next convenient self-service path.