Skip to content

fix: keep ui.Template a value, holding its state on the Element - #226

Merged
linkdata merged 4 commits into
mainfrom
fix/template-value-element-state
Aug 4, 2026
Merged

fix: keep ui.Template a value, holding its state on the Element#226
linkdata merged 4 commits into
mainfrom
fix/template-value-element-state

Conversation

@linkdata

@linkdata linkdata commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Fixes the container-reuse regression #221 introduced.

The regression

ContainerHelper.reconcile keys its reuse pool on childElem.UI()value equality. #221 gave ui.Template per-Element ownership state, which forced it to become a pointer, so a container whose JawsContains rebuilds ui.NewTemplate(...) children on every call stopped matching the pool. Every update removed and re-appended the entire collection: new Jids, full DOM churn, browser work proportional to the collection rather than to the change.

No test caught it, which is why it shipped. TestContainer_RebuiltTemplateChildrenAreReused is that test: it asserts child Jids are unchanged across updates and that an unchanged collection queues no Append/Remove/Order. Re-pointerising NewTemplate in a throwaway worktree fails it on the first update (child 0 Jid = Jid.5, want Jid.2).

The fix

The Element is the natural home for state keyed to an Element, so core gains one slot for it and Template goes back to a plain comparable value.

func ElementState(elem *Element) (state any)
func SetElementState(elem *Element, state any) error   // ErrElementStateClaimed / ErrElementStateNil

Design points that took some working out:

  • Loading and claiming are separate, so contention is reported rather than absorbed. A get-or-create returning the occupant would silently let a second Template mutate the first's generation and delete Elements it doesn't own. A second claim fails even for state of the same dynamic type — same type is not the same owner.
  • Nil is rejected. data any uses a nil interface to mean unclaimed, so accepting SetElementState(elem, nil) would report success while leaving the slot claimable, hollowing out claim-once. A typed nil is a non-nil interface and does claim the slot. The nil-argument check precedes the occupancy check.
  • Package-level, not methods. ui.With embeds *Element and ui.RequestWriter, so any method returning a value is callable from a template; {{$.Element.SetElementState $.Dot}} would let a template claim the slot out from under its renderer. html/template cannot call a package-level function.
  • Claiming happens only in JawsRender, before tag registration, handler registration and any write, so a contended Element fails having changed nothing — asserted, not just claimed.
  • execute takes the state as a parameter. pageTemplate.JawsRender bypasses render, so without that the page's nested UI would have been tracked by nothing at all; making it a parameter means the compiler asks each entry point.
  • The ownership walk matches *templateState specifically, not any elementOwner. The slot legitimately holds whatever a renderer put there, so a typed nil satisfying elementOwner through a promoted method — (*ui.Container)(nil) — would have been dereferenced during a parent Template's cleanup.

Released behaviour that narrows

Both follow from Template previously being stateless, and both are in the commit message for release notes:

  1. Any repeated Template render on one Element now fails the second claim with ErrElementStateClaimed — two distinct Templates, two equal ones, or the same value twice. Renderer.JawsRender permits delegation, so a composite renderer emitting several unwrapped partials this way was legitimate. Migration: combine the partials under one claiming Template, or give each its own Element, which a nested {{$.Template ...}} already does.
  2. A wrapped Template's JawsUpdate without a claim reports ErrElementStateUnclaimed through MustLog — which panics when no Jaws.Logger is configured, not a silent no-op. So a wrapped Template cannot be a Register/NewRegister/RequestWriter.Register updater. An unwrapped one still can: its updates are a documented no-op, though only RequestWriter.Register also delivers its event handlers, since Register embeds jaws.Updater and promotes no handler methods.

Delegated rendering and updating must use the same UI widget. For Template values, that means values equal under ==; using an unequal Template value for JawsUpdate is unsupported. Element state storage does not relax that lifecycle contract.

The MustLog outcome differs by call path and is tested per path: a template action has its panic recovered by html/template into a render error; a direct rw.Register call lets it escape; on the request loop Request.process consumes it and tears the request down, with TestServe's callback seeing nil precisely because process already consumed it.

Numbers

-benchtime=200x -count=6, arm64, baseline pinned to 9b02d46 and every benchmark name verified present in both outputs before benchstat:

ContainerOfTemplatesUpdate   (200 rebuilt children, unchanged collection)
  sec/op     1220.08µ ± 3%  ->  73.62µ ± 35%   -93.97% (p=0.002 n=6)
  B/op        194.93Ki ± 0%  ->  28.48Ki ±  0%  -85.39% (p=0.002 n=6)
  allocs/op     4046.0 ± 0%  ->    406.0 ±  0%  -89.97% (p=0.002 n=6)

ContainerOfStableChildrenUpdate  (already reusable before this change)
  sec/op        31.19µ ± 12%  ->  28.53µ ± 11%   ~ (p=0.132 n=6)
  allocs/op      205.0 ±  0%  ->   205.0 ±  0%   ~ (all equal)

ElementCreateBatch  (corrected timer; the cost every Element pays for the slot)
  sec/op         2.077µ       ->   2.240µ         +7.87% (p=0.002 n=6)
  B/op          4.000Ki       ->  5.000Ki        +25.00% (p=0.002 n=6)   # 16 B/Element
  allocs/op       64.00       ->     64.00          ~ (all equal)

TemplateUpdateOwnedCleanup/children=1000  (each rendered Template allocates its state)
  sec/op         3.650m ± 2%  ->  3.708m ±  2%   ~ (p=0.180 n=6)
  allocs/op      26.79k ± 0%  ->  27.79k ±  0%  +3.73% (p=0.002 n=6)

The two costs are real and measured rather than assumed: two words per Element for the slot, and one allocation per rendered Template for its state. Both are dwarfed by the container fix, and the cleanup path's time is unchanged. Small-generation cleanup variants (0 and 8 children) are included because the 1000-child case swamps any fixed per-update cost.

Also

  • TestElementState_* cover claim-once, both sentinels and their precedence, typed nil, per-Element independence, and concurrent claims under -race with exactly one winner.
  • Rendering delegation tests verify that a delegator's own child survives the Template's rollback, and that a claim persists after a handled render error so an equal Template value can later update the Element. Delegating JawsUpdate to an unequal Template value is unsupported.
  • Two existing tests needed real migration, not just an added render: TestTemplate_UpdateLogsExecuteError's template always fails, so it moves to a render-succeeds-then-update-fails fixture, and TestTemplate_RenderUpdateEventAndHelpers updated an unrendered Template with no logger, which would now panic.
  • Docs: UI multiplicity keeps its concrete-type opt-in rule (statelessness alone grants nothing), Renderer.JawsRender gains the delegation note, and lib/ui/doc.go's canonical classification moves Template back to the value/multi-Element group. The public API, README and tracked jaws skill now state the same-widget update contract, exact Dot dynamic-type and nil-interface rules, Register's updater-render behavior, and the unenforced Element provenance precondition.

