fix: keep ui.Template a value, holding its state on the Element - #226
Merged
Conversation
#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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the container-reuse regression #221 introduced.
The regression
ContainerHelper.reconcilekeys its reuse pool onchildElem.UI()— value equality. #221 gaveui.Templateper-Element ownership state, which forced it to become a pointer, so a container whoseJawsContainsrebuildsui.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_RebuiltTemplateChildrenAreReusedis that test: it asserts child Jids are unchanged across updates and that an unchanged collection queues noAppend/Remove/Order. Re-pointerisingNewTemplatein 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
Templategoes back to a plain comparable value.Design points that took some working out:
data anyuses a nil interface to mean unclaimed, so acceptingSetElementState(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.ui.Withembeds*Elementandui.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/templatecannot call a package-level function.JawsRender, before tag registration, handler registration and any write, so a contended Element fails having changed nothing — asserted, not just claimed.executetakes the state as a parameter.pageTemplate.JawsRenderbypassesrender, 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.*templateStatespecifically, not anyelementOwner. The slot legitimately holds whatever a renderer put there, so a typed nil satisfyingelementOwnerthrough a promoted method —(*ui.Container)(nil)— would have been dereferenced during a parent Template's cleanup.Released behaviour that narrows
Both follow from
Templatepreviously being stateless, and both are in the commit message for release notes:ErrElementStateClaimed— two distinct Templates, two equal ones, or the same value twice.Renderer.JawsRenderpermits 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.JawsUpdatewithout a claim reportsErrElementStateUnclaimedthroughMustLog— which panics when noJaws.Loggeris configured, not a silent no-op. So a wrapped Template cannot be aRegister/NewRegister/RequestWriter.Registerupdater. An unwrapped one still can: its updates are a documented no-op, though onlyRequestWriter.Registeralso delivers its event handlers, sinceRegisterembedsjaws.Updaterand promotes no handler methods.Delegated rendering and updating must use the same UI widget. For
Templatevalues, that means values equal under==; using an unequal Template value forJawsUpdateis unsupported. Element state storage does not relax that lifecycle contract.The
MustLogoutcome differs by call path and is tested per path: a template action has its panic recovered byhtml/templateinto a render error; a directrw.Registercall lets it escape; on the request loopRequest.processconsumes it and tears the request down, withTestServe's callback seeing nil precisely because process already consumed it.Numbers
-benchtime=200x -count=6, arm64, baseline pinned to9b02d46and every benchmark name verified present in both outputs before benchstat: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-racewith exactly one winner.JawsUpdateto an unequal Template value is unsupported.TestTemplate_UpdateLogsExecuteError's template always fails, so it moves to a render-succeeds-then-update-fails fixture, andTestTemplate_RenderUpdateEventAndHelpersupdated an unrendered Template with no logger, which would now panic.UImultiplicity keeps its concrete-type opt-in rule (statelessness alone grants nothing),Renderer.JawsRendergains the delegation note, andlib/ui/doc.go's canonical classification movesTemplateback to the value/multi-Element group. The public API, README and tracked jaws skill now state the same-widget update contract, exactDotdynamic-type and nil-interface rules,Register's updater-render behavior, and the unenforcedElementprovenance 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.ymlorder:go generate(tree unchanged),go vet,gofmt,staticcheck,golangci-lint(0 issues),gosec, both test legs withJAWS_REQUIRE_NODE=1, coverage (lib/uistill 100%),go build -v, the debug-tag leg, andgo docon every symbol whose contract this touches. The 386 job cannot execute on this arm64 host, so it was verified by compiling.