lab: patch live updates in place (keyed DOM morph) so background refresh stops wiping typed input #57

Closed
opened 2026-05-30 08:51:52 +02:00 by dominik.polakovics · 0 comments

Problem Statement

When I open lab on my phone and start typing a name into a project's "New instance" label field, the page refreshes itself every few seconds and wipes out what I've typed — along with my keyboard focus, so the on-screen keyboard dismisses. I can never finish naming an instance.

More broadly, the project list visibly "reloads" on a timer: it flickers, the "connecting…" spinner resets, and I can lose my scroll position — because every background refresh rebuilds the whole live region from scratch. The same refresh also clobbers a half-typed login code. The filter box is the only field that survives, and only because it happens to live outside the rebuilt region.

Solution

Background refreshes update the page in place — patching only what actually changed — instead of rebuilding the live region. Typing into any field (instance label, login code), my caret, my scroll position, my filter, and the "connecting…" spinner all survive a refresh, even when real data changes underneath me. Nothing visibly reloads.

The list still updates live (an instance flipping from "connecting…" to an "Open" link appears on its own, badge counts stay accurate), but it never interrupts what I'm doing. This also lays the groundwork so a future per-project context menu can stay open seamlessly while the list refreshes behind it.

User Stories

  1. As a lab user on mobile, I want to type a full instance label without it being erased by a background refresh, so that I can name instances meaningfully.
  2. As a lab user, I want my text caret position preserved across a refresh, so that typing is never interrupted mid-word.
  3. As a lab user, I want my keyboard focus to stay on the field I'm using after a refresh, so that the on-screen keyboard doesn't dismiss on mobile.
  4. As a lab user, I want a half-typed instance label to survive even if I tab away (blur) before submitting, so that I don't lose a draft.
  5. As a lab user, I want the project list to update in place without flicker, so that the UI feels calm rather than janky.
  6. As a lab user, I want my scroll position preserved during a background refresh, so that I don't lose my place in a long project list.
  7. As a lab user, I want the "connecting…" spinner to keep animating smoothly across refreshes, so that it doesn't visibly reset every few seconds.
  8. As a lab user, I want an instance to flip from "connecting…" to an "Open" link on its own, so that I know when a session is ready without a manual reload.
  9. As a lab user, I want the instance-count badge ("N running") and the slots indicator ("N/M instances") to update live, so that capacity is always accurate.
  10. As a lab user, I want my filter text and the filtered view to persist across refreshes, so that filtering isn't reset while the list updates underneath.
  11. As a lab user, I want the list to keep updating live while I'm filtering, so that I see fresh state without clearing my filter.
  12. As a lab user typing or pasting a login code, I want my partially-entered code preserved across a refresh, so that I can paste-and-edit without losing it.
  13. As a lab user, I want the login banner to appear and disappear cleanly when my Claude login state changes, without disturbing the rest of the page.
  14. As a lab user, I want the two-step "Stop all" confirmation (Confirm/Cancel) to stay armed across the refresh interval, so that the prompt isn't silently reverted while I decide.
  15. As a lab user, I want a project I just started to float to the top by moving its existing card, not rebuilding it, so that other cards' spinners and state are undisturbed.
  16. As a lab user, I want Start / Stop / New-instance / Stop-all actions to update the page in place, so that the experience is consistent with background refreshes.
  17. As a lab user, I want the busy spinner on a button I pressed to behave correctly during an in-flight action, so that I get feedback and the button isn't clobbered mid-request.
  18. As a lab user, I want the error banner to stay visible until I dismiss it, so that I don't miss a failure message because a refresh wiped it.
  19. As a lab user on a hidden/background tab, I want pointless refreshes suppressed, so that battery and bandwidth aren't wasted.
  20. As a lab user, I want an identical refresh (nothing changed) to be a no-op, so that the page never churns when there is nothing to update.
  21. As a lab user, I want the disabled/enabled state of Start and "New instance" at the instance cap to update live, so that the cap is enforced visibly.
  22. As a future lab user, I want a per-project context menu to be able to stay open while the list refreshes behind it, so that adjusting project options feels seamless.
  23. As a lab maintainer, I want the server↔client key contract the in-place update depends on to be protected by a test, so that a future template edit can't silently break live updates.
  24. As a no-JS lab user, I want the page to keep working via plain form posts and redirects, so that the fallback path is preserved.
  25. As a lab maintainer, I want the in-place-update implementation to stay dependency-free and require no JS build step, so that the Nix derivation stays reproducible and ADR-0004's constraints hold.

