← All posts
Release

spark-ssr 1.3.2 — 21 “bugs” from a real app, and what they actually were

We ported a real, non-trivial app to spark-ssr — a rotating-savings (“san”) app with auth, uploads, cross-user notifications, live badges, a draw animation, infinite scroll, the works — and kept a running list of everything that looked like a framework bug. Twenty-one entries.

Then we audited each one against the actual source, not against what we expected to happen. The result is the honest, slightly humbling breakdown below: three were real spark-ssr bugs (now fixed), a couple were spark-html core issues, and the large majority were the framework working exactly as designed — misread because we brought habits from frameworks that work differently.

We’re publishing the whole list on purpose. If something in spark-ssr surprises you, there’s a good chance it’s one of these — with a documented reason and a one-line “right way.” Read this before you file it.

What actually shipped

Three genuine spark-ssr defects, fixed and released:

  • 1.3.1 — live on raw writes. A custom api/*.html endpoint’s hand-written db.query("INSERT …") didn’t ping live tabs — only the auto-CRUD route did. The endpoint db handle now fans out to live + cache invalidation, coalesced per request.
  • 1.3.2 — auth table as an SSR source scopes to the caller (security). table="users" used as a page data source ran an unscoped SELECT, so a self-service “edit my profile” page that iterated it rendered every account. It now scopes to the signed-in user (WHERE id = ?) for non-admins — matching what the /api/users route already did.
  • 1.3.2 — dev-only false alarm. The “missing data source” banner flagged a JS-global call in a loop (each="n in Array(k).fill(0)") as an unresolved source. It now ignores built-in globals.

Two more were real, but in spark-html core, not spark-ssr: a phone-camera photo losing its EXIF orientation on upload (fixed upstream), and a boolean attribute bound to a SQLite 0 rendering disabled="0" (still on the core list — coerce with :disabled="!!col" for now). That’s it for defects. Everything below is not a bug.

The mental model that explains the rest

Almost every false alarm traces back to one of six design facts. Hold these and spark-ssr stops surprising you:

  1. The template is the schema and the API. table="X" means the X table as a source. Want a subset or a join? Write the SQL. The framework infers; it doesn’t second-guess what you wrote.
  2. A page is either interactive or not, and it matters. A page with handlers/binds hydrates: its <script> becomes the client component and never runs on the server. A page without them keeps its <script> as a server-only script. Same tag, opposite runtime.
  3. Escape hatches are doors, not ejection seats — and they’re deliberately unintercepted. The raw db handle, custom endpoints, spark-ignore: these give you full control precisely by not adding the framework’s automations.
  4. Components declare their own prop defaults with export let name = … — exactly like the README shows.
  5. The script layer is a string scanner, not a JS parser (by design, for size and speed). It reads structure from how you write code; it doesn’t fully parse it.
  6. navigate is onclick sugar, not a router. It smooths same-path query-string links; it is not a programmatic navigate(path).

“Bugs” that were the documented pattern

Unpassed component props blanked an expression. A component read {big ? 'a' : 'b'} but the call site passed no big=, and it threw on hydration. The fix is the framework’s own documented one: a component declares its defaults — <script>export let big = false;</script>. A prop you read is a prop you default.

A shared layout’s session broke on one page. Making a page interactive hydrates the layout with it, and a hydrating client component doesn’t receive the ambient server-side {session}. That’s the documented hydration boundary — read the user from a declared data source (me.id), and delegate any layout event from document, not a cached node.

redirect=/flash= “stopped working” on an interactive page. Those are the no-JS form mechanism. On a page that hydrates, the client intercepts the submit — so you drive the outcome from your own fetch handler instead. Both are documented; they’re just different layers (no-JS vs. hydrated).

A plain <form> was hijacked on an interactive page. Same root: an interactive page owns its submits. Use an explicit fetch() handler with your own reactive state. Not a bug — the two form models (no-JS redirect vs. hydrated fetch) are a choice you make per page.

onclick={() => fn(arg)} “broke.” This is already the correct spark pattern for passing an argument; the breakage was plain HTML quoting, not the framework. onclick={fn(arg)} also works — spark wraps it into a handler for you.

Deliberate boundaries, not defects

A nested const in a server-only script threw a ReferenceError. The top-level-name scanner is line-anchored (string scanner, not a parser), so a const at the start of a line inside a callback gets misread. Keep returned page-script vars at the top level, or inline the expression. This is the framework-wide “scanner, not parser” tradeoff, present in core too.

A server-only script couldn’t see a sibling <spark-ssr> source by name. Server scripts get exactly req, db, fetch, mail — the same scope-isolation rule the framework applies everywhere (no implicit cross-scope lookups). Re-run the query with await db.query(...) inside the script.

An interactive page’s <script> never ran on the server. Correct, and documented: on a hydrating page it’s the client component. Compute display fields (formatted dates, counts) in the data source, not the page script, so the first paint has them.

spark-ignore also disabled server-side interpolation. Yes — it’s symmetric by design, so a code sample renders identically on the server and the client. That’s the point.

An empty <input type="file"> still created a zero-byte upload. The raw upload path doesn’t apply per-field heuristics about “empty means skip” — guard it in your handler. A deliberate “raw and predictable” boundary.

Auto-CRUD didn’t coerce a form field to the column’s type. The generated write takes fields as-is (fast, predictable). Cast in SQL or a custom endpoint when you need a specific type. Inference describes your schema; it doesn’t silently rewrite your values.

navigate('/dashboard') threw after an await. The one that taught us the most. navigate is click-delegation sugar — onclick={navigate} — that intercepts same-path <a href="?q=…"> links and refetches via refresh(). It takes an event, not a path. It works beautifully in a single-route app (see examples/spark-chat, whose nav is all href="/?with=…" on one path), and is a no-op across a multi-route app’s distinct pages by design. For a cross-route redirect, use location.href or a real link; for app-wide client routing, use the spark-html-router companion. It was never a programmatic router.

spark-html core, not spark-ssr

A few surfaced through spark-ssr but live in the core runtime: a bound width/height on a raw <svg> being cleared on patch (size a wrapper via style instead); a bind:value input inside a display:none container losing its value across the toggle; and the boolean-attribute disabled="0" case above. Tracked on the core side — the app-level coercions (!!col, a static-sized wrapper) are the interim answer.

Not the framework at all

Two entries weren’t code issues: a dev server that survived pkill -f "bun spark-ssr" because the real process name differs (a shell thing), and the lack of a built-in HTTPS dev flag for testing camera/mic from a phone (a missing convenience, worked around with a local proxy). Neither is a defect to patch in the framework.

How to tell a real bug from a misunderstanding

The pattern in almost every false alarm: an expectation imported from a framework that describes your app twice (models + UI, or a client router + routes). spark-ssr reads the single description you already wrote — the template — so the questions to ask first are: Is this page interactive or not? Am I using a raw escape hatch (which is meant to be unintercepted)? Did I write the SQL/prop-default the framework infers from?

A real spark bug looks different: the DOM fails to converge, a documented promise is broken, or it fails silently where it should fail loud. Those we want — minimized repro, and they become permanent tests (that’s how #18, #7 and #13 got here). The rest of this list is the map for everything else.

Upgrading

1.3.2 is a bug-fix release. #7 changes one behavior deliberately — the auth/identity table, when read as a raw table= source, is now scoped to the caller for non-admins; if you truly want to list all accounts, write an explicit query. Otherwise it’s a clean bump:

bun add spark-ssr@^1.3.2

Details: spark-ssr on npm · ssr-v1.3.2 on GitHub.