via

Islands

via never generates JavaScript. An island is a container via renders once and then leaves to a library of yours: Go feeds it by writing signals, and your script draws.

On this page

#The island contract

  1. Mark the container

    h.DataIgnoreMorph keeps every later patch out of the subtree, so the DOM the library built survives re-renders of the unit around it.

  2. Make init idempotent

    h.DataEffect re-runs on every change of every signal it reads. Build the library instance once, keyed on state stored on the element (if (!el._map)), and on later runs only update it.

  3. Tear down on via:remove

    via dispatches via:remove on document for each element that leaves the page, with the removed root in e.detail.el. Free WebGL contexts, workers and observers there; via does not know the library holds them.

  4. Release server-side producers in OnDispose

    via.Ctx.OnDispose runs when the unit's connection closes. Whatever feeds the island from outside the unit stops there.

  5. Declare every script in Assets

    Scripts and styles go in the Assets field of the root's via.Meta, which decides the page's CSP. A relative URL is covered by 'self'; an absolute one adds its origin. It must be a constant of the type: via reads it at Mount and panics if a render disagrees.

#Feeding an island from Go

#Sparkline on a clock

A via.Ctx.Tick appends a sample and calls via.Signal.Set on a Signal[[]int] the View never binds or displays. Set is what declares the signal, so the value reaches the browser anyway, and viaChart in static/islands.js redraws the canvas each time the effect re-runs.

Try this

  • Watch the Wire pane: one patch a second, each carrying the whole series as a signal.
  • Open Source: the canvas has data-ignore-morph and a data-effect calling viaChart.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"math/rand/v2"
	"slices"
	"time"

	"github.com/go-via/via"
	"github.com/go-via/via/expr"
	"github.com/go-via/via/h"
)

const maxSamples = 40

// Sparkline feeds a canvas JavaScript owns from Go. Load is a Signal nothing
// renders: Set declares the slot, so every tick's patch carries the series to
// the island without a Bind or a Display. The tag starts it at an empty array,
// so the effect never sees the null a nil slice would marshal to.
type Sparkline struct {
	Load via.Signal[[]int] `via:"init=[]"`
	load []int
}

func (s *Sparkline) OnInit(ctx *via.Ctx) error {
	ctx.Tick(time.Second, s.beat)
	return nil
}

func (s *Sparkline) beat(ctx *via.Ctx) {
	s.load = append(s.load, 20+rand.IntN(80))
	if len(s.load) > maxSamples {
		s.load = s.load[1:]
	}
	// Clone: Set keeps the slice header, and the next beat's append would
	// otherwise write through it into the value already on the wire.
	s.Load.Set(slices.Clone(s.load))
}

func (s *Sparkline) View() h.H {
	// DataIgnoreMorph keeps the node across live patches, so the bitmap the
	// effect drew survives.
	return h.Canvas(h.Class("chart"), h.DataIgnoreMorph(),
		h.Width(420), h.Height(90),
		// A canvas is opaque to a screen reader; role and label are the only
		// description of it there is.
		h.Role("img"),
		h.Aria("label", "Line chart of the last 40 load samples, one a second"),
		h.DataEffect(expr.Call("viaChart", expr.El, s.Load.Ref(), expr.Val(maxSamples))))
}

#The JavaScript half

static/islands.js
window.viaMap = (el, view) => {
  if (!window.maplibregl) {
    el.textContent = "map library failed to load";
    return;
  }
  if (!el._map) {
    el._map = new maplibregl.Map({
      container: el,
      style: STYLE,
      interactive: true,
      attributionControl: false,
    });
    // Adding or removing a map reflows the grid: MapLibre only reads the
    // container size on its own resize event, which a reflow does not fire.
    el._ro = new ResizeObserver(() => el._map && el._map.resize());
    el._ro.observe(el);
    // The container is the map's own element, so the message cannot be
    // written into it; islands.css renders the attribute.
    el._map.on("error", (ev) => {
      el.setAttribute("data-error", "map failed to load: " + ((ev && ev.error && ev.error.message) || "unknown error"));
    });
  }
  el._map.jumpTo({center: view.center, zoom: view.zoom});
};

The first run builds the map and stores it on the element; every later run finds el._map and only calls jumpTo. The effect passes el and the signal's value as arguments, from expr.Call with expr.El and via.Signal.Ref.

static/islands.js
document.addEventListener("via:remove", (e) => {
  const root = e.detail.el;
  const gone = root.matches("[data-island-map]") ? [root] : root.querySelectorAll("[data-island-map]");
  for (const el of gone) {
    if (!el._map) continue;
    if (el._ro) {
      el._ro.disconnect();
      el._ro = null;
    }
    el._map.remove();
    el._map = null;
  }
});

