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 —
liveon raw writes. A customapi/*.htmlendpoint’s hand-writtendb.query("INSERT …")didn’t pinglivetabs — only the auto-CRUD route did. The endpointdbhandle now fans out tolive+ 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 unscopedSELECT, 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/usersroute 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:
- 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. - 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. - Escape hatches are doors, not ejection seats — and they’re
deliberately unintercepted. The raw
dbhandle, custom endpoints,spark-ignore: these give you full control precisely by not adding the framework’s automations. - Components declare their own prop defaults with
export let name = …— exactly like the README shows. - 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.
navigateisonclicksugar, not a router. It smooths same-path query-string links; it is not a programmaticnavigate(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.