feat(dev): morph lab live updates in place so refreshes stop wiping input #58

Merged
dominik.polakovics merged 1 commit from feat/lab-keyed-dom-morph into main 2026-05-30 09:24:52 +02:00

What

Implements #57: background refreshes now patch the lab UI in place via a keyed DOM morph instead of rebuilding the #live region with innerHTML. Typing into an instance label or login code, the caret, focus, scroll position, the filter, and the "connecting…" spinner all survive a refresh, while the list still updates live.

How

  • Morph (templates/index.html, inline, dependency-free, no build step): keyOf / sameType / patchAttrs / patchNode / morphChildren. Keyed children (cards by data-name, instance rows by the new data-instance, the four top blocks by data-key) are reused and reordered by key; unkeyed children match positionally, with a same-position tag mismatch replacing in place — so a "connecting…" span becomes an "Open" link without rebuilding its row, and a started project floats up as a moved card. Attributes reconcile including removals; value/checked/selected are never written, so a typed field survives a morph.
  • Glue: skip a byte-identical fetch; re-apply the client filter synchronously after a morph (it clears the inline display the filter sets) so the filtered view never flashes; pause polling only for what the morph can't cover (fetch in flight, armed Stop-all confirm, unread error, hidden tab). The pause-while-filter-focused clause and the login-code refocus hack are both removed.
  • Server: only two new key attributes in the template — no handler, route, or response-shape change; the no-JS form-post fallback is untouched.

Tests

  • TestLivePartial_keyContract pins the server↔client key contract (data-name / data-instance / data-key present, including the empty-state data-key="projects") so a future template edit can't silently drop a key and degrade live updates. go build + go test ./... pass locally (pre-commit is eval-only for lab, so I ran them by hand).
  • The morph JS has no committed harness (ADR-0004: no JS test toolchain). I verified it locally against a real DOM (ephemeral jsdom, not committed): 38 assertions across in-place patch, connecting→Open, banner appear/disappear, card reorder, projects↔empty tag-swap, filter-style clearing, attribute add/remove toggles, and idempotence — using isEqualNode as a structural oracle plus node-identity checks. All pass; the one skipped assertion (focus across a DOM move) is a jsdom-only gap that real browsers don't have.

Remaining manual verification (deploy/verify loop)

Per the PRD: after merge, fw pulls within ~5 min and the dev microvm rebuilds — re-verify on the authenticated URL and on a phone (type a label while a sibling instance connects; filter during live updates; armed Stop-all survives the refresh interval; a partial login code survives; ~44px tap targets and no flicker).

Closes #57

## What Implements #57: background refreshes now patch the lab UI **in place** via a keyed DOM morph instead of rebuilding the `#live` region with `innerHTML`. Typing into an instance label or login code, the caret, focus, scroll position, the filter, and the "connecting…" spinner all survive a refresh, while the list still updates live. ## How - **Morph** (`templates/index.html`, inline, dependency-free, no build step): `keyOf` / `sameType` / `patchAttrs` / `patchNode` / `morphChildren`. Keyed children (cards by `data-name`, instance rows by the new `data-instance`, the four top blocks by `data-key`) are reused and reordered by key; unkeyed children match positionally, with a same-position tag mismatch replacing in place — so a "connecting…" span becomes an "Open" link without rebuilding its row, and a started project floats up as a moved card. Attributes reconcile *including removals*; `value`/`checked`/`selected` are never written, so a typed field survives a morph. - **Glue**: skip a byte-identical fetch; re-apply the client filter synchronously after a morph (it clears the inline display the filter sets) so the filtered view never flashes; pause polling only for what the morph can't cover (fetch in flight, armed Stop-all confirm, unread error, hidden tab). The pause-while-filter-focused clause and the login-code refocus hack are both removed. - **Server**: only two new key attributes in the template — no handler, route, or response-shape change; the no-JS form-post fallback is untouched. ## Tests - `TestLivePartial_keyContract` pins the server↔client key contract (`data-name` / `data-instance` / `data-key` present, including the empty-state `data-key="projects"`) so a future template edit can't silently drop a key and degrade live updates. `go build` + `go test ./...` pass locally (pre-commit is eval-only for lab, so I ran them by hand). - The morph JS has no committed harness (ADR-0004: no JS test toolchain). I verified it locally against a real DOM (ephemeral jsdom, **not** committed): 38 assertions across in-place patch, connecting→Open, banner appear/disappear, card reorder, projects↔empty tag-swap, filter-style clearing, attribute add/remove toggles, and idempotence — using `isEqualNode` as a structural oracle plus node-identity checks. All pass; the one skipped assertion (focus across a DOM move) is a jsdom-only gap that real browsers don't have. ## Remaining manual verification (deploy/verify loop) Per the PRD: after merge, fw pulls within ~5 min and the dev microvm rebuilds — re-verify on the authenticated URL and on a phone (type a label while a sibling instance connects; filter during live updates; armed Stop-all survives the refresh interval; a partial login code survives; ~44px tap targets and no flicker). Closes #57
Background refreshes rebuilt the whole #live region with innerHTML, wiping a
half-typed instance label or login code, dropping the caret and (on mobile)
the on-screen keyboard, and flickering the project list every few seconds.

Replace the wholesale swap with a hand-rolled, dependency-free keyed DOM morph
that patches only what changed:

- Keyed children (data-name cards, the new data-instance rows, data-key top
  blocks) are reused and reordered by key; unkeyed children match positionally,
  a same-position tag mismatch replacing in place — so a "connecting…" span
  becomes an "Open" link without rebuilding its row, and a started project
  floats up as a moved card, not a rebuilt one.
- Attributes reconcile with removals (cap-disabled button, running/idle badge,
  .slots.full all toggle); value/checked/selected are never written, so typing
  into a field survives a morph — the crux of the fix.
- The refresh skips a byte-identical fetch, then re-applies the client filter
  synchronously after a morph (which clears the inline display the filter sets)
  so the filtered view never flashes.
- Poll pausing drops to what the morph can't cover: a fetch in flight, the armed
  Stop-all confirm, an unread error, or a hidden tab. Pausing while the filter
  is focused and the login-code refocus hack are both gone — the morph preserves
  focus and filtering natively.

The server change is just the two key attributes (data-instance on instance
rows, data-key on the four top-level live blocks); no handler, route, or
response-shape change, and the no-JS form-post fallback is untouched.

A Go test pins the server<->client key contract so a future template edit can't
silently drop a key and degrade live updates. The morph JS stays manually
verified per ADR-0004 (no JS test toolchain in the repo).

Closes #57
dominik.polakovics deleted branch feat/lab-keyed-dom-morph 2026-05-30 09:24:52 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos!58
No description provided.