Architecture Solid Client Runtime

Solid client runtime

Status: accepted for the client-route migration pilot.

Decision

Use Templ, HTMX, and Alpine for server-owned pages. Use ordinary Solid TSX for routes whose interaction state is client-owned. A DOM subtree has exactly one renderer owner: do not mount Solid inside an HTMX swap target or mount React and Solid into the same root.

The server remains authoritative for persisted data, RBAC, tenant identity, and business validation. Solid owns transient interaction state such as an editable draft, selection, pending input, and local preview. Route metadata and RPC contracts describe access and data, not the UI. There is no screen DSL, schema-driven layout, or feature-local host.

Go-owned feature API

Keep a Solid screen beside its owning Go presentation code and declare the connection once:

type Props struct { ProductID string `json:"productId"` } var Editor = solid.Import[Props]( "./ProductEditor.tsx", solid.WithRPC( solid.Query[LoadRequest, LoadResponse]("products.load", solid.Cacheable(2)), solid.Action[SaveRequest, SaveResponse]("products.save", solid.Invalidates("products.load")), ), )

Use application.ClientRoute("products.configure") on the existing Go route, set clienthost.Route.Feature to Editor, and return RoutePayload{Screen: Editor.Render(props)}. The import is declarative and does no I/O at package initialization.

Run iota-solid before frontend typecheck/build. It writes ProductEditor.solid.generated.ts beside the component and one generated lazy catalog in the frontend workspace. Both files are committed and checked with iota-solid -check. The generated module exports Props, RPC, rpcContracts, and a default component whose signature is checked against the Go props. iota-solid -watch reconciles add, move, and delete changes in dev.

The standard catalog host owns lazy loading, safe bundle errors, Retry, mount, and dispose. A feature owns only the states inside its mounted screen. Adding a second feature must not require a handwritten central registry, mount function, asset tag, or TypeScript route table.

Public boundaries

  • application.ClientRoute(routeID) declares a generated Solid feature route; the imported descriptor supplies its stable feature identity. New client routes also declare Public, Authenticated, RequireAll, or RequireAny explicitly.
  • pkg/clienthost renders the authenticated shell fragment and versioned JSON bootstrap. It carries SDK identity, route identity, session/CSRF state, locale messages, permissions, theme, and service endpoints.
  • @iota-uz/sdk/solid contains the Solid mount, host services, typed RPC primitives, managed streaming subscriptions, and the small draft primitive.
  • @iota-uz/sdk/styles.css publishes the same token source used by server components. Features may compose normal CSS and Solid components but must not introduce a competing global reset, font, palette, or theme model.

@iota-uz/sdk/client-host remains available for existing React consumers during migration. React renderer metadata is accepted only as compatibility; new routes use the generic client renderer. The two renderers never share a mounted subtree.

Lifecycle and failure semantics

The Solid host validates bootstrap protocol plus Go/npm/source identity before rendering. Mount fails if another renderer already owns the root. Dispose is idempotent and releases the Solid owner, session-independent host subscriptions, and dirty navigation listeners.

Queries cancel the previous reactive request and ignore late results. Actions never retry implicitly and expose typed field errors. createDraft deep-clones the server snapshot, tracks nested changes, supports reset, and accepts a save response only when no newer edit occurred. A newer server snapshot is not merged into a dirty draft; the feature owns that merge policy.

useManagedStream subscribes to one authenticated SSE stream per route lifetime through the host: session/CSRF headers and typed 401 re-auth, bounded reconnect with jitter, heartbeat stale detection, Last-Event-ID/cursor replay, and one deduplicated terminal event. The subscription closes with the owning Solid root, so route unmount cancels the request and releases resources. Stream events fold into feature state through one callback; the contract may declare RPC cache families to invalidate, but never a universal merge policy. Lifecycle states are exposed as a signal and to host telemetry under iota.stream.<state> with the bounded stream identity.

Deliberate exclusions

This runtime does not contain product or insurance code, a universal state framework, optimistic rollback policy, streaming cache merge, or generated screen structure. It generates data contracts and the lazy catalog, never UI layout. Full locale TOML generation/validation and replacement of the remaining React features are separate follow-up work.

Last updated on