From a8b333eb9cc70262bf7f2792512d317e27f66cf9 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Sat, 15 Aug 2026 23:39:33 +0200 Subject: [PATCH 1/2] fix(ui): reconcile cleared date inputs --- lib/ui/input_widgets.go | 28 +++++++-- lib/ui/input_widgets_test.go | 112 +++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/lib/ui/input_widgets.go b/lib/ui/input_widgets.go index 3196c8e7..6ded1d28 100644 --- a/lib/ui/input_widgets.go +++ b/lib/ui/input_widgets.go @@ -1,6 +1,7 @@ package ui import ( + "errors" "html/template" "io" "strconv" @@ -178,8 +179,11 @@ func (u *InputDate) JawsUpdate(elem *jaws.Element) { // JawsInput stores a browser-side date input value. // -// The browser sends a calendar date (YYYY-MM-DD), which [time.Parse] resolves -// to midnight UTC, so the stored [time.Time] drops any time-of-day and +// An empty browser value maps to the zero [time.Time], whose canonical control +// representation is "0001-01-01". +// +// A non-empty browser value is a calendar date (YYYY-MM-DD), which [time.Parse] +// resolves to midnight UTC, so the stored [time.Time] drops any time-of-day and // [time.Location] the previously bound value carried. In a non-UTC deployment // the stored instant therefore shifts by the zone offset, and because // [time.Time] inequality includes the location, re-selecting the same date @@ -193,6 +197,7 @@ func (u *InputDate) JawsUpdate(elem *jaws.Element) { // parse error and leaves the last accepted value in place instead of updating the // bound value. Keep bound years within 1..9999. func (u *InputDate) JawsInput(elem *jaws.Element, value string) (err error) { + input := value if value == "" { value = "0001-01-01" } @@ -200,8 +205,23 @@ func (u *InputDate) JawsInput(elem *jaws.Element, value string) (err error) { // Parse errors are malformed client frames: jaws.js reads elem.value from // browser date controls. Leave Last as the last accepted value. if v, err = time.Parse(assets.ISO8601, value); err == nil { - u.Last.Store(u.str(v)) - err = u.maybeDirty(elem, u.Setter.JawsSet(elem, v)) + canonical := u.str(v) + // Keep the cache canonical for targetless setters because Input + // documents that they do not reconcile automatically. + last := canonical + if u.tag != nil { + last = input + } + u.Last.Store(last) + err = u.Setter.JawsSet(elem, v) + if errors.Is(err, jaws.ErrValueUnchanged) { + if input != canonical && u.tag != nil { + elem.Dirty(elem) + } + err = nil + return + } + err = u.maybeDirty(elem, err) } return } diff --git a/lib/ui/input_widgets_test.go b/lib/ui/input_widgets_test.go index 8e7b8b9f..aa1782fe 100644 --- a/lib/ui/input_widgets_test.go +++ b/lib/ui/input_widgets_test.go @@ -4,7 +4,10 @@ import ( "errors" "html/template" "strings" + "sync" + "sync/atomic" "testing" + "testing/synctest" "time" "github.com/linkdata/deadlock" @@ -16,6 +19,16 @@ import ( "github.com/linkdata/jaws/lib/wire" ) +type inputDateCountingUI struct { + *Date + updateCalls atomic.Int32 +} + +func (u *inputDateCountingUI) JawsUpdate(elem *jaws.Element) { + u.updateCalls.Add(1) + u.Date.JawsUpdate(elem) +} + func TestInputTextWidgets(t *testing.T) { _, rq := newCoreRequest(t) ss := newTestSetter("foo") @@ -176,6 +189,105 @@ func TestInputDateWidget(t *testing.T) { date.JawsUpdate(elem) } +func TestInputDate_ClearReconcilesCanonicalZero(t *testing.T) { + tests := []struct { + name string + initial time.Time + wantPeer bool + wantInitial string + }{ + { + name: "changed value updates origin and peer", + initial: time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC), + wantPeer: true, + wantInitial: "2026-08-15", + }, + { + name: "unchanged zero corrects only origin", + initial: time.Time{}, + wantInitial: "0001-01-01", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + go jw.Serve() + tr := jawstest.NewTestRequest(jw, nil) + <-tr.ReadyCh + defer func() { + tr.Close() + jw.Close() + synctest.Wait() + }() + + var mu sync.Mutex + value := tt.initial + binding := bind.New(&mu, &value) + origin := &inputDateCountingUI{Date: NewDate(binding)} + peer := &inputDateCountingUI{Date: NewDate(binding)} + originElem := tr.NewElement(origin) + peerElem := tr.NewElement(peer) + for _, elem := range []*jaws.Element{originElem, peerElem} { + var output strings.Builder + if err = elem.JawsRender(&output, nil); err != nil { + t.Fatal(err) + } + if got := output.String(); !strings.Contains(got, `value="`+tt.wantInitial+`"`) { + t.Fatalf("initial date HTML = %q, want value %q", got, tt.wantInitial) + } + } + + tr.InCh <- wire.WsMsg{Jid: originElem.Jid(), What: what.Input, Data: ""} + synctest.Wait() + time.Sleep(jaws.DefaultUpdateInterval + time.Millisecond) + synctest.Wait() + + mu.Lock() + got := value + mu.Unlock() + if !got.IsZero() { + t.Fatalf("cleared date = %v, want zero time", got) + } + + want := map[jaws.Jid]bool{originElem.Jid(): true} + if tt.wantPeer { + want[peerElem.Jid()] = true + } + for len(want) > 0 { + select { + case msg := <-tr.OutCh: + if !want[msg.Jid] || msg.What != what.Value || msg.Data != "0001-01-01" { + t.Fatalf("date correction = %#v, want Value %q for one of %v", msg, "0001-01-01", want) + } + delete(want, msg.Jid) + default: + t.Fatalf("missing date corrections for %v", want) + } + } + select { + case msg := <-tr.OutCh: + t.Fatalf("unexpected additional date output: %#v", msg) + default: + } + wantPeerCalls := int32(0) + if tt.wantPeer { + wantPeerCalls = 1 + } + if got := origin.updateCalls.Load(); got != 1 { + t.Fatalf("origin JawsUpdate calls = %d, want 1", got) + } + if got := peer.updateCalls.Load(); got != wantPeerCalls { + t.Fatalf("peer JawsUpdate calls = %d, want %d", got, wantPeerCalls) + } + }) + }) + } +} + // TestInputDate_BrowserEditNormalizesToMidnightUTC locks in the documented // date-only behavior (issue #124): the control renders/reads a calendar date, so // a browser edit resolves through time.Parse to midnight UTC and drops the bound From c301667a48c4dbe0e12589e9b9aa48e5a2b26715 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Sun, 16 Aug 2026 00:15:32 +0200 Subject: [PATCH 2/2] refactor(ui): simplify date clear reconciliation --- lib/ui/input_widgets.go | 25 ++---- lib/ui/input_widgets_test.go | 167 +++++++++++++---------------------- 2 files changed, 69 insertions(+), 123 deletions(-) diff --git a/lib/ui/input_widgets.go b/lib/ui/input_widgets.go index 6ded1d28..98848ad7 100644 --- a/lib/ui/input_widgets.go +++ b/lib/ui/input_widgets.go @@ -179,12 +179,10 @@ func (u *InputDate) JawsUpdate(elem *jaws.Element) { // JawsInput stores a browser-side date input value. // -// An empty browser value maps to the zero [time.Time], whose canonical control -// representation is "0001-01-01". -// -// A non-empty browser value is a calendar date (YYYY-MM-DD), which [time.Parse] -// resolves to midnight UTC, so the stored [time.Time] drops any time-of-day and -// [time.Location] the previously bound value carried. In a non-UTC deployment +// An empty value maps to the zero [time.Time], which renders as "0001-01-01". +// Non-empty values are calendar dates (YYYY-MM-DD). [time.Parse] +// resolves them to midnight UTC, so the stored [time.Time] drops any time-of-day +// and [time.Location] the previously bound value carried. In a non-UTC deployment // the stored instant therefore shifts by the zone offset, and because // [time.Time] inequality includes the location, re-selecting the same date // still reports a change and broadcasts it. Bind a date whose clock and zone are @@ -205,19 +203,10 @@ func (u *InputDate) JawsInput(elem *jaws.Element, value string) (err error) { // Parse errors are malformed client frames: jaws.js reads elem.value from // browser date controls. Leave Last as the last accepted value. if v, err = time.Parse(assets.ISO8601, value); err == nil { - canonical := u.str(v) - // Keep the cache canonical for targetless setters because Input - // documents that they do not reconcile automatically. - last := canonical - if u.tag != nil { - last = input - } - u.Last.Store(last) + u.Last.Store(input) err = u.Setter.JawsSet(elem, v) - if errors.Is(err, jaws.ErrValueUnchanged) { - if input != canonical && u.tag != nil { - elem.Dirty(elem) - } + if input == "" && u.tag != nil && errors.Is(err, jaws.ErrValueUnchanged) { + elem.Dirty(elem) err = nil return } diff --git a/lib/ui/input_widgets_test.go b/lib/ui/input_widgets_test.go index aa1782fe..18970d70 100644 --- a/lib/ui/input_widgets_test.go +++ b/lib/ui/input_widgets_test.go @@ -4,8 +4,6 @@ import ( "errors" "html/template" "strings" - "sync" - "sync/atomic" "testing" "testing/synctest" "time" @@ -19,16 +17,6 @@ import ( "github.com/linkdata/jaws/lib/wire" ) -type inputDateCountingUI struct { - *Date - updateCalls atomic.Int32 -} - -func (u *inputDateCountingUI) JawsUpdate(elem *jaws.Element) { - u.updateCalls.Add(1) - u.Date.JawsUpdate(elem) -} - func TestInputTextWidgets(t *testing.T) { _, rq := newCoreRequest(t) ss := newTestSetter("foo") @@ -190,102 +178,71 @@ func TestInputDateWidget(t *testing.T) { } func TestInputDate_ClearReconcilesCanonicalZero(t *testing.T) { - tests := []struct { - name string - initial time.Time - wantPeer bool - wantInitial string - }{ - { - name: "changed value updates origin and peer", - initial: time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC), - wantPeer: true, - wantInitial: "2026-08-15", - }, - { - name: "unchanged zero corrects only origin", - initial: time.Time{}, - wantInitial: "0001-01-01", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - go jw.Serve() - tr := jawstest.NewTestRequest(jw, nil) - <-tr.ReadyCh - defer func() { - tr.Close() - jw.Close() - synctest.Wait() - }() - - var mu sync.Mutex - value := tt.initial - binding := bind.New(&mu, &value) - origin := &inputDateCountingUI{Date: NewDate(binding)} - peer := &inputDateCountingUI{Date: NewDate(binding)} - originElem := tr.NewElement(origin) - peerElem := tr.NewElement(peer) - for _, elem := range []*jaws.Element{originElem, peerElem} { - var output strings.Builder - if err = elem.JawsRender(&output, nil); err != nil { - t.Fatal(err) - } - if got := output.String(); !strings.Contains(got, `value="`+tt.wantInitial+`"`) { - t.Fatalf("initial date HTML = %q, want value %q", got, tt.wantInitial) - } + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + go jw.Serve() + tr := jawstest.NewTestRequest(jw, nil) + <-tr.ReadyCh + defer func() { + tr.Close() + jw.Close() + synctest.Wait() + }() + + source := newTestSetter(time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)) + originElem, _ := renderUI(t, tr.Request, NewDate(source)) + peerElem, _ := renderUI(t, tr.Request, NewDate(source)) + + waitForUpdate := func() { + synctest.Wait() + time.Sleep(jaws.DefaultUpdateInterval + time.Millisecond) + synctest.Wait() + } + readCorrection := func() (msg wire.WsMsg) { + t.Helper() + select { + case msg = <-tr.OutCh: + if msg.What != what.Value || msg.Data != "0001-01-01" { + t.Fatalf("date correction = %#v, want Value %q", msg, "0001-01-01") } + default: + t.Fatal("missing date correction") + } + return + } - tr.InCh <- wire.WsMsg{Jid: originElem.Jid(), What: what.Input, Data: ""} - synctest.Wait() - time.Sleep(jaws.DefaultUpdateInterval + time.Millisecond) - synctest.Wait() - - mu.Lock() - got := value - mu.Unlock() - if !got.IsZero() { - t.Fatalf("cleared date = %v, want zero time", got) - } + tr.InCh <- wire.WsMsg{Jid: originElem.Jid(), What: what.Input, Data: ""} + waitForUpdate() + if got := source.Get(); !got.IsZero() { + t.Fatalf("cleared date = %v, want zero time", got) + } + corrections := map[jaws.Jid]bool{ + readCorrection().Jid: true, + readCorrection().Jid: true, + } + if len(corrections) != 2 || !corrections[originElem.Jid()] || !corrections[peerElem.Jid()] { + t.Fatalf("corrected elements = %v, want origin and peer", corrections) + } + select { + case msg := <-tr.OutCh: + t.Fatalf("unexpected additional date output: %#v", msg) + default: + } - want := map[jaws.Jid]bool{originElem.Jid(): true} - if tt.wantPeer { - want[peerElem.Jid()] = true - } - for len(want) > 0 { - select { - case msg := <-tr.OutCh: - if !want[msg.Jid] || msg.What != what.Value || msg.Data != "0001-01-01" { - t.Fatalf("date correction = %#v, want Value %q for one of %v", msg, "0001-01-01", want) - } - delete(want, msg.Jid) - default: - t.Fatalf("missing date corrections for %v", want) - } - } - select { - case msg := <-tr.OutCh: - t.Fatalf("unexpected additional date output: %#v", msg) - default: - } - wantPeerCalls := int32(0) - if tt.wantPeer { - wantPeerCalls = 1 - } - if got := origin.updateCalls.Load(); got != 1 { - t.Fatalf("origin JawsUpdate calls = %d, want 1", got) - } - if got := peer.updateCalls.Load(); got != wantPeerCalls { - t.Fatalf("peer JawsUpdate calls = %d, want %d", got, wantPeerCalls) - } - }) - }) - } + tr.InCh <- wire.WsMsg{Jid: originElem.Jid(), What: what.Input, Data: ""} + waitForUpdate() + if msg := readCorrection(); msg.Jid != originElem.Jid() { + t.Fatalf("unchanged correction Jid = %v, want %v", msg.Jid, originElem.Jid()) + } + select { + case msg := <-tr.OutCh: + t.Fatalf("unexpected additional date output: %#v", msg) + default: + } + }) } // TestInputDate_BrowserEditNormalizesToMidnightUTC locks in the documented