Add LiveDocument: a page whose JS session survives across interactions - #89
Merged
Merged
Conversation
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>
This was referenced Sep 2, 2026
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>
This was referenced Sep 2, 2026
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>
This was referenced Sep 2, 2026
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Sessionper render.settle()now returns the live*js.Sessioninstead of closing it internally;renderCoreStagedreturns(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.interactive.go:Engine.Open/OpenDocumentrun the same fetch→cascade→layout→settle pipeline asRender, but keep the session open.LiveDocument.Interact(ctx, fn)runsfn(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
RenderDocumentagain 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.TestLiveDocumentJSStateSurvivesInteractis the load-bearing proof: a script-held counter must not advance acrossInteractcalls, while the visible re-layout/repaint from each call's own DOM mutation must still happen.Test plan
go test ./...), incl.-racego vet/gofmtcleaninteractive.goRenderDocumenton the same source, idempotentClose, liveOpenfetch + 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 onLiveDocument), and therenderedEmptywipe-guard on interaction🤖 Generated with Claude Code