Skip to content

Add LiveDocument: a page whose JS session survives across interactions - #89

Merged
tannevaled merged 2 commits into
mainfrom
feat/interactive-session
Sep 2, 2026
Merged

tannevaled merged 2 commits into
mainfrom
feat/interactive-session

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Summary

Foundation for interactive form input (see the go-aiquota project's embedded, isolated per-account login window, which needs this): a page whose JS session stays alive across separate, later synthetic interactions instead of one js.Session per render.

  • settle() now returns the live *js.Session instead of closing it internally; renderCoreStaged returns (renderPass, Session, stopHeap) instead of closing/stopping them itself. Pure lifecycle refactor, no behavior change — every existing caller (renderCore, buildProgressive) closes both at the same point in the call sequence as before.
  • New interactive.go: Engine.Open/OpenDocument run the same fetch→cascade→layout→settle pipeline as Render, but keep the session open. LiveDocument.Interact(ctx, fn) runs fn (a DOM mutation today; a later phase's synthetic focus/type/click dispatch) and resettles — re-cascade, re-layout, re-run any newly-inserted <script>, reload images if the layout changed — without re-running the page's initial scripts, so a stateful script's own in-memory state survives across calls the way it would across two real keystrokes in a browser.

Why this matters

A naive "just call RenderDocument again after each keystroke" would spin up a brand-new JS runtime every time, silently discarding anything a stateful script holds that isn't reflected back into the DOM (a controlled React input's own state, focus/debounce timers, etc.) — wrong in ways that could corrupt a login flow rather than fail loudly. TestLiveDocumentJSStateSurvivesInteract is the load-bearing proof: a script-held counter must not advance across Interact calls, while the visible re-layout/repaint from each call's own DOM mutation must still happen.

Test plan

  • Full existing test suite passes unmodified (go test ./...), incl. -race
  • go vet / gofmt clean
  • 100% coverage on interactive.go
  • New tests: JS-state-survives (the core proof), Frame parity vs RenderDocument on the same source, idempotent Close, live Open fetch + fetch-error path, stylesheet-link-set refetch on interaction (caught and fixed a real bug: an early version compared the link digest against itself since it was recomputed post-mutation — now carried across calls on LiveDocument), and the renderedEmpty wipe-guard on interaction

🤖 Generated with Claude Code

tannevaled and others added 2 commits September 2, 2026 16:19
Pure lifecycle refactor, no behavior change: settle() now returns the
live *js.Session instead of closing it itself, and renderCoreStaged
returns (renderPass, Session, stopHeap) instead of closing/stopping
internally. Every existing caller (renderCore, buildProgressive) closes
both at the same point in the call sequence as before, so
Render/RenderWithLinks/the progressive path stay byte-identical (full
existing test suite passes unmodified, -race clean).

This is the foundation for a persistent interactive session (a
LiveDocument that stays open across separate synthetic user
interactions instead of one js.Session per render) — see the plan at
hand; that type lands in a follow-up commit once this lifecycle change
is isolated and verified on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Engine.Open/OpenDocument run the same fetch/cascade/layout/settle
pipeline as Render, but keep the resulting js.Session alive instead of
closing it. LiveDocument.Interact(fn) runs fn (a DOM mutation now; a
later phase's synthetic focus/type/click dispatch) and then resettles
— re-cascade, re-layout, re-run any newly-inserted <script>, reload
images if the layout changed — WITHOUT re-running the page's initial
scripts, so a stateful script's own in-memory state survives across
separate calls the way it would across two real keystrokes in a
browser. This is the prerequisite the go-aiquota embedded-login window
needs: a naive re-render per keystroke would silently discard exactly
that state.

resettle is a fresh implementation reusing the same pure functions
dynamic.go's settle already uses (css.CascadeVW, layout.LayoutDocument,
layoutWithContainers, renderedEmpty, reskin, newLayoutMetrics) rather
than sharing code with settle's own loop, which is left untouched.

Caught by TestLiveDocumentResettleRefetchesChangedStylesheets during
development: an early version computed the stylesheet-link-set digest
fresh at the top of each resettle call, which is AFTER a caller's fn
already ran — so it always compared a value against itself and never
refetched. Fixed by carrying the digest on LiveDocument across calls
instead of recomputing it from the (already mutated) DOM. Left the
test in as a regression guard.

100% coverage on the new file, full existing suite + -race unaffected
(builds on the prior lifecycle-only refactor, no further changes to
dynamic.go).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 1e50d8f into main Sep 2, 2026
7 checks passed
@tannevaled
tannevaled deleted the feat/interactive-session branch September 2, 2026 17:29
tannevaled added a commit that referenced this pull request Sep 2, 2026
…91)

The actual "drive a login form" API on top of the LiveDocument
foundation (#89) and hit-testing (#90).

js/events.go: dispatch now bubbles — fires target-phase listeners on
the node, then (when event.bubbles is true and nothing called
stopPropagation) walks up the ancestor chain doing the same, updating
target/currentTarget as it goes. Distinguishes stopPropagation (later
listeners on the SAME node still run) from stopImmediatePropagation
(they don't), which a single flag would have blurred. This is a
behavior change to a function every existing page script's click()/
dispatchEvent() call already goes through — verified against the full
existing test suite (unaffected: real content bubbles=false by
default, matching spec) before adding anything on top.

js/session.go: Session.Dispatch(n, typ, EventInit) is the new
Go-facing seam — fires a synthetic event exactly as if the page's own
script had called element.dispatchEvent(), and reports
defaultPrevented so a caller (native form submission, a later phase)
can honor it.

synthetic.go (engine package): LiveDocument.Focus/Blur/Type/Click
compose real event sequences through that seam — Type fires
keydown->input->keyup PER CHARACTER (not one bulk value assignment),
which is what lets a controlled React/Vue onChange see every
keystroke; Focus/Blur track which control is focused and fire
change only when its value actually differs at blur, matching real
browser semantics; Click does mousedown->mouseup->click and reports
defaultPrevented. Each interaction resettles (F0) so the live session
keeps running, not restarting.

TestSyntheticLoginFlow is the decisive end-to-end proof: a login form
whose JS accumulates typed text in its OWN closure variables (not
just DOM attributes) across many separate Focus/Type/Click calls, only
succeeding once both fields match — exactly the shape a real login
page's JS takes, and exactly what a naive re-render-per-keystroke
would have silently corrupted.

100% coverage on every new/changed function, full existing suite +
-race unaffected.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 2, 2026
F4 of the interactive-form-input plan (#89-#91): the path for a plain
server-rendered login form with no JS submit handler at all.

LiveDocument.Submit(ctx, form) dispatches "submit" through the live
session first, exactly like a real browser. If a listener calls
preventDefault() (the common SPA case, already reachable via
Focus/Type/Click plus the engine's existing fetch()/XHR support),
nothing further happens and the current page stays open. Otherwise it
gathers named form-control values (input/textarea/select, skipping
disabled controls and unchecked checkboxes/radios, matching what a
real browser submits) and performs the actual GET-with-querystring or
POST-urlencoded request per the form's action/method, then opens the
response as a new LiveDocument and closes the old one — a real
navigation ending the previous page's JS, same as a browser.

Caught two real gaps while writing this against the actual DOM model
rather than assuming: <select>'s submitted value needs its own
selected-option walk (no shared helper existed at the engine-package
level); <textarea> has no dedicated JS value accessor (js/dom.go's
generic "value" property just reads the "value" attribute, which a
textarea doesn't carry until something explicitly sets it) — Submit's
own value-gathering falls back to the element's text content for an
untouched textarea, matching what a real browser's textarea.value
defaults to, while documenting the JS-binding gap for anyone who hits
it from script instead.

100% coverage on every function except postForm (83.3% — the
remaining gaps are low-level I/O failure branches http.NewRequest/
ReadAll/decodeCharset/dom.Parse can each return, consistent with this
codebase's own existing tolerance for that class of near-unreachable
path: Fetch and decodeCharset sit at 82.4%/75% on main today for the
same reason). Full existing suite + -race unaffected.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 3, 2026
* Lay out and paint form controls — they had no box at all until now

Writing the F6 capstone test (a login flow driven by pixel coordinates
only, the way any real host — go-aiquota/tray's onboarding window,
browserproxy's click forwarding — actually drives one, never a direct
*dom.Node reference) failed immediately: #email/#password/#submit were
missing from the element hit-map entirely.

Root cause traced to layout/layout.go's isReplacedTag, which only
recognizes "img"/"svg" — input/button/select/textarea default to
display:inline (css/ua.go) and have NEVER been given a box or a paint
step anywhere in this engine. This is not specific to the F0-F5 work
(#89-93): it means an <input> has been invisible and unclickable in
this engine, full stop, independent of the JS/event mechanics those
PRs correctly built.

Two-part fix, since replaced/atomic inline content actually has TWO
entry points in this codebase and only one was obvious from reading
isReplacedTag's own doc comment:
  - contents() (layout.go): the rarer path — a control given
    `display:block` (or similar) by author CSS routes through the
    normal block-box path.
  - appendElementInline(): the COMMON path — an inline-display control
    (the default) nested in ordinary flow content, discovered by
    instrumenting the box tree directly when the "obvious" fix (only
    touching contents()) still left the capstone test failing with an
    empty box tree.
Both reuse the same formControlSize/formControlDefaultSize (explicit
CSS width/height first, else a UA-shaped default per control kind —
checkbox/radio a small square, a button-like control sized to fit its
own label via the same Measurer already threaded through layout,
everything else a fixed text-field-shaped box) and controlLabel
helpers, proven to agree by TestFormControlDisplayBlockRoutesThrough
Contents.

paint/paint.go: it.FormControl (a new InlineItem field, the Image
field's sibling) gets a real background+1px-border box, checkbox/radio
a state-colored square, and — for anything else — its value/label text
(password masked as bullets, a placeholder drawn muted, a button's
label centered, matching real UA behavior close enough to read as
intentional rather than exact chrome fidelity).

100% coverage on every new function in both packages; full existing
suite + -race unaffected (the one existing fixture using <input>,
checkbox_hack.html, sets it display:none via its own CSS, so it never
reaches either new code path). The capstone test (e2e_login_test.go)
now passes: Open -> Elements()/ElementAt (coordinate resolution) ->
Focus/Type/Click, entirely by pixel coordinate, correctly authenticates
against a page whose own JS tracks the typed values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Close the last gaps against layout's ratcheted 100% coverage gate

CI's coverage-gate.sh enforces a strict, ratcheted 100% floor on
layout (and paint, css, dom) — a floor my local check didn't
reproduce, since I'd been comparing against the unratcheted `engine`
package's own looser tolerance. Two fixes:

- formControlDefaultSize: restructured to named returns assigned by
  each case and returned once at the bottom, instead of each case
  returning directly — the trailing fallback after the switch was
  unreachable by construction (the only two call sites already gate on
  isFormControlTag), and 100% coverage can't be reached by testing
  around an unreachable line, only by removing it.
- Added tests for three real branches that were reachable but
  untested: a form control preceded by inline text with trailing
  whitespace (needs its own SpaceBefore, the same collapsible-space
  handling img/svg items already get — a real gap, "Label <input>" is
  the normal shape of an actual form), and the hidden-input check at
  BOTH its entry points (appendElementInline for the common inline
  case, contents() for the rarer display:block one) rather than just
  one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant