via

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.Text with h.Str.
  • Delete the View(ctx) parameter and load what the view reads in OnInit.
  • StateTab becomes State: drop .Op(ctx), and Read and Write become Get and Set.
  • via.New() and via.Mount[Page](app, …) become via.NewRouter() and via.Mount(r, …, Page{}).
  • Move path:"…" tags to ctx.Param in OnInit. 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.7v0.8Caught by
h.Text(s), h.T(s), h.Textf(f, …)h.Str(s), h.Str(fmt.Sprintf(f, …))compiler
View(ctx *via.CtxR) h.HView() h.H; load what it reads into fields in OnInitcompiler
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 optionshttp.ListenAndServe(addr, r), or your own http.Servercompiler
StateTab[T] and its Num, Str, Bool, Slice, Map shapesState[T]; rendering one makes the unit livecompiler
.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 shapesSignal[T]compiler
via:"name,init=v" field tagvia:"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 fieldscompiler
an action func(*via.Ctx) error, WithActionErrorHandlerfunc(*via.Ctx); handle the error insidecompiler
an OnInit error, logged while the page renders anywayan OnInit error aborts: via.ErrNotFound answers 404, via.ErrForbidden 403, any other 500silent
path:"id" field tagctx.Param[T]("id") in OnInit, stored in a fieldsilent
query:"q" field tagctx.Request().URL.Query() in OnInit; it is empty on actions, so list state belongs in the path or the sessionsilent
h.If(cond, node)via.When(cond, p.part): the node becomes a method returning h.H, called only when cond holdscompiler
h.When(cond, fn)via.When(cond, fn)compiler
h.IfElse, h.WhenElse, h.Switch, h.Maybea Go if or switch in a method returning h.Hcompiler
h.Each(items, fn), h.EachIndexed, h.EachSeqvia.Each(items, p.row), or a loopcompiler
h.Fragment(…)pass the nodes to the parent element, or collect a []h.H and spread itcompiler
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 filtercompiler
on.Indicator(sig), on.Confirm, on.SetSignalh.DataIndicator(sig.Ref()); Confirm and SetSignal are gonecompiler
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 neededcompiler
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 Gocompiler
h.Tag, h.NewTag, h.VoidTagh.El(tag, …)compiler
h.Raw(html), h.Static, h.Withgone; there is no unescaped HTML nodecompiler
h.Title(s), the <title> elementPageMeta() returning via.Meta{Title: s}; h.Title is now the title attributesilent
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 typeone value per session: a second Put replaces the first, so put one structsilent
StateSess[T]a topic per ctx.Session().ID(), followed with State.Track in OnInitcompiler
StateApp[T]your own store, injected; a topic.Topic[T] and via.StateTrack to push its changescompiler
app.Broadcast, BroadcastSignals, BroadcastNotify, via.BroadcastSignaltopic.New[T](), subscribed with ctx.Listen in OnInitcompiler
OnConnect(ctx) errorctx.Tick or ctx.Listen in OnInit; ctx.OnConnect(fn) for work on stream openwarning
OnDispose(ctx)ctx.OnDispose(fn), registered in OnInitsilent
via.Stream(ctx, d, fn)ctx.Tick(d, fn) in OnInitcompiler
ctx.Notify, ctx.ExecScript, ctx.Reload, ctx.SyncNow, ctx.Patchgonecompiler
ctx.Cookie, ctx.SetCookie, ctx.Writer()ctx.Request().Cookie(name); there is no response access, so keep the value in the sessioncompiler
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().FormValuecompiler
WithTitle, WithDescriptionPageMeta() via.Meta on the mounted pagecompiler
WithLang, app.AppendToHead, app.AppendToFootvia.WithHead(via.Head{Lang, Raw, Assets}); scripts and styles go in Assetscompiler
WithPlugins(picocss.…)your own CSS in Head.Assets.Stylescompiler
WithPlugins(echarts.…), maplibrean island: h.DataIgnoreMorph and h.DataEffect around a script of yourscompiler
a Secure session cookie unless WithInsecureCookiesSecure 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 WithSecureCookiessilent
ctx.Redirect("https://other.example/…")dropped and logged (Unreleased; v0.8.3 follows it); leave the site with ctx.RedirectExternalsilent
app.Use, app.Group, app.Handle, app.HandleStaticyour own http.ServeMux and middleware around the *via.Routercompiler
WithLogger(via.Logger), WithMaxRequestBody, WithMaxUploadSizeWithLogger(*slog.Logger), WithMaxBody, WithMaxUploadcompiler
WithNotFoundWithErrorPagecompiler
WithBackplane, StateAppEvents, vianatsgone; state lives in one processcompiler

#What the compiler won't catch

These compile and start. The first sign is behaviour:

  • an OnInit error, logged while the page renders anyway → an OnInit error aborts: via.ErrNotFound answers 404, via.ErrForbidden 403, any other 500
  • path:"id" field tag → ctx.Param[T]("id") in OnInit, stored in a field
  • 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
  • h.Title(s), the <title> element → PageMeta() returning via.Meta{Title: s}; h.Title is now the title attribute
  • one session value per type → one value per session: a second Put replaces the first, so put one struct
  • OnDispose(ctx) → ctx.OnDispose(fn), registered in OnInit
  • 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
  • ctx.Redirect("https://other.example/…") → dropped and logged (Unreleased; v0.8.3 follows it); leave the site with ctx.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 sess subpackage. Its functions are methods on ctx.Session().
  • StateSess, StateApp, StateAppEvents, the numeric shapes and .Op(ctx).
  • Broadcast, BroadcastSignal, BroadcastSignals and BroadcastNotify.
  • via.File, via.Files, ctx.MultipartReader and via.DecodeForm. via.PostForm is always multipart; read a file with ctx.Request().FormFile(name).
  • app.Group and its middleware. A page's access check moves into its OnInit.
  • WithBackplane, vianats and the key store. State lives in one process.
  • Most tuning options. WithSSEHeartbeat and WithSSEWriteTimeout are constants now; WithMaxSSEConn and WithPinnedDeadline are 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, not via_tab.
  • A signal's wire name is its field path, first letter lower-cased: count, and chat__draft inside an embedded Chat. The via:"name" override is gone.

#New startup panics

  • A via tag that is not init=<json>, or whose value is not JSON for the field's type.
  • A Signal that is not a plain field of its composition, reached through a pointer, slice, array or map, or held by a type whose View has a value receiver, panics at Mount or Child.
  • Two fields that mint the same slot name panic: a nested A.B (a_b) next to a field A_b.
  • Mount renders the mounted value once, without OnInit, 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 named viatab, a child without a View. One behind a branch the empty value skips panics at the first render that takes it.
  • A Mount path with a {name...} or {$} wildcard, or one named {child} or {act}, panics, and so does mounting both /docs and /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.