A removed subtree can hold many islands, so the listener checks the root and everything under it.

#Many instances, one script

#Six maps, one script

The MapLibre script is declared once in the page's Assets and serves however many maps the grid holds. The grid is one unit with a fixed Signal per slot, not a child per map; Compositions covers why a child's key must not move.

2 of 6

Try this

  • Shuffle: the maps jump, none is rebuilt.
  • Add map, then Remove last: the via:remove listener calls _map.remove() on the map that left.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"math/rand/v2"
	"strconv"

	"github.com/go-via/via"
	"github.com/go-via/via/expr"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
)

// MapView is what one map reads: the JSON maplibre's jumpTo wants, spelled in
// Go. Center is [longitude, latitude]. Name is json:"-" because it is for the
// container's aria-label and not for the library.
type MapView struct {
	Center [2]float64 `json:"center"`
	Zoom   float64    `json:"zoom"`
	Name   string     `json:"-"`
}

var cities = []MapView{
	{[2]float64{-9.14, 38.72}, 4, "Lisbon"},
	{[2]float64{139.69, 35.69}, 4, "Tokyo"},
	{[2]float64{-43.20, -22.91}, 3, "Rio de Janeiro"},
	{[2]float64{18.42, -33.92}, 3, "Cape Town"},
	{[2]float64{-74.01, 40.71}, 4, "New York"},
	{[2]float64{151.21, -33.87}, 3, "Sydney"},
	{[2]float64{28.98, 41.01}, 4, "Istanbul"},
	{[2]float64{77.21, 28.61}, 3, "Delhi"},
}

// maxMaps is the cap the grid is written to. One Signal per map, as fixed
// fields: the number of maps changes while the page is open, and a Signal
// reached through a slice has no field name to spell in a Datastar
// expression. A slice of via.Child would have the same problem one level up —
// a child's key is its position among its parent's Child calls and must not
// move for the life of the connection.
const maxMaps = 6

// MapGrid is one live unit holding every map, not a parent with a child per
// map: only the unit a Signal is a field of can Set it onto the wire, so the
// buttons and the six views live together.
type MapGrid struct {
	N                      via.State[int]      `via:"init=2"`
	V0, V1, V2, V3, V4, V5 via.Signal[MapView] `via:"init={\"center\":[0,20],\"zoom\":0}"`
}

func (g *MapGrid) views() []*via.Signal[MapView] {
	return []*via.Signal[MapView]{&g.V0, &g.V1, &g.V2, &g.V3, &g.V4, &g.V5}
}

func (g *MapGrid) OnInit(ctx *via.Ctx) error {
	for i, v := range g.views()[:g.N.Get()] {
		v.Set(cities[i])
	}
	return nil
}

func (g *MapGrid) Add(ctx *via.Ctx) {
	n := g.N.Get()
	if n >= maxMaps {
		return
	}
	// The signal first: the patch-signals frame goes out before the element
	// patch, so the new container's effect runs against a view, not the seed.
	g.views()[n].Set(cities[rand.IntN(len(cities))])
	g.N.Set(n + 1)
}

func (g *MapGrid) Remove(ctx *via.Ctx) {
	if n := g.N.Get(); n > 1 {
		g.N.Set(n - 1)
	}
}

// Shuffle moves every map without rebuilding any of them: the effect re-runs
// on the new signal value and takes the jumpTo branch.
func (g *MapGrid) Shuffle(ctx *via.Ctx) {
	for _, v := range g.views()[:g.N.Get()] {
		v.Set(cities[rand.IntN(len(cities))])
	}
}

func (g *MapGrid) View() h.H {
	grid := []h.H{h.Class("map-grid")}
	for i, v := range g.views()[:g.N.Get()] {
		// A stable id so a morph pairs each map with its own element;
		// DataIgnoreMorph so the patch leaves MapLibre's own DOM alone.
		// MapLibre injects a focusable canvas, so the group is the screen
		// reader's handle; outside the ignore-morph container, its label
		// follows the shuffle.
		grid = append(grid, h.Div(h.Class("map-slot"), h.Role("group"),
			h.Aria("label", "Map of "+v.Get().Name),
			h.Div(h.Class("map"), h.ID("map-"+strconv.Itoa(i)),
				h.Data("island-map", ""), h.DataIgnoreMorph(),
				h.DataEffect(expr.Call("viaMap", expr.El, v.Ref())))))
	}
	return h.Div(
		h.Div(h.Class("row"),
			h.Button(on.Click(g.Add), h.Str("Add map")),
			h.Button(on.Click(g.Remove), h.Str("Remove last")),
			h.Button(on.Click(g.Shuffle), h.Str("Shuffle")),
			h.Small(g.N.Display(), h.Str(" of "), h.Str(maxMaps)),
		),
		h.Div(grid...),
	)
}

#One signal per action

#The same island, no clock

The same viaChart, driven by a click. This unit never ticks and holds no State, so the POST's own response answers it: one patch-signals frame carrying the one signal the handler wrote. The effect re-runs on arrival.

Try this

  • Click New data, then open the newest Wire entry, the patch-signals response: it holds chart__series and nothing else.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"math/rand/v2"

	"github.com/go-via/via"
	"github.com/go-via/via/expr"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
)

// Chart drives the same island function as Sparkline, from a click instead of
// a clock. It holds no State and never ticks, so this demo does not stream:
// the plain action answers a patch-signals frame carrying the one signal the
// handler wrote, and the effect re-runs on it.
type Chart struct {
	Series via.Signal[[]int] `via:"init=[]"`
}

func (c *Chart) OnInit(ctx *via.Ctx) error {
	c.Series.Set(samples())
	return nil
}

func (c *Chart) Fresh(ctx *via.Ctx) { c.Series.Set(samples()) }

func (c *Chart) View() h.H {
	return h.Div(
		h.Div(h.Class("row"), h.Button(on.Click(c.Fresh), h.Str("New data"))),
		h.Canvas(h.Class("chart"), h.DataIgnoreMorph(),
			h.Width(420), h.Height(90),
			// A canvas is opaque to a screen reader; role and label are the
			// only description of it there is.
			h.Role("img"),
			h.Aria("label", "Line chart of 16 random samples"),
			h.DataEffect(expr.Call("viaChart", expr.El, c.Series.Ref()))),
	)
}

func samples() []int {
	s := make([]int, 16)
	for i := range s {
		s[i] = 10 + rand.IntN(90)
	}
	return s
}

#Stopping a producer on disconnect

prices.go
// Prices is an island fed by one producer that every open tab shares. The
// stream opening claims it; the stream closing releases it.
type Prices struct {
	Series via.Signal[[]int] `via:"init=[]"`
}

func (p *Prices) OnInit(ctx *via.Ctx) error {
	ctx.Tick(time.Second, p.poll)
	ctx.OnConnect(feed.Acquire)
	ctx.OnDispose(feed.Release)
	return nil
}

func (p *Prices) poll(ctx *via.Ctx) { p.Series.Set(feed.Last()) }

func (p *Prices) View() h.H {
	return h.Canvas(h.DataIgnoreMorph(), h.Role("img"), h.Aria("label", "Price chart"),
		h.DataEffect(expr.Call("viaChart", expr.El, p.Series.Ref())))
}

via.Ctx.OnConnect and via.Ctx.OnDispose register in OnInit and run once per connection. OnInit runs on every request that renders the unit, so an acquire there would fire for requests that never open a stream.

#Libraries from another origin

gauge.go
// Gauge draws with ECharts from a CDN. Declaring the script is what puts the
// CDN's origin in the page's script-src; nothing else widens the policy.
type Gauge struct {
	Value via.Signal[int] `via:"init=0"`
}

func (g *Gauge) PageMeta() via.Meta {
	return via.Meta{
		Title: "Gauge",
		Assets: via.Assets{Scripts: []via.Script{
			{Src: "https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js", Defer: true},
			{Src: "/static/gauge.js", Defer: true},
		}},
	}
}

func (g *Gauge) View() h.H {
	return h.Div(h.Class("gauge"), h.DataIgnoreMorph(),
		h.DataEffect(expr.Call("viaGauge", expr.El, g.Value.Ref())))
}

Mounted on a router with no other assets, that type is served this policy. The text below is the header via returned when this site started, one directive per line:

Content-Security-Policy
default-src 'self';
script-src 'self' 'nonce-vT0BhkQ_yBP3kKSvq1tgkw' 'sha256-qgomxTCf6EyY9wRYTe+McNJfTHmvsnuHqNwURb6wkX0=' 'sha256-g7YdaHyafXvG7LGcI8Rr65SfNdLx6li/nDY+t9Lb4sY=' 'sha256-2TX8Ens+wrX3VPIQX1+1OWZMcSu8dc4f2EwNYTMzJTs=' https://cdn.jsdelivr.net;
style-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self'

The CDN origin joins script-src and nothing else changes. The three hashes are via's own inline scripts. Sessions & security goes through the policy directive by directive.