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
1 change: 0 additions & 1 deletion lib/ui/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ func (rw RequestWriter) Register(updater jaws.Updater, params ...any) jid.Jid {
// documented usage puts the returned Jid inside an attribute
// (<div id="{{$.Register .X}}">), where that comment would corrupt the markup.
elem := rw.NewElement(registerUI{Updater: updater})
rw.trackElement(elem)
elem.Tag(updater)
// The registerUI Element's UI is not the updater, so events reach the
// updater only through the element's handler list, not the elem.UI() fallback.
Expand Down
16 changes: 11 additions & 5 deletions lib/ui/requestwriter.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,23 @@ func (rw RequestWriter) trackElement(elem *jaws.Element) {
}
}

// NewElement creates a new [jaws.Element] for ui.
//
// It has the same ownership and multiplicity requirements as
// [jaws.Request.NewElement]. An Element created through the RequestWriter passed
// to a [Template] execution belongs to that Template.
func (rw RequestWriter) NewElement(ui jaws.UI) *jaws.Element {
elem := rw.Request.NewElement(ui)
rw.trackElement(elem)
return elem
}

// NewUI creates an element for ui and renders it to the underlying writer.
//
// The ui value must satisfy the ownership and live-Element multiplicity
// requirements documented by [jaws.UI].
func (rw RequestWriter) NewUI(ui jaws.UI, params ...any) (err error) {
elem := rw.NewElement(ui)
// Report the Element before rendering it, so the owner's set is complete even for
// one that fails. That set may then hold an Element already unregistered below,
// which costs nothing: Request.DeleteElements skips elements it finds
// unregistered, and every rollback path deletes the whole set at once.
rw.trackElement(elem)
if err = elem.JawsRender(rw, params); err != nil {
// Unregister anything the failed Element already owns along with it, so no
// widget can strand a subtree by not rolling back itself.
Expand Down
119 changes: 116 additions & 3 deletions lib/ui/template_owned_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"html/template"
"io"
"slices"
"strings"
"sync"
Expand Down Expand Up @@ -35,10 +36,14 @@ const ownedTestTemplates = `
{{define "owned-radiogroup-failafter"}}{{range $.RequestWriter.RadioGroup $.Dot.Radios}}{{.Radio}}{{.Label}}{{end}}{{$.Dot.Check}}{{end}}
{{define "owned-radio-outer"}}{{$.RequestWriter.Template "div" "owned-radio-inner" ($.Dot.Box $.RequestWriter)}}{{end}}
{{define "owned-radio-inner"}}{{range $.Dot.Elements}}{{.Radio}}{{.Label}}{{end}}{{end}}
{{define "owned-new-element"}}{{$.Dot.RawChild $.RequestWriter}}{{end}}
{{define "owned-new-element-failafter"}}{{$.Dot.RawChild $.RequestWriter}}{{$.Dot.Check}}{{end}}
`

var errOwnedDotCheck = errors.New("owned dot check failed")

const ownedRawChildTag = tag.Tag("owned-raw-child")

// ownedDot is the template data for the ownership tests. A pointer is usable as a
// tag, and Check fails template execution after nested UI has already rendered.
type ownedDot struct {
Expand Down Expand Up @@ -68,6 +73,12 @@ func (d *ownedDot) Names() []string { return d.names }

func (d *ownedDot) Radios() *named.BoolArray { return d.radios }

// RawChild manually creates and renders a child through rw.
func (d *ownedDot) RawChild(rw RequestWriter) (string, error) {
elem := rw.NewElement(NewSpan(testHTMLGetter("raw child")))
return "", elem.JawsRender(rw, []any{ownedRawChildTag})
}

// Box builds the radio group with the passed-in writer — the outer template's — and
// returns the box carrying the RadioElement values to the nested template, whose dot it
// becomes. It is a pointer so it is usable as a tag.
Expand Down Expand Up @@ -392,6 +403,108 @@ func TestRequestWriter_NewUIReportsElementBeforeRendering(t *testing.T) {
}
}

func TestRequestWriter_TracksEachElementOnce(t *testing.T) {
for _, tt := range []struct {
name string
create func(RequestWriter) error
}{
{"NewElement", func(rw RequestWriter) error {
rw.NewElement(NewSpan(testHTMLGetter("new element")))
return nil
}},
{"Register", func(rw RequestWriter) error {
rw.Register(new(testRWUpdater))
return nil
}},
} {
t.Run(tt.name, func(t *testing.T) {
_, rq := newCoreRequest(t)
var seen []*jaws.Element
rw := RequestWriter{
Request: rq,
Writer: io.Discard,
elementCreated: func(elem *jaws.Element) {
seen = append(seen, elem)
},
}
if err := tt.create(rw); err != nil {
t.Fatal(err)
}
if len(seen) != 1 {
t.Fatalf("elementCreated calls = %d, want 1", len(seen))
}
if got := rq.GetElementByJid(seen[0].Jid()); got != seen[0] {
t.Fatalf("registered Element = %p, want %p", got, seen[0])
}
})
}
}

func TestTemplate_UpdateReplacesRequestWriterNewElement(t *testing.T) {
_, rq := newOwnedRequest(t)

dot := &ownedDot{}
tmpl := NewTemplate("div", "owned-new-element", dot)
elem := renderOwned(t, rq, tmpl)
first := rq.GetElements(ownedRawChildTag)
if len(first) != 1 {
t.Fatalf("raw children after render = %d, want 1", len(first))
}

tmpl.JawsUpdate(elem)
second := rq.GetElements(ownedRawChildTag)
if len(second) != 1 {
t.Fatalf("raw children after update = %d, want 1", len(second))
}
if first[0] == second[0] {
t.Fatal("update retained the previous raw child")
}
if !first[0].Deleted() || rq.GetElementByJid(first[0].Jid()) != nil {
t.Fatal("previous raw child remains registered")
}
if got := countRegistered(t, rq); got != 2 {
t.Fatalf("registered elements after update = %d, want 2", got)
}
}

func TestTemplate_UpdateFailureKeepsRequestWriterNewElement(t *testing.T) {
jw, rq := newOwnedRequest(t)
logger := new(templateLogger)
jw.Logger = logger

dot := &ownedDot{}
tmpl := NewTemplate("div", "owned-new-element-failafter", dot)
elem := renderOwned(t, rq, tmpl)
first := rq.GetElements(ownedRawChildTag)
if len(first) != 1 {
t.Fatalf("raw children after render = %d, want 1", len(first))
}
firstJids := registeredJids(t, rq)

dot.setFail(errOwnedDotCheck)
tmpl.JawsUpdate(elem)
logged := logger.sync(t, jw)
if len(logged) != 1 || !errors.Is(logged[0], errOwnedDotCheck) {
t.Fatalf("logged errors = %v, want one %v", logged, errOwnedDotCheck)
}
if got := registeredJids(t, rq); !slices.Equal(got, firstJids) {
t.Fatalf("registered jids after failed update = %v, want %v", got, firstJids)
}
if got := rq.GetElements(ownedRawChildTag); len(got) != 1 || got[0] != first[0] {
t.Fatalf("raw children after failed update = %v, want previous child %v", got, first[0])
}

dot.setFail(nil)
tmpl.JawsUpdate(elem)
second := rq.GetElements(ownedRawChildTag)
if len(second) != 1 || second[0] == first[0] {
t.Fatalf("raw children after recovery = %v, want one replacement", second)
}
if !first[0].Deleted() || rq.GetElementByJid(first[0].Jid()) != nil {
t.Fatal("previous raw child remains registered after recovery")
}
}

// TestTemplate_UpdateReclaimsWholeSubtree covers the recursive walk: the wrapper
// owns a nested template that owns another one.
func TestTemplate_UpdateReclaimsWholeSubtree(t *testing.T) {
Expand Down Expand Up @@ -501,9 +614,9 @@ func TestPageTemplate_RenderFailureDeletesOwnedElements(t *testing.T) {
}

// TestTemplate_UpdateTracksRegisterAndRadioGroup covers the two helpers that create
// their Elements through Request.NewElement rather than RequestWriter.NewUI: they
// report them to the writer's owner, so the counts stay flat across updates with no
// client attached to acknowledge DOM removals.
// Elements without going through RequestWriter.NewUI. Both report them to the
// writer's owner, so the counts stay flat across updates with no client attached to
// acknowledge DOM removals.
//
// The label-only case is why ownership is recorded at creation: RadioElement.Label
// creates the radio Element for its for= attribute without ever rendering it, so an
Expand Down
Loading