Morph armor — protecting client/wizard state from server morphs
A region whose CHILDREN carry in-flight state (a multi-step wizard body, a component-owned DOM island) must survive the page-wide morphs a land-and-stream app fires on every publish. Verified against the bundle source (gf-51):
data-ignore-morph— when BOTH the DOM node and the incoming node carry it, the morph walk returns the node untouched, subtree included. This is the tool for subtree state. (One side only ≠ armor.)- Replace-mode patches bypass the morpher (plain
replaceWith) — which is how the armored region's OWNER updates it: the server command answers withPatchElements(html, WithModeReplace()), and the rendered fragment re-carries the id + the armor attribute so later patches and morphs behave the same. data-preserve-attris NOT armor — it shields the listed attributes (openon a dialog, inlinestyle) while the children still morph. Right tool for keeping a dialog open across a morph; wrong tool for keeping what's inside it.
The worked example: the connector wizard (connWizDialog in
view/settings.templ, its commands in handler/connector.go) — a
stepped dialog whose station is derived from what the org has saved,
each command a 204 + org publish, the page's own fat-morph re-landing
the next station. (The CSV import wizard that first carried this armor
retired with the seam; the lab's mock (system-css/site/lab) keeps its shape.)
Two more morph/fetch traps, verified in the bundle source (gf-52):
-
data-on:submitALWAYS prevents the native submit (bundle:el instanceof HTMLFormElement && event === "submit" && preventDefault(), with or without__prevent). A nativemethod="post"form must carry nodata-on:submit; a Datastar form isdata-on:submit__prevent+@post(url, {contentType: 'form'})+ a 204 (gf-358, the contact dialog lost a day of messages to this). -
File uploads need
enctype="multipart/form-data"ON THE FORM.@post(url, {contentType: 'form'})builds a FormData, but ships it as the body only when the form declares that enctype — otherwise the bundle flattens it toURLSearchParamsand every file input silently drops (server sees "no file"). The attribute is load-bearing even though no native submit ever happens. -
A morphed checkbox freezes after its first flip. The morpher diffs
checkedby comparing the ATTRIBUTE on old-vs-new but writes only the PROPERTY, leaving the old attribute stale — every later morph false-negatives. Fix: key the input'sidby state (e.g.act-7-on/act-7-off) so old and new never pair and the morph replaces the node.
Rocket gotchas (each cost a real debugging cycle here)
-
A rocket host RE-RENDERS its template on every prop change (gf-323, learned on the virtual-scroll bench, retired gf-324): every
data-attr:*landing after first render runsrenderagain, so element references captured inonFirstRenderpoint at detached nodes while Datastar keeps patching the live ones by id. Re-query the nodes at the top of every reset (host.querySelector('[data-viewport]')), and pass STATIC props as plain attributes — only a value that truly changes ridesdata-attr:. The reference virtual-scroll also averaged empty blocks into a running height total; measure only what holds rows, by its own row count. -
A rocket that renders its own subtree must OWN it end to end (gf-323, on prod): the page-wide morph on every publish deletes what the server did not send, so the host needs
data-ignore-morph— and that same flag makes Datastar skip any patch aimed inside the subtree, the rocket's own block replies included. Apply those by hand (parse thedatastar-patch-elementsevent, swap the live element's children by id) instead of dispatching them back to Datastar. Verify under publishes, not on a quiet page: the scratch server showed nothing because nothing published. And any scroller that re-positions content above the viewport setsoverflow-anchor: noneon it, or Chrome's scroll anchoring will move scrollTop after every re-position and the scroller will chase its own tail. -
$$nameis the instance-local signal. Insiderender, write$$da,$$mode; insetup,$$.da = ''. Rocket rewrites$$to a per-instance path, so multiple instances on a page don't collide. Helpers are still plain JS onwindow, called with$$values as args (props-down, unchanged from the recipe). -
Register actions with
action(name, fn)— not$$.fn = …. Defining$$.commit = () => {}does not make@commit()callable; you get "Undefined Rocket action: commit." Destructureactionfrom the setup context and callaction('commit', () => {...}). -
Local actions take NO arguments — pass data via a signal. Calling
@pick(cellIso($$v,i))makes Rocket rewrite the dispatch and recurse into aMaximum call stack size exceeded. The working pattern mirrors no-arg@commit(): set a signal first, then call the bare action which reads it —data-on:click="$$clicked=cellIso($$v,i); @pick()", and inside,const iso = $$.clicked;. -
Boolean-coerce every
data-attrexpression with!!(...). Rocket sets an attribute whenever the expression yields a string;$$da && …returns''(empty string, notfalse) when$$dais empty, sodata-ui-selected=""lands on every cell and presence-based CSS ([data-ui-selected]) matches all of them.data-attr:data-ui-selected="!!($$da && cellIso($$v,i)===$$da)"forces a real boolean, so Rocket omits the attribute when false. (This is the Rocket-specific counterpart to the vanilla presence rule above.) -
mode: 'light'so the component lives in the page's DOM/CSS and inherits system.css tokens. Shadow DOM (open/closed) would wall off the stylesheet; only reach for it if you want encapsulation and theme purely via custom props. -
Commit on light-dismiss. A
[popover]firestoggle; wiredata-on:toggle="evt.newState==='closed' && <valid> && @commit()"so clicking outside locks in the selection (no separate Cancel needed).
Project integration (Go + templ, or any SSR)
- One file per component, served as a static asset:
static/rocket/<name>.js. Its bundle import must be absolute (/static/datastar.js) — a relative./datastar.jsresolves against the subfolder and 404s. - A thin typed wrapper in your view layer (one templ component) gives the
drop-in API and lets the compiler catch attribute typos:
go
templ EventDatePicker(mode, value, valueEnd string) { <event-date-picker mode={ mode } if value != "" { value={ value } } if valueEnd != "" { value-end={ valueEnd } } ></event-date-picker> } - Load it once from the base layout (
<script type="module" src="/static/rocket/<name>.js">). Registration is idempotent and order-independent — custom elements upgrade whenever the definition lands — so per-page placement doesn't matter and HTTP caching makes it a one-time download. - Shipping a new component: the
.jsgoes in static/rocket/, the templ wrapper in view/; runtempl generateand confirm the wrapper compiled — and give it a bench page in the lab (system-css/site/lab/rocket-.html) so regressions are seen in the lab first. - Whole-page morph caveat (land-and-stream): when an SSE update morphs a page
with the component open, the morph diffs against Rocket's managed interior. If
the internals get clobbered mid-interaction, keep the morph off the host's
subtree (
data-ignore-morphon BOTH nodes) and let Rocket own it — and update the armored region only via replace-mode patches (the fat-morph bypass; see land-and-stream §2). - Observers get cleanup, always. A MutationObserver left running under a fat-morphing page re-scans every morphed node — that leak was the bulk of the phantom ~5s morph cost (gf-98). Register observers in setup, disconnect in cleanup, re-observe on re-attach; props are codec'd; define is idempotent.
- Datastar signals only — no custom
data-q/data-sortstamps (gf-66): server state rides real signals or server-rendered attributes the engine already knows (data-ui-*, ARIA), never an invented data-* vocabulary.