From 7e18cc3720f00c0ec3c308ed35a99cd900ebd10d Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 6 Aug 2026 21:28:50 +0200 Subject: [PATCH 1/3] fix(ui): default empty template wrappers to div --- .agents/skills/jaws/SKILL.md | 38 +++++++---- contracts.go | 7 +- lib/ui/README.md | 32 +++++++--- lib/ui/container.go | 3 + lib/ui/container_reuse_test.go | 66 +++++++++++++++++++ lib/ui/container_state_owned_test.go | 2 +- lib/ui/doc.go | 4 ++ lib/ui/errelementstateunclaimed.go | 6 +- lib/ui/example_test.go | 8 +++ lib/ui/handler.go | 7 +- lib/ui/register.go | 2 +- lib/ui/template.go | 69 +++++++++++--------- lib/ui/template_handler_test.go | 57 +++++++++++------ lib/ui/template_owned_benchmark_test.go | 8 +-- lib/ui/template_owned_test.go | 21 +++--- lib/ui/template_register_test.go | 85 ++++--------------------- lib/ui/template_state_test.go | 21 ++++-- 17 files changed, 262 insertions(+), 174 deletions(-) diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index eda7d027..e28bad14 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -46,8 +46,7 @@ JaWS is an immediate-mode, server-driven UI framework, not an MVC framework. nil `UI` interface is a no-op. Surviving such a call is up to the concrete type, not a requirement: a widget that dereferences its fields panics, and none of the standard `lib/ui` widgets document nil-receiver tolerance. Do not pass a nil pointer - of a type that does not; use its zero value where that type documents one (for example - `ui.Template{}`). + of a type that does not; use a zero value only where that type documents one. - Every JaWS `UI` value is request-scoped. Once used by one Request, never use that value with another Request; construct fresh widgets per request. The widgets may still refer to shared, synchronized application state, binders, @@ -60,7 +59,10 @@ JaWS is an immediate-mode, server-driven UI framework, not an MVC framework. - `jaws.Container.JawsContains` must return `UI` items that are comparable and equal to themselves (see above); returning one that is not cancels the `Request`. The returned slice must not be mutated after return. A UI value may occur more than - once in one returned slice only when its type supports multiple live Elements. + once in one returned slice only when its type supports multiple live Elements. Each + child must render one addressable direct DOM node carrying its Element's JaWS ID so + removal and ordering can target it; `ui.NewTemplate` provides that node through its + generated wrapper. - Treat the package documentation shown by `go doc github.com/linkdata/jaws/lib/ui` as the canonical standard-widget multiplicity summary, and consult each concrete type's docs for its conditions. @@ -124,6 +126,9 @@ These are the two usual building blocks for widget handlers passed to `$.Button` supports multiple live Elements. They remain request-scoped; construct fresh widget values for another Request even when those values refer to shared synchronized application state. +- Each reconciled child must render one addressable direct DOM node carrying its Element's + JaWS ID. Removal and ordering target that node; construct Template children with + `ui.NewTemplate`, which always provides a generated wrapper. - A nil-interface child provider is a valid part of the Go value but is not renderable: zero Container/Tbody and `NewContainer`/`NewTbody` with nil providers panic when render/update calls `JawsContains`. Zero Select and `NewSelect(nil)` likewise panic in @@ -134,6 +139,9 @@ These are the two usual building blocks for widget handlers passed to `$.Button` - `ui.Template` expands `Dot` into tags via `tag.TagExpand` (package `github.com/linkdata/jaws/lib/tag`, imported as `tag`); the root dot is part of identity/tag behavior. - `ui.Template` is for partial templates only; full document/page templates should be rendered through `ui.Handler`. +- An empty wrapper passed to `ui.NewTemplate` or `$.Template` defaults to `div`. Choose a + semantic wrapper for constrained DOM contexts; use native `{{template "name" pipeline}}` + inclusion for an unwrapped structural fragment owned by a surrounding Template. - A nil-interface Template `Dot` is valid and contributes no tag; a typed nil follows its dynamic type's comparability and expansion behavior. - The root dot **must** be comparable at runtime and equal to itself: `ui.NewTemplate` @@ -184,11 +192,18 @@ These are the two usual building blocks for widget handlers passed to `$.Button` ```gotemplate {{$.Template "div" "partialName" .Dot "class=\"panel\""}} {{$.Template "tr" "rowPartial" . "class=\"selected\""}} -{{$.Template "" "barePartial" .Dot}} ``` The outer tag should match the DOM context where the generated JaWS wrapper will -be inserted. An empty outer tag renders the template without a generated wrapper. +be inserted. An empty outer tag selects the default `div` wrapper. Use a semantic +wrapper such as `tr`, `td`, `li`, or `option` where the DOM context requires it. + +For a static structural fragment that needs no JaWS-managed wrapper, use Go's native +template inclusion so the surrounding Template owns the DOM: + +```gotemplate +{{template "barePartial" .Dot}} +``` JaWS parses template params as: - HTML attrs: `string`, `[]string`, `template.HTMLAttr`, `[]template.HTMLAttr` @@ -198,9 +213,10 @@ JaWS parses template params as: Implications: - Non-comparable handlers are not auto-tagged unless they implement `tag.TagGetter`. - Pass explicit tags when dirty targeting depends on them. -- HTML attributes passed to `$.Template(...)` are applied to the generated template wrapper, if one exists. +- HTML attributes passed to `$.Template(...)` are applied to the generated template wrapper. - Template bodies used with `$.Template(...)` must be partials, not full documents. -- Unwrapped templates have no wrapper-owned DOM element for direct template updates; use nested JaWS UI for dynamic regions. +- Managed Template values always need one addressable wrapper for updates and container + removal or ordering. - For dynamic button text, avoid passing plain static strings if the value must change after render; use getter-based values so updates reflect new state. ## Event handling model @@ -268,11 +284,9 @@ For clickable content rendering: Element; - a composite UI must use Template values equal under `==` for rendering and updating an Element; using unequal values is unsupported; - - a **wrapped** Template updates only an Element rendered by an equal Template value, so - it is not usable as a `$.Register` updater; - - an **unwrapped** Template is usable there — its updates are a documented no-op — and - `$.Register` automatically attaches its click/input/context-menu handlers; a bare - `ui.Register`/`ui.NewRegister` value promotes no handler methods. + - a Template updates only an Element rendered by an equal Template value, so it is not + usable as a `$.Register` updater; `$.Register` never invokes its renderer and therefore + cannot establish the wrapper state an update needs. - Call `$.RadioGroup` from the template that renders the group: its Elements belong to the template whose body called it, not to the wrapper their markup lands in. - HTML getter paths must not mutate domain state, but they may call element update methods (`SetClass`, `RemoveClass`, `SetAttr`, `RemoveAttr`, etc.) on the passed-in `*Element` to co-ordinate wrapper class/attribute changes with the inner-HTML refresh. No custom `JawsUpdate` is needed for that case — the queued wrapper updates flush alongside the `SetInner` from `HTMLInner.JawsUpdate`. diff --git a/contracts.go b/contracts.go index 7641ccd1..c5984752 100644 --- a/contracts.go +++ b/contracts.go @@ -17,9 +17,10 @@ type Container interface { // NaN) cancels the [Request] instead of being reconciled. A typed nil is usable. // The slice contents must not be modified after returning it. Returning a usable // child UI again from a later call lets the container reuse its existing live - // [Element]. The same UI may occur more than once in one returned slice only when - // its type documents support for backing multiple live Elements. A child UI must - // not be shared with a different [Request]. + // [Element]. Each child must render one direct DOM node carrying its Element's JaWS + // ID, because reconciliation removes and orders that node. The same UI may occur more + // than once in one returned slice only when its type documents support for backing + // multiple live Elements. A child UI must not be shared with a different [Request]. JawsContains(elem *Element) (contents []UI) } diff --git a/lib/ui/README.md b/lib/ui/README.md index cce27689..3d1ba6c3 100644 --- a/lib/ui/README.md +++ b/lib/ui/README.md @@ -15,12 +15,23 @@ This package is the home of JaWS widget implementations. `rw.Text(...)`, and `rw.Select(...)` for concise template use. `rw.Template(tag, ...)` renders partial templates inside a generated JaWS wrapper using the provided HTML tag, so template bodies should let that wrapper -own JaWS identity and wrapper-level attributes. Passing an empty tag renders the -template without a generated wrapper. Attribute params passed to -`rw.Template(...)` are applied to the generated wrapper when one exists. +own JaWS identity and wrapper-level attributes. Passing an empty tag selects the +default `div` wrapper. Attribute params passed to `rw.Template(...)` are applied +to that generated wrapper. Template bodies used with `rw.Template(...)` must be partials; full page templates should be rendered through `ui.Handler`. +Use Go's native template action when a static structural fragment must be included +without another JaWS-managed wrapper: + +```gotemplate +{{template "partial" .}} +``` + +JaWS-managed partials need one addressable direct DOM node for updates and +container reconciliation. Choose the semantic wrapper required by the DOM context, +such as `tr`, `td`, `li`, or `option`, instead of relying on the `div` default there. + Template execution is best-effort rather than transactional. Nested UI helpers such as `{{$.Span ...}}` register elements as the template runs, and custom template actions may queue updates or mutate application state. If execution @@ -70,12 +81,11 @@ scalar in `tag.Tag("...")` or a comparable struct when it should be a tag. A template claims that slot while rendering, so at most one template may render a given element. A composite UI must use template values equal under `==` for rendering and -updating that element; using unequal values is unsupported. A wrapped template is -therefore not usable as a `$.Register` updater — `$.Register` never invokes its updater's -render method — while an unwrapped one is, since its updates are a documented no-op. On -an element no template claimed, a wrapped template's update reports -`ErrElementStateUnclaimed` through `jaws.Request.MustLog`, which **panics** when no -`Jaws.Logger` is configured. +updating that element; using unequal values is unsupported. A Template is not usable as a +`$.Register` updater — `$.Register` never invokes its updater's render method, so no +wrapper state exists to reconcile. On an element no template claimed, a Template returned +by `NewTemplate` reports `ErrElementStateUnclaimed` through `jaws.Request.MustLog`, which +**panics** when no `Jaws.Logger` is configured. ## Container-family value widgets @@ -100,6 +110,10 @@ values may back several live Elements within one request when their providers or handlers are safe for all calls and each child UI value reused across those Elements supports multiple live Elements. +Each child must render one addressable direct DOM node carrying its Element's JaWS ID, +because removal and ordering target that node. Construct Template children with +`NewTemplate`, which always supplies a wrapper. + Reconciliation updates direct children only. Reordering retained equal children preserves their Elements and nested subtrees; changed nested containers need their own update. Child Element identity is parent-scoped, so moving a child definition between diff --git a/lib/ui/container.go b/lib/ui/container.go index 25debe6b..644eb6f8 100644 --- a/lib/ui/container.go +++ b/lib/ui/container.go @@ -17,6 +17,9 @@ import ( // preserves their Elements and nested subtrees. Nested containers whose children // change need their own update. Child Element identity is scoped to its parent; // moving a child definition between parents does not preserve its Element. +// Each child must render one addressable direct DOM node carrying its Element's JaWS +// ID, because removal and ordering target that node. Construct Template children with +// [NewTemplate], which always supplies a wrapper. // // Equal Container values may back multiple live Elements in one [jaws.Request] // when the provider is safe for all calls and each child UI value reused across diff --git a/lib/ui/container_reuse_test.go b/lib/ui/container_reuse_test.go index 096266a5..75669ae4 100644 --- a/lib/ui/container_reuse_test.go +++ b/lib/ui/container_reuse_test.go @@ -143,6 +143,72 @@ func TestContainer_RebuiltTemplateChildrenAreReused(t *testing.T) { } } +// TestContainer_DefaultWrappedTemplateChildrenCanReorderAndRemove verifies that the +// wrapper NewTemplate supplies for an empty tag gives reconciliation a direct DOM node +// for each child. Reordering retained children must queue only Order, while removing one +// must target its existing Jid rather than replace the remaining children. +func TestContainer_DefaultWrappedTemplateChildrenCanReorderAndRemove(t *testing.T) { + tr := newReuseRequest(t) + + first := &reuseRow{id: 1} + second := &reuseRow{id: 2} + third := &reuseRow{id: 3} + tc := &rebuildingContainer{ + rows: []*reuseRow{first, second, third}, + build: func(row *reuseRow) jaws.UI { + return NewTemplate("", "row", row) + }, + } + elem := tr.NewElement(NewContainer("section", tc)) + var output strings.Builder + if err := elem.JawsRender(&output, nil); err != nil { + t.Fatal(err) + } + if got := strings.Count(output.String(), `
{{end}} -{{define "state-owned-container-child"}}{{$.RequestWriter.Template "" "owned-leaf" $.Dot}}{{$.RequestWriter.Container "div" $.Dot.Container}}{{end}} +{{define "state-owned-container-child"}}{{$.RequestWriter.Template "div" "owned-leaf" $.Dot}}{{$.RequestWriter.Container "div" $.Dot.Container}}{{end}} ` type registerContainerDot struct { diff --git a/lib/ui/doc.go b/lib/ui/doc.go index adca2e66..e723d0f6 100644 --- a/lib/ui/doc.go +++ b/lib/ui/doc.go @@ -26,6 +26,10 @@ // [Register] does so only when its updater does; input widgets and [JsVar] require // distinct widget values. // +// Container children must render one addressable direct DOM node carrying their +// Element's JaWS ID so reconciliation can remove and order them. [NewTemplate] +// supplies that node through its generated wrapper. +// // HTML-inner widgets route content through [bind.MakeHTMLGetter]. Plain strings // are treated as trusted HTML, while [bind.Getter][string], [bind.Binder][string] // and [fmt.Stringer] values are escaped. Raw [template.HTMLAttr] params are also diff --git a/lib/ui/errelementstateunclaimed.go b/lib/ui/errelementstateunclaimed.go index 1e4595ce..188c1c4c 100644 --- a/lib/ui/errelementstateunclaimed.go +++ b/lib/ui/errelementstateunclaimed.go @@ -4,11 +4,11 @@ import ( "strconv" ) -// ErrElementStateUnclaimed reports that a wrapped [Template] tried to update a -// [jaws.Element] for which no Template claimed the state slot during rendering. +// ErrElementStateUnclaimed reports that a [Template] with a generated wrapper tried to +// update a [jaws.Element] for which no Template claimed the state slot during rendering. // // With no claim there is no previous generation to reconcile against, so the update -// executes nothing. The usual cause is using a wrapped Template as a +// executes nothing. The usual cause is using a Template returned by [NewTemplate] as a // [RequestWriter.Register] updater; RequestWriter.Register does not call // [Template.JawsRender], so it cannot claim the Element while rendering. // diff --git a/lib/ui/example_test.go b/lib/ui/example_test.go index 7a57861e..bf5d7e35 100644 --- a/lib/ui/example_test.go +++ b/lib/ui/example_test.go @@ -89,6 +89,14 @@ func ExampleTemplate_failureBehavior() { // true } +func ExampleNewTemplate_defaultWrapper() { + tmpl := ui.NewTemplate("", "partial", tag.Tag("dot")) + fmt.Println(tmpl.OuterHTMLTag) + + // Output: + // div +} + type exampleContainer []string func (c exampleContainer) JawsContains(elem *jaws.Element) (contents []jaws.UI) { diff --git a/lib/ui/handler.go b/lib/ui/handler.go index a647b533..012d89d2 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -89,7 +89,9 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Request.NewElement if a bare pageTemplate value (whose Dot is any) were used. // The pointer identity is always comparable and fresh per request. Element tracking // lives in the page Element's state slot claimed by pageTemplate.JawsRender. - pt := &pageTemplate{Template: Template{Name: h.name, Dot: h.dot}} + // The private constructor bypasses NewTemplate's "div" default. pageTemplate + // executes the document directly and deliberately emits no generated wrapper. + pt := &pageTemplate{Template: newTemplate("", h.name, h.dot)} if err := rw.NewUI(pt); err != nil { _ = h.Log(err) // A failure before any output (for example a missing template) can still @@ -107,7 +109,8 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // The returned handler can be registered directly with a router. Each request // results in the template being looked up through the configured template // lookupers and rendered with a [With] value as the template data, exposing -// dot through its Dot field. +// dot through its Dot field. Handler renders the whole document without the +// generated wrapper used by [NewTemplate]. func Handler(jw *jaws.Jaws, name string, dot any) http.Handler { return uiHandler{Jaws: jw, name: name, dot: dot} } diff --git a/lib/ui/register.go b/lib/ui/register.go index 2d51f708..3b5a6d3d 100644 --- a/lib/ui/register.go +++ b/lib/ui/register.go @@ -16,7 +16,7 @@ import ( // // Register does not call the updater's [jaws.Renderer.JawsRender]. The updater // must support [jaws.Updater.JawsUpdate] without render-time initialization. A -// wrapped [Template] does not; [Container], [Tbody], and [Select] do. +// [Template] returned by [NewTemplate] does not; [Container], [Tbody], and [Select] do. // // Register does not forward event-handler methods. [RequestWriter.Register] // automatically attaches handler methods implemented by its updater. diff --git a/lib/ui/template.go b/lib/ui/template.go index a17335f2..5f6be49b 100644 --- a/lib/ui/template.go +++ b/lib/ui/template.go @@ -42,9 +42,9 @@ import ( // a tag. // // The state slot is claimed while rendering, so at most one Template may render a given -// Element. On an Element no Template claimed, an unwrapped [Template.JawsUpdate] is a -// no-op while a wrapped one executes nothing and reports [ErrElementStateUnclaimed] -// through [jaws.Request.MustLog], which panics when no [jaws.Jaws.Logger] is configured. +// Element. On an Element no Template claimed, an update by a Template constructed with +// [NewTemplate] executes nothing and reports [ErrElementStateUnclaimed] through +// [jaws.Request.MustLog], which panics when no [jaws.Jaws.Logger] is configured. // A composite UI that delegates rendering and updating to a Template must use Template // values equal under == for both calls; using unequal values is unsupported. A claim // survives a render error the delegating renderer handles, so a later update through an @@ -52,13 +52,14 @@ import ( // DOM target. // // The OuterHTMLTag field identifies the generated wrapper element used for -// partial templates. If OuterHTMLTag is empty, the template is rendered without -// a generated wrapper. Name identifies the template to execute and Dot contains -// the data exposed to the template through the [With] structure constructed -// during rendering. Wrapped templates receive the JaWS ID and any HTML -// attributes supplied at render time through the [RequestWriter.Template] -// helper. The referenced template must be a partial template, not a full HTML -// document. +// partial templates. Construct Templates with [NewTemplate], which defaults an empty +// wrapper argument to "div". Reusable Template values require one addressable direct DOM +// node, so direct struct literals must set OuterHTMLTag to a suitable element such as +// "tr", "li" or "option" for their DOM context. Name identifies the template to execute +// and Dot contains the data exposed to the template through the [With] structure +// constructed during rendering. The wrapper receives the JaWS ID and any HTML attributes +// supplied at render time through the [RequestWriter.Template] helper. The referenced +// template must be a partial template, not a full HTML document. // // Every Element a template creates through the [RequestWriter] it is given belongs to // the Template that rendered it — the widget helpers, [RequestWriter.Register], @@ -82,7 +83,7 @@ import ( // validate data before rendering and keep template actions infallible once they // start emitting output or nested UI. type Template struct { - OuterHTMLTag string // Optional wrapper tag for partial templates, for example "div" or "tr"; empty renders unwrapped. + OuterHTMLTag string // Wrapper tag; an empty direct field renders unwrapped, while NewTemplate defaults empty to "div". Name string // Template name to be looked up using Jaws.LookupTemplate. Dot any // Dot value to place in With. } @@ -255,10 +256,10 @@ func (tmpl Template) JawsRender(elem *jaws.Element, w io.Writer, params []any) ( // JawsUpdate re-renders the Template into its wrapper. // -// Unwrapped templates have no generated DOM element to update, so updates are -// ignored; nested JaWS UI rendered by the template can still update through its own -// elements. The wrapper's SetInner is queued only after execution succeeds (see the -// best-effort error behavior on [Template]). +// The wrapper's SetInner is queued only after execution succeeds (see the best-effort +// error behavior on [Template]). A directly constructed Template with an empty +// OuterHTMLTag has no DOM target, so its update does nothing; such a value is not suitable +// as a reusable Container child. [NewTemplate] always supplies a wrapper. // // A successful update unregisters the tracked Elements from the previous execution, // along with any they own: SetInner replaces the DOM that held them. If execution @@ -266,14 +267,14 @@ func (tmpl Template) JawsRender(elem *jaws.Element, w io.Writer, params []any) ( // instead and the previous ones stay live to match the unchanged DOM. See [Template] // for which Elements are tracked. // -// A wrapped Template updates only an Element rendered by a Template value equal under == -// (see [jaws.SetElementState]); using an unequal value for the update is unsupported. With -// no claim there is nothing to reconcile against, so it executes nothing and reports -// [ErrElementStateUnclaimed]. That makes a wrapped Template unusable as a -// [RequestWriter.Register] updater, since RequestWriter.Register never invokes the -// updater's [Template.JawsRender]. Lookup happens first, so a missing template reports -// [ErrMissingTemplate] and the missing-claim diagnostic is reached only after a -// successful lookup. +// A Template with a generated wrapper updates only an Element rendered by a Template +// value equal under == (see [jaws.SetElementState]); using an unequal value for the update +// is unsupported. With no claim there is nothing to reconcile against, so it executes +// nothing and reports [ErrElementStateUnclaimed]. That makes a Template returned by +// [NewTemplate] unusable as a [RequestWriter.Register] updater, since +// RequestWriter.Register never invokes the updater's [Template.JawsRender]. Lookup happens +// first, so a missing template reports [ErrMissingTemplate] and the missing-claim +// diagnostic is reached only after a successful lookup. // // Lookup, missing-state or execution errors are reported through // [jaws.Request.MustLog], which may panic when no [jaws.Jaws.Logger] is configured. @@ -332,10 +333,12 @@ func (tmpl Template) JawsInput(elem *jaws.Element, value string) (err error) { // exposed as [With.Dot]. // // outerHTMLTag names the generated wrapper element that owns the JaWS ID and -// render-time HTML attributes, or renders unwrapped (and [Template.JawsUpdate] has no -// wrapper to update) if empty. The name is resolved at render or update time via -// [jaws.Jaws.LookupTemplate]. See [Template] for the field semantics, event -// delegation and best-effort error behavior. +// render-time HTML attributes. If outerHTMLTag is empty, "div" is used. Choose a tag +// suitable for the DOM context, such as "tr", "td", "li" or "option". For an unwrapped +// structural fragment, use html/template's native {{template "name" pipeline}} action +// inside the Template that owns the surrounding DOM. The name is resolved at render or +// update time via [jaws.Jaws.LookupTemplate]. See [Template] for the field semantics, +// event delegation and best-effort error behavior. // // The returned Template holds no per-Element state, so equal Templates are // interchangeable and a container that rebuilds its children on every @@ -346,12 +349,20 @@ func (tmpl Template) JawsInput(elem *jaws.Element, value string) (err error) { // dynamic types; the rules distinguish aliases from new defined types. Use the returned // Template as a value; taking its address is unsupported. func NewTemplate(outerHTMLTag, name string, dot any) Template { + if outerHTMLTag == "" { + outerHTMLTag = "div" + } + return newTemplate(outerHTMLTag, name, dot) +} + +func newTemplate(outerHTMLTag, name string, dot any) Template { return Template{OuterHTMLTag: outerHTMLTag, Name: name, Dot: dot} } // Template renders the named partial template with dot exposed as [With.Dot], -// wrapping the output in a generated outerHTMLTag element (unwrapped if empty) that -// owns the JaWS ID and any HTML attrs in params. See [NewTemplate] and [Template]. +// wrapping the output in a generated outerHTMLTag element that owns the JaWS ID and any +// HTML attrs in params. If outerHTMLTag is empty, "div" is used. See [NewTemplate] and +// [Template]. func (rw RequestWriter) Template(outerHTMLTag, name string, dot any, params ...any) error { return rw.NewUI(NewTemplate(outerHTMLTag, name, dot), params...) } diff --git a/lib/ui/template_handler_test.go b/lib/ui/template_handler_test.go index 809285c4..cccce317 100644 --- a/lib/ui/template_handler_test.go +++ b/lib/ui/template_handler_test.go @@ -178,8 +178,10 @@ func TestTemplate_RenderUpdateEventAndHelpers(t *testing.T) { func TestTemplate_RenderWithTableRowWrapper(t *testing.T) { jw, rq := newCoreRequest(t) + // The native template action includes the structural td fragment without another + // JaWS wrapper; the managed row Template supplies the one addressable tr. _ = jw.AddTemplateLookuper(template.Must(template.New("row").Parse( - `{{.Dot}}`, + `{{template "cell" .}}{{define "cell"}}{{.Dot}}{{end}}`, ))) var sb bytes.Buffer @@ -195,23 +197,45 @@ func TestTemplate_RenderWithTableRowWrapper(t *testing.T) { } } -func TestTemplate_RenderWithoutWrapper(t *testing.T) { +func TestTemplate_RenderWithDefaultWrapper(t *testing.T) { jw, rq := newCoreRequest(t) _ = jw.AddTemplateLookuper(template.Must(template.New("bare").Parse( - `{{.Dot}}`, + `{{.Dot}}`, ))) var sb bytes.Buffer rw := RequestWriter{Request: rq, Writer: &sb} - if err := rw.Template("", "bare", tag.Tag("cell"), `class="ignored"`); err != nil { + dot := tag.Tag("cell") + if err := rw.Template("", "bare", dot, `class="defaulted"`); err != nil { t.Fatal(err) } - got := sb.String() - if got != `cell` { - t.Fatalf("unexpected unwrapped template output: %q", got) + elems := rq.GetElements(dot) + if len(elems) != 1 { + t.Fatalf("elements tagged with %q = %d, want 1", dot, len(elems)) } - if strings.Contains(got, "Jid.") { - t.Fatalf("unwrapped template should not contain generated wrapper markers: %q", got) + want := `
cell
` + if got := sb.String(); got != want { + t.Fatalf("default-wrapped template output = %q, want %q", got, want) + } +} + +// TestTemplate_DirectEmptyWrapperRendersUnwrapped preserves the raw path used by +// private construction and the zero value. Public callers using NewTemplate or +// RequestWriter.Template get the default wrapper covered above. +func TestTemplate_DirectEmptyWrapperRendersUnwrapped(t *testing.T) { + jw, rq := newCoreRequest(t) + _ = jw.AddTemplateLookuper(template.Must(template.New("bare").Parse( + `{{.Dot}}`, + ))) + + var sb bytes.Buffer + rw := RequestWriter{Request: rq, Writer: &sb} + tmpl := newTemplate("", "bare", tag.Tag("cell")) + if err := rw.NewUI(tmpl, `class="ignored"`); err != nil { + t.Fatal(err) + } + if got, want := sb.String(), `cell`; got != want { + t.Fatalf("direct empty-wrapper output = %q, want %q", got, want) } } @@ -388,17 +412,10 @@ func TestTemplate_UpdateLogsMissingTemplate(t *testing.T) { } } -func TestTemplate_UpdateWithoutWrapperNoop(t *testing.T) { - jw, rq := newCoreRequest(t) - logger := new(templateLogger) - jw.Logger = logger - - tpl := NewTemplate("", "missingtemplate", tag.Tag("dot")) - elem := rq.NewElement(tpl) - tpl.JawsUpdate(elem) - - if len(logger.errors) != 0 { - t.Fatalf("logged errors = %d, want 0", len(logger.errors)) +func TestNewTemplate_EmptyWrapperDefaultsToDiv(t *testing.T) { + tpl := NewTemplate("", "partial", tag.Tag("dot")) + if tpl.OuterHTMLTag != "div" { + t.Fatalf("OuterHTMLTag = %q, want %q", tpl.OuterHTMLTag, "div") } } diff --git a/lib/ui/template_owned_benchmark_test.go b/lib/ui/template_owned_benchmark_test.go index 2e26113b..80fe3c05 100644 --- a/lib/ui/template_owned_benchmark_test.go +++ b/lib/ui/template_owned_benchmark_test.go @@ -12,11 +12,11 @@ import ( "github.com/linkdata/jaws/lib/tag" ) -// benchOwnedTemplates renders a wrapped template containing many unwrapped nested -// templates, the shape that makes a template update replace a whole generation of -// Elements at once. +// benchOwnedTemplates renders a wrapped template containing many nested templates, +// the shape that makes a template update replace a whole generation of Elements at +// once. const benchOwnedTemplates = ` -{{define "bench-parent"}}{{range $.Dot.Names}}{{$.RequestWriter.Template "" "bench-leaf" $.Dot}}{{end}}{{end}} +{{define "bench-parent"}}{{range $.Dot.Names}}{{$.RequestWriter.Template "div" "bench-leaf" $.Dot}}{{end}}{{end}} {{define "bench-leaf"}}leaf{{end}} ` diff --git a/lib/ui/template_owned_test.go b/lib/ui/template_owned_test.go index 3ac81365..222b63c5 100644 --- a/lib/ui/template_owned_test.go +++ b/lib/ui/template_owned_test.go @@ -21,11 +21,11 @@ import ( // The templates used by the ownership tests. Every nested helper passes $.Dot along, // so one dot tags the whole subtree and GetElements(dot) counts it. const ownedTestTemplates = ` -{{define "owned-parent"}}{{$.RequestWriter.Template "" "owned-leaf" $.Dot}}{{end}} +{{define "owned-parent"}}{{$.RequestWriter.Template "div" "owned-leaf" $.Dot}}{{end}} {{define "owned-leaf"}}leaf{{end}} -{{define "owned-deep"}}{{$.RequestWriter.Template "" "owned-parent" $.Dot}}{{end}} -{{define "owned-failafter"}}{{$.RequestWriter.Template "" "owned-leaf" $.Dot}}{{$.Dot.Check}}{{end}} -{{define "owned-many"}}{{range $.Dot.Names}}{{$.RequestWriter.Template "" "owned-leaf" $.Dot}}{{end}}{{end}} +{{define "owned-deep"}}{{$.RequestWriter.Template "div" "owned-parent" $.Dot}}{{end}} +{{define "owned-failafter"}}{{$.RequestWriter.Template "div" "owned-leaf" $.Dot}}{{$.Dot.Check}}{{end}} +{{define "owned-many"}}{{range $.Dot.Names}}{{$.RequestWriter.Template "div" "owned-leaf" $.Dot}}{{end}}{{end}} {{define "owned-container"}}{{$.RequestWriter.Container "div" $.Dot.Container}}{{end}} {{define "owned-register"}}
{{end}} {{define "owned-radiogroup"}}{{range $.RequestWriter.RadioGroup $.Dot.Radios}}{{.Radio}}{{.Label}}{{end}}{{end}} @@ -120,8 +120,8 @@ func newOwnedRequest(t *testing.T) (*jaws.Jaws, *jaws.Request) { const maxProbedJid = jaws.Jid(500) // countRegistered returns how many Elements are still registered in rq, probing the -// Jid space directly so Elements carrying no tag of their own (unwrapped nested -// templates, container children) are counted too. +// Jid space directly so Elements carrying no unique tag of their own (for example, +// container children) are counted too. func countRegistered(t *testing.T, rq *jaws.Request) (count int) { t.Helper() for jid := jaws.Jid(1); jid <= maxProbedJid; jid++ { @@ -143,10 +143,9 @@ func renderOwned(t *testing.T, rq *jaws.Request, ui jaws.UI) *jaws.Element { return elem } -// TestTemplate_NestedUnwrappedDoesNotLeakOnUpdate is the reported issue (#216): a -// wrapped template invoking an unwrapped nested one must not register an extra -// Element on every update. -func TestTemplate_NestedUnwrappedDoesNotLeakOnUpdate(t *testing.T) { +// TestTemplate_NestedTemplateDoesNotLeakOnUpdate verifies that a template invoking a +// nested one does not register an extra Element on every update. +func TestTemplate_NestedTemplateDoesNotLeakOnUpdate(t *testing.T) { jw, err := jaws.New() if err != nil { t.Fatal(err) @@ -392,7 +391,7 @@ func TestRequestWriter_NewUIReportsElementBeforeRendering(t *testing.T) { } // TestTemplate_UpdateReclaimsWholeSubtree covers the recursive walk: the wrapper -// owns a nested unwrapped template that owns another one. +// owns a nested template that owns another one. func TestTemplate_UpdateReclaimsWholeSubtree(t *testing.T) { _, rq := newOwnedRequest(t) diff --git a/lib/ui/template_register_test.go b/lib/ui/template_register_test.go index 9f07797a..85b0d210 100644 --- a/lib/ui/template_register_test.go +++ b/lib/ui/template_register_test.go @@ -4,7 +4,6 @@ import ( "errors" "html/template" "strings" - "sync/atomic" "testing" "time" @@ -36,11 +35,11 @@ func newRegisterRequest(t *testing.T, logger *templateLogger) (*jaws.Jaws, *jaws return jw, rq } -// TestRegister_WrappedTemplateUpdaterReportsUnclaimed checks every reporting path for a -// wrapped Template updater. RequestWriter.Register never calls the Template's renderer, +// TestRegister_TemplateUpdaterReportsUnclaimed checks every reporting path for a +// Template updater. RequestWriter.Register never calls the Template's renderer, // so the Template has no state claim to update against. -func TestRegister_WrappedTemplateUpdaterReportsUnclaimed(t *testing.T) { - wrapped := NewTemplate("div", "reg-plain", tag.Tag("dot")) +func TestRegister_TemplateUpdaterReportsUnclaimed(t *testing.T) { + tmpl := NewTemplate("div", "reg-plain", tag.Tag("dot")) t.Run("direct call logs", func(t *testing.T) { logger := new(templateLogger) @@ -48,7 +47,7 @@ func TestRegister_WrappedTemplateUpdaterReportsUnclaimed(t *testing.T) { var sb strings.Builder rw := RequestWriter{Request: rq, Writer: &sb} - if jid := rw.Register(wrapped); !jid.IsValid() { + if jid := rw.Register(tmpl); !jid.IsValid() { t.Fatal("expected a valid Jid even though the update failed") } if len(logger.errors) != 1 || !errors.Is(logger.errors[0], ErrElementStateUnclaimed) { @@ -67,7 +66,7 @@ func TestRegister_WrappedTemplateUpdaterReportsUnclaimed(t *testing.T) { // Register runs the update immediately, so MustLog's panic escapes to the caller. recovered := func() (recovered any) { defer func() { recovered = recover() }() - rw.Register(wrapped) + rw.Register(tmpl) return }() if err, ok := recovered.(error); !ok || !errors.Is(err, ErrElementStateUnclaimed) { @@ -86,7 +85,7 @@ func TestRegister_WrappedTemplateUpdaterReportsUnclaimed(t *testing.T) { // html/template recovers a panic raised by a called method and returns it as an // execution error, so this surfaces as a render error rather than escaping. The // wrapping preserves the sentinel, so match on that rather than on the text. - err := rw.Template("div", "reg-page", ®isterDot{updater: wrapped}) + err := rw.Template("div", "reg-page", ®isterDot{updater: tmpl}) if err == nil { t.Fatal("expected a render error from the recovered panic") } @@ -101,7 +100,7 @@ func TestRegister_WrappedTemplateUpdaterReportsUnclaimed(t *testing.T) { var sb strings.Builder rw := RequestWriter{Request: rq, Writer: &sb} - if err := rw.Template("div", "reg-page", ®isterDot{updater: wrapped}); err != nil { + if err := rw.Template("div", "reg-page", ®isterDot{updater: tmpl}); err != nil { t.Fatalf("render = %v, want nil: the diagnostic is logged, not fatal", err) } if !strings.Contains(sb.String(), "") { @@ -113,10 +112,10 @@ func TestRegister_WrappedTemplateUpdaterReportsUnclaimed(t *testing.T) { }) } -// TestRegister_WrappedTemplateUpdaterOnTheRequestLoop reaches the diagnostic from the +// TestRegister_TemplateUpdaterOnTheRequestLoop reaches the diagnostic from the // request loop, which RequestWriter.Register cannot do because it updates immediately: a // NewRegister child is rendered with a tag, then that tag is dirtied. -func TestRegister_WrappedTemplateUpdaterOnTheRequestLoop(t *testing.T) { +func TestRegister_TemplateUpdaterOnTheRequestLoop(t *testing.T) { for _, tt := range []struct { name string withLog bool @@ -150,12 +149,12 @@ func TestRegister_WrappedTemplateUpdaterOnTheRequestLoop(t *testing.T) { <-tr.ReadyCh dirty := tag.Tag("registered") - wrapped := NewTemplate("div", "reg-plain", tag.Tag("dot")) + tmpl := NewTemplate("div", "reg-plain", tag.Tag("dot")) // Build the Register Element by hand: RequestWriter.Register would run the // failing update immediately, and Register.JawsRender documents that it ignores // params, so NewUI would not apply the tag. This leaves the first failing update // to the request loop. - regElem := tr.NewElement(NewRegister(wrapped)) + regElem := tr.NewElement(NewRegister(tmpl)) regElem.Tag(dirty) regElem.Freeze() @@ -196,63 +195,3 @@ func TestRegister_WrappedTemplateUpdaterOnTheRequestLoop(t *testing.T) { }) } } - -// TestRegister_UnwrappedTemplateUpdaterStaysUsable covers the other half of the narrowing: -// an unwrapped Template remains a valid Register updater because its updates are a -// documented no-op — but only RequestWriter.Register also delivers its event handlers, -// since Register embeds jaws.Updater and therefore promotes no handler methods. -func TestRegister_UnwrappedTemplateUpdaterStaysUsable(t *testing.T) { - dot := &clickCountingDot{} - unwrapped := NewTemplate("", "reg-plain", dot) - - // The UI value itself delegates nothing: Register embeds jaws.Updater, whose method set - // is JawsUpdate alone, so a Template's handler methods are never promoted onto it. - if _, ok := any(NewRegister(unwrapped)).(jaws.ClickHandler); ok { - t.Fatal("Register promoted a ClickHandler; it embeds only jaws.Updater") - } - - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - t.Cleanup(jw.Close) - logger := new(templateLogger) - jw.Logger = logger - if err = jw.AddTemplateLookuper(template.Must(template.New("reg-plain").Parse(`plain`))); err != nil { - t.Fatal(err) - } - go jw.Serve() - tr := jawstest.NewTestRequest(jw, nil) - t.Cleanup(func() { - tr.Close() - <-tr.DoneCh - }) - <-tr.ReadyCh - - var sb strings.Builder - rw := RequestWriter{Request: tr.Request, Writer: &sb} - jid := rw.Register(unwrapped) - if len(logger.errors) != 0 { - t.Fatalf("logged errors = %v, want none for an unwrapped Template", logger.errors) - } - - // RequestWriter.Register added the concrete updater to the Element's handler list, so a - // browser event reaches the Template and through it the Dot. - // Click data is "X Y kstate name"; a bare name does not parse. - tr.InCh <- wire.WsMsg{Jid: jid, What: what.Click, Data: "1 2 0 btn"} - deadline := time.Now().Add(2 * time.Second) - for dot.clicks.Load() == 0 && time.Now().Before(deadline) { - time.Sleep(time.Millisecond) - } - if got := dot.clicks.Load(); got != 1 { - t.Fatalf("clicks delivered through the handler list = %d, want 1", got) - } -} - -// clickCountingDot counts clicks delivered through a Template's event delegation. -type clickCountingDot struct{ clicks atomic.Int32 } - -func (d *clickCountingDot) JawsClick(*jaws.Element, jaws.Click) error { - d.clicks.Add(1) - return nil -} diff --git a/lib/ui/template_state_test.go b/lib/ui/template_state_test.go index 872e88db..6ef30a4c 100644 --- a/lib/ui/template_state_test.go +++ b/lib/ui/template_state_test.go @@ -5,6 +5,7 @@ import ( "html/template" "io" "strings" + "sync/atomic" "testing" "time" @@ -17,14 +18,22 @@ import ( ) const templateStateTemplates = ` -{{define "state-parent"}}{{$.RequestWriter.Template "" "state-leaf" $.Dot}}{{end}} +{{define "state-parent"}}{{$.RequestWriter.Template "div" "state-leaf" $.Dot}}{{end}} {{define "state-leaf"}}leaf{{end}} -{{define "state-failafter"}}{{$.RequestWriter.Template "" "state-leaf" $.Dot}}{{$.Dot.Check}}{{end}} +{{define "state-failafter"}}{{$.RequestWriter.Template "div" "state-leaf" $.Dot}}{{$.Dot.Check}}{{end}} {{define "state-plain"}}plain{{end}} {{define "state-b"}}b{{end}} {{define "state-span"}}{{$.RequestWriter.Span "x"}}{{end}} ` +// clickCountingDot counts clicks delivered through a Template's event delegation. +type clickCountingDot struct{ clicks atomic.Int32 } + +func (d *clickCountingDot) JawsClick(*jaws.Element, jaws.Click) error { + d.clicks.Add(1) + return nil +} + func newStateRequest(t *testing.T) (*jaws.Jaws, *jaws.Request) { t.Helper() return newConfiguredStateRequest(t, nil) @@ -397,12 +406,12 @@ func TestTemplate_UpdateToleratesTypedNilContainerState(t *testing.T) { } } -// TestTemplate_UnwrappedUpdateStaysSilent pins the path the missing-claim diagnostic does -// not apply to: an unwrapped Template returns before the state slot is consulted. -func TestTemplate_UnwrappedUpdateStaysSilent(t *testing.T) { +// TestTemplate_ZeroValueUpdateStaysSilent preserves the zero value's update behavior: it +// has no wrapper target, so it returns before consulting the state slot. +func TestTemplate_ZeroValueUpdateStaysSilent(t *testing.T) { _, rq := newStateRequest(t) // No logger is configured, so MustLog would panic if this reported anything. - tmpl := NewTemplate("", "state-plain", tag.Tag("dot")) + var tmpl Template tmpl.JawsUpdate(rq.NewElement(tmpl)) } From 1a7b1bf6ed77f4f996e0a1be1c5f533c839fa020 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 07:13:46 +0200 Subject: [PATCH 2/3] chore: restart GitHub Actions From 6b4e685b5c97863e0108b0a9ce43764e6abd473e Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 10:23:52 +0200 Subject: [PATCH 3/3] refactor(ui)!: make Register adapter private Keep RequestWriter.Register as the template-authored DOM escape hatch while removing the exported Register and NewRegister adapter API. --- .agents/skills/jaws/SKILL.md | 47 +++++-- contracts.go | 8 +- lib/ui/README.md | 109 +++++++--------- lib/ui/container.go | 9 +- lib/ui/container_reuse_benchmark_test.go | 4 +- lib/ui/container_state_owned_test.go | 12 +- lib/ui/containerstate.go | 4 +- lib/ui/doc.go | 15 ++- lib/ui/errelementstateunclaimed.go | 16 +-- lib/ui/handler.go | 5 +- lib/ui/html_widgets_test.go | 9 -- lib/ui/input_widgets.go | 2 +- lib/ui/register.go | 73 +++++------ lib/ui/requestwriter_test.go | 9 ++ lib/ui/template.go | 152 +++++++---------------- lib/ui/template_register_test.go | 12 +- 16 files changed, 200 insertions(+), 286 deletions(-) diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index e28bad14..79c12bcd 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -126,9 +126,6 @@ These are the two usual building blocks for widget handlers passed to `$.Button` supports multiple live Elements. They remain request-scoped; construct fresh widget values for another Request even when those values refer to shared synchronized application state. -- Each reconciled child must render one addressable direct DOM node carrying its Element's - JaWS ID. Removal and ordering target that node; construct Template children with - `ui.NewTemplate`, which always provides a generated wrapper. - A nil-interface child provider is a valid part of the Go value but is not renderable: zero Container/Tbody and `NewContainer`/`NewTbody` with nil providers panic when render/update calls `JawsContains`. Zero Select and `NewSelect(nil)` likewise panic in @@ -139,9 +136,6 @@ These are the two usual building blocks for widget handlers passed to `$.Button` - `ui.Template` expands `Dot` into tags via `tag.TagExpand` (package `github.com/linkdata/jaws/lib/tag`, imported as `tag`); the root dot is part of identity/tag behavior. - `ui.Template` is for partial templates only; full document/page templates should be rendered through `ui.Handler`. -- An empty wrapper passed to `ui.NewTemplate` or `$.Template` defaults to `div`. Choose a - semantic wrapper for constrained DOM contexts; use native `{{template "name" pipeline}}` - inclusion for an unwrapped structural fragment owned by a surrounding Template. - A nil-interface Template `Dot` is valid and contributes no tag; a typed nil follows its dynamic type's comparability and expansion behavior. - The root dot **must** be comparable at runtime and equal to itself: `ui.NewTemplate` @@ -215,10 +209,34 @@ Implications: - Pass explicit tags when dirty targeting depends on them. - HTML attributes passed to `$.Template(...)` are applied to the generated template wrapper. - Template bodies used with `$.Template(...)` must be partials, not full documents. -- Managed Template values always need one addressable wrapper for updates and container - removal or ordering. - For dynamic button text, avoid passing plain static strings if the value must change after render; use getter-based values so updates reflect new state. +## Registering template-authored elements + +- `$.Register(updater, params...)` binds a render-independent `jaws.Updater` to a DOM + element whose markup is written by the surrounding template. The returned Jid must be + used as that element's HTML `id`: + + ```gotemplate +
...
+ ``` + +- Register never calls `JawsRender`; use it only for a custom updater designed to work + without render-time initialization. It uses the updater as a tag, attaches its event + handlers, applies tag and handler params, and invokes `JawsUpdate` once. HTML attribute + params are ignored; write attributes on the template-authored element. +- The updater must be a non-nil interface whose dynamic value is comparable at runtime, + equal to itself, and usable as a tag. A typed nil is invoked normally and must tolerate + its nil receiver. Reuse one updater for live Elements only when it supports that use + without retaining Element-specific state on the shared value; it must be safe for + concurrent use when shared across Requests. +- Prefer ordinary widget rendering. The container family supports update-only + registration with the limitations below; typed inputs omit render-derived metadata, + while `ui.JsVar` and Templates with a non-empty `OuterHTMLTag` require rendering. +- Always emit the returned Jid as the element's `id`. A surrounding Template owns the + registered Element; otherwise it remains until explicit deletion, reported DOM + removal, or Request shutdown. + ## Event handling model On incoming events, JaWS dispatches in this order: @@ -256,8 +274,8 @@ For clickable content rendering: should register and use a usable tag exposed by its handler. - Container ownership also lives in `containerState`, not in `elem.UI()`. Cleanup detaches children under the state mutex and recurses after unlocking, so it also finds - children when the Element's visible UI is a `Register` wrapper. Failed render and - append paths unregister every child and nested owner they created. + children when the Element's visible UI is the private registration wrapper. Failed + render and append paths unregister every child and nested owner they created. - Provider callbacks and child validation run without the state mutex. Reconciliation holds it only while matching children and calling `Request.NewElement`; rendering, removal, cancellation, recursive cleanup and logging happen after it is released. @@ -284,9 +302,10 @@ For clickable content rendering: Element; - a composite UI must use Template values equal under `==` for rendering and updating an Element; using unequal values is unsupported; - - a Template updates only an Element rendered by an equal Template value, so it is not - usable as a `$.Register` updater; `$.Register` never invokes its renderer and therefore - cannot establish the wrapper state an update needs. + - a Template with a non-empty `OuterHTMLTag`, including any returned by + `ui.NewTemplate`, updates only an Element rendered by an equal Template value, so it + is not usable as a `$.Register` updater; `$.Register` never invokes its renderer and + therefore cannot establish the wrapper state an update needs. - Call `$.RadioGroup` from the template that renders the group: its Elements belong to the template whose body called it, not to the wrapper their markup lands in. - HTML getter paths must not mutate domain state, but they may call element update methods (`SetClass`, `RemoveClass`, `SetAttr`, `RemoveAttr`, etc.) on the passed-in `*Element` to co-ordinate wrapper class/attribute changes with the inner-HTML refresh. No custom `JawsUpdate` is needed for that case — the queued wrapper updates flush alongside the `SetInner` from `HTMLInner.JawsUpdate`. @@ -352,6 +371,8 @@ Guideline: definition equality with pointer identity. - Passing a runtime-incomparable application object directly to a container-family constructor instead of retaining it behind a stable pointer. +- Using `$.Register` for a widget that can render its own element, or failing to place + its returned Jid on the template-authored DOM node the updater controls. - Returning a shared/group tag from an item's `JawsGetTag` (bundling it into the item's own dirty identity), which makes a single-item `Dirty` fan out to the whole group. - Passing explicit template click handlers when dot-owned `JawsClick` already covers behavior. - Adding custom browser JavaScript for state that can be expressed through JaWS events and server updates. diff --git a/contracts.go b/contracts.go index c5984752..a14245cc 100644 --- a/contracts.go +++ b/contracts.go @@ -37,11 +37,9 @@ type Renderer interface { // Do not call this yourself unless it is from within another JawsRender implementation. // The engine does not invoke this once the [Element] is deleted (see [Element.Deleted]). // - // When delegating, note that a renderer may claim the Element's widget state slot - // (see [SetElementState]) and that only one of them can: a delegate whose own - // renderer claims the slot — [github.com/linkdata/jaws/lib/ui.Template] does — - // fails with [ErrElementStateClaimed] if the delegating renderer, or an earlier - // delegate, already claimed it. + // A delegating renderer and its delegates may claim the Element's widget state + // slot only once. A later claim fails with [ErrElementStateClaimed]; see + // [SetElementState]. JawsRender(elem *Element, w io.Writer, params []any) error } diff --git a/lib/ui/README.md b/lib/ui/README.md index 3d1ba6c3..4fc36c35 100644 --- a/lib/ui/README.md +++ b/lib/ui/README.md @@ -21,71 +21,54 @@ to that generated wrapper. Template bodies used with `rw.Template(...)` must be partials; full page templates should be rendered through `ui.Handler`. +`rw.Register(...)` is the escape hatch for attaching a render-independent updater +to an element whose markup is written directly in the surrounding template. Its +returned JaWS ID must become that element's `id`: + +```gotemplate +
+ template-authored content +
+``` + +`Register` never calls `JawsRender`; use it for a custom updater designed to work +without render-time initialization. It tags the element with the updater, attaches +its event handlers, and calls `JawsUpdate` once for initial state. Write HTML +attributes in the template because attribute params are ignored. Prefer a normal +widget helper whenever the widget can render its own element; see +`RequestWriter.Register` for the standard-widget limitations. + Use Go's native template action when a static structural fragment must be included without another JaWS-managed wrapper: ```gotemplate -{{template "partial" .}} +{{template "partial" .Dot}} ``` -JaWS-managed partials need one addressable direct DOM node for updates and -container reconciliation. Choose the semantic wrapper required by the DOM context, -such as `tr`, `td`, `li`, or `option`, instead of relying on the `div` default there. - -Template execution is best-effort rather than transactional. Nested UI helpers -such as `{{$.Span ...}}` register elements as the template runs, and custom -template actions may queue updates or mutate application state. If execution -later returns an error, JaWS returns or logs that error and preserves whatever -already happened; it does not roll back partial output, queued messages, or -application side effects. The tracked elements the failed execution registered are -unregistered, since nothing will update them. - -A template owns every element created through the `RequestWriter` it is given — -`{{$.Span ...}}`, `{{$.Button ...}}`, `{{$.Register ...}}`, `{{$.RadioGroup ...}}`, -a nested `{{$.Template ...}}`, and so on. A successful update unregisters the ones -the previous render left behind, along with the DOM that `SetInner` replaces. -Ownership is recorded when an element is created rather than after it renders, so -an element that never reached the browser is reclaimed too. On updates that -`SetInner` is queued only after a complete successful render, so a failed update -leaves the browser DOM unchanged — and with it the previous render's elements — -while earlier server-side side effects from that attempted render may remain. Treat -template execution errors as application bugs: validate data before rendering and -keep template actions infallible once they start emitting output or nested UI. - -`$.RadioGroup` has one attribution condition: its radio and label elements belong to -the template whose body called it, not to the wrapper their markup lands in. Call it -from the template that renders the group; see `RequestWriter.RadioGroup` for what -happens when the two differ. - -The ownership set lives in the element's widget state slot (`jaws.SetElementState`), -not on the `ui.Template` value, which is what keeps `NewTemplate` returning a plain -value. That matters for containers: a `JawsContains` implementation may -rebuild equal child values on every call and the container will still reuse their -elements, because that equality *is* the reuse key. Always use `ui.Template` as a -value, as `NewTemplate` returns it; taking its address is unsupported because it changes -container reuse to pointer identity. Under the general `jaws.UI` contract, the resulting -Template must be comparable at runtime and equal to itself — a slice, map or func `Dot` -makes the whole widget unusable, and implementing `tag.TagGetter` does not change that, -since it addresses tag resolution rather than widget comparability. - -Comparability alone is not enough. A nil-interface `Dot` is valid and contributes no tag. -A typed nil is a non-nil interface and follows its dynamic type's comparability and -expansion rules. Rendering expands a non-nil-interface `Dot` through `tag.TagExpand`, -which rejects the exact dynamic types `string`, `bool`, `int`/`int8`/`int16`/`int32`/`int64`, -`uint`/`uint8`/`uint16`/`uint32`/`uint64`, `float32`/`float64`, `template.HTML`, -`template.HTMLAttr`, `jid.Jid` and `key.Key`. Aliases of a rejected type have that same -dynamic type and are rejected. `uintptr` and the complex types are not on the rejection -list. Other defined types are not rejected merely because their underlying predeclared -type is on it; they must still be comparable and equal to themselves. Wrap a rejected -scalar in `tag.Tag("...")` or a comparable struct when it should be a tag. - -A template claims that slot while rendering, so at most one template may render a given -element. A composite UI must use template values equal under `==` for rendering and -updating that element; using unequal values is unsupported. A Template is not usable as a -`$.Register` updater — `$.Register` never invokes its updater's render method, so no -wrapper state exists to reconcile. On an element no template claimed, a Template returned -by `NewTemplate` reports `ErrElementStateUnclaimed` through `jaws.Request.MustLog`, which -**panics** when no `Jaws.Logger` is configured. +`rw.Template` supplies an addressable wrapper for updates and container +reconciliation. Choose the semantic element required by the DOM context, such as +`tr`, `td`, `li`, or `option`, instead of relying on the `div` default there. + +Template execution is not transactional. An error may leave partial output, queued +messages, or application side effects in place. Elements created by the failed +attempt are unregistered; a failed update retains the previous browser DOM and its +Elements. + +A Template owns every Element created through its `RequestWriter`. A successful +update unregisters the previous generation when it replaces the wrapper content. + +Call `$.RadioGroup` from the Template that renders the group; ownership follows the +call site rather than the wrapper receiving its markup. + +Use the `ui.Template` value returned by `NewTemplate` directly; taking its address +changes container identity to pointer identity. A container may retain Elements for +equal values rebuilt by `JawsContains`. A Template must be comparable and equal to +itself, and its `Dot` must be nil or usable as a tag under `tag.TagExpand`. Use +`tag.Tag("...")` for string tags. + +A Template with a non-empty `OuterHTMLTag` can update only an Element rendered by +an equal Template value. It is not usable as a `$.Register` updater because +registration does not call its renderer. ## Container-family value widgets @@ -124,11 +107,9 @@ provider, panic when rendering or updating calls the missing provider. A zero Select behaves the same for render and update, while its `JawsInput` is a no-op. A typed-nil provider is called normally and must tolerate its nil receiver itself. -Container, Tbody, and Select support update-only use through `Register`. A Select -registered this way retains no handler-derived tag for its own post-set dirtying. -Any handler-initiated dirtying still occurs, and separately registered tags remain -registered. Use ordinary Select rendering when Select should register and use a usable -tag exposed by its handler. +Container, Tbody, and Select support update-only use through +`RequestWriter.Register`, although ordinary rendering provides their full +initialization. A registered Select has no getter-derived tag for post-input dirtying. You can also use explicit constructors through: diff --git a/lib/ui/container.go b/lib/ui/container.go index 644eb6f8..28fd36e2 100644 --- a/lib/ui/container.go +++ b/lib/ui/container.go @@ -17,9 +17,8 @@ import ( // preserves their Elements and nested subtrees. Nested containers whose children // change need their own update. Child Element identity is scoped to its parent; // moving a child definition between parents does not preserve its Element. -// Each child must render one addressable direct DOM node carrying its Element's JaWS -// ID, because removal and ordering target that node. Construct Template children with -// [NewTemplate], which always supplies a wrapper. +// Each child must render one direct DOM node carrying its Element's JaWS ID. Use +// [NewTemplate] for Template children so removal and ordering can target a wrapper. // // Equal Container values may back multiple live Elements in one [jaws.Request] // when the provider is safe for all calls and each child UI value reused across @@ -50,8 +49,8 @@ func (u Container) JawsRender(elem *jaws.Element, w io.Writer, params []any) err // JawsUpdate reconciles u's direct children. // -// JawsUpdate supports update-only use through [Register]. If elem's widget state -// cannot be used, it reports [jaws.ErrElementStateClaimed] through +// JawsUpdate supports update-only use through [RequestWriter.Register]. If elem's +// widget state cannot be used, it reports [jaws.ErrElementStateClaimed] through // [jaws.Request.MustLog] without calling the provider or queuing browser work. func (u Container) JawsUpdate(elem *jaws.Element) { u.update(elem) diff --git a/lib/ui/container_reuse_benchmark_test.go b/lib/ui/container_reuse_benchmark_test.go index df63f68f..634d6d07 100644 --- a/lib/ui/container_reuse_benchmark_test.go +++ b/lib/ui/container_reuse_benchmark_test.go @@ -219,7 +219,7 @@ func BenchmarkContainerAppendRemoveUpdate(b *testing.B) { } // BenchmarkContainerRegisterFirstUpdate measures the lazy state-claim path used by an -// update-only Register Element. +// update-only registered Element. func BenchmarkContainerRegisterFirstUpdate(b *testing.B) { b.StopTimer() tr := newReuseRequest(b) @@ -231,7 +231,7 @@ func BenchmarkContainerRegisterFirstUpdate(b *testing.B) { batchSize := min(benchmarkContainerBatchSize, b.N-completed) elems := make([]*jaws.Element, batchSize) for i := range elems { - elems[i] = tr.NewElement(NewRegister(container)) + elems[i] = tr.NewElement(registerUI{Updater: container}) } b.StartTimer() diff --git a/lib/ui/container_state_owned_test.go b/lib/ui/container_state_owned_test.go index e4bd7046..e0a8cd0d 100644 --- a/lib/ui/container_state_owned_test.go +++ b/lib/ui/container_state_owned_test.go @@ -52,8 +52,8 @@ func containerStateOwnedTemplateChildren(t *testing.T, elem *jaws.Element) (chil } // TestTemplateRegisterContainerReclaimsChildren covers the ownership path whose -// Element stores a Register UI wrapper rather than the Container updater. Recursive -// cleanup must find the Container's children through the Element state slot. +// Element stores a private registration wrapper rather than the Container updater. +// Recursive cleanup must find the Container's children through the Element state slot. func TestTemplateRegisterContainerReclaimsChildren(t *testing.T) { jw, rq := newOwnedRequest(t) addContainerStateOwnedTemplates(t, jw) @@ -63,7 +63,7 @@ func TestTemplateRegisterContainerReclaimsChildren(t *testing.T) { tmpl := NewTemplate("div", "state-register-container", dot) wrapper := renderOwned(t, rq, tmpl) - const wantRegistered = 3 // Template wrapper, Register Element, Container child. + const wantRegistered = 3 // Template wrapper, registered Element, Container child. if got := countRegistered(t, rq); got != wantRegistered { t.Fatalf("registered elements after render = %d, want %d", got, wantRegistered) } @@ -71,19 +71,19 @@ func TestTemplateRegisterContainerReclaimsChildren(t *testing.T) { for round := 1; round <= 3; round++ { generation := containerStateOwnedTemplateChildren(t, wrapper) if len(generation) != 1 { - t.Fatalf("round %d: Template owns %d Elements, want the Register Element", round, len(generation)) + t.Fatalf("round %d: Template owns %d Elements, want the registered Element", round, len(generation)) } registerElem := generation[0] children := containerStateOwnedChildren(t, registerElem) if len(children) != 1 { - t.Fatalf("round %d: Register Container owns %d children, want 1", round, len(children)) + t.Fatalf("round %d: registered Container owns %d children, want 1", round, len(children)) } childElem := children[0] tmpl.JawsUpdate(wrapper) if !registerElem.Deleted() || rq.GetElementByJid(registerElem.Jid()) != nil { - t.Fatalf("round %d: previous Register Element %v is still registered", round, registerElem.Jid()) + t.Fatalf("round %d: previous registered Element %v is still registered", round, registerElem.Jid()) } if !childElem.Deleted() || rq.GetElementByJid(childElem.Jid()) != nil { t.Fatalf("round %d: previous Container child %v is still registered", round, childElem.Jid()) diff --git a/lib/ui/containerstate.go b/lib/ui/containerstate.go index 32c730f1..8a8cb2ec 100644 --- a/lib/ui/containerstate.go +++ b/lib/ui/containerstate.go @@ -16,7 +16,7 @@ type containerState struct { mu sync.Mutex // rendering is true from the successful state claim until JawsRender finishes. // It keeps an update from treating a published but incomplete state as the lazy - // state of an update-only Register Element. + // state of an update-only registered Element. rendering bool dirtyTag any contents []*jaws.Element @@ -68,7 +68,7 @@ func claimContainerState(elem *jaws.Element) (st *containerState, err error) { // stateForContainerUpdate returns usable container state for elem, claiming an empty // state when the slot is unclaimed. func stateForContainerUpdate(elem *jaws.Element) (st *containerState, err error) { - // Update-only Register is the intended lazy-claim path, but the state slot does + // Update-only registration is the intended lazy-claim path, but the state slot does // not encode how the Element was created. Treat an occupied foreign slot, typed // nil, in-progress render, or lost concurrent claim as contention. switch state := jaws.ElementState(elem).(type) { diff --git a/lib/ui/doc.go b/lib/ui/doc.go index e723d0f6..933a5b04 100644 --- a/lib/ui/doc.go +++ b/lib/ui/doc.go @@ -11,10 +11,10 @@ // for each request. Widgets from different requests may refer to the same // synchronized application state, binders, handlers, or tags. // -// [Container], [Tbody], [Select], [Option], [Register], and [Template] -// constructors return values; other constructors generally return pointers. Use -// Container, Tbody, Select, and Template as values because taking their addresses -// changes definition equality to pointer identity. +// [Container], [Tbody], [Select], [Option], and [Template] constructors return +// values; other constructors generally return pointers. Use Container, Tbody, +// Select, and Template as values because taking their addresses changes definition +// equality to pointer identity. // // See [jaws.UI] and each concrete widget for comparability, typed-nil, and // zero-value behavior. No pointer widget in this package documents nil-receiver @@ -23,8 +23,11 @@ // Within one request, a widget normally backs one live [jaws.Element]. The // HTML-inner widgets, [Img], [Option], [Template], [Container], [Tbody], and // [Select] support multiple live Elements under their documented conditions. -// [Register] does so only when its updater does; input widgets and [JsVar] require -// distinct widget values. +// Input widgets and [JsVar] require distinct widget values. +// +// [RequestWriter.Register] binds a render-independent updater to a DOM element +// authored by the surrounding template. It is an escape hatch for custom markup, +// not a replacement for rendering a widget normally. // // Container children must render one addressable direct DOM node carrying their // Element's JaWS ID so reconciliation can remove and order them. [NewTemplate] diff --git a/lib/ui/errelementstateunclaimed.go b/lib/ui/errelementstateunclaimed.go index 188c1c4c..3c1a8d2b 100644 --- a/lib/ui/errelementstateunclaimed.go +++ b/lib/ui/errelementstateunclaimed.go @@ -4,19 +4,11 @@ import ( "strconv" ) -// ErrElementStateUnclaimed reports that a [Template] with a generated wrapper tried to -// update a [jaws.Element] for which no Template claimed the state slot during rendering. +// ErrElementStateUnclaimed reports an update of an Element that no [Template] +// rendered. // -// With no claim there is no previous generation to reconcile against, so the update -// executes nothing. The usual cause is using a Template returned by [NewTemplate] as a -// [RequestWriter.Register] updater; RequestWriter.Register does not call -// [Template.JawsRender], so it cannot claim the Element while rendering. -// -// [Template.JawsUpdate] reports it through [jaws.Request.MustLog] rather than returning -// it, so it reaches a caller as a returned error only where that panic is recovered: an -// `html/template` action wraps it in an execution error, which [errors.Is] resolves -// through. Template lookup happens first, so a missing template reports -// [ErrMissingTemplate] and this error is reached only after a successful lookup. +// [Template.JawsUpdate] reports this error through [jaws.Request.MustLog] instead +// of returning it. var ErrElementStateUnclaimed errElementStateUnclaimed type errElementStateUnclaimed string diff --git a/lib/ui/handler.go b/lib/ui/handler.go index 012d89d2..4561e7e6 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -109,8 +109,9 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // The returned handler can be registered directly with a router. Each request // results in the template being looked up through the configured template // lookupers and rendered with a [With] value as the template data, exposing -// dot through its Dot field. Handler renders the whole document without the -// generated wrapper used by [NewTemplate]. +// dot through its Dot field. Handler renders without a generated wrapper and does +// not use dot as a tag, so dot may be arbitrary template data. The handler reuses +// dot across requests; dot and its callbacks must support concurrent execution. func Handler(jw *jaws.Jaws, name string, dot any) http.Handler { return uiHandler{Jaws: jw, name: name, dot: dot} } diff --git a/lib/ui/html_widgets_test.go b/lib/ui/html_widgets_test.go index 9d617748..8726c9fa 100644 --- a/lib/ui/html_widgets_test.go +++ b/lib/ui/html_widgets_test.go @@ -117,15 +117,6 @@ func TestOption_RenderBoolNameTakesPrecedenceOverCallerValue(t *testing.T) { } } -func TestRegister_Render(t *testing.T) { - _, rq := newCoreRequest(t) - ui := NewRegister(NewSpan(testHTMLGetter("x"))) - _, got := renderUI(t, rq, ui) - if got != "" { - t.Fatalf("expected empty output got %q", got) - } -} - func TestHTMLInner_RenderInitialHTMLAttrFromObject(t *testing.T) { _, rq := newCoreRequest(t) diff --git a/lib/ui/input_widgets.go b/lib/ui/input_widgets.go index 1206ec77..cc406259 100644 --- a/lib/ui/input_widgets.go +++ b/lib/ui/input_widgets.go @@ -170,7 +170,7 @@ func (u *InputFloat) JawsUpdate(elem *jaws.Element) { elem.Cancel(fmt.Errorf("%w: %g", jaws.ErrValueNotFinite, v)) return } - // An empty Last (no value stored yet, e.g. an update-only Register that never ran + // An empty Last (no value stored yet, e.g. update-only registration that never ran // renderFloatInput) makes the float64 assertion fail with ok==false; send the // initial value unconditionally in that case, matching how the other input // widgets' nil != value comparison sends on their first update. diff --git a/lib/ui/register.go b/lib/ui/register.go index 3b5a6d3d..cd080ad8 100644 --- a/lib/ui/register.go +++ b/lib/ui/register.go @@ -7,55 +7,42 @@ import ( "github.com/linkdata/jaws/lib/jid" ) -// Register is an update-only widget that renders no HTML; it exists so its -// embedded [jaws.Updater] receives dynamic updates. -// -// One Register value may back multiple live [jaws.Element] values only when its -// Updater supports those calls without retaining Element-specific state on a -// shared value. -// -// Register does not call the updater's [jaws.Renderer.JawsRender]. The updater -// must support [jaws.Updater.JawsUpdate] without render-time initialization. A -// [Template] returned by [NewTemplate] does not; [Container], [Tbody], and [Select] do. -// -// Register does not forward event-handler methods. [RequestWriter.Register] -// automatically attaches handler methods implemented by its updater. -type Register struct{ jaws.Updater } +// registerUI adapts a render-independent updater to [jaws.UI]. The surrounding +// template renders the Element's DOM node and places its JaWS ID on that node. +type registerUI struct{ jaws.Updater } -// NewRegister returns an update-only widget that invokes updater during updates. -func NewRegister(updater jaws.Updater) Register { return Register{Updater: updater} } - -// JawsRender renders no HTML for update-only registration. -// -// It ignores params; to attach extra tags or event handlers, use -// [RequestWriter.Register], which applies them before the element is frozen. -func (u Register) JawsRender(elem *jaws.Element, w io.Writer, params []any) error { +func (registerUI) JawsRender(*jaws.Element, io.Writer, []any) error { return nil } -// Register creates an update-only Element and returns its [jid.Jid]. +// Register binds updater to a template-authored HTML element. +// +// The returned [jid.Jid] must be the element's id. Register never calls +// [jaws.Renderer.JawsRender], so updater must work without render-time +// initialization. Prefer [RequestWriter.NewUI] or a widget helper when the widget +// can render its own element. // -// The updater is also a tag for dynamic updates. Additional tags may be provided -// in params. -// If updater also implements an event handler interface, it receives matching -// events after handlers provided in params have had a chance to handle them. -// The updater's [jaws.Updater.JawsUpdate] method will be called immediately to -// ensure the initial rendering is correct. +// Register tags the Element with updater, applies tag and event-handler params, +// attaches event-handler methods implemented by updater, and invokes +// [jaws.Updater.JawsUpdate] once for the initial browser state. Updater handlers +// are tried only after applicable param handlers return [jaws.ErrEventUnhandled]. +// HTML attribute params have no effect; write attributes in the template. // -// A surrounding [Template] owns the Element and unregisters it when its content -// is replaced. Otherwise ordinary DOM-removal handling unregisters it; an Element -// whose Jid never reaches the DOM remains until explicitly removed or its -// [jaws.Request] ends. +// The updater must be non-nil, comparable at runtime, equal to itself, and usable +// as a tag. A typed nil is invoked normally and must tolerate its nil receiver. +// The same updater may back multiple live Elements only when it supports that use +// without retaining Element-specific state on the shared value. If shared across +// requests, it must be safe for concurrent use. // -// Register does not call the updater's [jaws.Renderer.JawsRender]; see [Register] -// for updater constraints. It automatically attaches event-handler methods -// implemented by updater. +// A surrounding [Template] owns and cleans up the registered Element. Outside a +// Template, it remains registered until explicitly deleted, DOM removal is +// reported, or its [jaws.Request] ends; always emit the returned Jid. // -// A Select registered this way retains no handler-derived tag for its own post-set -// dirtying. Any handler-initiated dirtying still occurs, and separately registered -// tags remain registered. Use [RequestWriter.Select] when Select should register and -// use a usable tag exposed by its handler. Typed input widgets require their ordinary -// NewUI or RequestWriter rendering path. +// [Container], [Tbody], and [Select] support registration, though ordinary +// rendering is preferable. A registered Select omits its handler-derived tag for +// post-input dirtying. Typed input widgets omit getter-derived attributes, +// handlers, and their getter-derived dirty tag. [JsVar] and a [Template] with a +// non-empty OuterHTMLTag require ordinary rendering. // // The returned Jid is suitable for including as an HTML id attribute: // @@ -66,10 +53,10 @@ func (rw RequestWriter) Register(updater jaws.Updater, params ...any) jid.Jid { // JawsRender, which appends a debug comment when Jaws.Debug is set, and the // documented usage puts the returned Jid inside an attribute // (
), where that comment would corrupt the markup. - elem := rw.NewElement(Register{Updater: updater}) + elem := rw.NewElement(registerUI{Updater: updater}) rw.trackElement(elem) elem.Tag(updater) - // The wrapping Register element's UI is not the updater, so events reach the + // 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. switch updater.(type) { case jaws.InputHandler, jaws.ClickHandler, jaws.ContextMenuHandler: diff --git a/lib/ui/requestwriter_test.go b/lib/ui/requestwriter_test.go index f9147ea2..be75f28c 100644 --- a/lib/ui/requestwriter_test.go +++ b/lib/ui/requestwriter_test.go @@ -203,6 +203,15 @@ func TestRequestWriter_RegisterFreezesElement(t *testing.T) { elem.AddHandlers(struct{}{}) // production logs and drops, must not panic } +func TestRegisterUI_RenderDoesNothing(t *testing.T) { + _, rq := newCoreRequest(t) + registered := registerUI{Updater: NewSpan(testHTMLGetter("x"))} + _, got := renderUI(t, rq, registered) + if got != "" { + t.Fatalf("registerUI render output = %q, want empty", got) + } +} + func TestRequestWriter_RegisterUsesUpdaterEventHandler(t *testing.T) { _, rq := newCoreRequest(t) var buf bytes.Buffer diff --git a/lib/ui/template.go b/lib/ui/template.go index 5f6be49b..2c6f071b 100644 --- a/lib/ui/template.go +++ b/lib/ui/template.go @@ -11,81 +11,33 @@ import ( "github.com/linkdata/jaws/lib/tag" ) -// Template references a Go [html/template] template to be rendered through JaWS. +// Template renders a named Go [html/template] partial through JaWS. // -// A Template retains no Element-specific state and may back multiple live -// [jaws.Element] values: the Elements created while it executes are tracked in the -// rendering Element's widget state slot (see [jaws.SetElementState]), not on the -// Template. Its Dot and any callbacks reached during execution are shared by those -// Elements and must be safe for their render, update and event calls. +// Use Templates as values; [NewTemplate] is the usual constructor for wrapped +// Templates. A Template may back multiple live [jaws.Element] values in one +// [jaws.Request]; its Dot and callbacks must support all of their render, update, +// and event calls. Taking a Template's address is unsupported because it changes +// container reuse from value identity to pointer identity. // -// Template is a value widget and must be passed to JaWS as a value, normally the value -// returned by [NewTemplate]. Do not take its address: although Go gives *Template the -// value receiver's method set, a container would then key reuse on pointer identity -// instead of Template value equality. +// Dot may be a nil interface. Otherwise the Template must be comparable at +// runtime and equal to itself, and Dot must be usable as a tag under +// [tag.TagExpand]. // -// Like every [jaws.UI] value passed to [jaws.Request.NewElement], a Template must be -// comparable at runtime and equal to itself because the container widgets use UI values -// as map keys. A Dot holding a slice, map, func or NaN makes the Template unusable. -// Comparability is necessary but not sufficient. A nil-interface Dot is valid and -// contributes no tag. A typed nil is a non-nil interface and follows its dynamic type's -// comparability and expansion rules. Rendering expands a non-nil-interface Dot through -// [github.com/linkdata/jaws/lib/tag.TagExpand], which rejects the exact dynamic types -// string, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, -// float32, float64, [html/template.HTML], [html/template.HTMLAttr], -// [github.com/linkdata/jaws/lib/jid.Jid] and [github.com/linkdata/jaws/lib/key.Key]. -// Aliases of a rejected type have that same dynamic type and are rejected. uintptr and -// the complex types are not on the rejection list. Other defined types are not rejected -// merely because their underlying predeclared type is on it; they must still be comparable -// and equal to themselves. [Handler] is the arbitrary-Dot exception; its private -// whole-page renderer is distinct from a Template widget and does not use the page dot as -// a tag. +// OuterHTMLTag names the wrapper that receives the JaWS ID and render-time HTML +// attributes. An empty field renders without a wrapper, making [Template.JawsUpdate] +// a no-op. [NewTemplate] defaults an empty wrapper argument to "div". The named +// template must be a partial; use [Handler] for a complete document. // -// The state slot is claimed while rendering, so at most one Template may render a given -// Element. On an Element no Template claimed, an update by a Template constructed with -// [NewTemplate] executes nothing and reports [ErrElementStateUnclaimed] through -// [jaws.Request.MustLog], which panics when no [jaws.Jaws.Logger] is configured. -// A composite UI that delegates rendering and updating to a Template must use Template -// values equal under == for both calls; using unequal values is unsupported. A claim -// survives a render error the delegating renderer handles, so a later update through an -// equal Template value can still run when the delegator preserves the wrapped Template's -// DOM target. +// A Template owns the Elements created through its [RequestWriter]. A successful +// update unregisters Elements from the previous execution. Elements created by a +// failed execution are also unregistered. // -// The OuterHTMLTag field identifies the generated wrapper element used for -// partial templates. Construct Templates with [NewTemplate], which defaults an empty -// wrapper argument to "div". Reusable Template values require one addressable direct DOM -// node, so direct struct literals must set OuterHTMLTag to a suitable element such as -// "tr", "li" or "option" for their DOM context. Name identifies the template to execute -// and Dot contains the data exposed to the template through the [With] structure -// constructed during rendering. The wrapper receives the JaWS ID and any HTML attributes -// supplied at render time through the [RequestWriter.Template] helper. The referenced -// template must be a partial template, not a full HTML document. -// -// Every Element a template creates through the [RequestWriter] it is given belongs to -// the Template that rendered it — the widget helpers, [RequestWriter.Register], -// [RequestWriter.RadioGroup] and a nested [RequestWriter.Template] alike. When -// [Template.JawsUpdate] replaces the wrapper's content, those Elements are -// unregistered along with the DOM that held them, and a nested widget's own Elements -// go with it. -// -// Ownership is recorded when an Element is created rather than after it renders, so -// one that never reaches the browser is reclaimed too: an Element whose render failed, -// or a radio Element left unrendered by a [RadioElement.Label] without its -// [RadioElement.Radio]. See [RequestWriter.RadioGroup] for the one attribution -// condition, which applies when a group's markup ends up in a different wrapper than -// the RadioGroup call. -// -// Template execution is best-effort rather than transactional. Template actions -// and nested JaWS helpers run as the template executes, so an execution error -// after partial output can leave already-written HTML, queued messages, domain -// mutations or other side effects in place. The tracked Elements created by the -// failed execution are unregistered. Treat such errors as application bugs: -// validate data before rendering and keep template actions infallible once they -// start emitting output or nested UI. +// Execution is not transactional. An error may leave partial output, queued +// messages, or application side effects in place. type Template struct { - OuterHTMLTag string // Wrapper tag; an empty direct field renders unwrapped, while NewTemplate defaults empty to "div". + OuterHTMLTag string // Wrapper element; empty renders unwrapped and disables JawsUpdate. Name string // Template name to be looked up using Jaws.LookupTemplate. - Dot any // Dot value to place in With. + Dot any // Template data exposed as With.Dot and expanded for tag registration. } var ( @@ -242,13 +194,11 @@ func (tmpl Template) render(elem *jaws.Element, w io.Writer, params []any) (err return } -// JawsRender renders t through the request's configured template lookupers, -// streaming output directly to w. Template execution has the best-effort error -// behavior described on [Template]. +// JawsRender renders t through the request's configured template lookupers. // -// It claims the [jaws.Element]'s widget state slot before doing anything else, so -// rendering a second Template into one Element fails with -// [jaws.ErrElementStateClaimed] having changed nothing. +// If elem's widget state is occupied, JawsRender returns +// [jaws.ErrElementStateClaimed] without output. Other errors may leave partial +// output or side effects as described on [Template]. func (tmpl Template) JawsRender(elem *jaws.Element, w io.Writer, params []any) (err error) { err = tmpl.render(elem, w, params) return @@ -256,27 +206,16 @@ func (tmpl Template) JawsRender(elem *jaws.Element, w io.Writer, params []any) ( // JawsUpdate re-renders the Template into its wrapper. // -// The wrapper's SetInner is queued only after execution succeeds (see the best-effort -// error behavior on [Template]). A directly constructed Template with an empty -// OuterHTMLTag has no DOM target, so its update does nothing; such a value is not suitable -// as a reusable Container child. [NewTemplate] always supplies a wrapper. +// An empty OuterHTMLTag has no DOM target, so the update is a no-op. Otherwise +// elem must have been rendered by an equal Template value; using an unequal value +// is unsupported. After a successful lookup, missing Template state reports +// [ErrElementStateUnclaimed]. // -// A successful update unregisters the tracked Elements from the previous execution, -// along with any they own: SetInner replaces the DOM that held them. If execution -// fails nothing is queued, so the tracked Elements it created are unregistered -// instead and the previous ones stay live to match the unchanged DOM. See [Template] -// for which Elements are tracked. +// On success, JawsUpdate replaces the wrapper content and unregisters Elements +// from the previous execution. On execution failure, it keeps the previous DOM and +// Elements and unregisters Elements created by the failed attempt. // -// A Template with a generated wrapper updates only an Element rendered by a Template -// value equal under == (see [jaws.SetElementState]); using an unequal value for the update -// is unsupported. With no claim there is nothing to reconcile against, so it executes -// nothing and reports [ErrElementStateUnclaimed]. That makes a Template returned by -// [NewTemplate] unusable as a [RequestWriter.Register] updater, since -// RequestWriter.Register never invokes the updater's [Template.JawsRender]. Lookup happens -// first, so a missing template reports [ErrMissingTemplate] and the missing-claim -// diagnostic is reached only after a successful lookup. -// -// Lookup, missing-state or execution errors are reported through +// Lookup, state, and execution errors are reported through // [jaws.Request.MustLog], which may panic when no [jaws.Jaws.Logger] is configured. func (tmpl Template) JawsUpdate(elem *jaws.Element) { if tmpl.OuterHTMLTag != "" { @@ -334,20 +273,13 @@ func (tmpl Template) JawsInput(elem *jaws.Element, value string) (err error) { // // outerHTMLTag names the generated wrapper element that owns the JaWS ID and // render-time HTML attributes. If outerHTMLTag is empty, "div" is used. Choose a tag -// suitable for the DOM context, such as "tr", "td", "li" or "option". For an unwrapped -// structural fragment, use html/template's native {{template "name" pipeline}} action -// inside the Template that owns the surrounding DOM. The name is resolved at render or -// update time via [jaws.Jaws.LookupTemplate]. See [Template] for the field semantics, -// event delegation and best-effort error behavior. +// suitable for the DOM context. For an unwrapped fragment, use html/template's native +// {{template "name" pipeline}} action. The name is resolved at render and update time. // -// The returned Template holds no per-Element state, so equal Templates are -// interchangeable and a container that rebuilds its children on every -// [jaws.Container.JawsContains] call still reuses their Elements. dot may be a nil -// interface, which contributes no tag. Otherwise it must be comparable at runtime, equal -// to itself and usable as a tag because rendering expands it through -// [github.com/linkdata/jaws/lib/tag.TagExpand]. See [Template] for the exact rejected -// dynamic types; the rules distinguish aliases from new defined types. Use the returned -// Template as a value; taking its address is unsupported. +// dot may be a nil interface. Otherwise it must make the returned Template +// comparable and equal to itself, and it must be usable as a tag under +// [tag.TagExpand]. Use the returned Template as a value; taking its address is +// unsupported. func NewTemplate(outerHTMLTag, name string, dot any) Template { if outerHTMLTag == "" { outerHTMLTag = "div" @@ -359,10 +291,10 @@ func newTemplate(outerHTMLTag, name string, dot any) Template { return Template{OuterHTMLTag: outerHTMLTag, Name: name, Dot: dot} } -// Template renders the named partial template with dot exposed as [With.Dot], -// wrapping the output in a generated outerHTMLTag element that owns the JaWS ID and any -// HTML attrs in params. If outerHTMLTag is empty, "div" is used. See [NewTemplate] and -// [Template]. +// Template renders the named partial template with dot exposed as [With.Dot]. +// +// The generated outerHTMLTag wrapper owns the JaWS ID and HTML attributes in +// params. An empty outerHTMLTag defaults to "div". See [NewTemplate]. func (rw RequestWriter) Template(outerHTMLTag, name string, dot any, params ...any) error { return rw.NewUI(NewTemplate(outerHTMLTag, name, dot), params...) } diff --git a/lib/ui/template_register_test.go b/lib/ui/template_register_test.go index 85b0d210..371a12cf 100644 --- a/lib/ui/template_register_test.go +++ b/lib/ui/template_register_test.go @@ -114,7 +114,7 @@ func TestRegister_TemplateUpdaterReportsUnclaimed(t *testing.T) { // TestRegister_TemplateUpdaterOnTheRequestLoop reaches the diagnostic from the // request loop, which RequestWriter.Register cannot do because it updates immediately: a -// NewRegister child is rendered with a tag, then that tag is dirtied. +// registerUI child is rendered with a tag, then that tag is dirtied. func TestRegister_TemplateUpdaterOnTheRequestLoop(t *testing.T) { for _, tt := range []struct { name string @@ -150,11 +150,11 @@ func TestRegister_TemplateUpdaterOnTheRequestLoop(t *testing.T) { dirty := tag.Tag("registered") tmpl := NewTemplate("div", "reg-plain", tag.Tag("dot")) - // Build the Register Element by hand: RequestWriter.Register would run the - // failing update immediately, and Register.JawsRender documents that it ignores - // params, so NewUI would not apply the tag. This leaves the first failing update - // to the request loop. - regElem := tr.NewElement(NewRegister(tmpl)) + // Build the registered Element by hand: RequestWriter.Register would run the + // failing update immediately, and registerUI.JawsRender ignores params, so + // NewUI would not apply the tag. This leaves the first failing update to the + // request loop. + regElem := tr.NewElement(registerUI{Updater: tmpl}) regElem.Tag(dirty) regElem.Freeze()