Skip to content

WIP: V2.16.0 add state control with wasm#19

Draft
felipegenef wants to merge 26 commits into
mainfrom
v2.16.0-add-state-control-with-wasm
Draft

WIP: V2.16.0 add state control with wasm#19
felipegenef wants to merge 26 commits into
mainfrom
v2.16.0-add-state-control-with-wasm

Conversation

@felipegenef
Copy link
Copy Markdown
Owner

@felipegenef felipegenef commented May 14, 2026

v2.16.0 - Client-Side State with WASM

This release adds a full reactive client-side state system to Gothic, built on TinyGo WASM. Pages and components can now declare local reactive state, observe changes, and manipulate the DOM — all written in Go, compiled to a per-page WASM binary that is served, cached, and decompressed automatically by the framework. No JavaScript needed.

Highlights

  • ClientSideState func() field on RouteConfig — declare WASM state inline in your .templ file
  • Reactive observables: CreateObservable, Observe, ObserveWithCleanup — fine-grained reactivity without a virtual DOM
  • Shared context: type-safe cross-component state with per-field subscriptions and zero-copy binary codec
  • DOM helpers: SetText, SetHTML, SetValue, GetValue, SetAttr, SetStyle, AddClass, RemoveClass, ToggleClass
  • HTTP helpers: Fetch and FetchBytes — call any API from WASM with Go-style (value, error) returns
  • File helpers: GetFileBytes — read a file input as []byte for uploads or local processing
  • JS function bridge: CreateWasmFunc, CreateWasmStringFunc, CreateWasmBoolFunc — expose Go functions as global JS callables
  • Per-page WASM binaries: each page with ClientSideState gets its own compiled binary with dead-code elimination
  • Brotli or gzip compression per page via WasmCompression on RouteConfig
  • Stateful components: StatefulComponentOf for lazy-loaded HTMX component wrappers
  • AST-based pipeline: all code generation uses proper Go AST parsing — no regex string slicing

Client-Side State

Add a ClientSideState function to any RouteConfig to opt a page into WASM:

var MyPageConfig = routes.RouteConfig[MyProps]{
    Type:            routes.DYNAMIC,
    HttpMethod:      routes.GET,
    WasmCompression: routes.BROTLI,
    Middleware: func(w http.ResponseWriter, r *http.Request) MyProps {
        return MyProps{}
    },
    ClientSideState: func() {
        count := CreateObservable(0)

        Observe(func() {
            SetText("counter", fmt.Sprintf("%d", count.Get()))
        }, count)

        CreateWasmFunc("increment", func() {
            count.Set(count.Get() + 1)
        })
    },
}
  • The ClientSideState body is extracted by the CLI, tree-shaken, and compiled with TinyGo to a .wasm binary.
  • The binary is served automatically alongside the page with correct Content-Type and compression headers.
  • Helper functions defined in the same package are automatically inlined — no manual copying required.

Reactive Observables

Function Description
CreateObservable[T](initial T) *Observable[T] Creates a reactive value of any type
obs.Get() T Reads the current value
obs.Set(v T) Updates the value and notifies all observers
Observe(fn func(), deps ...any) *Subscription Runs fn immediately and re-runs whenever any dep changes
ObserveWithCleanup(fn func() func(), deps ...any) *Subscription Same as Observe but fn returns a cleanup function called before each re-run
sub.Stop() Unsubscribes the observer
name := CreateObservable("world")

Observe(func() {
    SetText("greeting", "Hello, "+name.Get()+"!")
}, name)

CreateWasmStringFunc("setName", func(s string) {
    name.Set(s)
})

Shared Context (Cross-Component State)

Pages and components on the same page can share typed state through a centralized context system. State is encoded in a compact binary format and broadcast per-field — only components subscribed to a specific field receive updates.

Defining a context

Create a struct that embeds GothicSharedContext and tags each field with its wire name and codec hint. The CLI generates a PageContext() function from this struct automatically.

Required: context files must live inside src/context/. The CLI scans that directory specifically to find structs embedding GothicSharedContext and generate the codec, the context manager WASM binary, and the accessor function. Files placed anywhere else are ignored by the generator.

Naming convention: the generated accessor is always <StructName>Context(). A struct named Page produces PageContext(), a struct named Dashboard produces DashboardContext(), and so on.

