diff --git a/lib/bind/setterfloat64.go b/lib/bind/setterfloat64.go
index a66b37e9..97c11517 100644
--- a/lib/bind/setterfloat64.go
+++ b/lib/bind/setterfloat64.go
@@ -3,6 +3,7 @@ package bind
import (
"errors"
"fmt"
+ "html/template"
"math"
"strconv"
@@ -22,10 +23,50 @@ type numeric interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
+// setterFloat64 adapts a numeric [Setter] to a Setter[float64], sanitizing every
+// write through sanitizeFloatForT.
+//
+// Embedding Setter[T] promotes only JawsGet and JawsSet, so the adapter forwards
+// the optional event and attribute interfaces explicitly. Without that, wrapping
+// would hide them from [jaws.Element.ApplyGetter], silently dropping the Clicked,
+// ContextMenu and InitialHTMLAttr hooks a [Binder] exposes. Each forward reports
+// "not handled" when the wrapped Setter does not implement the interface, which is
+// what ApplyGetter and the event dispatch already expect for a plain setter.
type setterFloat64[T numeric] struct {
Setter[T]
}
+func (s setterFloat64[T]) JawsClick(elem *jaws.Element, click jaws.Click) (err error) {
+ err = jaws.ErrEventUnhandled
+ if h, ok := s.Setter.(jaws.ClickHandler); ok {
+ err = h.JawsClick(elem, click)
+ }
+ return
+}
+
+func (s setterFloat64[T]) JawsContextMenu(elem *jaws.Element, click jaws.Click) (err error) {
+ err = jaws.ErrEventUnhandled
+ if h, ok := s.Setter.(jaws.ContextMenuHandler); ok {
+ err = h.JawsContextMenu(elem, click)
+ }
+ return
+}
+
+func (s setterFloat64[T]) JawsInput(elem *jaws.Element, value string) (err error) {
+ err = jaws.ErrEventUnhandled
+ if h, ok := s.Setter.(jaws.InputHandler); ok {
+ err = h.JawsInput(elem, value)
+ }
+ return
+}
+
+func (s setterFloat64[T]) JawsInitialHTMLAttr(elem *jaws.Element) (attr template.HTMLAttr) {
+ if h, ok := s.Setter.(jaws.InitialHTMLAttrHandler); ok {
+ attr = h.JawsInitialHTMLAttr(elem)
+ }
+ return
+}
+
// sanitizeFloatForT validates value before it is converted to T and reports
// whether it may be the canonical float64 view of more than one T integer. It
// rejects non-finite values for every numeric T, and for integer T also rejects
@@ -193,6 +234,13 @@ func makeSetterFloat64for[T numeric](s *Setter[float64], value any) bool {
// bridge can lose precision: not every integer magnitude beyond 2^53 is exactly
// representable as float64.
//
+// Writes are sanitized whatever the bound type. [Setter.JawsSet] returns
+// [ErrFloatNotFinite] for a NaN or infinite value and [ErrFloatOutOfRange] for a
+// finite value that does not fit the bound type, leaving the bound value
+// unchanged in both cases. The float64 case is sanitized too, so an untrusted
+// value cannot store a NaN that would defeat the equality comparison every
+// change-detecting setter relies on.
+//
// When an integer loses precision in that conversion, writing the canonical
// float64 returned by [Getter.JawsGet] back through [Setter.JawsSet] preserves
// the underlying integer and returns [jaws.ErrValueUnchanged]. This also applies
@@ -216,7 +264,13 @@ func makeSetterFloat64for[T numeric](s *Setter[float64], value any) bool {
func MakeSetterFloat64(value any) (s Setter[float64]) {
switch v := value.(type) {
case Setter[float64]:
- return v
+ // Wrap rather than pass through: setterFloat64 is where the non-finite guard
+ // lives, and a float64 setter is the one settable case that needs no
+ // conversion, so passing it through unchanged would be the only binding that
+ // accepts NaN. The wrapper costs nothing else here: sanitizeFloatForT[float64]
+ // takes the finiteness-only default branch and reports mayAlias false, and
+ // T(value) is the identity, so no conversion or extra JawsGet is introduced.
+ return setterFloat64[float64]{Setter: v}
case Getter[float64]:
return setterReadOnly[float64]{v}
case float64:
diff --git a/lib/bind/setterfloat64_test.go b/lib/bind/setterfloat64_test.go
index d6ace41b..5f365e94 100644
--- a/lib/bind/setterfloat64_test.go
+++ b/lib/bind/setterfloat64_test.go
@@ -2,6 +2,7 @@ package bind
import (
"errors"
+ "html/template"
"math"
"reflect"
"strconv"
@@ -22,6 +23,7 @@ func (tg testGetter[T]) JawsGet(elem *jaws.Element) T {
}
func Test_makeSetterFloat64types(t *testing.T) {
+ tsfloat64 := newTestSetter(float64(0))
tsint := newTestSetter(int(0))
tsuintptr := newTestSetter(uintptr(0))
tests := []struct {
@@ -31,8 +33,8 @@ func Test_makeSetterFloat64types(t *testing.T) {
}{
{
name: "Setter[float64]",
- v: setterFloat64[float64]{},
- wantS: setterFloat64[float64]{},
+ v: tsfloat64,
+ wantS: setterFloat64[float64]{tsfloat64},
},
{
name: "Getter[float64]",
@@ -569,3 +571,180 @@ func Test_makeSetterFloat64_panicNamedNumeric(t *testing.T) {
assertPanics("value", Celsius(20))
assertPanics("setter", newTestSetter[Celsius](20))
}
+
+// Test_setterFloat64_rejectsNonFiniteForEveryNumericType pins that
+// [MakeSetterFloat64] applies the non-finite guard uniformly, whatever the bound
+// numeric type. float64 is the case worth pinning: it is the only settable type
+// that reaches JawsSet without a conversion step, so a missing guard there would
+// store a NaN that permanently defeats the equality-based update dedup in
+// binder.JawsSetLocked (NaN != NaN) and, for the float widgets in lib/ui, would
+// terminate every Request that later renders it.
+func Test_setterFloat64_rejectsNonFiniteForEveryNumericType(t *testing.T) {
+ nonFinite := []float64{math.NaN(), math.Inf(1), math.Inf(-1)}
+
+ assertRejects := func(t *testing.T, name string, s Setter[float64], unchanged func() bool) {
+ t.Helper()
+ for _, bad := range nonFinite {
+ if err := s.JawsSet(nil, bad); !errors.Is(err, ErrFloatNotFinite) {
+ t.Errorf("%s: JawsSet(%v) = %v, want ErrFloatNotFinite", name, bad, err)
+ }
+ if !unchanged() {
+ t.Fatalf("%s: JawsSet(%v) mutated the bound value", name, bad)
+ }
+ }
+ }
+
+ t.Run("float64", func(t *testing.T) {
+ var mu sync.Mutex
+ value := 1.5
+ assertRejects(t, "float64", MakeSetterFloat64(New(&mu, &value)), func() bool {
+ return value == 1.5
+ })
+ })
+
+ t.Run("float32", func(t *testing.T) {
+ var mu sync.Mutex
+ value := float32(1.5)
+ assertRejects(t, "float32", MakeSetterFloat64(New(&mu, &value)), func() bool {
+ return value == 1.5
+ })
+ })
+
+ t.Run("int", func(t *testing.T) {
+ var mu sync.Mutex
+ value := 3
+ assertRejects(t, "int", MakeSetterFloat64(New(&mu, &value)), func() bool {
+ return value == 3
+ })
+ })
+}
+
+// Test_setterFloat64_float64PassThroughBehavior pins the behavior the non-finite
+// guard must not disturb for a plain float64 binding: finite values are stored,
+// an unchanged value still reports jaws.ErrValueUnchanged, and the setter still
+// resolves to the same tag key as the underlying Binder.
+func Test_setterFloat64_float64PassThroughBehavior(t *testing.T) {
+ var mu sync.Mutex
+ value := 0.0
+ bind := New(&mu, &value)
+ s := MakeSetterFloat64(bind)
+
+ if err := s.JawsSet(nil, 2.5); err != nil {
+ t.Fatalf("JawsSet(2.5): %v", err)
+ }
+ if value != 2.5 {
+ t.Fatalf("stored value = %v, want 2.5", value)
+ }
+ if got := s.JawsGet(nil); got != 2.5 {
+ t.Fatalf("JawsGet() = %v, want 2.5", got)
+ }
+ if err := s.JawsSet(nil, 2.5); !errors.Is(err, jaws.ErrValueUnchanged) {
+ t.Fatalf("JawsSet(2.5) again = %v, want ErrValueUnchanged", err)
+ }
+
+ tags, err := tag.TagExpand(s)
+ if err != nil {
+ t.Fatalf("TagExpand: %v", err)
+ }
+ if len(tags) != 1 || tags[0] != any(&value) {
+ t.Fatalf("TagExpand() = %#v, want [%p]", tags, &value)
+ }
+}
+
+// inputSetterFloat64 is a Setter[float64] that also handles raw browser input, so
+// the tests can exercise setterFloat64's InputHandler forwarding. bind.Binder
+// covers the click, context-menu and initial-attribute interfaces but not this one.
+type inputSetterFloat64 struct {
+ *testSetter[float64]
+ got string
+}
+
+func (is *inputSetterFloat64) JawsInput(elem *jaws.Element, value string) error {
+ is.got = value
+ return nil
+}
+
+// Test_setterFloat64_forwardsOptionalInterfaces pins that adapting a numeric
+// Setter stays transparent to jaws.Element.ApplyGetter. The adapter embeds
+// Setter[T], which promotes only JawsGet and JawsSet, so the optional event and
+// attribute interfaces must be forwarded explicitly or a Binder's Clicked,
+// ContextMenu and InitialHTMLAttr hooks would silently stop firing once the value
+// is bound to a float widget.
+func Test_setterFloat64_forwardsOptionalInterfaces(t *testing.T) {
+ t.Run("delegates to the wrapped setter", func(t *testing.T) {
+ var mu sync.Mutex
+ value := 1.5
+ var clicked, contextMenu int
+ b := New(&mu, &value).
+ Clicked(func(Binder[float64], *jaws.Element, jaws.Click) error {
+ clicked++
+ return nil
+ }).
+ ContextMenu(func(Binder[float64], *jaws.Element, jaws.Click) error {
+ contextMenu++
+ return nil
+ }).
+ InitialHTMLAttr(func(Binder[float64], *jaws.Element) template.HTMLAttr {
+ return `data-x="1"`
+ })
+ s := MakeSetterFloat64(b)
+
+ ch, ok := s.(jaws.ClickHandler)
+ if !ok {
+ t.Fatal("adapter does not implement jaws.ClickHandler")
+ }
+ if err := ch.JawsClick(nil, jaws.Click{}); err != nil || clicked != 1 {
+ t.Errorf("JawsClick() = %v, clicked = %d, want nil and 1", err, clicked)
+ }
+
+ cm, ok := s.(jaws.ContextMenuHandler)
+ if !ok {
+ t.Fatal("adapter does not implement jaws.ContextMenuHandler")
+ }
+ if err := cm.JawsContextMenu(nil, jaws.Click{}); err != nil || contextMenu != 1 {
+ t.Errorf("JawsContextMenu() = %v, contextMenu = %d, want nil and 1", err, contextMenu)
+ }
+
+ ah, ok := s.(jaws.InitialHTMLAttrHandler)
+ if !ok {
+ t.Fatal("adapter does not implement jaws.InitialHTMLAttrHandler")
+ }
+ if got := ah.JawsInitialHTMLAttr(nil); got != `data-x="1"` {
+ t.Errorf("JawsInitialHTMLAttr() = %q, want %q", got, `data-x="1"`)
+ }
+ })
+
+ t.Run("forwards input to a wrapped InputHandler", func(t *testing.T) {
+ is := &inputSetterFloat64{testSetter: newTestSetter(1.5)}
+ s := MakeSetterFloat64(is)
+ ih, ok := s.(jaws.InputHandler)
+ if !ok {
+ t.Fatal("adapter does not implement jaws.InputHandler")
+ }
+ if err := ih.JawsInput(nil, "2.5"); err != nil {
+ t.Fatalf("JawsInput(): %v", err)
+ }
+ if is.got != "2.5" {
+ t.Errorf("wrapped JawsInput got %q, want %q", is.got, "2.5")
+ }
+ })
+
+ t.Run("reports unhandled for a plain setter", func(t *testing.T) {
+ // A Setter that implements none of the optional interfaces must leave the
+ // event unhandled so dispatch falls through to the widget, and must
+ // contribute no initial attribute.
+ s := MakeSetterFloat64(newTestSetter(1.5))
+ if err := s.(jaws.ClickHandler).JawsClick(nil, jaws.Click{}); !errors.Is(err, jaws.ErrEventUnhandled) {
+ t.Errorf("JawsClick() = %v, want ErrEventUnhandled", err)
+ }
+ if err := s.(jaws.ContextMenuHandler).JawsContextMenu(nil, jaws.Click{}); !errors.Is(err, jaws.ErrEventUnhandled) {
+ t.Errorf("JawsContextMenu() = %v, want ErrEventUnhandled", err)
+ }
+ if err := s.(jaws.InputHandler).JawsInput(nil, "1"); !errors.Is(err, jaws.ErrEventUnhandled) {
+ t.Errorf("JawsInput() = %v, want ErrEventUnhandled", err)
+ }
+ if got := s.(jaws.InitialHTMLAttrHandler).JawsInitialHTMLAttr(nil); got != "" {
+ t.Errorf("JawsInitialHTMLAttr() = %q, want empty", got)
+ }
+ })
+}
diff --git a/lib/ui/input_widgets_test.go b/lib/ui/input_widgets_test.go
index 5f6208f2..57990e08 100644
--- a/lib/ui/input_widgets_test.go
+++ b/lib/ui/input_widgets_test.go
@@ -631,3 +631,72 @@ func TestInputTextWidget_InitialHTMLAttrFromBinder(t *testing.T) {
_, got := renderUI(t, rq, NewText(b))
mustMatch(t, `^$`, got)
}
+
+// TestInputFloat_BinderAdapterStaysTransparent guards every binding that reaches a
+// float widget through bind.MakeSetterFloat64, which wraps the Binder in an adapter
+// so non-finite browser values are rejected. The adapter must not shadow what
+// jaws.Element.ApplyGetter looks for: the Binder's Clicked and InitialHTMLAttr hooks
+// and its pointer-derived dirty tag all have to survive the wrapping. An integer
+// binding is covered as well as float64, because it takes the converting path.
+func TestInputFloat_BinderAdapterStaysTransparent(t *testing.T) {
+ t.Run("float64", func(t *testing.T) {
+ _, rq := newCoreRequest(t)
+ var mu deadlock.Mutex
+ value := 1.5
+ var clicked int
+ b := bind.New(&mu, &value).
+ Clicked(func(bind.Binder[float64], *jaws.Element, jaws.Click) error {
+ clicked++
+ return nil
+ }).
+ InitialHTMLAttr(func(bind.Binder[float64], *jaws.Element) template.HTMLAttr {
+ return `data-unit="cm"`
+ })
+ number := NewNumber(bind.MakeSetterFloat64(b))
+
+ elem, got := renderUI(t, rq, number)
+ mustMatch(t, `^$`, got)
+
+ // The dirty target must still be the bound pointer, so a browser edit
+ // reconciles through the same tag the application dirties.
+ if !elem.HasTag(&value) {
+ t.Fatalf("element not registered under the bound pointer; tags: %v", rq.TagsOf(elem))
+ }
+ if err := jaws.CallEventHandlers(elem.UI(), elem, what.Click, "0 0 0 x"); err != nil {
+ t.Fatalf("click: %v", err)
+ }
+ if clicked != 1 {
+ t.Fatalf("Clicked hook fired %d times, want 1", clicked)
+ }
+
+ if err := number.JawsInput(elem, "2.5"); err != nil {
+ t.Fatalf("JawsInput(2.5): %v", err)
+ }
+ if value != 2.5 {
+ t.Fatalf("stored value = %v, want 2.5", value)
+ }
+
+ // A non-finite value is refused, leaving the bound value intact rather than
+ // storing a NaN that would break change detection.
+ if err := bind.MakeSetterFloat64(b).JawsSet(elem, math.NaN()); !errors.Is(err, bind.ErrFloatNotFinite) {
+ t.Fatalf("JawsSet(NaN) = %v, want bind.ErrFloatNotFinite", err)
+ }
+ if value != 2.5 {
+ t.Fatalf("stored value = %v after NaN, want 2.5", value)
+ }
+ })
+
+ t.Run("int", func(t *testing.T) {
+ _, rq := newCoreRequest(t)
+ var mu deadlock.Mutex
+ value := 3
+ b := bind.New(&mu, &value).InitialHTMLAttr(
+ func(bind.Binder[int], *jaws.Element) template.HTMLAttr { return `data-unit="px"` })
+
+ elem, got := renderUI(t, rq, NewNumber(bind.MakeSetterFloat64(b)))
+ mustMatch(t, `^$`, got)
+ if !elem.HasTag(&value) {
+ t.Fatalf("element not registered under the bound pointer; tags: %v", rq.TagsOf(elem))
+ }
+ })
+}