Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions .agents/skills/jaws/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,33 @@ These are the two usual building blocks for widget handlers passed to `$.Button`
- The root dot **must** be comparable at runtime and equal to itself: `ui.NewTemplate`
returns a value, so the dot is part of the widget the container widgets use as a map key.
A slice, map, func or NaN-bearing dot makes the widget unusable as a container child.
- Implementing `JawsGetTag(tag.Context) any` does **not** fix a non-comparable dot — it
- Implementing `JawsGetTag() any` does **not** fix a non-comparable dot — it
resolves the *tag*, not the widget's comparability. A non-comparable dot is unsupported.
Always use the Template itself as a value; taking its address is unsupported because it
changes container reuse to pointer identity. `ui.Handler` is the arbitrary-dot exception.
- `JawsGetTag` is the canonical public accessor for an object's tags, and application code may
call it directly. Callers needing flattened, validated keys pass the object to
`tag.TagExpand`. It takes no context argument: a tag value expands the same way regardless
of which request or goroutine expands it.
- `Element.ApplyGetter` invokes `JawsGetTag` to obtain a tag candidate, then expands that
candidate for registration. This expansion may invoke `JawsGetTag` again when the candidate
is itself a `tag.TagGetter` or contains one. Standard getter-backed widgets invoke
`ApplyGetter` once during initial render, but there is no `JawsGetTag` call-count guarantee:
`tag.TagExpand`, dirtying, broadcasts, and application code may make further calls.
- Except for an explicitly documented initialization phase that returns nil, a `tag.TagGetter`
must be idempotent in tag identity. After its first non-nil result, every call must return a
value that `tag.TagExpand` expands to the same set of keys. Previously returned containers
must continue expanding to the key set they produced when returned and must be treated as
read-only. Fresh containers and equivalent representations are allowed. Non-idempotent
`tag.TagGetter` implementations are unsupported.
- JaWS does not serialize `JawsGetTag` calls. A getter used concurrently must synchronize its
state and safely publish any returned containers.
- `ui.JsVar` is the one in-tree initialization case: `JawsGetTag` returns nil before its first
render initializes the dirty tag, and that nil is not a dirty target. A getter in its nil phase
is therefore not usable as a tag: passing a not-yet-rendered `JsVar` as a tag to another widget
expands to no keys, so that widget registers under nothing and a later dirty of the JsVar never
reaches it. Render the JsVar first, or tag the other widget with an independent value. `ui.Object`
propagates the phase of any chained getter that has one.
- `tag.TagExpand` rejects exactly these as tags: `string`, `bool`, `int`/`int8`/`int16`/`int32`/`int64`,
`uint`/`uint8`/`uint16`/`uint32`/`uint64`, `float32`/`float64`, `template.HTML`, `template.HTMLAttr`,
`jid.Jid` and `key.Key`. It is a switch on exact types, so aliases of a rejected type are rejected,
Expand Down Expand Up @@ -198,12 +221,14 @@ For clickable content rendering:

## Dirtying rules

- Prefer `Request.Dirty(...)` when in request context.
- Avoid `Jaws.Dirty(...)` unless necessary; its tag expansion runs with nil request context.
- `Request.Dirty` and `Jaws.Dirty` are equivalent: both expand through `Jaws.MustTagExpand` and
dirty every matching element across all live Requests. `Request.Dirty` is *not* scoped to its
own Request.
- Dirty only precise tags whose output depends on the changed state.
- Avoid broad model-level dirty tags when finer-grained element-level tags are practical.
- For broad refreshes, attach a shared dependency tag to all relevant elements and dirty that shared tag instead of enumerating many element tags.
- `Request.Dirty` runs the tag list through `tag.TagExpand`, which has a hard cap of 100 expanded entries and returns `tag.ErrTooManyTags` above that. When a mutation might touch more items than that, prefer the shared group tag over enumerating individual item tags.
- `Request.Dirty` runs the tag list through `Jaws.MustTagExpand`, which has a hard cap of 100 expanded entries. Above that, expansion fails with `tag.ErrTooManyTags`: with a `Jaws.Logger` configured the error is logged and the partial expansion is still applied, and without one the call panics before anything is dirtied. When a mutation might touch more items than that, prefer the shared group tag over enumerating individual item tags.
- `Request.TagsOf(elem)` reports every tag actually registered on an element, including tags added separately from the UI object — use it when a dirty target seems not to reach an element.
- Redundant-update filtering is asymmetric: input widgets (`InputText`, `InputBool`, `InputFloat`, `InputDate`) compare the new getter output against a stored `Last` value and skip `SetValue` when unchanged, but `HTMLInner`-backed widgets (spans, divs, buttons) do not — `JawsUpdate` unconditionally calls `SetInner`. For HTML-inner widgets, ensure dirty scope matches fields that actually changed, otherwise unrelated status/label spans will re-render (and lose selection, transitions, etc.) on every event. Usually the mutation code already knows what it changed and can dirty accordingly; fall back to snapshot-and-diff only when outcomes are hard to predict up front (e.g. flood-fill or win-condition checks) and the snapshot is cheap.

## HTML safety rules
Expand Down
39 changes: 12 additions & 27 deletions broadcast.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ import (
// Dest is expanded into tags. Plain strings and [Jid] values are illegal tag
// types; use [tag.Tag], a domain tag, or an [Element] method instead.
//
// A [wire.Message.Dest] that cannot be expanded into tags (an illegal tag type)
// is reported through [Jaws.MustLog], which panics when no [Jaws.Logger] is
// set; with a Logger the error is logged and the message is sent to the
// destinations that did expand.
// That expansion runs through [Jaws.MustTagExpand], which reports a failure such as an
// illegal tag type through [Jaws.MustLog]: that panics when no [Jaws.Logger] is set,
// while with a Logger the error is logged and the message is sent to the destinations
// that did expand.
func (jw *Jaws) Broadcast(msg wire.Message) {
switch msg.What {
case what.Replace:
Expand All @@ -61,9 +61,7 @@ func (jw *Jaws) Broadcast(msg wire.Message) {
return
}
default:
expanded, err := tag.TagExpand(nil, msg.Dest)
jw.MustLog(err)
expanded = jw.dropNonComparableTags(expanded)
expanded := jw.dropNonComparableTags(jw.MustTagExpand(msg.Dest))
switch len(expanded) {
case 0:
// no tags, so no requests will match
Expand Down Expand Up @@ -99,10 +97,8 @@ func (jw *Jaws) dropNonComparableTags(tags []any) []any {
// setDirty marks all Elements that have one or more of the given tags as dirty.
func (jw *Jaws) setDirty(tags []any) {
jw.mu.Lock()
// Release the lock with defer so it is freed even if a map insert panics: a tag
// that passed the static comparability check in ensureUsableTag can still be
// non-comparable at runtime (a comparable struct holding e.g. a func in an
// interface field) and panic when used as a map key here.
// Public paths validate keys through TagExpand; the deferred unlock is
// defense-in-depth if an internal caller violates that invariant.
defer jw.mu.Unlock()
for _, tagValue := range tags {
jw.dirtOrder++
Expand All @@ -112,23 +108,12 @@ func (jw *Jaws) setDirty(tags []any) {

// Dirty marks all [Element] values that have one or more of the given tags as dirty.
//
// If any tag implements [tag.TagGetter] it is called with a nil [Request]; prefer
// [Request.Dirty], which avoids this. A tag that is not hashable panics the calling
// goroutine, but the panic is contained there and the [Jaws.Serve] loop is
// unaffected. [Request.Dirty] behaves the same here.
// The tags are expanded through [Jaws.MustTagExpand]: with a [Jaws.Logger] configured
// an expansion error is logged and the partial result is still applied, while without
// one the call panics before anything is marked dirty. [Request.Dirty] is equivalent;
// both mark matching Elements on every live [Request].
func (jw *Jaws) Dirty(dirtyTags ...any) {
// A non-hashable tag panics here in the caller's goroutine rather than being
// logged-and-dropped the way Broadcast handles a bad wire.Message.Dest: Broadcast
// hashes the destination in the Serve goroutine, where a panic would crash the
// process, whereas this hashing happens in setDirty under the caller's goroutine
// with the lock released on panic via defer, so Serve is unaffected.
//
// Use TagExpand+MustLog rather than MustTagExpand: with a nil Context the latter
// panics on an illegal tag even in production, unlike Request.Dirty and Broadcast.
// Log and continue with the partial result.
expanded, err := tag.TagExpand(nil, dirtyTags)
jw.MustLog(err)
jw.setDirty(expanded)
jw.setDirty(jw.MustTagExpand(dirtyTags))
}

// dirtPair pairs a dirty tag with its insertion-order rank, used by sortedDirtTags
Expand Down
9 changes: 0 additions & 9 deletions contracts.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,6 @@ type Container interface {
JawsContains(elem *Element) (contents []UI)
}

// InitHandler allows initializing UI getters and setters before their use.
//
// You can of course initialize them in the call from the template engine,
// but at that point you don't have access to the [Element], [Request.Context]
// or [Request.Session].
type InitHandler interface {
JawsInit(elem *Element) (err error)
}

// Logger is satisfied by a [*log/slog.Logger] via its Info, Warn and Error methods.
type Logger interface {
Info(msg string, args ...any)
Expand Down
42 changes: 21 additions & 21 deletions element.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ func (elem *Element) Tag(tags ...any) {
}

// HasTag returns true if this Element has the given tag.
//
// It reports false for a deleted Element. tagValue is not expanded; see
// [Request.HasTag].
func (elem *Element) HasTag(tagValue any) bool {
return !elem.deleted.Load() && elem.Request.HasTag(elem, tagValue)
}
Expand Down Expand Up @@ -475,34 +478,34 @@ func (elem *Element) ApplyParams(params []any) (attrs []template.HTMLAttr) {

// ApplyGetter examines getter and resolves its tag candidate.
//
// If getter implements [tag.TagGetter], the candidate is its returned value;
// otherwise the candidate is getter itself. TagGetter values, supported tag
// slices and runtime-comparable candidates are passed to [Element.Tag] for normal
// validation. Other non-comparable candidates are not automatically tagged,
// matching [ParseParams].
// If getter implements [tag.TagGetter], the candidate is the value returned by
// [tag.TagGetter.JawsGetTag]; otherwise the candidate is getter itself. Eligible
// candidates — TagGetter values, supported tag slices and runtime-comparable
// values — are passed to [Element.Tag] for normal expansion and validation.
// That expansion may invoke JawsGetTag again when the candidate is itself a
// TagGetter or contains one. Other non-comparable candidates are not automatically
// tagged, matching [ParseParams].
//
// If getter is an [InputHandler], [ClickHandler], [ContextMenuHandler] or
// [InitialHTMLAttrHandler], relevant values are added to the [Element].
//
// Finally, if getter is an [InitHandler], its JawsInit
// function is called.
//
// Returns the tag that was added (nil if none was added, whether because getter
// was nil or its candidate was not usable as a tag), any initial HTML attrs
// provided by InitialHTMLAttrHandler, and any error returned from JawsInit() if it
// was called.
// The returned value is the candidate that was passed for tagging, not confirmation
// that every expanded key was registered; it is nil if getter was nil or its candidate
// was not usable as a tag. Callers may retain it for later dirtying; what makes that
// safe is [tag.TagGetter]'s idempotent tag identity. attrs holds any initial HTML
// attributes provided by InitialHTMLAttrHandler.
//
// If the [Element] is already frozen and getter is an event handler, the handler
// is not added: in production with a [Jaws.Logger] configured this is logged and
// tag and init processing still occur, while debug builds and servers without a
// Logger panic via reportMisuse, aborting before tag and init processing. A
// non-event-handler getter never calls reportMisuse, so its tag and init
// processing always occur.
func (elem *Element) ApplyGetter(getter any) (tagValue any, attrs []template.HTMLAttr, err error) {
// initial-attribute and tag processing still occur, while debug builds and servers
// without a Logger panic via reportMisuse, aborting before them. A non-event-handler
// getter never calls reportMisuse, so its initial-attribute and tag processing always
// occur.
func (elem *Element) ApplyGetter(getter any) (tagValue any, attrs []template.HTMLAttr) {
if getter != nil {
tagValue = getter
if tagger, ok := getter.(tag.TagGetter); ok {
tagValue = tagger.JawsGetTag(elem.Request)
tagValue = tagger.JawsGetTag()
}
if _, ok := getter.(InputHandler); ok {
elem.appendHandlers(getter)
Expand All @@ -521,9 +524,6 @@ func (elem *Element) ApplyGetter(getter any) (tagValue any, attrs []template.HTM
} else {
tagValue = nil
}
if initer, ok := getter.(InitHandler); ok {
err = initer.JawsInit(elem)
}
}
return
}
Loading
Loading