diff --git a/lib/ui/register.go b/lib/ui/register.go index ee930607..668750f8 100644 --- a/lib/ui/register.go +++ b/lib/ui/register.go @@ -57,7 +57,6 @@ func (rw RequestWriter) Register(updater jaws.Updater, params ...any) jid.Jid { // documented usage puts the returned Jid inside an attribute // (
), 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. diff --git a/lib/ui/requestwriter.go b/lib/ui/requestwriter.go index 1eabeb36..520bec07 100644 --- a/lib/ui/requestwriter.go +++ b/lib/ui/requestwriter.go @@ -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. diff --git a/lib/ui/template_owned_test.go b/lib/ui/template_owned_test.go index 674b1eae..45f52292 100644 --- a/lib/ui/template_owned_test.go +++ b/lib/ui/template_owned_test.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "html/template" + "io" "slices" "strings" "sync" @@ -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 { @@ -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. @@ -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) { @@ -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