Not addressed here, deliberately: tag resolution/snapshotting (#224) and generalising per-Element state to the other by-value widget candidates (#225).

Gate run locally in build.yml order: go generate (tree unchanged), go vet, gofmt, staticcheck, golangci-lint (0 issues), gosec, both test legs with JAWS_REQUIRE_NODE=1, coverage (lib/ui still 100%), go build -v, the debug-tag leg, and go doc on every symbol whose contract this touches. The 386 job cannot execute on this arm64 host, so it was verified by compiling.

#221 gave ui.Template per-Element ownership state, which forced it to become a
pointer. ContainerHelper.reconcile keys its reuse pool on childElem.UI() — value
equality — so a container whose JawsContains rebuilds ui.NewTemplate(...) children
on every call stopped matching the pool. Every update removed and re-appended the
whole collection: new Jids, full DOM churn, browser work proportional to the
collection rather than to the change.

The Element is the natural place for state keyed to an Element, so core gains one
slot for it and Template goes back to being a plain comparable value.

jaws.ElementState and jaws.SetElementState reach a single any field on Element,
guarded by Request.mu. Loading and claiming are separate so contention is reported
rather than absorbed: a second claim returns ErrElementStateClaimed even for state
of the same type, since same type does not mean same owner, and a get-or-create
would silently let a second Template mutate the first one's generation. A nil
interface returns ErrElementStateNil and stores nothing, because nil is how an
unclaimed slot is represented; that check precedes the occupancy check. A typed nil
is a non-nil interface and does claim the slot. Both functions are package-level
rather than methods: ui.With embeds *Element and ui.RequestWriter, so any method
returning a value is callable from a template, and {{$.Element.SetElementState
$.Dot}} would let a template claim the slot out from under its renderer.

Claiming happens only in JawsRender, before tag registration, handler registration
and any write, so a contended Element fails having changed nothing. Template.render
and pageTemplate.JawsRender each claim and pass the state to execute, which takes it
as a parameter precisely so a second entry point cannot silently skip the claim —
pageTemplate bypasses render, and would otherwise track nothing at all.
Template.JawsUpdate only loads, reporting ErrElementStateUnclaimed for an Element no
Template rendered, and lib/ui's ownership walk looks in the slot as well as on the
UI value, matching *templateState specifically: the slot may legitimately hold a
typed nil that satisfies elementOwner through a promoted method, which a broader
assertion would dereference.

Two released behaviours narrow, both because Template was previously stateless.
Rendering the same or another Template twice onto one Element now fails the second
claim; combine the partials under one claiming Template, or give each its own
Element, which a nested {{$.Template ...}} already does. And a wrapped Template
updating an Element it never rendered is no longer silent — it reports through
MustLog, which panics when no Jaws.Logger is configured — so a wrapped Template
cannot be a Register updater. An unwrapped Template still can: its updates are a
documented no-op, though only RequestWriter.Register also delivers its event
handlers, since Register embeds jaws.Updater and promotes no handler methods.

BenchmarkContainerOfTemplatesUpdate, 200 rebuilt children with an unchanged
collection, arm64, -benchtime=200x -count=6:

    sec/op      1220.08µ ± 3%  ->  73.62µ ± 35%  -93.97% (p=0.002 n=6)
    B/op         194.93Ki ± 0%  ->  28.48Ki ± 0%  -85.39% (p=0.002 n=6)
    allocs/op      4046.0 ± 0%  ->    406.0 ± 0%  -89.97% (p=0.002 n=6)

Costs, both expected and measured rather than assumed. Every Element pays two
words for the slot, which BenchmarkElementCreateBatch shows as +8.17% B/op over a
64-element batch (16 bytes each) with time and allocation count unchanged. Each
rendered Template allocates its state, visible as +3.7% allocs/op in the
1000-nested-child cleanup benchmark, with sec/op unchanged. Containers whose
children are already stable are unaffected.
Review follow-ups to the Template value change. No production behaviour
changes; the benchmark, the tests and the documentation do.

BenchmarkElementCreateBatch never called b.ResetTimer, so building the Jaws and
the Request was divided by b.N into every reported figure, and the deferred
Close ran after the loop's final b.StartTimer. Both time and allocations were
affected, and the byte figure depended on b.N rather than on the Element:

    -benchtime    200x      2000x     20000x
    before        13540     5965      5204      B/op
    after          5127     5121      5120      B/op

Against the merge base the honest numbers are B/op 4.000Ki -> 5.000Ki
(+25.00%, p=0.002 n=6) and sec/op 2.077µ -> 2.240µ (+7.87%, p=0.002 n=6). The
struct grows by two machine words — 64 -> 80 bytes on 64-bit, 40 -> 48 on 386
and arm — but unsafe.Sizeof establishes struct growth, not heap bytes per
operation, so both figures come from the corrected benchmark.

Four tests assigned Jaws.Logger to a Jaws that already had a Request, one of
them directly below a comment saying that is unsupported. The Jaws contract is
explicit that the exported configuration fields must be set before Requests are
created, so newConfiguredCoreRequest takes a configure hook that runs first.
Five AddTemplateLookuper errors and one JawsRender error were blank-discarded,
and a render error was matched by substring where errors.Is resolves the
sentinel through html/template's ExecError.

TestTemplate_EqualValuesKeepIndependentGenerations asserted only that four
Elements stayed registered. An update that reclaimed the wrong wrapper's child
and created a replacement leaves the same count, so it now captures each
wrapper's tracked generation and asserts the replacement is scoped to it.

TestTemplate_SecondClaimOnOneElementFails was cited as proving the rejected
Template adds no handler, but supplied and observed none. Handlers are
unexported, so TestTemplate_SecondClaimRegistersNoHandler delivers a real
click instead, with a control Element rendered by a claiming Template carrying
the same handler through the same params path — otherwise "not called" would
pass with events broken entirely — and that control's second click bounds the
drain.

assertNoDOMMutation treated a 300ms timeout as success, so a stalled machine
produced a false pass, and it slept 1.2s per run. It now drains to an Alert
probe. Two steps are needed for different reasons: the update ran on the test
goroutine, so its messages are already queued and the unbuffered InCh send
forces that batch out through the request loop's sendQueue before anything else
is selected; only then is the Alert queued, since getSendMsgs sorts by Jid and
would otherwise place a Jid-0 Alert ahead of the element-addressed operations
in one flush. The two container tests now run ten times in 1.4s.

Documentation, all describing behaviour the code already had:

  - Template said JawsUpdate "does nothing" on an unclaimed Element; a wrapped
    one reports ErrElementStateUnclaimed through MustLog, which panics with no
    Logger configured. Register's type doc omitted the same consequence.
  - Comparability is necessary but not sufficient for a Dot: rendering expands
    it, and TagExpand rejects string, bool, the sized and unsized integer and
    float types, template.HTML, template.HTMLAttr, jid.Jid and key.Key. A
    plain string Dot is comparable and reflexive yet fails at render. The skill
    said "numeric", which both omits jid.Jid and key.Key and implies uintptr
    and the complex types are rejected when they are not.
  - ErrElementStateUnclaimed said "is returned"; it is reported through
    MustLog, reaching a caller only where html/template recovers that panic.
    Lookup runs first, so a missing template reports ErrMissingTemplate.
  - ElementState said a widget finding no state "did not render" the Element;
    most widgets never claim, so it says nothing about rendering. Neither
    function documented its concurrency guarantee, that only the claim is
    synchronized, or that the precondition is a Request-backed Element rather
    than merely a non-nil one.
  - The lock hierarchy called per-Element state a leaf lock and then said the
    widget state slot is not one.
@linkdata
linkdata merged commit f80dcc2 into main Aug 4, 2026
7 checks passed
@linkdata
linkdata deleted the fix/template-value-element-state branch August 4, 2026 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant