Migrating from v0.7
v0.8 is a rebuild. The module never left v0.x, so go get -u moves v0.7 to v0.8 as an ordinary bump and nothing warns you. Most v0.7 code does not port line by line, because the ideas changed, not only the spellings. v0.7 is preserved on the v1 branch.
On this page
#Short version
- Replace
h.Textwithh.Str. - Delete the
View(ctx)parameter and load what the view reads inOnInit. StateTabbecomesState: drop.Op(ctx), andReadandWritebecomeGetandSet.via.New()andvia.Mount[Page](app, …)becomevia.NewRouter()andvia.Mount(r, …, Page{}).- Move
path:"…"tags toctx.ParaminOnInit. Nothing flags a leftover tag. - Keep package
on:on.Click(p.Inc)reads the same.
The compiler finds most of the rest. The changes it cannot see are listed after the mapping.
#The four shifts
#State is bare; ctx is for the request
Mutators take no ctx: c.N.Set(c.N.Get() + 1). ctx is the request, used for sessions, path params and subscriptions. via.State[T] is per tab and rendering one makes its unit live, so the page streams. For a value every visitor shares, inject your own store and let the re-render read it.
#View is pure and takes no context
Anything a view needs is a field before View runs. OnInit(*via.Ctx) error loads it, on every request. v0.7 logged an OnInit error and rendered anyway; v0.8 stops: via.ErrNotFound answers 404, via.ErrForbidden 403, any other error 500. The hooks are duck-typed, so via panics on a hook name with the wrong signature and warns on a near-miss name that has the right one.
#Composition is via.Child, and roots are taken by value
v0.7 rendered a child by calling its View by hand, p.A.View(ctx, …), passing whatever it needed. In v0.8 a child is a plain struct field rendered with via.Child(p.A), with its own OnInit; what used to be arguments are fields. via.Handler and via.Mount take the root by value, Counter{…} and not &Counter{…}. A live child may sit under plain ancestors; a live child inside another live one panics at render.
#Fan-out is scoped to a topic
app.Broadcast and its siblings are gone. Publish on a topic.Topic[T] from anywhere and subscribe with ctx.Listen in OnInit; the subscription ends with the child. Nothing can push to a page that did not ask.
#The counter, both ways
v0.7, with per-tab state and the numeric shape:
type Counter struct{ N via.StateTabNum[int] }
func (c *Counter) Inc(ctx *via.Ctx) { c.N.Op(ctx).Inc() }
func (c *Counter) Dec(ctx *via.Ctx) { c.N.Op(ctx).Dec() }
func (c *Counter) View(ctx *via.CtxR) h.H {
return h.Div(h.Class("row"),
h.Button(on.Click(c.Dec), h.Text("−")),
h.Span(c.N.Text(ctx)),
h.Button(on.Click(c.Inc), h.Text("+")),
)
}
func main() {
app := via.New()
via.Mount[Counter](app, "/")
app.Start()
}v0.8, the same counter as a State[int], the source of the Counter card on Actions:
package demos
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/go-via/via/on"
)
// Counter is the smallest live unit: rendering a State is what earns this
// child its own SSE stream, and a click patches only this region.
type Counter struct{ N via.State[int] }
func (c *Counter) Inc(ctx *via.Ctx) { c.N.Set(c.N.Get() + 1) }
func (c *Counter) Dec(ctx *via.Ctx) { c.N.Set(c.N.Get() - 1) }
func (c *Counter) View() h.H {
return h.Div(h.Class("row"),
h.Button(on.Click(c.Dec), h.Str("−")),
h.Output(c.N.Display()),
h.Button(on.Click(c.Inc), h.Str("+")),
)
}
Serve it with http.ListenAndServe(":3000", via.Handler(Counter{})).
#Mapping
Ordered by how early a port hits each change. Caught by says what tells you: the compiler, a panic or a warning when Mount walks the type at startup (a child's warning waits for its first render), or nothing.
| v0.7 | v0.8 | Caught by |
|---|---|---|
h.Text(s), h.T(s), h.Textf(f, …) | h.Str(s), h.Str(fmt.Sprintf(f, …)) | compiler |
View(ctx *via.CtxR) h.H | View() h.H; load what it reads into fields in OnInit | compiler |
via.New(), via.Mount[Page](app, "/") | via.Handler(Page{}), or via.NewRouter() and via.Mount(r, "/", Page{}) | compiler |
app.Start(), app.Run(), WithAddr, the timeout options | http.ListenAndServe(addr, r), or your own http.Server | compiler |
StateTab[T] and its Num, Str, Bool, Slice, Map shapes | State[T]; rendering one makes the unit live | compiler |
.Read(ctx), .Write(ctx, v), .Update(ctx, fn) | .Get(), .Set(v) | compiler |
.Op(ctx).Inc(), .Add(n), .Toggle(), … | Go on the value: s.Set(s.Get() + n) | compiler |
s.Text(ctx), sig.Text(), sig.TextSpan() | s.Display() | compiler |
SignalNum[T] and the other Signal shapes | Signal[T] | compiler |
via:"name,init=v" field tag | via:"init=<json>"; the wire name is the field name, and a string seed is JSON: init="all" | panic at Mount |
a child rendered by hand: p.A.View(ctx, …) | via.Child(p.A); what the child's View took as arguments becomes its fields | compiler |
an action func(*via.Ctx) error, WithActionErrorHandler | func(*via.Ctx); handle the error inside | compiler |
an OnInit error, logged while the page renders anyway | an OnInit error aborts: via.ErrNotFound answers 404, via.ErrForbidden 403, any other 500 | silent |
path:"id" field tag | ctx.Param[T]("id") in OnInit, stored in a field | silent |
query:"q" field tag | ctx.Request().URL.Query() in OnInit; it is empty on actions, so list state belongs in the path or the session | silent |
h.If(cond, node) | via.When(cond, p.part): the node becomes a method returning h.H, called only when cond holds | compiler |
h.When(cond, fn) | via.When(cond, fn) | compiler |
h.IfElse, h.WhenElse, h.Switch, h.Maybe | a Go if or switch in a method returning h.H | compiler |
h.Each(items, fn), h.EachIndexed, h.EachSeq | via.Each(items, p.row), or a loop | compiler |
h.Fragment(…) | pass the nodes to the parent element, or collect a []h.H and spread it | compiler |
on.Debounce("250ms"), on.Throttle("1s") | a time.Duration: on.WithDebounce(250*time.Millisecond) | compiler |
on.Key("Enter", fn) | on.Keydown(fn), which has no key filter | compiler |
on.Indicator(sig), on.Confirm, on.SetSignal | h.DataIndicator(sig.Ref()); Confirm and SetSignal are gone | compiler |
sig.Show(), sig.ShowUnless() | h.DataShow(sig.Ref()), h.DataShow(sig.Ref().Not()) | compiler |
sig.Class(n), sig.Style(p), sig.Attr(n) | h.DataClass(n, sig.Ref()), h.DataStyle(p, sig.Ref()), h.DataAttr(n, sig.Ref()); expr.Class in a CS handler when no signal is needed | compiler |
h.DataShow(f, args…), h.DataClass(n, f, args…), h.DataOnClick(f, …) | an expr.Expr: h.DataShow(e), h.DataClass(n, e), on.ClickCS(e) | compiler |
via.Local("name") | a via.SignalCS[T] field; Toggle() is on.ClickCS(s.Ref().Toggle()) | compiler |
via.Computed(k, e), via.Effect(e) | h.DataComputed(k, e), h.DataEffect(e) | compiler |
h.Attr(n, v), h.AttrNum(n, v) | h.RawAttr(n, v) | compiler |
h.Checked(), h.Disabled(), h.Required(), h.Selected() | a bool argument: h.Checked(true) | compiler |
h.ColSpan("2"), h.TabIndex("0"), h.MinNum(n), h.ValueNum(n) | h.ColSpan(2), h.TabIndex(0), and the generic h.Min(n), h.Value(n) | compiler |
h.Classes(…), h.ClassMap(m), h.Styles(…) | h.Class(names…) and h.Style(css), with the names worked out in Go | compiler |
h.Tag, h.NewTag, h.VoidTag | h.El(tag, …) | compiler |
h.Raw(html), h.Static, h.With | gone; there is no unescaped HTML node | compiler |
h.Title(s), the <title> element | PageMeta() returning via.Meta{Title: s}; h.Title is now the title attribute | silent |
sess.Put(ctx, v), sess.Get[T](ctx), sess.Clear[T](ctx), sess.Rotate(ctx) | ctx.Session().Put(v), .Get[T](), .Delete(), .Rotate() | compiler |
| one session value per type | one value per session: a second Put replaces the first, so put one struct | silent |
StateSess[T] | a topic per ctx.Session().ID(), followed with State.Track in OnInit | compiler |
StateApp[T] | your own store, injected; a topic.Topic[T] and via.StateTrack to push its changes | compiler |
app.Broadcast, BroadcastSignals, BroadcastNotify, via.BroadcastSignal | topic.New[T](), subscribed with ctx.Listen in OnInit | compiler |
OnConnect(ctx) error | ctx.Tick or ctx.Listen in OnInit; ctx.OnConnect(fn) for work on stream open | warning |
OnDispose(ctx) | ctx.OnDispose(fn), registered in OnInit | silent |
via.Stream(ctx, d, fn) | ctx.Tick(d, fn) in OnInit | compiler |
ctx.Notify, ctx.ExecScript, ctx.Reload, ctx.SyncNow, ctx.Patch | gone | compiler |
ctx.Cookie, ctx.SetCookie, ctx.Writer() | ctx.Request().Cookie(name); there is no response access, so keep the value in the session | compiler |
ctx.Done() | ctx.Context().Done() | compiler |
via.File and via.Files fields, ctx.MultipartReader() | via.PostForm and ctx.Request().FormFile(name) | compiler |
via.DecodeForm(ctx, &dst) | a bound Signal per field, read with Get(); or via.PostForm and ctx.Request().FormValue | compiler |
WithTitle, WithDescription | PageMeta() via.Meta on the mounted page | compiler |
WithLang, app.AppendToHead, app.AppendToFoot | via.WithHead(via.Head{Lang, Raw, Assets}); scripts and styles go in Assets | compiler |
WithPlugins(picocss.…) | your own CSS in Head.Assets.Styles | compiler |
WithPlugins(echarts.…), maplibre | an island: h.DataIgnoreMorph and h.DataEffect around a script of yours | compiler |
a Secure session cookie unless WithInsecureCookies | Secure only over TLS, X-Forwarded-Proto: https or Forwarded: proto=https (Unreleased; v0.8.3 reads TLS only); behind a proxy that sends neither set WithSecureCookies | silent |
ctx.Redirect("https://other.example/…") | dropped and logged (Unreleased; v0.8.3 follows it); leave the site with ctx.RedirectExternal | silent |
app.Use, app.Group, app.Handle, app.HandleStatic | your own http.ServeMux and middleware around the *via.Router | compiler |
WithLogger(via.Logger), WithMaxRequestBody, WithMaxUploadSize | WithLogger(*slog.Logger), WithMaxBody, WithMaxUpload | compiler |
WithNotFound | WithErrorPage | compiler |
WithBackplane, StateAppEvents, vianats | gone; state lives in one process | compiler |
#What the compiler won't catch
These compile and start. The first sign is behaviour:
- an
OnIniterror, logged while the page renders anyway → anOnIniterror aborts:via.ErrNotFoundanswers 404,via.ErrForbidden403, any other 500 path:"id"field tag →ctx.Param[T]("id")inOnInit, stored in a fieldquery:"q"field tag →ctx.Request().URL.Query()inOnInit; it is empty on actions, so list state belongs in the path or the sessionh.Title(s), the<title>element →PageMeta()returningvia.Meta{Title: s};h.Titleis now thetitleattribute- one session value per type → one value per session: a second
Putreplaces the first, so put one struct OnDispose(ctx)→ctx.OnDispose(fn), registered inOnInit- a Secure session cookie unless
WithInsecureCookies→ Secure only over TLS,X-Forwarded-Proto: httpsorForwarded: proto=https(Unreleased; v0.8.3 reads TLS only); behind a proxy that sends neither setWithSecureCookies ctx.Redirect("https://other.example/…")→ dropped and logged (Unreleased; v0.8.3 follows it); leave the site withctx.RedirectExternal
A leftover OnConnect(ctx) error is warned about, with or without an OnInit next to it: at Mount on the page, on a child's first render. A leftover OnDispose(ctx) has an action's shape, so nothing reports it.
#Removed outright
- Plugins. picocss and its themes become your own CSS through
WithHead; echarts and maplibre become an island. WithoutSSEReconnect. The reconnect manager is always on.- The
sesssubpackage. Its functions are methods onctx.Session(). StateSess,StateApp,StateAppEvents, the numeric shapes and.Op(ctx).Broadcast,BroadcastSignal,BroadcastSignalsandBroadcastNotify.via.File,via.Files,ctx.MultipartReaderandvia.DecodeForm.via.PostFormis always multipart; read a file withctx.Request().FormFile(name).app.Groupand its middleware. A page's access check moves into itsOnInit.WithBackplane,vianatsand the key store. State lives in one process.- Most tuning options.
WithSSEHeartbeatandWithSSEWriteTimeoutare constants now;WithMaxSSEConnandWithPinnedDeadlineare the SSE options left.
#Wire breaks
Nothing in your code builds these, so there is nothing to port. A tab left open across the upgrade fails once and comes back correct on reload.
- Actions moved from
/_action/{id}to{path}/_via/a/{child}/{id}, where id is a hash of the handler's Go name and the field it was called on. A stale tab's click answers 404. - The tab id signal is
viatab, notvia_tab. - A signal's wire name is its field path, first letter lower-cased:
count, andchat__draftinside an embeddedChat. Thevia:"name"override is gone.
#New startup panics
- A
viatag that is notinit=<json>, or whose value is not JSON for the field's type. - A
Signalthat is not a plain field of its composition, reached through a pointer, slice, array or map, or held by a type whoseViewhas a value receiver, panics atMountorChild. - Two fields that mint the same slot name panic: a nested
A.B(a_b) next to a fieldA_b. Mountrenders the mounted value once, withoutOnInit, and panics on a wiring mistake it reaches: a func literal bound per row, an interface or ambiguous value-receiver method,h.El("script"), a Signal with no slot, a signal namedviatab, a child without aView. One behind a branch the empty value skips panics at the first render that takes it.- A
Mountpath with a{name...}or{$}wildcard, or one named{child}or{act}, panics, and so does mounting both/docsand/docs/.
#Names removed after v0.8
Port straight to package on and expr.Val. v0.8 deprecated via.On and via.OnArg in favour of on.Click, on.Event and on.WithArg (now on.Bind), and expr.Lit in favour of expr.Val; all three are removed. Package on and expr.Val are newer than the v0.8.1 tag: on v0.8.1 itself, a click is via.On("click", p.Inc).
#Staying on v0.7
The v1 branch is preserved and its tags still resolve:
go get github.com/go-via/[email protected]v0.7 is frozen: no features, and no commitment to backport security fixes. Pinning it means taking on its dependency maintenance yourself.
The full text, including the security defaults that moved and the rough edges in v0.8, is MIGRATION.md on GitHub.