system.cssthe engine GitHub

Morph armor

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 with PatchElements(html, WithModeReplace()), and the rendered fragment re-carries the id + the armor attribute so later patches and morphs behave the same.
  • data-preserve-attr is NOT armor — it shields the listed attributes (open on a dialog, inline style) 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:submit ALWAYS prevents the native submit (bundle: el instanceof HTMLFormElement && event === "submit" && preventDefault(), with or without __prevent). A native method="post" form must carry no data-on:submit; a Datastar form is data-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 to URLSearchParams and 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 checked by 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's id by 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 runs render again, so element references captured in onFirstRender point 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 rides data-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 the datastar-patch-elements event, 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 sets overflow-anchor: none on it, or Chrome's scroll anchoring will move scrollTop after every re-position and the scroller will chase its own tail.

  • $$name is the instance-local signal. Inside render, write $$da, $$mode; in setup, $$.da = ''. Rocket rewrites $$ to a per-instance path, so multiple instances on a page don't collide. Helpers are still plain JS on window, 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." Destructure action from the setup context and call action('commit', () => {...}).

  • Local actions take NO arguments — pass data via a signal. Calling @pick(cellIso($$v,i)) makes Rocket rewrite the dispatch and recurse into a Maximum 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-attr expression with !!(...). Rocket sets an attribute whenever the expression yields a string; $$da && … returns '' (empty string, not false) when $$da is empty, so data-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] fires toggle; wire data-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.js resolves 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 .js goes in static/rocket/, the templ wrapper in view/; run templ generate and 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-morph on 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-sort stamps (gf-66): server state rides real signals or server-rendered attributes the engine already knows (data-ui-*, ARIA), never an invented data-* vocabulary.