Skip to content

ui: nested unwrapped templates leak an Element on every parent update #216

Description

@linkdata

Summary

Re-rendering a wrapped ui.Template that invokes an unwrapped nested template registers a fresh nested Element on every update. The old nested Elements are never deleted even though the parent's SetInner replaces their output.

Class / severity

Resource lifecycle — High. Ordinary repeated updates cause unbounded growth of the Request's Element registry and tag routing state.

Contract and valid usage

ui.Template explicitly allows OuterHTMLTag to be empty and says nested JaWS UI rendered by an unwrapped template can update through its own Elements. RequestWriter.Template documents the same unwrapped mode. Calling that helper from a Go template is therefore supported usage.

Reproduction

package ui_test

import (
	"html/template"
	"testing"
	"time"

	"github.com/linkdata/jaws"
	"github.com/linkdata/jaws/jawstest"
	"github.com/linkdata/jaws/lib/tag"
	"github.com/linkdata/jaws/lib/ui"
	"github.com/linkdata/jaws/lib/what"
	"github.com/linkdata/jaws/lib/wire"
)

func TestNestedUnwrappedTemplateDoesNotLeakOnParentUpdate(t *testing.T) {
	jw, err := jaws.New()
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(jw.Close)
	go jw.Serve()

	lookuper := template.Must(template.New("templates").Parse(`
		{{define "parent"}}{{$.RequestWriter.Template "" "child" $.Dot}}{{end}}
		{{define "child"}}child{{end}}
	`))
	if err := jw.AddTemplateLookuper(lookuper); err != nil {
		t.Fatal(err)
	}

	tr := jawstest.NewTestRequest(jw, nil)
	t.Cleanup(func() {
		tr.Close()
		<-tr.DoneCh
	})
	<-tr.ReadyCh

	marker := tag.Tag("nested")
	rw := ui.RequestWriter{Request: tr.Request, Writer: tr.Recorder}
	if err := rw.NewUI(ui.NewTemplate("div", "parent", marker)); err != nil {
		t.Fatal(err)
	}
	if got := len(tr.GetElements(marker)); got != 2 {
		t.Fatalf("initial tagged elements = %d, want 2", got)
	}

	tr.BcastCh <- wire.Message{Dest: marker, What: what.Update}
	select {
	case <-tr.OutCh:
	case <-time.After(time.Second):
		t.Fatal("timeout waiting for parent update")
	}

	if got := len(tr.GetElements(marker)); got != 2 {
		t.Fatalf("tagged elements after one update = %d, want 2", got)
	}
}

The final count is 3, and it increases by one on every subsequent update.

Root cause

Template.JawsUpdate (lib/ui/template.go:142-149) executes the parent template into a new builder. The nested RequestWriter.Template reaches RequestWriter.NewUI, which unconditionally calls Request.NewElement (lib/ui/requestwriter.go:20-24). On success nothing records ownership of that nested Element or removes the previous one before SetInner replaces the DOM content.

An unwrapped nested template makes the leak especially direct: its registered Element has no generated DOM identity of its own, but remains in rq.elems and rq.tagMap for the Request lifetime.

Impact

Frequently updated pages accumulate Elements, tags, handlers, and referenced application state. Tag broadcasts increasingly visit stale Elements, increasing memory and update work until the Request ends.

Suggested fix

Track Elements created while executing a template as descendants of its wrapper and reconcile/delete the previous descendant set when SetInner replaces it. Ensure rollback also removes newly allocated descendants if execution fails.

Confidence

Confirmed on main at 6fd13a2.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions