Skip to content

feat: typed reactive API — production runtime, h rewrite, sub-packages#30

Merged
joaomdsg merged 148 commits into
mainfrom
feat/types-api
May 27, 2026
Merged

feat: typed reactive API — production runtime, h rewrite, sub-packages#30
joaomdsg merged 148 commits into
mainfrom
feat/types-api

Conversation

@joaomdsg

@joaomdsg joaomdsg commented May 16, 2026

Copy link
Copy Markdown
Member

Summary

Typed reactive surface

  • Signal[T], State[T] split into StateTab / StateSess / StateApp, Mount[C], DecodeForm[T], GetSess[T], typed query params
  • Typed shape handles + specialized Op(ctx) chains; Update(ctx, fn) transform API
  • StateSess / StateApp writes auto fan-out to subscribed tabs
  • App.Broadcast / App.BroadcastSignals for cross-tab pushes
  • Lifecycle: Initializer / Connector / Disposer interfaces; Stream(ctx, interval, fn) helper

Production middleware & server hardening

  • RequestID, AccessLog, Recover, HSTS, RedirectHTTPS, StrictCSP, RotateSession, WithMaxContexts, Defaults
  • WithReadTimeout / WriteTimeout / IdleTimeout / ReadHeaderTimeout, App.HTTPServer() factory
  • WithNotFound custom 404, panic at registration on route conflicts
  • HandleStatic for embed.FS-backed assets
  • Pluggable Logger interface + SlogLogger adapter; via.Log(ctx) threads via_tab + rid

Sub-packages

  • via/sess — typed sessions (PutSess, GetSess[T], ClearSess)
  • via/mw — pre-built middleware
  • via/on — event bindings (Debounce, Throttle, Prevent, Stop, Key, SetSignal)
  • via/vt — name-addressed test client; NewCtx for direct action unit tests
  • internal/spec — action-trigger plumbing

h package

  • Reimplemented without gomponents
  • Adds Each / EachIndexed / When / Group / Fragment / Switch / Case / Default / Classes / ClassMap / IfStr

Plugins ported to typed API with tests: picocss, echarts

Echarts dense time-series toolkit (split into plugin.go / chart.go / runtime.go / series.go / doc.go with paired tests; 99.0% coverage)

  • Safety: marshal-error returns on SetOption / SetSeries / AppendData; eager WithInitialOption validation; mustJSON strict-panic; whitespace + empty-string panics on WithElementID / WithClass / WithVersion / WithSource; window.__viaCharts registry replacing per-chart var globals; ResizeObserver cleanup on Dispose; SetTheme preserves WithGroup linkage
  • Chart options: WithTimeAxis, WithYAxisRange, WithYAxisFormat, WithRightYAxis, WithDataZoom, WithCrosshair, WithCompactGrid, WithPalette, WithLegend, WithGroup, WithClass, WithSource, WithInitialOption
  • Series helpers: Pie, Scatter, Heatmap, LineDense, LineAreaDense, ScatterDense, plus composable SeriesOption pattern (Dense, Filled, Stacked, Color, LineWidth, Symbol, Smoothed, Stepped, EndLabel, ConnectNulls, Progressive, Silent, YAxisIndex, MarkLine, MarkArea, Field escape hatch). Utilities: Points, Tail
  • Runtime methods: SetTitle, SetSubtitle, SetLegend, SetYAxisRange, SetXAxisRange, SetTheme, SetLoading, Resize, Clear, Dispose, AppendPoint, AppendXY, AppendXYAt
  • Package doc rewritten as a worked dense-time-series example; runnable godoc examples in example_test.go

Examples: counter, countercomp, counterscope, greeter, pathparams, picocss, sysmon (modernized to use the new echarts toolkit), auth (typed sessions), plus new todos / feed / upload

Perf

  • Render buffer pool, cached method indices, cached reflect.ValueOf per Ctx
  • Brotli compression on SSE
  • CI alloc gates per benchmark

Introspection: App.Routes(), App.Compositions(), App.LiveTabs()

Runtime split into focused files (broadcast, push, server, document, encoding, info, intents, ctx, sse, stream, walker, file, form, csp, log, …)

Test plan

  • ci-check.sh green: gofmt, go vet, build all packages, build all 11 examples, go test, alloc gates
  • go test -race ./... clean
  • Allocation budget: CounterRender ≤180, CounterAction ≤149, CounterActionWithLogger ≤149
  • Integration test exercising the full production stack (Defaults + RotateSession + HSTS + RedirectHTTPS + StrictCSP)
  • CodeQL + dependency-review workflows added

joaomdsg added 30 commits April 20, 2026 10:45
- Add `via.Composition` interface: `View(ctx *via.Ctx) h.H`
  - Add `Mount[C]` and `MountOn[C]` generic factories
  - Add `cmpDescriptor` with one-time reflection walk at mount
  - Decode `path:"x"` struct tags into {x} route params via strconv
    (string/int*/uint*/float*/bool)
  - Panic at registration on: non-struct C, missing View, unsupported
    field kind, path tag with no matching route segment
  - Pool *C instances via sync.Pool per descriptor
- Move v0.2.x code to internal/viaold/
- Move h/ package from old/ to root
- Delete mount.go and composition_new_test.go (old API)
- Add markdownlint config
- Update ci-check.sh for new structure
- Add via-implementation-plan.md
- Add sysmon example
- Add datastar.js
…+action handlers, http.Handler App

- App now implements http.Handler so via composes into any std mux.
- Mount[C] reflects once per type, caches a cmpDescriptor with signal/
  state/path/action slots, and registers a route handler that allocates
  a fresh *C, decodes path params + signal init tags, optionally calls
  Init, renders the view inside the HTML5+datastar envelope, and parks
  the *Ctx in the registry for the SSE stream and action POSTs.
- Signal[T]/State[T] are zero-value-usable typed handles. Storage lives
  in the composition struct field; slot/key are stamped per-request.
  Get/Set take ctx; Bind/Text/Show return h.H attributes/nodes.
- SSE handler drains a coalescing patch queue (elements last-wins,
  signals merged, scripts concatenated, redirect preempts).
- Action handler dispatches POST /_action/{methodName} to the bound
  *C method by reflect, applying request-side signal updates first
  and flushing dirty bits on return.
- WithMaxRequestBody / WithActionErrorHandler / WithHTTPServer carry
  over the production hardening from main.
- Removed Config() accessor and its lifecycle-getter tests; defaults
  are now observable through behaviour.
- on.Click/Change/Input/Submit/Key build Datastar @post attributes from
  bound method values. The method name is recovered via reflect on the
  closure PC and runtime.FuncForPC, with the -fm bound-method suffix
  stripped.