// src/context/PageContext.go
package gothicwasm

import . "github.com/felipegenef/gothicframework/pkg/wasm"

type Page struct {
    GothicSharedContext `name:"page" compression:"brotli"`
    Pings               int    `gothic:"i32"`
    Label               string
    Theme               string
    Matrix              map[string]map[string]int
}

Reading and writing from a page or component

Call PageContext() inside ClientSideState to get a handle to the shared state. Each field is an *Observable[T] — observe it with Observe, read it with .Get(), and write it with .Set(). Local state (not shared) lives alongside it as a regular CreateObservable.

var PingMirrorConfig = routes.RouteConfig[PingMirrorProps]{
    Type:            routes.DYNAMIC,
    HttpMethod:      routes.GET,
    WasmCompression: routes.BROTLI,
    Middleware: func(w http.ResponseWriter, r *http.Request) PingMirrorProps {
        return nil
    },
    ClientSideState: func() {
        pageCtx   := PageContext()
        localCount := CreateObservable[int](0)

        // Re-runs whenever any shared field changes
        Observe(func() {
            SetText("pm-pings", strconv.Itoa(pageCtx.Pings.Get()))
            SetText("pm-label", pageCtx.Label.Get())
            SetText("pm-theme", pageCtx.Theme.Get())
        }, pageCtx.Pings, pageCtx.Label, pageCtx.Theme)

        // Local-only observable — not shared with other components
        Observe(func() {
            SetText("pm-local-count", strconv.Itoa(localCount.Get()))
        }, localCount)

        CreateWasmFunc("pingMirrorBump", func() {
            localCount.Set(localCount.Get() + 1)
        })
    },
}

Any page or component on the same page that calls PageContext() shares the same state. Writing to pageCtx.Pings from one component immediately updates all other subscribers — only the fields that changed are transmitted over the wire.

Supported field types

Category Examples
Primitives int, int8int64, uintuint64, float32, float64, bool, string
Slices []string, []int, []*MyStruct
Pointers *MyStruct
Maps map[string]int, map[string]MyStruct, map[string]map[string]bool
Nested structs Any struct whose fields are themselves supported types

The codec is fully generated by the CLI from your struct definition using Go AST — no encoding/json, no reflection at runtime.


DOM Helpers

All helpers are available via dot-import and compile as no-ops server-side:

import . "github.com/felipegenef/gothicframework/pkg/wasm"

Text and HTML

Function Description
SetText(id, value string) Sets element.textContent
SetHTML(id, html string) Sets element.innerHTML
SetValue(id, value string) Sets element.value (inputs)
GetValue(id string) string Returns element.value

CSS Classes

Function Description
AddClass(id, className string) Adds a CSS class
RemoveClass(id, className string) Removes a CSS class
ToggleClass(id, className string) Toggles a CSS class

Attributes and Styles

Function Description
SetAttr(id, attr, value string) Sets an HTML attribute
SetStyle(id, property, value string) Sets an inline style property

All helpers are scope-aware: when the same component is rendered multiple times on a page, each instance operates only on its own DOM subtree.


HTTP Helpers

// GET
body, err := Fetch("https://api.example.com/data")

// POST with JSON
body, err := Fetch("https://api.example.com/submit", FetchConfig{
    Method:  "POST",
    Headers: map[string]string{"Content-Type": "application/json"},
    Body:    `{"key":"value"}`,
})

// Binary response (images, files, PDFs)
data, err := FetchBytes("https://api.example.com/report.csv")

// With query parameters
body, err := Fetch("https://api.example.com/search", FetchConfig{
    Query: map[string]string{"q": "gothic"},
})
Function Returns Use when
Fetch(url, ...FetchConfig) (string, error) Response body as string JSON, text, HTML responses
FetchBytes(url, ...FetchConfig) ([]byte, error) Response body as []byte Binary responses — preserves every byte via arrayBuffer()

File Helpers

CreateWasmFunc("upload", func() {
    data := GetFileBytes("file-input") // reads <input type="file" id="file-input">
    if data == nil {
        SetText("status", "no file selected")
        return
    }
    _, err := Fetch("/api/upload", FetchConfig{
        Method:    "POST",
        Headers:   map[string]string{"Content-Type": "application/octet-stream"},
        BodyBytes: data,
    })
    if err != nil {
        SetText("status", "upload failed: "+err.Error())
        return
    }
    SetText("status", fmt.Sprintf("uploaded %d bytes", len(data)))
})

GetFileBytes(id string) []byte reads the first selected file from a <input type="file"> using the browser's FileReader API. Blocks until reading completes. Returns nil if no file is selected.


JS Function Bridge

Expose Go functions as globally callable JS functions for use in onclick, oninput, and similar HTML event handlers:

Function JS signature
CreateWasmFunc(name, fn func()) name()
CreateWasmStringFunc(name, fn func(string)) name(str)
CreateWasmBoolFunc(name, fn func(bool)) name(bool)
<button onclick="increment()">+1</button>
<input oninput="setFilter(this.value)" />

Per-Page WASM Compilation

The gothicframework wasm command (run automatically during gothicframework dev) scans all pages with ClientSideState and compiles a separate TinyGo binary for each:

WASM  fetchtest    → 286KB → 90KB  (brotli)
WASM  counter      → 153KB → 44KB  (brotli)
WASM  localtests   → 251KB → 82KB  (brotli)
  • Binaries are cached by checksum — unchanged pages are skipped on rebuild.
  • Compression is configurable per page: WasmCompression: routes.BROTLI or routes.GZIP.
  • The bootstrap script and wasm_exec.js are injected automatically into the page's <head>.
  • Dead-code elimination is applied at the TinyGo level — only code reachable from ClientSideState is included.

Stateful Components

Components with their own WASM state can be lazy-loaded into any page:

@gothicComponents.StatefulComponentOf(&components.MyWidgetConfig) {
    <div class="animate-pulse h-12 w-full bg-gray-700 rounded"></div>
}

The children slot is shown as a loading placeholder while the component binary loads. StatefulComponentOf reads the route path from RouteConfig.Path — no magic strings.


AST-Based Code Generation

The entire WASM pipeline now uses proper Go AST parsing instead of regex and string slicing:

  • pkg/helpers/wasm/astx: loads user packages, extracts ClientSideState bodies, and performs tree-shaking using go/packages and go/ast.
  • typeRef interface (Named, SliceOf, MapOf, PointerOf, PointerOf): structural type representation replaces string-sliced type names throughout the codec generator.
  • reflect.StructTag parsing replaces manual strings.Fields / strings.Trim for struct tag extraction.
  • bufio.Scanner replaces strings.Split(string(body), "\n") in checksum parsing.
  • Rewrite functions (rewriteAutoKeys, rewriteContextCalls) return (string, error) with positioned errors instead of silently falling back to regex.

This eliminates an entire class of subtle bugs where type names containing generics, pointers, or nested brackets would confuse the old string-based parser.


What Changed

New Packages

Package Purpose
pkg/wasm User-facing WASM API — stubs + embedded runtime binary
pkg/wasm/wasm-runtime/runtime TinyGo WASM runtime: DOM, HTTP, context, observables, scheduler
pkg/helpers/wasm CLI-side pipeline: scan, rewrite, codec generation, build, cache, compress
pkg/helpers/wasm/astx AST package loader and ClientSideState extractor
pkg/helpers/routes/wasm_bootstrap.go HTML bootstrap script injection

New RouteConfig Fields

Field Type Description
ClientSideState func() Marks the page as WASM-enabled; body is compiled to a binary
WasmCompression CompressionMethod routes.GZIP (default) or routes.BROTLI

New Commands

Command Description
gothicframework wasm Compile WASM binaries for all pages with ClientSideState

New Dependencies

Package Purpose
tinygo (external) WASM compilation — must be installed separately
golang.org/x/sync/errgroup Concurrent file generation in cmd/init.go

@felipegenef felipegenef changed the title V2.16.0 add state control with wasm WIP: V2.16.0 add state control with wasm May 14, 2026
@felipegenef felipegenef self-assigned this May 21, 2026
@felipegenef felipegenef added enhancement New feature or request wasm Client state control and DOM manipulation via Web Assembly modules labels May 21, 2026
@felipegenef felipegenef marked this pull request as draft May 21, 2026 00:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request wasm Client state control and DOM manipulation via Web Assembly modules

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant