Severity: medium
Summary
Action handlers have built-in panic recovery (action.go:197, routed through via.WithActionErrorHandler). View renders do not — a panic in viewFn at render.go:63 propagates to the embedding http.Server, which logs a stack trace to stderr and returns a bare 500 Internal Server Error body. The user only gets structured logging + a controlled response if they remember to wire mw.Recover (or mw.Defaults).
Where
render.go:62-65 — synchronous viewFn call with no recover.
- Compare
runtime.go:244 (defer recoverLog(ctx, "OnDispose")) and the OnInit guard at render.go:46-52, which already do this for the lifecycle callbacks. View is the odd one out.
Why it matters
The asymmetry is the surprise: identical-looking programmer mistakes get wildly different treatment depending on which callback they sit in.
- A panic inside
Inc(ctx) → recovered, logged through app.Logger() with structured kv, and the user-configured ActionErrorHandler decides what the client sees (toast by default).
- The same panic inside
View(ctx) (e.g. on.Click(nil), an on.Click(closure), a nil-deref in a state read) → falls off the framework, no via log line, naked stderr stack on the server, naked "500 Internal Server Error" on the wire.
Registration-time mistakes (the on.* panics that #36 just split into nil/closure/top-level/non-func) almost always surface at View time, not action time — so the newly-precise messages are most useful exactly where the framework doesn't structure them today. The fix to #36 makes the panic text better; this issue is about making sure the user actually sees it.
Suggested fix
Wrap the viewFn call in render.go:62-65 with a recover that:
- Logs via
a.Logger() at LogError with the route + recovered value (mirroring mw.Recover at mw/mw.go:106-119).
- Writes a 500 response so the surrounding
http.Server doesn't have to.
- Optionally routes through the existing
ActionErrorHandler hook (or a sibling ViewErrorHandler) so apps that want a custom error UI can opt into one.
This is a few lines in core (no mw dependency — app.Logger() is right there) and matches the pattern already used for OnInit / OnDispose / OnConnect.
mw.Recover stays — it's still the right backstop for non-via handlers wired through HandleFunc, plugin endpoints, custom middleware. mw.Defaults becomes additive (log format, request-id stamping) rather than load-bearing for basic safety.
SSE event-loop callbacks deserve the same treatment if they don't already have it — worth auditing the broadcast path while we're in there.
Open question
Should this be unconditional, or gated by a via.WithBuiltinRecover(false) for users who genuinely want raw panic propagation (e.g. in tests, or to defer to a custom outer middleware)? I'd lean unconditional — mw.Recover already exists for users who want a different shape — but the option keeps the door open.
Severity: medium
Summary
Action handlers have built-in panic recovery (
action.go:197, routed throughvia.WithActionErrorHandler). View renders do not — a panic inviewFnatrender.go:63propagates to the embeddinghttp.Server, which logs a stack trace to stderr and returns a bare500 Internal Server Errorbody. The user only gets structured logging + a controlled response if they remember to wiremw.Recover(ormw.Defaults).Where
render.go:62-65— synchronousviewFncall with no recover.runtime.go:244(defer recoverLog(ctx, "OnDispose")) and theOnInitguard atrender.go:46-52, which already do this for the lifecycle callbacks. View is the odd one out.Why it matters
The asymmetry is the surprise: identical-looking programmer mistakes get wildly different treatment depending on which callback they sit in.
Inc(ctx)→ recovered, logged throughapp.Logger()with structured kv, and the user-configuredActionErrorHandlerdecides what the client sees (toast by default).View(ctx)(e.g.on.Click(nil), anon.Click(closure), a nil-deref in a state read) → falls off the framework, no via log line, naked stderr stack on the server, naked "500 Internal Server Error" on the wire.Registration-time mistakes (the
on.*panics that #36 just split into nil/closure/top-level/non-func) almost always surface at View time, not action time — so the newly-precise messages are most useful exactly where the framework doesn't structure them today. The fix to #36 makes the panic text better; this issue is about making sure the user actually sees it.Suggested fix
Wrap the
viewFncall inrender.go:62-65with a recover that:a.Logger()atLogErrorwith the route + recovered value (mirroringmw.Recoveratmw/mw.go:106-119).http.Serverdoesn't have to.ActionErrorHandlerhook (or a siblingViewErrorHandler) so apps that want a custom error UI can opt into one.This is a few lines in core (no
mwdependency —app.Logger()is right there) and matches the pattern already used for OnInit / OnDispose / OnConnect.mw.Recoverstays — it's still the right backstop for non-via handlers wired throughHandleFunc, plugin endpoints, custom middleware.mw.Defaultsbecomes additive (log format, request-id stamping) rather than load-bearing for basic safety.SSE event-loop callbacks deserve the same treatment if they don't already have it — worth auditing the broadcast path while we're in there.
Open question
Should this be unconditional, or gated by a
via.WithBuiltinRecover(false)for users who genuinely want raw panic propagation (e.g. in tests, or to defer to a custom outer middleware)? I'd lean unconditional —mw.Recoveralready exists for users who want a different shape — but the option keeps the door open.