- via.MethodName, via.ActionFn, via.TriggerSpec, via.TriggerOption are
  the contracts the on/* package consumes. Modifiers (Prevent, Stop),
  timing (Debounce, Throttle), and key filters (on.Key) chain via
  TriggerOption.
- New tests cover: rendered Bind/Text/Show attributes, default key
  derivation from field names, init=… tag values surfacing in initial
  data-signals, on.Click rendering /_action/<method>, 404 on unknown
  methods, MethodName resolving bound methods.
…State[T] re-render integration

- scope.User[T] and scope.App[T] are session/app-scoped reactive
  values. Distinct types keep mismatched scopes a compile error.
  Set marks the current Ctx dirty via the new exported
  via.MarkDirty(ctx) helper.
- via/test exposes Client / Action / Signal / SSE for integration
  tests. NewClient does the GET, picks up the tab id from the
  rendered HTML, and authenticates subsequent action POSTs and SSE
  reads with the right cookies.
- State[T] now exercised end-to-end: a counter mounted at /,
  three Inc POSTs, an SSE consumer observes <p>3</p> arrive in the
  flushed element patch.
- Path-param tests cover int + string decoding from {id}/{slug} and
  panic-at-Mount on tag/route mismatch.
- Descriptor walk is now recursive: composition-typed struct fields
  produce qualified wire keys ("Card.title" instead of "title")
  so siblings of the same type don't collide. fieldPath replaces
  fieldIndex on signal/param slots; runtime addresses nested fields
  through reflect.Value.FieldByIndex.
- A child satisfies the recursion if its pointer type implements View
  and the type lives outside the via package — keeping the walk away
  from Signal[T]'s own private struct fields.
- internal/examples/counter/main.go: end-to-end demonstration —
  via.State[int], via.Signal[int] with init=1, on.Click(c.Inc), and
  the App handed straight to http.ListenAndServe.
- App.RegisterAppSignal(key, value) seeds a named, app-wide value into
  every page's <meta data-signals>. Plugins use it to publish client-
  driven signals ($_picoTheme, $_picoDarkMode) without a server-side
  reactive handle.
- Ctx.ExecScript / Redirect / Sync round out the patch surface that
  plugins (and apps) need: queue a script, ask the browser to navigate,
  or force a re-render.
- plugins/picocss is a near-mechanical port: same fetch-on-Register,
  same per-theme caching, etag handling, gzip variants, blocking head
  script for zero-flash theme switch. Initial signals now go through
  RegisterAppSignal instead of the old AppSignal handle.
- plugins/echarts ports verbatim — its only via API touchpoint is
  AppendToHead and Ctx.ExecScript, both unchanged in the new API.
- pico_test.go exercises the plugin against the real CDN; -short skips.
- greeter: a Signal[string] driven by two action methods. Demonstrates
  the typed init=value tag.
- pathparams: path:"counter_id" / path:"start_at_step" decoding
  into typed string and int fields. picocss layout for visual parity
  with the viaold version.
- countercomp: two CounterCard children mounted on one Page.
  Showcases the v1 nesting model — children render their own state and
  receive the click handler from the parent so each card dispatches a
  distinct action (IncA / IncB).
- picocss: large showcase page using the picocss plugin's new
  ThemeRef/DarkModeRef helpers. The plugin's RegisterAppSignal seeds
  $_picoTheme and $_picoDarkMode into every page's data-signals.
- pico.go gets ThemeRef() and DarkModeRef() helpers so callers don't
  have to hard-code the underscore-prefixed signal names.
- cmpDescriptor caches viewIdx, initIdx, disposeIdx at Mount time;
  per-request hot path now does cmpVal.Method(idx) directly instead
  of MethodByName (which scans the method table on every call).
- renderBufPool sync.Pool reuses 8 KiB *bytes.Buffer across element-
  patch flushes. Outsize buffers (>1 MiB) are dropped on Put rather
  than retained.
- BenchmarkCounterRender / BenchmarkCounterAction added under -benchmem
  to track regressions. Local result: ~204 allocs/render and ~142
  allocs/action — sets the floor for further pooling work.
- Quick start now shows a Counter struct with via.State[int], a
  via.Signal[int] tagged with init=1, on.Click(c.Inc), and the App
  passed straight to http.ListenAndServe.
- Documents the four state scopes (Signal, State, scope.User, scope.App),
  the lifecycle hooks (Init/Dispose/View), the on/* event surface,
  path:"…" tag decoding, plugin registration, and the via/test
  client.
- Updated the configuration table to reflect the production-hardening
  options (max body, action error handler, http.Server hook).
- A composition method OnConnect(ctx) error fires once when the SSE
  stream is established for that tab. Bots that hit GET without ever
  opening the SSE stream don't trigger it, so expensive background
  goroutines (tickers, fan-out) live in OnConnect rather than Init.
- Descriptor caches initIdx, connectIdx, disposeIdx alongside viewIdx
  to keep dispatch reflection-light.
- sysmon example demonstrates the new hook: chart options seeded in
  OnConnect, then a per-tab ticker goroutine reads CPU/RAM/disk/network
  counters every 1 s, pushes State updates, and replaces ECharts series
  data — all inside the existing patch queue.
- removeExpiredContexts sweeps tabs whose lastAccess hasn't been
  touched within contextTTL (default 15 minutes); the goroutine ticks
  on every contextTTL/2 and runs disposeCtx on each expired entry,
  which closes Done and invokes the optional Dispose method.
- TestDispose_runsOnAppShutdown asserts Dispose fires when an active
  Ctx still in the registry is shut down — closes the parity gap with
  the previous Cmp-style Dispose hook.
- Ctx.reflectArgs is a single-element [reflect.Value] array boxed
  with reflect.ValueOf(ctx) at request entry. Init/View/OnConnect/
  Action/Dispose dispatch all reuse the same slice instead of
  re-allocating one per call. Drops per-action alloc from 142 → 143
  allocs/op (the slice elision wins go to render-buffer reuse on
  state flushes), trims ~150 B/op.
- Stream wires the most common OnConnect pattern — ticker goroutine
  + auto-flush on tick — into one call. The goroutine stops on
  ctx.Done; panics inside the callback are recovered and logged
  rather than crashing the server.
- After each fn returns, dirty signals/state are auto-flushed so the
  callback only has to call Set; no explicit Sync().
- Tests cover the happy path (3+ ticks observed in SSE element
  patches) and disposal (no goroutine leak after SSE cancel + close
  beacon, race-clean).
- h.Each(items, fn) renders one node per element. Drops the boilerplate
  of building a []h.H + spreading it as variadic. Empty input renders
  nothing — useful when the list is a State[T] whose initial value is
  empty.
- h.EachIndexed passes the index alongside the value for keyed lists.
- h.When(cond, fn) is If with a builder, so node construction is lazy:
  expensive pieces only get built when the condition is true.
- h.Group bundles many H nodes into one so functions can return a
  multi-node fragment from a single return path. Uses a tiny []gNode
  wrapper and Render iterates it.
- All four covered by table-style tests in h_test.go.
- Mount now validates the signatures of View, Init, OnConnect, Dispose
  at registration time, not at first dispatch. Wrong signatures panic
  with the format-the-fix-yourself expected form so the user sees the
  exact method header to write.
- Missing View names the offending type and includes the example
  signature inline.
- Tests cover: missing View, View without ctx, Init returning nothing,
  Dispose without ctx — each asserting the panic mentions the offending
  method's name.
- on.SetSignal(&c.Field, value) is a TriggerOption: it prepends a
  client-side $key=value statement to the rendered Datastar
  expression so the signal updates synchronously before the @post
  fires. The action handler reads the new value through the standard
  signal-injection path.
- Bundling pre-statements went into TriggerSpec.Pre + AppendPre on
  the via package so the on/* sub-package owns rendering but doesn't
  monopolise the data shape.
- Tests: int values ($step=5) and string values ($theme="red")
  both render with HTML-escaped quotes and a leading semicolon
  separator before the @post.
…request handle

Before: scope.User[T] stored the value inline on the handle (which is
allocated fresh per request), so a Set in one tab couldn't be Get-ed
in another tab of the same session — silent data loss with the
exact-opposite of the documented semantics. scope.App[T] had the same
shape, even worse (no sharing across sessions).

Fix: storage moves to the via runtime.

- via.SessionLoad/Store(ctx, key, value) reads/writes the per-session
  sync.Map. scope.User wraps these.
- via.AppLoad/Store(ctx, key, value) reads/writes a sync.Map on App.
  scope.App wraps these.
- Descriptor walk recognises scope.User[T]/scope.App[T] fields and
  records a scopeSlot with the qualified wire key (parent.field path
  for nested compositions). Per request the runtime writes WireKey
  (now an exported field) into each handle so Get/Set know what to
  look up.
- getOrCreateSession also AddCookie's the freshly-minted cookie onto
  r so renderPage's sessionFromRequest call (later in the same chain)
  finds the session it just created — needed because the session is
  set on the response, not the request, by default.
- Tests cover both: scope.User write surfaces in the SSE re-render of
  the same tab; scope.App.Bump persists across distinct sessions
  (separate cookie jars).
Group.Use accepted middleware but never applied it — the chain was
recorded on the Group but HandleFunc registered handlers directly on
the app mux, bypassing it. Same for via.MountOn[C](g, route): the
descriptor went straight to the app's route table.

- Group.HandleFunc and Group.Handle now wrap the registered handler
  in applyMiddleware(g.middleware, ...) before mux.Handle.
- MountOn snapshots the group middleware onto the descriptor's
  groupMW slice; registerDescriptor wraps the rendered route handler
  with that chain. Mount[C] (no group) is unaffected.
- Three new tests pin the contract: HandleFunc-wrapped middleware
  sets a header, can short-circuit a leaky inner handler, and
  applies to MountOn-mounted compositions.
joaomdsg added 2 commits May 21, 2026 11:04
Reverses the previous "remove State.Text" commit. All three State
types now share the same Text shape, taking the readCtx interface so
both *via.Ctx and *via.CtxR work:

  func (s *StateTab[T])  Text(_ readCtx) h.H
  func (s *StateSess[T]) Text(rc readCtx) h.H
  func (a *StateApp[T])  Text(rc readCtx) h.H

StateTab ignores the ctx (value lives on the struct) — the parameter
is for signature parity, so a View can call s.Text(ctx) regardless of
whether s is StateTab / StateSess / StateApp.

Removing the helper made callers spell out h.Text(s.Get(ctx)) /
h.Textf("%v", s.Get(ctx)) at every render site, which read worse than
the dedicated helper and forced format-string decisions per type.
Re-adding it with the unified ctx-taking signature gets the symmetry
this task wanted in the first place.
Move three Ctx methods onto a Patch sub-object so they read as the
escape hatch they are, not first-reach:

  ctx.PatchSignal  → ctx.Patch.Signal(key, value)
  ctx.PatchSignals → ctx.Patch.Signals(map[string]any{...})
  ctx.SyncElements → ctx.Patch.Elements(...h.H)
                     ctx.Patch.Element(h.H)  // NEW singular form

The two-step access (ctx.Patch.X) is the tiering signal — a user
typing `ctx.` sees Patch as one entry among the first-class controls
(SyncNow, SyncOff, Session, Cookie, …) and has to opt into it. The
flat naming used Patch* / Sync* awkwardly: "Patch" lived on the
signal side, "Sync" on the element side, while the actual semantics
are reversed (signals get set, not partially patched; elements get
morph-patched, not synchronized).

The new shape matches Datastar's wire protocol — both groups
correspond to datastar-patch-{signals,elements} SSE event names — so
debugging the SSE stream lines up with the method names.

Patch is a thin struct {ctx *Ctx} allocated eagerly in newCtx (same
pattern as the cached *CtxR). Zero-alloc field access after that.

Element(h.H) is a singular convenience over Elements(...h.H) for the
common one-element case, mirroring the Signal / Signals split. Both
keep the original guards (empty key / nil map / nil element / empty
variadic = no-op).
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Comment thread h/escape.go Fixed
Comment thread ctx.go Fixed
Comment thread file.go Fixed
Comment thread log.go Fixed
Comment thread log.go Fixed
Comment thread mw/mw.go Fixed
Comment thread mw/mw.go Fixed
Comment thread mw/mw.go Fixed
joaomdsg and others added 10 commits May 21, 2026 16:07
Phase A of the typed-reactive-ops redesign (master task #17).

Mechanical rename across the four reactive kinds:
  Signal[T].Get(ctx)     → Signal[T].Read(ctx)
  StateTab[T].Get(ctx)   → StateTab[T].Read(ctx)
  StateSess[T].Get(ctx)  → StateSess[T].Read(ctx)
  StateApp[T].Get(ctx)   → StateApp[T].Read(ctx)

Read is the better name once the reactive API gains Op(ctx) chain
operations (Phase B+) — read/op pair as the two halves of "access
the reactive value." Get was a generic accessor verb that didn't
telegraph the framework's role.

Existing Set/Update/Op contracts unchanged. The readCtx interface
parameter (accepts both *Ctx and *CtxR) carries through unmodified.

Swept every caller in tests, examples, and the picocss/echarts
plugins. Stdlib false positives from the gofmt rewrite
(http.Client.Get, http.Header.Get, reflect.StructTag.Get,
textproto.MIMEHeader.Get) were reverted.
Phase B of the typed-reactive-ops redesign.

Every reactive kind (Signal[T] / StateTab[T] / StateSess[T] /
StateApp[T]) gains an Op(ctx) method returning *Ops[T]. The generic
Ops[T] exposes the two universal verbs:

  Op(ctx).Apply(func(T) T)   — custom transform escape hatch
  Op(ctx).To(v)              — replace with a constant value

Both flow through the handle's existing Update path, so atomicity,
dirty marking, and broadcast semantics are unchanged. Phase C will
extend Ops with shape-specific verbs (Add/Toggle/Append/...) via
specialized handle types.

Set(ctx, v) and Update(ctx, fn) continue to work. Op(ctx) is purely
additive — nothing migrated yet, that's Phase D's job. This commit
lays the chain entry pattern.
Phase C of the typed-reactive-ops redesign.

Five shape ops surfaces — each carries the shape's typed verbs plus
the inherited Ops[T] (Apply/To):

  NumOps[T Number]   Add / Sub / Mul / Div / Inc / Dec / Zero /
                     Min(lo) / Max(hi)
  BoolOps            Toggle / True / False
  StrOps             Append / Prepend / Clear
  SliceOps[T any]    Append / Prepend / Pop / Shift / Empty /
                     Take(n) / Drop(n) / Filter(pred)
  MapOps[K comparable, V any]   Put / Delete / Empty

Twenty user-facing specialized types (5 shapes × 4 kinds) that pick
the right ops via Op(ctx):

  SignalNum[T]     StateTabNum[T]     StateSessNum[T]     StateAppNum[T]
  SignalBool       StateTabBool       StateSessBool       StateAppBool
  SignalStr        StateTabStr        StateSessStr        StateAppStr
  SignalSlice[T]   StateTabSlice[T]   StateSessSlice[T]   StateAppSlice[T]
  SignalMap[K,V]   StateTabMap[K,V]   StateSessMap[K,V]   StateAppMap[K,V]

Each wrapper embeds its underlying generic (Signal[T] / StateTab[T] /
StateSess[T] / StateApp[T]) so Read/Set/Update/Text/Key/Bind/Show/Attr
/Style etc. promote for free. Op(ctx) is overridden to return the
shape-typed ops.

Walker discovery moves from name-prefix matching to unexported marker
interfaces (signalMarker / stateTabMarker / stateSessMarker /
stateAppMarker). Each generic carries the marker method; embedded
specialized wrappers inherit it via promotion, so the walker
classifies them correctly without per-name maintenance.

Nothing migrated yet — generic Signal[T] / StateTab[T] / StateSess[T]
/ StateApp[T] still work for custom T. Phase D sweeps tests +
examples to use the specialized types where the common case fits.
Phase D of the typed-reactive-ops redesign.

Field declarations swept across every test, example, and plugin:

  via.Signal[int]      → via.SignalNum[int]
  via.Signal[bool]     → via.SignalBool
  via.Signal[string]   → via.SignalStr
  via.Signal[[]T]      → via.SignalSlice[T]
  via.StateTab[…]      → via.StateTab*…  (same family)
  via.StateSess[…]     → via.StateSess*…
  via.StateApp[…]      → via.StateApp*…

Generic forms retained where T is a custom struct/interface and the
shape doesn't fit a typed bucket (render_test marshalUnfriendly,
on_test unmarshalable, upload example's LastUpload).

Side effect — on.SetSignal still takes *via.Signal[T] (the bound
typed handle), so test/example call sites that wrap with a typed
wrapper now reach for the embedded field explicitly:
  on.SetSignal(&p.Theme.Signal, "red")
Same DX cost everywhere, easy to grep. A future PR can make
on.SetSignal accept any wrapper through an interface; out of scope
for this phase.

README updated with the typed-shape table + verb cheatsheet alongside
the existing generic Update(ctx, fn) examples.

Net effect of the four phases:
  Phase A — Get → Read
  Phase B — Op(ctx).Apply / Op(ctx).To on every handle
  Phase C — typed shape handles (20) + shape ops (5)
  Phase D — codebase migrated to typed shapes
Phase E — read/write naming symmetry.

Pairs naturally with Read(ctx) renamed in Phase A:

  Signal[T].Set(ctx, v)    → Signal[T].Write(ctx, v)
  StateTab[T].Set(ctx, v)  → StateTab[T].Write(ctx, v)

StateSess[T] and StateApp[T] still have no direct write — their
shared-state semantics demand Update(ctx, fn) so the load → fn →
store sequence stays atomic under the per-key mutex (a top-level
"blind write" verb on those would be a race in disguise).

Swept all callers in tests, examples, and plugins. Stdlib false
positives from gofmt -r (http.Header.Set, http.Header.Add) were
reverted.

README + docstrings updated to the Read/Write pair vocabulary.
Phase F of the typed-reactive-ops redesign.

The Update surface gains an error path on every reactive kind:

  Update(ctx, fn func(T) (T, error)) error

fn returns the new value and an optional error. On non-nil error:
  - the store is unchanged (value, dirty bit, broadcast all skipped),
  - the error is returned to the caller.

This is the only mutation path that lets the user reject a write
in-band — useful for validation, conflict detection, budget guards.
The atomic load → fn → store sequence honours the rollback because
the kvStore.Update implementation now stores only on nil error.

Op chain's Apply method is removed. Update IS the fallible/custom
transform entry; the chain is reserved for canned typed verbs.

  // before
  p.Count.Op(ctx).Apply(func(n int) int { return n + 1 })

  // after
  p.Count.Op(ctx).Add(1)                       // typed verb
  err := p.Count.Update(ctx, func(n int) (int, error) {
      if n >= max { return 0, errBudget }
      return n + 1, nil
  })

Write becomes one-line sugar over Update:

  func (s *StateTab[T]) Write(ctx *Ctx, v T) {
      _ = s.Update(ctx, func(T) (T, error) { return v, nil })
  }

Swept every Update caller across tests, examples, plugins, and
shape ops. Stdlib false positives (http.Header.Set, etc.) from the
gofmt -r rewrite were reverted. Linter (errcheck) flagged the
discarded errors at infallible Update sites; those got an explicit
`_ =` to make the intent visible. The shape ops methods (Add /
Toggle / Append / …) discard internally since their closures can't
fail.

A new test (TestOp_UpdateErrorRejectsTheWrite) pins the rollback
contract: a failing Update must leave the previous value intact.

README updated with the new fn shape and an "Update returns error"
example.
…(ctx)

Phase G of the typed-reactive-ops redesign.

With Write available on per-tab kinds (Signal[T] / StateTab[T]) and
Update being the universal mutator for shared kinds, the chain's To(v)
verb is redundant — and dropping it preserves the framework's
"no blind Write on shared state" stance: a user who really wants to
discard the old value on StateSess/StateApp must spell out
Update(ctx, func(T) (T, error) { return v, nil }), so the friction
is in front of their eyes.

Cascading cleanup:
  - To removed from the shape ops surface.
  - The base Ops[T] had no exported methods left → renamed to
    unexported ops[T], used purely as an internal embed.
  - Op(ctx) removed from the four generic kinds (Signal[T] /
    StateTab[T] / StateSess[T] / StateApp[T]) — without To, it
    returned an empty type with no user-facing purpose.
  - Op(ctx) survives only on specialized types (SignalNum,
    StateTabBool, etc.) where it returns the shape-typed ops.

Shape constants that used to call o.To inline (BoolOps.True/False,
NumOps.Zero, StrOps.Clear, SliceOps.Empty, MapOps.Empty) now build
their own update closure directly. Cheap, explicit.

Sweep — every Op(ctx).To callsite swept:
  per-tab kinds (Signal*, StateTab*)  → handle.Write(ctx, v)
  shared kinds (StateSess*, StateApp*) → handle.Update(ctx, fn)

README updated with the new "Write vs Update" guidance.

Net surface after Phases A–G:

  Read              uniform across the family — handle.Read(ctx)
  Write             per-tab handles only — handle.Write(ctx, v)
  Update            uniform — handle.Update(ctx, fn func(T) (T, error)) error
  Op(ctx).<verb>    typed shape verbs on specialized handles
                    (SignalNum/Bool/Str/Slice/Map and the StateTab/
                    Sess/App equivalents)
Task 4 of the original DX punch list.

Returning *via.RedirectError or *via.ToastError from an action handler
to trigger a side effect was clever but conflated two concepts:

  - Go errors mean "this failed."
  - via.Redirect / via.Toast meant "this succeeded, and also do this."

Readers had to know the framework's special sentinel-unwrap to
interpret the return — and the imperative form (ctx.Redirect,
ctx.Toast) already did the same thing without smuggling intent
through the error channel.

Removed:
  - intents.go (RedirectError + Redirect + ToastError + Toast)
  - intents_test.go
  - The sentinel-unwrap branch in runAction

Sites that returned the sentinels now use ctx.Redirect / ctx.Toast
imperatively before `return nil`. README updated.

Net effect: one less concept to learn. Errors mean errors; side
effects go through ctx methods.
Task 7 of the original DX punch list.

The framework discovers OnInit / OnConnect / OnDispose by method-name
reflection at Mount, so no public interface was required. But that
meant the lifecycle hooks were invisible to Go tooling — users had to
read the README to learn they existed and what their signatures were.

Declared three optional interfaces alongside Composition:

  type Initializer interface { OnInit(ctx *Ctx) error }
  type Connector   interface { OnConnect(ctx *Ctx) error }
  type Disposer    interface { OnDispose(ctx *Ctx) }

Satisfying them is not required — Mount still works on the same
reflection path. But declaring them on a composition:

  - documents the hook signature inline,
  - makes hooks discoverable via `go doc`, IDE jump-to-interface,
    and stdlib-style "implements" exploration,
  - lets the compiler catch signature drift earlier (a stray return
    type or extra parameter becomes a "does not satisfy" error).

Naming follows Go's one-method-per-interface convention
(io.Reader → Read, fmt.Stringer → String).
ExecScriptf duplicated ExecScript(fmt.Sprintf(...)); Ticker.Paused was
an unused observation on a controller; Page existed only for editor
autocomplete of optional hooks already documented by the
Initializer / Connector / Disposer interfaces.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@joaomdsg joaomdsg changed the title feat: typed reactive API + production middleware stack feat: typed reactive API — production runtime, h rewrite, sub-packages May 21, 2026
joaomdsg added 6 commits May 22, 2026 08:31
…tate

Quick Start no longer compiles against current API: View takes *CtxR,
StateTab.Text takes ctx, and the typed shape handles (StateTabNum /
SignalNum) match the real counter example. Reactive-state prose still
referred to Set after the Set→Write rename, and Security still pointed
at RotateSession after the move to sess.Rotate.
Lead with what Via is and who it's for. Promote honesty sections
(What Via is NOT, Restart and tab survivability) above the fold so
production constraints are visible before a reader invests. Compress
the four-reactive-shapes reference middle; escape-hatch trivia moves
to godoc.
Replaces a timing guess (time.Sleep(20 * time.Millisecond)) with a
deterministic wait for the SSE goroutine to enter its select loop.

runSSEStream now writes a `: ready\n\n` SSE comment immediately after
OnConnect and the backlog drain, just before entering the select loop.
SSE comments are silently ignored by Datastar (and any conformant
client) per spec, so this adds no event surface for real consumers — it
only gives the test harness a deterministic signal that an action POST
won't race the handshake.

Adds vt.Client.SSEReady and a matching rawTabClient.OpenSSEReady in
form_test that open the stream and consume the comment off the channel
before returning. All 49 call sites of the
`tc.SSE() + time.Sleep(20*time.Millisecond)` idiom across 12 test files
migrate to the new helpers, eliminating the suite's last source of
wall-clock-timing brittleness on slow CI runners.
Six verbs previously had 0% statement coverage. Extends the existing
shapePage + TestShape_NumOps / TestShape_SliceOps scaffolding with
matching action methods and SSE-frame assertions, following the same
behavioral-through-the-public-API pattern as the surrounding tests.

Also migrates this file's SSE handshake waits to vt.Client.SSEReady,
landing alongside the suite-wide migration in the preceding commit.
AccessLog and Recover scrub CR/LF from r.Method, r.URL.Path, and rid
before logging; defaultLogger scrubs the assembled line. Closes
CodeQL alerts #3-#7 (go/log-injection).

File.Save now propagates os.File.Close errors via named return so a
flush failure on the writable path is surfaced instead of silently
dropped. Closes CodeQL alert #8 (go/unhandled-writable-file-close).

Adds 3 regression tests in middleware_test.go covering forged path,
forged rid, and forged method on the panic path.
Restyles the counterscope example to match the rest of the demos: pico
header + nav with a brand link, a two-column grid for the Local/Shared
cards (HGroup + Article + Footer with full-width +1 buttons), big
tabular-num counters, centered footer.

Adds an upright lightning-bolt logo (closed path, currentColor stroke,
square caps) inline as viaLogo and saves the same SVG at repo root as
logo.svg for reuse elsewhere.
Comment thread log.go Fixed
Comment thread log.go Fixed
Comment thread mw/mw.go Fixed
Comment thread mw/mw.go Fixed
Comment thread mw/mw.go Fixed
joaomdsg added 5 commits May 26, 2026 21:39
…e-1.0 wording

- Embed logo.svg as a centered hero above the badges.
- Document mw.AccessLog/Recover CR/LF scrub (CWE-117) and the
  RedirectHTTPS vs RedirectHTTPSStrict split.
- Rephrase the pre-1.0 NOT-bullet so it parses cleanly.
Reorganize plugins/echarts into concern-named files (plugin.go,
chart.go, runtime.go, series.go, doc.go) with paired tests; all under
CONVENTIONS' 300-line guideline. Coverage at 99.0%.

Safety hardening:
- SetOption / SetSeries / AppendData return marshal errors instead of
  silently emitting _c.setOption(null)
- WithInitialOption validates input eagerly; mustJSON panics on
  internal marshal failure (was returning "null")
- WithElementID / WithClass panic on whitespace; WithVersion /
  WithSource panic on empty strings
- window.__viaCharts registry replaces per-chart `var echart_N` /
  `var echart_ro_N` window globals
- ResizeObserver callback guards against null.resize() after Dispose
- SetTheme preserves WithGroup linkage across the dispose+reinit

Chart options for streaming dashboards: WithTimeAxis, WithYAxisRange,
WithYAxisFormat, WithRightYAxis, WithDataZoom, WithCrosshair,
WithCompactGrid, WithPalette, WithLegend, plus WithGroup, WithClass,
WithSource, WithInitialOption.

Series helpers: Pie, Scatter, Heatmap, LineDense, LineAreaDense,
ScatterDense. Composable SeriesOption pattern with Dense, Filled,
Stacked, Color, LineWidth, Symbol, Smoothed, Stepped, EndLabel,
ConnectNulls, Progressive, Silent, YAxisIndex, MarkLine, MarkArea,
Field (escape hatch). Utilities: Points (zip parallel slices), Tail
(sliding-window trim).

Runtime methods: SetTitle, SetSubtitle, SetLegend, SetYAxisRange,
SetXAxisRange, SetTheme, SetLoading, Resize, Clear, Dispose,
AppendPoint, AppendXY, AppendXYAt.

Package doc rewritten around the dense-time-series story as a
worked example; runnable godoc examples in example_test.go.

sysmon modernized: dropped local timeAxisOpt / lineSeries helpers
in favour of WithTimeAxis + WithCompactGrid + WithYAxisRange /
WithYAxisFormat at chart construction, plus LineDense in the stream
callback. OnConnect's per-chart SetOption setup pass removed —
config now baked at OnInit time.
@joaomdsg
joaomdsg merged commit 0268886 into main May 27, 2026
5 checks passed
@joaomdsg
joaomdsg deleted the feat/types-api branch May 27, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants