package app import ( "bytes" "io" "math/rand" chart "github.com/wcharczuk/go-chart/v2" . "kjol/vdom" "kjol/wasmruntime" ui "kjol/webui" ) var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} // fixed initial data so the server SSR and the client's first render match. func fixedChartData() []int { return []int{42, 17, 63, 28, 55, 9, 71} } func randomValues() []int { v := make([]int, len(chartLabels)) for i := range v { v[i] = rand.Intn(95) + 5 } return v } func renderSVG(c interface { Render(chart.RendererProvider, io.Writer) error }) string { var buf bytes.Buffer if c.Render(chart.SVG, &buf) != nil { return "

chart error

" } return buf.String() } func barSVG(values []int) string { bars := make([]chart.Value, len(values)) for i, v := range values { bars[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]} } return renderSVG(&chart.BarChart{ Title: "Weekly values (bar)", TitleStyle: chart.Style{FontSize: 15}, Background: chart.Style{Padding: chart.Box{Top: 48, Left: 16, Right: 16, Bottom: 16}}, Height: 320, BarWidth: 48, Bars: bars, }) } func pieSVG(values []int) string { vs := make([]chart.Value, len(values)) for i, v := range values { vs[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]} } return renderSVG(&chart.PieChart{ Title: "Share by day (pie)", TitleStyle: chart.Style{FontSize: 15}, Background: chart.Style{Padding: chart.Box{Top: 48}}, Width: 320, Height: 320, Values: vs, }) } // chartSkeleton is what the SERVER puts where a chart is going to be: a box of the right // height, so nothing jumps when the real one arrives. func chartSkeleton(height string) *VNode { return Div(Attr("class", "flex animate-pulse items-center justify-center rounded-default bg-surface-muted "+height), Span(Attr("class", "text-xs text-ink-faint"), Text("drawing…")), ) } // newChartDrawing returns a signal that is FALSE on the server and on the client's first // render, and true from the moment the WebAssembly has committed that first render. // // It is what keeps the charts CLIENT-DRAWN. go-chart is ordinary Go and would run just as // happily on the server — it used to, and this page's markup carried two finished SVGs. // Two reasons not to: // // - It is work the server does on every single request for a picture that only matters // once the page is alive. Drawing it in the browser costs the server nothing and the // reader nothing they can see. // - It is the more honest demonstration. A Go charting library, compiled to WebAssembly, // drawing an SVG in the browser is the thing this layer claims it can do. Shipping a // server-rendered picture of one proves the opposite point. // // The false-on-first-render part is not optional: hydration walks the server's DOM // alongside the client's first tree, so that tree has to be the SAME tree. Draw the charts // on the client's first pass and the two disagree, and the reconciler has to rebuild what // it should have adopted. func newChartDrawing() *Signal[bool] { drawn := NewSignal(false) wasmruntime.AfterRender(func() { if !drawn.Get() { drawn.Set(true) // a write re-renders; the second pass draws for real } }) return drawn } // chartBox renders one chart, or the placeholder standing in for it. draw is a closure so // that on the server go-chart is never called at all — not called and discarded, but never // entered. func chartBox(class, height string, drawn bool, draw func() string) *VNode { if !drawn { return Div(Attr("class", class), chartSkeleton(height)) } return Div(Attr("class", class), Raw(draw())) } //gowasm:page /wasm/chart static layout=app func ChartPage(d Deps) func() *VNode { data := NewSignal(fixedChartData()) drawn := newChartDrawing() return func() *VNode { values := data.Get() return docPage("Rendering", "SSR & hydration", "A static route is rendered to HTML by the server, so the page is complete before any "+ "WebAssembly has downloaded. The same component then runs in the browser, adopts the markup "+ "that is already there, and takes over. One function, two runtimes.", docSection("the-directive", "Marking a route static", prose("static on the page directive is what puts a route in the server's pre-render set. Leave "+ "it off and the route renders on the client only — which is the right choice when the page "+ "is behind a login, or its content depends on something only the browser knows."), code("app/chart.go", chartSnippet), note("Hydration adopts, it does not rebuild", "The client renders the same tree the server did and walks the existing DOM alongside it, "+ "wiring event handlers to the nodes that are already on the page. If the two trees "+ "disagree, the CLIENT wins — a stale server binary should not be able to pin a wrong "+ "class onto the page forever."), ), docSection("charts", "A worked example: charts", prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing "+ "about the browser. They are drawn by the WEBASSEMBLY, in your browser, and never by the "+ "server: what the server sends is the two placeholders you may have seen for a moment, "+ "and the WebAssembly replaces them on its first commit."), prose("That is the demonstration. A Go charting library, compiled to wasm, drawing an SVG in "+ "the browser is exactly what this layer claims it can do — and a server-rendered picture "+ "of a chart would prove the opposite point while looking identical. Shuffle redraws them, "+ "and no request is made."), prose("The rest of the page IS server-rendered — the headings, the prose, the code you are "+ "reading. Static and client-drawn are not opposites: a route can be pre-rendered and still "+ "leave the expensive, browser-only parts of itself for the client."), Div(Attr("class", "mt-4"), ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data", OnClick: func() { data.Set(randomValues()) }}), ), Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-12"), chartBox("lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto", "h-[260px]", drawn.Get(), func() string { return barSVG(values) }), chartBox("lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto", "h-[320px]", drawn.Get(), func() string { return pieSVG(values) }), ), note("go-chart lives in the EXAMPLE, not in kjol", "The engine is standard-library-only. This example is its own Go module precisely so a "+ "charting dependency it happens to want does not become a dependency of everyone who "+ "uses the framework."), ), docSection("api", "Reference", apiTable( apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."}, apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."}, apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."}, apiRow{"vdom.Raw", "Insert markup verbatim — how the SVG the WebAssembly drew gets in. The reconciler clears it correctly when the element is reused."}, ), ), ) } } const chartSnippet = `//gowasm:page /wasm/chart static layout=app func ChartPage(d Deps) func() *VNode { data := NewSignal(fixedChartData()) drawn := NewSignal(false) // false on the server AND on the first client render // AfterRender is the post-commit hook. It fires once the WebAssembly has put its // first tree on the page — the earliest moment at which drawing is a client act. wasmruntime.AfterRender(func() { if !drawn.Get() { drawn.Set(true) // a write re-renders; the second pass draws } }) return func() *VNode { return Div( ui.Button(ui.ButtonProps{ Text: "Shuffle data", OnClick: func() { data.Set(randomValues()) }, }), // The server never enters barSVG: chartBox takes a CLOSURE, and calls it only // once drawn is true. It renders the placeholder instead, and the WebAssembly // swaps in the real chart on its first commit. // // drawn must be FALSE on the client's first render too. Hydration walks the // server's DOM alongside the client's first tree, so the two have to BE the // same tree; draw on that first pass and the reconciler rebuilds what it // should have adopted. chartBox("", "h-[260px]", drawn.Get(), func() string { return barSVG(data.Get()) }), ) } } // chartBox is the whole trick, and it is four lines. func chartBox(class, height string, drawn bool, draw func() string) *VNode { if !drawn { return Div(Attr("class", class), chartSkeleton(height)) } return Div(Attr("class", class), Raw(draw())) }`