Implementation Decisions

Approach. Replace the wholesale innerHTML swap of the live region with an in-place, keyed DOM morph that patches only what changed. Hand-rolled, vanilla, inline in the template — no library, no JS build step. The architecture stays server-rendered hypermedia; the server keeps returning the same HTML fragment.

Deep module — the morph. A single function with a tiny, stable interface — morph(liveContainer, incomingContainer) — that mutates the live container to match the incoming one and hides all reconciliation complexity:

  • Keyed children are matched by key: reuse-and-patch the existing node, move it into the new order, create missing ones, remove leftovers.
  • Non-keyed children are matched positionally: if the tag differs, replace; otherwise reconcile attributes (including attribute removals, so toggles like the cap-disabled state and the running/idle badge work), then recurse; text nodes are patched by value.
  • A "connecting…" span becoming an "Open" link is a tag change, so that child is replaced while its enclosing keyed instance row is preserved.
  • A project floating to the top on recency is a move of the existing keyed card, not a rebuild — so unrelated cards' spinner/animation state is undisturbed.

Input contract. The morph never writes value or selection on any input/textarea. These are always client-owned (the server renders them empty), so typing survives even a genuine data update. This is the crux of the fix.

Key contract (server-rendered attributes the morph depends on).

  • Project cards keyed by their existing project-name attribute (data-name).
  • Instance rows keyed by a new attribute carrying the unique session name (data-instance).
  • The four top-level blocks of the live region (login banner, status line, hint, projects-or-empty) carry stable keys (data-key), so the banner appearing/disappearing on a login-state change doesn't misalign positional matching.

Live-update loop (glue around the morph).

  • The refresh fetches the fragment; if the fetched HTML is byte-identical to the previous fetch, it does nothing (skip-unchanged fast path). Otherwise it morphs, then re-applies the client filter synchronously before paint (the morph clears the inline display state the filter sets), so there is no flash.
  • Successful actions morph the returned fragment in place and refresh the skip-unchanged cache.
  • Poll pausing is reduced to the cases the morph genuinely cannot cover: an action fetch in flight, the armed "Stop all" confirm (client-only state the morph would revert), an unread error banner, and a hidden tab. The previous pause-while-filter-focused is removed, since filtering now survives a morph.
  • The login-code refocus workaround is removed — the morph preserves focus natively by patching the node in place rather than replacing it.

Server changes are minimal. Add the data-instance attribute to instance rows and data-key to the top-level blocks in the live fragment template. No handler, route, or response-shape changes; no JSON API.

Rejected alternatives (see Further Notes for rationale): a minimal pause-while-typing band-aid; adopting htmx + idiomorph; a client-rendered SPA with a JSON API. The morph was chosen as the smallest change that fully fixes the clobber and the flicker while preserving the dependency-free, no-build, no-JS-fallback shape.

Testing Decisions

  • What makes a good test here: assert external behavior and the server↔client contract, not implementation internals. The stable, valuable seam is the set of key attributes the in-place update depends on — that is what a test should pin.
  • Module under test: the live-fragment template rendering. A Go test in the style of the existing lab handler/template tests renders the live fragment from a synthetic snapshot (a project with at least one instance, plus the logged-out banner state) and asserts the key attributes are present (data-name, data-instance, data-key). This guards against a future template edit silently dropping a key and degrading live updates.
  • Prior art: the existing lab tests that construct a server and assert on rendered output follow this exact shape (build a server, render the fragment, assert on the output string). The new test mirrors them; tests that build a server also implicitly catch a broken template because the template is must-parsed at construction.
  • Out of automated scope (verified manually): the morph JS and the poll loop. Consistent with ADR-0004, lab has no CI as Go code and deliberately no JS test harness. The manual checklist: type a label while a sibling instance is connecting (text + caret survive, sibling flips to Open undisturbed); filter during live updates; the armed Stop-all confirm survives the refresh interval; scroll stays put; a partial login code survives; and a phone pass for ~44px tap targets and no flicker (mobile-first is a hard requirement for lab).

