Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ type LiveDocument struct {
vpW, vpH int
stopHeap func()
closed bool
// focused is the node synthetic.go's Focus most recently moved input
// focus to (nil if none), and focusedValue is that node's value at the
// moment it was focused — the baseline Blur compares against to decide
// whether a real "change" event is due, matching a browser firing
// change only when a focused control's value actually differs at blur,
// not on every keystroke (that is what "input" is for).
focused *dom.Node
focusedValue string
// prevLinks is the stylesheet <link> set digest AS OF THE LAST SETTLED
// FRAME — carried across Interact calls (unlike settle's own prevLinks,
// which is scoped to one render). resettle compares against this to
Expand Down
94 changes: 94 additions & 0 deletions js/dispatch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) the go-webengine/engine authors.
// SPDX-License-Identifier: BSD-3-Clause

package js

import (
"testing"
"time"

"github.com/go-webengine/engine/dom"
)

// TestSessionDispatchReachesJSListener is the load-bearing proof for the
// Go-facing seam: a listener a page's OWN script registered must fire when
// something OUTSIDE this package (a host synthesizing a real user
// interaction) calls Session.Dispatch, exactly as if the page had called
// element.dispatchEvent() itself.
func TestSessionDispatchReachesJSListener(t *testing.T) {
const src = `<html><body>
<div id="parent"><input id="field"></div>
<script>
document.getElementById('field').addEventListener('keydown', function(e){
console.log('keydown key=' + e.key);
});
document.getElementById('field').addEventListener('input', function(e){
console.log('input data=' + e.data + ' type=' + e.inputType);
});
document.getElementById('parent').addEventListener('click', function(e){
console.log('delegated click target=' + e.target.id);
});
</script>
</body></html>`
root, err := dom.Parse(src)
if err != nil {
t.Fatal(err)
}
field := dom.Find(root, "input")

var logs []string
sess := Begin(root, Options{PageURL: "https://demo.test/", Timeout: 3 * time.Second,
Log: func(l string) { logs = append(logs, l) }})
defer sess.Close()
sess.RunInitial()

sess.Dispatch(field, "keydown", EventInit{Bubbles: true, Cancelable: true, Key: "a"})
sess.Dispatch(field, "input", EventInit{Bubbles: true, Data: "a", InputType: "insertText"})
// Delegated: the listener is on #parent, the event targets #field.
sess.Dispatch(field, "click", EventInit{Bubbles: true, Cancelable: true})

mustHaveJS(t, logs, "keydown key=a", "input data=a type=insertText", "delegated click target=field")
}

// TestSessionDispatchReportsPreventDefault is what a native-form-submission
// fallback (a later phase) needs: knowing whether a script intercepted the
// event, without the caller having to inspect the event object itself.
func TestSessionDispatchReportsPreventDefault(t *testing.T) {
const src = `<html><body><form id="f"></form>
<script>
document.getElementById('f').addEventListener('submit', function(e){ e.preventDefault(); });
</script>
</body></html>`
root, err := dom.Parse(src)
if err != nil {
t.Fatal(err)
}
form := dom.Find(root, "form")

sess := Begin(root, Options{PageURL: "https://demo.test/", Timeout: 3 * time.Second})
defer sess.Close()
sess.RunInitial()

if prevented := sess.Dispatch(form, "submit", EventInit{Bubbles: true, Cancelable: true}); !prevented {
t.Fatal("Dispatch: want defaultPrevented=true, the listener called preventDefault()")
}
}

// TestSessionDispatchNoListenersIsSafe covers dispatching to a node with no
// registered listeners at all (a plain <button> nobody wired up) — must not
// panic, and must report defaultPrevented=false.
func TestSessionDispatchNoListenersIsSafe(t *testing.T) {
root, err := dom.Parse(`<html><body><button id="b">Go</button></body></html>`)
if err != nil {
t.Fatal(err)
}
btn := dom.Find(root, "button")

sess := Begin(root, Options{PageURL: "https://demo.test/", Timeout: 3 * time.Second})
defer sess.Close()
sess.RunInitial()

if prevented := sess.Dispatch(btn, "click", EventInit{Bubbles: true}); prevented {
t.Fatal("Dispatch with no listeners: want defaultPrevented=false")
}
}
66 changes: 52 additions & 14 deletions js/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,60 @@ func (b *binder) removeListener(n *dom.Node, typ string, handler goja.Value) {
}
}

// dispatch fires every listener registered for typ on n, invoking each with the
// event as argument and n as `this`. Handler errors/panics are contained.
// dispatch fires every listener registered for typ on n (the target phase),
// then — when event.bubbles is true and no handler called stopPropagation/
// stopImmediatePropagation — continues up n's ancestor chain doing the same,
// matching real DOM event bubbling (needed for delegation: a handler
// registered on a container rather than the specific button/input a
// synthetic click/keystroke targets). event.target and .currentTarget are
// kept live across the walk. Handler errors/panics are contained per node,
// same as before this walked more than one node.
//
// Scope: does not continue past the document root to a window-level
// listener (window has no place in n's Parent chain) — document-level
// delegation, the common real-world case, works; window-level does not yet.
func (b *binder) dispatch(n *dom.Node, typ string, event goja.Value) {
m := b.listeners[n]
if m == nil {
return
}
self := b.wrap(n)
if n == b.windowNode || n == b.docNode {
self = b.vm.GlobalObject()
obj, _ := event.(*goja.Object)
// stopped halts moving to an ancestor; stoppedImmediate ALSO halts the
// remaining listeners on the CURRENT node — the real distinction between
// stopPropagation (later listeners on this same node still run) and
// stopImmediatePropagation (they don't) that a single flag would blur.
stopped, stoppedImmediate := false, false
bubbles := false
if obj != nil {
obj.Set("target", b.wrap(n))
obj.Set("stopPropagation", func(goja.FunctionCall) goja.Value { stopped = true; return goja.Undefined() })
obj.Set("stopImmediatePropagation", func(goja.FunctionCall) goja.Value {
stopped, stoppedImmediate = true, true
return goja.Undefined()
})
if bv := obj.Get("bubbles"); bv != nil {
bubbles = bv.ToBoolean()
}
}
// Copy so a handler that mutates the list mid-dispatch is safe.
hs := append([]goja.Value(nil), m[typ]...)
for _, h := range hs {
fn := b.handlerFunc(h)
b.callSafely(fn, self, event)

for cur := n; cur != nil; cur = cur.Parent {
if m := b.listeners[cur]; m != nil {
self := b.wrap(cur)
if cur == b.windowNode || cur == b.docNode {
self = b.vm.GlobalObject()
}
if obj != nil {
obj.Set("currentTarget", self)
}
// Copy so a handler that mutates the list mid-dispatch is safe.
hs := append([]goja.Value(nil), m[typ]...)
for _, h := range hs {
fn := b.handlerFunc(h)
b.callSafely(fn, self, event)
if stoppedImmediate {
break
}
}
}
if stopped || !bubbles {
break
}
}
}

Expand Down
82 changes: 82 additions & 0 deletions js/js_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,88 @@ func TestEvents(t *testing.T) {
}
}

func TestEventBubbling(t *testing.T) {
// Three independent subtrees (not one reused tree) so each scenario's
// listeners can't accumulate onto another's nodes.
const src = `<html><body>
<div id="a-grandparent"><div id="a-parent"><div id="a-child"></div></div></div>
<div id="b-grandparent"><div id="b-parent"><div id="b-child"></div></div></div>
<div id="c-parent"><div id="c-child"></div></div>
<script>
var log = [];
document.getElementById('a-grandparent').addEventListener('click', function(){ log.push('grandparent'); });
document.getElementById('a-parent').addEventListener('click', function(){ log.push('parent'); });
document.getElementById('a-child').addEventListener('click', function(){ log.push('child'); });
document.getElementById('a-child').dispatchEvent(new Event('click', {bubbles: true}));
console.log('bubbled=' + log.join(','));

log = [];
document.getElementById('b-grandparent').addEventListener('click', function(){ log.push('grandparent'); });
document.getElementById('b-parent').addEventListener('click', function(e){ log.push('parent'); e.stopPropagation(); });
document.getElementById('b-child').addEventListener('click', function(){ log.push('child'); });
document.getElementById('b-child').dispatchEvent(new Event('click', {bubbles: true}));
console.log('stopped=' + log.join(','));

log = [];
document.getElementById('c-parent').addEventListener('click', function(){ log.push('parent'); });
document.getElementById('c-child').addEventListener('click', function(){ log.push('child'); });
document.getElementById('c-child').dispatchEvent(new Event('click', {bubbles: false}));
console.log('nonbubbling=' + log.join(','));
</script>
</body></html>`
_, logs, _ := runJS(t, src)
mustHave(t, logs,
"bubbled=child,parent,grandparent",
// child's own (target-phase) listener still fires, parent's fires
// and calls stopPropagation — grandparent's must NOT.
"stopped=child,parent",
// bubbles:false must not even reach parent.
"nonbubbling=child")
}

// TestStopPropagationVsStopImmediate covers the real distinction: plain
// stopPropagation still lets LATER listeners on the SAME node run (only
// ancestors are skipped); stopImmediatePropagation does not.
func TestStopPropagationVsStopImmediate(t *testing.T) {
const src = `<html><body>
<div id="a-parent"><div id="a-child"></div></div>
<div id="b-parent"><div id="b-child"></div></div>
<script>
var log = [];
document.getElementById('a-child').addEventListener('click', function(e){ log.push('first'); e.stopPropagation(); });
document.getElementById('a-child').addEventListener('click', function(){ log.push('second'); });
document.getElementById('a-parent').addEventListener('click', function(){ log.push('parent'); });
document.getElementById('a-child').dispatchEvent(new Event('click', {bubbles: true}));
console.log('stopProp=' + log.join(','));

log = [];
document.getElementById('b-child').addEventListener('click', function(e){ log.push('first'); e.stopImmediatePropagation(); });
document.getElementById('b-child').addEventListener('click', function(){ log.push('second'); });
document.getElementById('b-parent').addEventListener('click', function(){ log.push('parent'); });
document.getElementById('b-child').dispatchEvent(new Event('click', {bubbles: true}));
console.log('stopImmediate=' + log.join(','));
</script>
</body></html>`
_, logs, _ := runJS(t, src)
mustHave(t, logs,
"stopProp=first,second", // second same-node listener still ran; parent did not
"stopImmediate=first") // second same-node listener did NOT run
}

func TestEventCurrentTargetTracksBubblePhase(t *testing.T) {
const src = `<html><body>
<div id="parent"><div id="child"></div></div>
<script>
document.getElementById('parent').addEventListener('click', function(e){
console.log('ct=' + e.currentTarget.id + ' target=' + e.target.id);
});
document.getElementById('child').dispatchEvent(new Event('click', {bubbles: true}));
</script>
</body></html>`
_, logs, _ := runJS(t, src)
mustHave(t, logs, "ct=parent target=child")
}

func TestScriptErrorContained(t *testing.T) {
root, logs, res := runJS(t, `<html><head><title>T</title></head><body>
<script>throw new Error('boom')</script>
Expand Down
43 changes: 43 additions & 0 deletions js/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,49 @@ func Begin(root *dom.Node, opt Options) *Session {
// binding reads back. Call it after each layout pass, before running scripts.
func (s *Session) SetMetrics(m Metrics) { s.b.metrics = m }

// EventInit carries the extra fields a synthetic event needs beyond
// type/target — which ones matter depends on typ, mirroring the real DOM
// (Key for keydown/keyup, Data/InputType for input). Bubbles/Cancelable
// default to false; a caller sets them per the real event's own defaults
// (e.g. click/keydown/keyup/input/change/submit all bubble in a real
// browser — Dispatch does not assume this for you, since it also serves
// dispatchEvent-style callers that want an exact, spec-shaped event).
type EventInit struct {
Bubbles bool
Cancelable bool
Key string // keydown / keyup
Data string // input
InputType string // input
}

// Dispatch fires a synthetic DOM event of type typ on n — the seam a host
// outside this package uses to synthesize real user interaction (focus, a
// keystroke, a click) against the live session, exactly as if the page's
// own script had called element.dispatchEvent(). It bubbles per init.
// Bubbles (see binder.dispatch) and reports whether a listener called
// preventDefault(), so a caller (e.g. native form submission) can honor it.
// Contained against a listener panic, like every other script execution
// path in this package.
func (s *Session) Dispatch(n *dom.Node, typ string, init EventInit) (defaultPrevented bool) {
var ev *goja.Object
s.guard(func() {
ev = s.b.newEvent(typ)
ev.Set("bubbles", init.Bubbles)
ev.Set("cancelable", init.Cancelable)
if init.Key != "" {
ev.Set("key", init.Key)
}
if init.Data != "" {
ev.Set("data", init.Data)
}
if init.InputType != "" {
ev.Set("inputType", init.InputType)
}
s.b.dispatch(n, typ, ev)
})
return ev != nil && ev.Get("defaultPrevented").ToBoolean()
}

// RunInitial executes every page <script> in document order, then dispatches
// DOMContentLoaded/load, draining queued timers/promises/XHR callbacks to
// quiescence within the budget. Contained against panics.
Expand Down
Loading