Skip to content

Add synthetic focus/type/click: real DOM events, not attribute pokes - #91

Merged
tannevaled merged 1 commit into
mainfrom
feat/synthetic-input
Sep 2, 2026
Merged

tannevaled merged 1 commit into
mainfrom
feat/synthetic-input

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Summary

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

  • js/events.go: dispatch now bubbles — target-phase listeners on the node, then (when event.bubbles and nothing called stopPropagation) up the ancestor chain, updating target/currentTarget as it goes. Distinguishes stopPropagation (later listeners on the SAME node still run) from stopImmediatePropagation (they don't). This is a behavior change to a function every existing page script's .click()/dispatchEvent() already goes through — verified against the full existing suite before building anything on top (unaffected: real content defaults bubbles=false, matching spec).
  • js/session.go: Session.Dispatch(n, typ, EventInit) — the Go-facing seam, firing a synthetic event exactly as if the page's own script had called element.dispatchEvent(), reporting defaultPrevented.
  • synthetic.go: LiveDocument.Focus/Blur/Type/Click. Type fires keydowninputkeyup per character (not one bulk value assignment) — what lets a controlled React/Vue onChange see every keystroke. Focus/Blur track the focused control and fire change only when its value actually differs at blur. Click does mousedownmouseupclick and reports defaultPrevented.

Test plan

  • Full existing suite + -race pass unmodified, go vet/gofmt clean, 100% coverage on every new/changed function
  • TestSyntheticLoginFlow — the decisive 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. This is exactly what a naive re-render-per-keystroke would have silently corrupted, and exactly the shape a real login page's JS takes.
  • TestEventBubbling/TestStopPropagationVsStopImmediate/TestEventCurrentTargetTracksBubblePhase — the bubbling mechanics in isolation
  • TestFocusFiresBlurAndChangeOnThePreviousField — the blur/change fallout when focus moves

🤖 Generated with Claude Code

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
tannevaled merged commit d274ef0 into main Sep 2, 2026
7 checks passed
@tannevaled
tannevaled deleted the feat/synthetic-input branch September 2, 2026 17:47
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>
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