Out of Scope

  • The per-project context menu and project-options UI/persistence — a separate follow-up. This PRD only lays the in-place-update groundwork plus the decision that the menu will use the native HTML Popover API (top layer, immune to the morph by construction).
  • Changing the update transport from polling to server-push (SSE/WebSocket).
  • Adopting htmx/idiomorph or any third-party morph/reactivity library.
  • A client-rendered SPA or JSON API rewrite.
  • Introducing any JS build tooling or JS test toolchain into the repo.
  • Expanding (or removing) the no-JS fallback — it must keep working, but it is not being extended.
  • Server-side handler/route/state-schema changes beyond adding the key attributes to the template.
  • Poll cadence changes — the existing fast/idle timings stay; the morph makes them non-disruptive.

Further Notes

  • Why hand-rolled, not a library or SPA. lab is a single-user, 2FA-gated dev control panel that is already a well-factored server-rendered hypermedia app with a no-JS fallback and zero build step. The input-clobber is fundamentally a DOM-reconciliation problem; a keyed morph solves it with ~80–120 lines of dependency-free inline JS while preserving the reproducible embedded-template build. htmx + idiomorph was considered (a cleaner declarative foundation if interactivity grows steadily) but its wins are muted for this app's bespoke behaviors and it adds a dependency; a client-rendered SPA was considered and rejected as disproportionate — it would drag a JS build chain into the Nix repo, add a JSON API surface, and lose the no-JS fallback — for a roadmap whose ceiling is "a context menu with a few project options."
  • Why a small hand-rolled morph is safe here. All event handlers are delegated on the document, so morphing or replacing nodes inside the live region never detaches a listener.
  • Accepted edge case. If a concurrent instance-cap flip (e.g. hitting the cap from another tab) structurally removes the very form you are typing in, that input is gone with its text. Judged acceptable given how contrived it is for a single user.
  • Mobile-first is a hard requirement for lab (single-column, ~44px tap targets, reliable per-instance Stop). The morph reduces churn and should improve perceived smoothness on phones; verify on a real device.
  • Deploy/verify loop. Build and run lab locally against a projects root to exercise the morph in a desktop browser and a mobile viewport; after merge, fw pulls within ~5 minutes and the dev microvm rebuilds, at which point re-verify on the authenticated URL and on a phone.
  • Origin: distilled from a design grilling of the lab live-update UX.
## Problem Statement When I open lab on my phone and start typing a name into a project's "New instance" label field, the page refreshes itself every few seconds and wipes out what I've typed — along with my keyboard focus, so the on-screen keyboard dismisses. I can never finish naming an instance. More broadly, the project list visibly "reloads" on a timer: it flickers, the "connecting…" spinner resets, and I can lose my scroll position — because every background refresh rebuilds the whole live region from scratch. The same refresh also clobbers a half-typed login code. The filter box is the only field that survives, and only because it happens to live outside the rebuilt region. ## Solution Background refreshes update the page **in place** — patching only what actually changed — instead of rebuilding the live region. Typing into any field (instance label, login code), my caret, my scroll position, my filter, and the "connecting…" spinner all survive a refresh, even when real data changes underneath me. Nothing visibly reloads. The list still updates live (an instance flipping from "connecting…" to an "Open" link appears on its own, badge counts stay accurate), but it never interrupts what I'm doing. This also lays the groundwork so a future per-project context menu can stay open seamlessly while the list refreshes behind it. ## User Stories 1. As a lab user on mobile, I want to type a full instance label without it being erased by a background refresh, so that I can name instances meaningfully. 2. As a lab user, I want my text caret position preserved across a refresh, so that typing is never interrupted mid-word. 3. As a lab user, I want my keyboard focus to stay on the field I'm using after a refresh, so that the on-screen keyboard doesn't dismiss on mobile. 4. As a lab user, I want a half-typed instance label to survive even if I tab away (blur) before submitting, so that I don't lose a draft. 5. As a lab user, I want the project list to update in place without flicker, so that the UI feels calm rather than janky. 6. As a lab user, I want my scroll position preserved during a background refresh, so that I don't lose my place in a long project list. 7. As a lab user, I want the "connecting…" spinner to keep animating smoothly across refreshes, so that it doesn't visibly reset every few seconds. 8. As a lab user, I want an instance to flip from "connecting…" to an "Open" link on its own, so that I know when a session is ready without a manual reload. 9. As a lab user, I want the instance-count badge ("N running") and the slots indicator ("N/M instances") to update live, so that capacity is always accurate. 10. As a lab user, I want my filter text and the filtered view to persist across refreshes, so that filtering isn't reset while the list updates underneath. 11. As a lab user, I want the list to keep updating live while I'm filtering, so that I see fresh state without clearing my filter. 12. As a lab user typing or pasting a login code, I want my partially-entered code preserved across a refresh, so that I can paste-and-edit without losing it. 13. As a lab user, I want the login banner to appear and disappear cleanly when my Claude login state changes, without disturbing the rest of the page. 14. As a lab user, I want the two-step "Stop all" confirmation (Confirm/Cancel) to stay armed across the refresh interval, so that the prompt isn't silently reverted while I decide. 15. As a lab user, I want a project I just started to float to the top by moving its existing card, not rebuilding it, so that other cards' spinners and state are undisturbed. 16. As a lab user, I want Start / Stop / New-instance / Stop-all actions to update the page in place, so that the experience is consistent with background refreshes. 17. As a lab user, I want the busy spinner on a button I pressed to behave correctly during an in-flight action, so that I get feedback and the button isn't clobbered mid-request. 18. As a lab user, I want the error banner to stay visible until I dismiss it, so that I don't miss a failure message because a refresh wiped it. 19. As a lab user on a hidden/background tab, I want pointless refreshes suppressed, so that battery and bandwidth aren't wasted. 20. As a lab user, I want an identical refresh (nothing changed) to be a no-op, so that the page never churns when there is nothing to update. 21. As a lab user, I want the disabled/enabled state of Start and "New instance" at the instance cap to update live, so that the cap is enforced visibly. 22. As a future lab user, I want a per-project context menu to be able to stay open while the list refreshes behind it, so that adjusting project options feels seamless. 23. As a lab maintainer, I want the server↔client key contract the in-place update depends on to be protected by a test, so that a future template edit can't silently break live updates. 24. As a no-JS lab user, I want the page to keep working via plain form posts and redirects, so that the fallback path is preserved. 25. As a lab maintainer, I want the in-place-update implementation to stay dependency-free and require no JS build step, so that the Nix derivation stays reproducible and ADR-0004's constraints hold. ## Implementation Decisions **Approach.** Replace the wholesale `innerHTML` swap of the live region with an in-place, keyed DOM **morph** that patches only what changed. Hand-rolled, vanilla, inline in the template — no library, no JS build step. The architecture stays server-rendered hypermedia; the server keeps returning the same HTML fragment. **Deep module — the morph.** A single function with a tiny, stable interface — `morph(liveContainer, incomingContainer)` — that mutates the live container to match the incoming one and hides all reconciliation complexity: - Keyed children are matched by key: reuse-and-patch the existing node, move it into the new order, create missing ones, remove leftovers. - Non-keyed children are matched positionally: if the tag differs, replace; otherwise reconcile attributes (including attribute *removals*, so toggles like the cap-disabled state and the running/idle badge work), then recurse; text nodes are patched by value. - A "connecting…" span becoming an "Open" link is a tag change, so that child is replaced while its enclosing keyed instance row is preserved. - A project floating to the top on recency is a *move* of the existing keyed card, not a rebuild — so unrelated cards' spinner/animation state is undisturbed. **Input contract.** The morph never writes `value` or selection on any input/textarea. These are always client-owned (the server renders them empty), so typing survives even a genuine data update. This is the crux of the fix. **Key contract (server-rendered attributes the morph depends on).** - Project cards keyed by their existing project-name attribute (`data-name`). - Instance rows keyed by a **new** attribute carrying the unique session name (`data-instance`). - The four top-level blocks of the live region (login banner, status line, hint, projects-or-empty) carry stable keys (`data-key`), so the banner appearing/disappearing on a login-state change doesn't misalign positional matching. **Live-update loop (glue around the morph).** - The refresh fetches the fragment; if the fetched HTML is byte-identical to the previous fetch, it does nothing (skip-unchanged fast path). Otherwise it morphs, then re-applies the client filter synchronously before paint (the morph clears the inline display state the filter sets), so there is no flash. - Successful actions morph the returned fragment in place and refresh the skip-unchanged cache. - Poll pausing is reduced to the cases the morph genuinely cannot cover: an action fetch in flight, the armed "Stop all" confirm (client-only state the morph would revert), an unread error banner, and a hidden tab. The previous pause-while-filter-focused is removed, since filtering now survives a morph. - The login-code refocus workaround is removed — the morph preserves focus natively by patching the node in place rather than replacing it. **Server changes are minimal.** Add the `data-instance` attribute to instance rows and `data-key` to the top-level blocks in the live fragment template. No handler, route, or response-shape changes; no JSON API. **Rejected alternatives** (see Further Notes for rationale): a minimal pause-while-typing band-aid; adopting htmx + idiomorph; a client-rendered SPA with a JSON API. The morph was chosen as the smallest change that fully fixes the clobber and the flicker while preserving the dependency-free, no-build, no-JS-fallback shape. ## Testing Decisions - **What makes a good test here:** assert external behavior and the server↔client contract, not implementation internals. The stable, valuable seam is the set of key attributes the in-place update depends on — that is what a test should pin. - **Module under test:** the live-fragment template rendering. A Go test in the style of the existing lab handler/template tests renders the live fragment from a synthetic snapshot (a project with at least one instance, plus the logged-out banner state) and asserts the key attributes are present (`data-name`, `data-instance`, `data-key`). This guards against a future template edit silently dropping a key and degrading live updates. - **Prior art:** the existing lab tests that construct a server and assert on rendered output follow this exact shape (build a server, render the fragment, assert on the output string). The new test mirrors them; tests that build a server also implicitly catch a broken template because the template is must-parsed at construction. - **Out of automated scope (verified manually):** the morph JS and the poll loop. Consistent with ADR-0004, lab has no CI as Go code and deliberately no JS test harness. The manual checklist: type a label while a *sibling* instance is connecting (text + caret survive, sibling flips to Open undisturbed); filter during live updates; the armed Stop-all confirm survives the refresh interval; scroll stays put; a partial login code survives; and a phone pass for ~44px tap targets and no flicker (mobile-first is a hard requirement for lab). ## Out of Scope - The per-project **context menu** and project-options UI/persistence — a separate follow-up. This PRD only lays the in-place-update groundwork plus the decision that the menu will use the native HTML Popover API (top layer, immune to the morph by construction). - Changing the update transport from polling to server-push (SSE/WebSocket). - Adopting htmx/idiomorph or any third-party morph/reactivity library. - A client-rendered SPA or JSON API rewrite. - Introducing any JS build tooling or JS test toolchain into the repo. - Expanding (or removing) the no-JS fallback — it must keep working, but it is not being extended. - Server-side handler/route/state-schema changes beyond adding the key attributes to the template. - Poll cadence changes — the existing fast/idle timings stay; the morph makes them non-disruptive. ## Further Notes - **Why hand-rolled, not a library or SPA.** lab is a single-user, 2FA-gated dev control panel that is already a well-factored server-rendered hypermedia app with a no-JS fallback and zero build step. The input-clobber is fundamentally a DOM-reconciliation problem; a keyed morph solves it with ~80–120 lines of dependency-free inline JS while preserving the reproducible embedded-template build. htmx + idiomorph was considered (a cleaner declarative foundation if interactivity grows steadily) but its wins are muted for this app's bespoke behaviors and it adds a dependency; a client-rendered SPA was considered and rejected as disproportionate — it would drag a JS build chain into the Nix repo, add a JSON API surface, and lose the no-JS fallback — for a roadmap whose ceiling is "a context menu with a few project options." - **Why a small hand-rolled morph is safe here.** All event handlers are delegated on the document, so morphing or replacing nodes inside the live region never detaches a listener. - **Accepted edge case.** If a concurrent instance-cap flip (e.g. hitting the cap from another tab) structurally removes the very form you are typing in, that input is gone with its text. Judged acceptable given how contrived it is for a single user. - **Mobile-first** is a hard requirement for lab (single-column, ~44px tap targets, reliable per-instance Stop). The morph reduces churn and should improve perceived smoothness on phones; verify on a real device. - **Deploy/verify loop.** Build and run lab locally against a projects root to exercise the morph in a desktop browser and a mobile viewport; after merge, `fw` pulls within ~5 minutes and the dev microvm rebuilds, at which point re-verify on the authenticated URL and on a phone. - Origin: distilled from a design grilling of the lab live-update UX.
Sign in to join this conversation.
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#57
No description provided.