Skip to content

feat(v4): settle the attribute grammar, and unify its four parsers - #859

Merged
titouanmathis merged 6 commits into
mainfrom
feat/v4-attribute-grammar
Aug 19, 2026
Merged

feat(v4): settle the attribute grammar, and unify its four parsers#859
titouanmathis merged 6 commits into
mainfrom
feat/v4-attribute-grammar

Conversation

@titouanmathis

Copy link
Copy Markdown
Contributor

Closes J1, J2 and J3 of #780 — the declarative attribute language.

Four families had grown the same shape, data-<ns>[-<subject>]:<qualifier>[.<part>…], with four independent parsers, one of them core's. This settles the grammar, replaces the duplicated mechanism with one primitive, and replaces the two modifier parsers with one.

J1 — the grammar

Each separator looked like it meant two things: a colon introducing the subject declared (data-on:click) or a variant of the thing on its left (data-option-columns:s); a dot introducing modifiers (click.prevent) or a name (data-bind:prop.value).

The ruling is that the colon means one thing — pick one member of the vocabulary the namespace declares — and that the asymmetry is in what a namespace is, not in the separator. A namespace is fixed, written in a module (data-component, data-on, data-bind), or generated, one per declared option, so columns owns data-option-columns and the colon after it picks a breakpoint exactly as the colon after data-on picks an event.

What falls out is a checkable invariant — at most one colon per attribute — which makes data-on:click:s ill-formed rather than merely unimplemented, keeps the parse an indexOf, and is pinned by a spec. The dot splits the qualifier into parts and core reads none of them: core owns when a declaration is re-parsed and how the attribute is observed, the namespace's owner owns what the string means.

RESPONSIVE_SEPARATOR becomes QUALIFIER_SEPARATOR. One meaning, one name.

Two alternatives were weighed and refused

data-option:columns needs a second separator for the breakpoint, and both answers cost more than the uniformity buys. data-option:columns:s gives up the invariant; data-option:columns.s moves the ambiguity onto the dot, which would then mean modifier, name and breakpoint. It also puts a colon on every option instead of on the rare scoped spelling, costing dataset.optionColumns for the base value, escaping in selectors, and a special character in every template that sets an option.

data-columns claims the whole data-* space. Action declares an option called on, so data-on would be both the option and the handler namespace; it collides with everything else on the page that writes data-*; and it deletes isOptionAttribute(), so shape stops telling an undeclared option from an attribute that was never ours.

J2 — watchAttributeNamespace()

Action.mounted() and AbstractTrack.mounted() were the same fifteen lines down to the justifying comments, written independently by two ports of two unrelated families. Data was the same shape one generation behind, memoising its data-bind:* parse so an attribute rewritten in place kept its first reading forever — the exact bug watchAttributes() was built to fix, and which the other two already consumed.

mounted() {
  return watchAttributeNamespace(this.$el, 'data-on', ({ qualifier, value }) =>
    new ActionEvent(this, qualifier, value).attach(),
  );
}

Declare the prefix, return each binding's release from the binder, and get the per-element observation, the bindings keyed by the attribute that produced them, the declaration order preserved across a rewrite, and the teardown. One code path covers all three shapes a change takes: added attaches with nothing to release, changed releases then attaches, removed releases with nothing to attach.

  • All three families lose their scan, their watcher and their #bind().
  • Action keeps one release of its own for the binding derived from the on/target/effect triple — the one thing a namespace cannot own, because it comes from three options rather than one attribute.
  • Data gets the live rebinding it lacked: a data-bind:* rewritten, added or removed now takes effect on the value already in force. Three specs on it.

One correction to the issue's framing

J2 asked for "declare whether the qualifiers are finite or open, and get the mechanism chosen for you". That is not the axis, and data-bind is what settles it: its six binding types are finite while the class, property or attribute name after the dot is not, so its names cannot be listed and it must watch-and-rebind despite a finite vocabulary.

The real rule is enumerability of the whole name, and it follows from J1's two kinds of namespace:

Namespace Names Mechanism
data-option-columns attribute × breakpoint — enumerable registered in the one attributeFilter
data-component fixed plus one per breakpoint — enumerable registered in the one attributeFilter
data-on, data-track any DOM event — open watchAttributeNamespace()
data-bind finite head, open name — not enumerable watchAttributeNamespace()

A generated namespace is enumerable because it comes from a declaration, which is the argument gap 33 already made when it rejected watchAttributes() for responsive options. So responsive options keep their own registration and consume only the grammar helpers: they are the only enumerable namespace, and their cascade, negation and setBreakpoints() replacement generalise to nothing — a mechanism selector with one caller on one side would be a facade.

Validation is an independent axis, available to a watched namespace and a registered one alike. An optional finite head vocabulary turns data-bind:txet from an attribute that silently does nothing into attribute.unknown-qualifier — the typo warning three of the four families had no version of.

G6 (gap 28) is absorbed by the same ruling: with<Service> names one hook per class, so a component whose subscriptions are declared in markup cannot use it, and a namespace is how those subscriptions are read instead.

J3 — one modifier parser in ui

ActionEvent and TrackEvent each split the same vocabulary over '.', and Track's was Action's plus throttle — a superset, not a variant. The barrels already carried the evidence: Track/index.ts re-exported its Modifier as TrackModifier with a comment explaining the collision it was avoiding. That collision is gone.

migration/event-modifiers.ts stays in ui deliberately — prevent and throttle200 are product vocabulary, not framework concepts.

The defaults are what actually differed, so the parser reports only the delay an author wrote and each family keeps its own fallback: Action debounces at 100 and Track at 300 from the same modifier. modifiers becomes a ReadonlySet, which is what every consumer was doing with the array anyway. An unknown modifier now warns instead of being pushed through as if it were real.

Verification

  • npm test -w @studiometa/js-toolkit-v41409 passed, 91 files
  • tsc -p tsconfig.json && tsc -p tsconfig.coexistence.json — clean
  • oxlint --type-aware packages/v4 — clean (the one src/context.ts warning is pre-existing and untouched)
  • oxfmt --check . and npm run subpaths:check — clean
  • npm test -w @studiometa/eslint-plugin-js-toolkit — 55 passed

New specs: the one-colon invariant and the grammar predicates in attributes.spec.ts, ten on the primitive in attribute-namespaces.spec.ts (including declaration order across a rewrite and warn-once), three on live data-bind rebinding, and the parser's own in TrackEvent.spec.ts.

The root entry goes from 82 to 84 exports (watchAttributeNamespace, namespaceQualifier), each with its generated subpath.

🤖 Generated with Claude Code

https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9

titouanmathis and others added 3 commits August 19, 2026 11:47
Four families had grown the same declarative shape with four independent
parsers, and the thing that had to be settled before unifying them is
whether the separators mean one thing each. Each looked like it meant
two: a colon introducing the subject declared (`data-on:click`) or a
variant of the thing on its left (`data-option-columns:s`), a dot
introducing modifiers (`click.prevent`) or a name (`data-bind:prop.value`).

The ruling is that the colon means one thing — pick one member of the
vocabulary the namespace declares — and that the asymmetry is in what a
namespace is. A namespace is fixed, written in a module, or generated,
one per declared option, so `columns` owns `data-option-columns` and the
colon after it picks a breakpoint exactly as the colon after `data-on`
picks an event. What falls out is a checkable invariant: at most one
colon per attribute, which makes `data-on:click:s` ill-formed rather
than merely unimplemented and keeps the parse an `indexOf`.

Aligning options onto `data-option:columns` was weighed and refused. It
needs a second separator for the breakpoint, and both answers cost more
than the uniformity buys: two colons give up the invariant, and
`data-option:columns.s` moves the ambiguity onto the dot, which would
then mean modifier, name and breakpoint. Dropping the prefix for
`data-columns` was refused outright — `Action` declares an option called
`on`, so `data-on` would be both the option and the handler namespace,
and `isOptionAttribute()` could no longer tell an undeclared option from
an attribute that was never ours.

The ruling earns its keep by deciding the mechanism, which the flat
"finite or open" framing could not: a generated namespace is enumerable
because it comes from a declaration, so it is registered in the one
`attributeFilter`, which is the argument gap 33 already made when it
rejected `watchAttributes()` for responsive options.

`RESPONSIVE_SEPARATOR` becomes `QUALIFIER_SEPARATOR` — one meaning, one
name — and `attributes.ts` gains the predicates the grammar needs, so
`isComponentAttribute` and the option cascade stop each spelling the
prefix test themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
`Action.mounted()` and `AbstractTrack.mounted()` were the same fifteen
lines down to the justifying comments — scan the attributes, key each
binding by the name that produced it, watch the prefix, release the map
on teardown — written independently by two ports of two unrelated
families. `Data` was the same shape one generation behind, memoising its
`data-bind:*` parse so an attribute rewritten in place kept its first
reading forever: the exact bug `watchAttributes()` was built to fix and
which the other two already consumed.

`watchAttributeNamespace(el, namespace, bind, options?)` is that block,
once. Declare the prefix, return each binding's release from the binder,
and get the per-element observation, the keyed bindings, the declaration
order preserved across a rewrite, and the teardown. All three families
lose their scan, their watcher and their `#bind()`; `Action` keeps one
release of its own for the binding derived from the `on`/`target`/`effect`
triple, which is the one thing a namespace cannot own because it comes
from three options rather than one attribute.

`Data` gets the live rebinding it lacked: a `data-bind:*` rewritten,
added or removed now takes effect on the value already in force.

The mechanism follows from whether the whole set of names is enumerable,
not from whether the qualifier vocabulary is finite, and `data-bind` is
what settles that. Its six binding types are finite while the class,
property or attribute name after the dot is not, so the names cannot be
listed and the namespace must be watched. Validation is therefore an
independent axis: an optional finite head vocabulary turns
`data-bind:txet` from an attribute that silently did nothing into
`attribute.unknown-qualifier`, the typo warning three of the four
families had no version of.

Responsive options keep their own registration rather than being wrapped
in a mechanism selector. They are the only enumerable namespace, and
their cascade, negation and `setBreakpoints()` replacement generalise to
nothing — a switch with one caller on one side would be a facade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
`ActionEvent` and `TrackEvent` each split the same modifier vocabulary
over `'.'`, and Track's was Action's plus `throttle` — a superset, not a
variant. The barrels already carried the evidence: `Track/index.ts`
re-exported its `Modifier` as `TrackModifier` with a comment explaining
the collision it was avoiding.

`migration/event-modifiers.ts` is the one implementation. It stays in ui
deliberately: core owns when a declaration is re-parsed and how the
attribute is observed, while `prevent` and `throttle200` are product
vocabulary, so what the parts of a qualifier mean belongs to whoever
declared the namespace.

The defaults are what actually differed between the two, so the parser
reports only the delay an author wrote and each family keeps its own
fallback — `Action` debounces at 100 and `Track` at 300 from the same
modifier. `modifiers` becomes a `ReadonlySet`, which is what every
consumer was doing with the array anyway.

An unknown modifier now warns instead of being pushed through as if it
were real, so a typo stops being silent on a listener that still bound.

The migration report records J1, J2 and J3 as gap 44.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Export size

Bundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped.

@studiometa/js-toolkit-v4

Export Size (gzip) Diff
watchAttributeNamespace 2.55 kB +2.55 kB (+100.0%)
(barrel) 22.61 kB +330 B (+1.4%)
namespaceQualifier 120 B +120 B (+100.0%)
DIAGNOSTICS 714 B +25 B (+3.6%)
utils/selectorFor 2.78 kB +18 B (+0.6%)
getInstances 2.86 kB +17 B (+0.6%)
whenDOMSettled 2.35 kB +12 B (+0.5%)
utils 9.25 kB +9 B (+0.1%)
swap 2.9 kB +8 B (+0.3%)
createFallbackProvider 1.33 kB +1 B (+0.1%)
withRaf 2.63 kB +1 B (+0.0%)
withScroll 3.34 kB +1 B (+0.0%)
createLocalStorage 2.33 kB -1 B (-0.0%)
createSessionStorage 2.33 kB -1 B (-0.0%)
createUrlSearchParamsStorage 2.34 kB -1 B (-0.0%)
emitExtendable 1.09 kB -1 B (-0.1%)
usePointer 1.85 kB -1 B (-0.1%)
registerComponent 11.25 kB -2 B (-0.0%)
component 11.64 kB -7 B (-0.1%)
registerComponents 11.26 kB -7 B (-0.1%)
registerManifest 11.31 kB -17 B (-0.1%)
Unchanged (372)

@studiometa/js-toolkit

Export Size (gzip) Diff
(barrel) 17.48 kB
AbstractService 598 B
Base 9.1 kB
ComponentLoader 2.31 kB
DEFAULT_DIAGNOSTIC_PREFIX 102 B
DragService 2.02 kB
IDLE_TIMEOUT 57 B
KeyService 935 B
LoadService 666 B
MutationService 849 B
PointerService 1.13 kB
RafService 1020 B
ResizeService 1.12 kB
ScrollService 1.36 kB
VISIBLE_ROOT_MARGIN 72 B
autoload 2.4 kB
closestComponent 419 B
composeManifests 119 B
createApp 996 B
defineFeatures 326 B
defineManifest 512 B
fromMetaGlob 228 B
fromWebpackContext 131 B
getClosestParent 197 B
getDirectChildren 202 B
getInstanceFromElement 125 B
getInstances 187 B
getScopedGroups 104 B
importOnInteraction 926 B
importOnMediaQuery 243 B
importWhenIdle 225 B
importWhenPrefersMotion 271 B
importWhenVisible 935 B
isDirectChild 218 B
logTree 551 B
queryComponent 594 B
queryComponentAll 601 B
readEagerTokens 201 B
registerComponent 305 B
registerComponents 356 B
registerManifest 2.87 kB
registerManifests 2.89 kB
useDrag 2.05 kB
useKey 943 B
useLoad 676 B
useMutation 876 B
usePointer 1.15 kB
useRaf 1 kB
useResize 1.13 kB
useScroll 1.36 kB
utils 10.08 kB
utils/Queue 291 B
utils/SmartQueue 473 B
utils/addClass 240 B
utils/addStyle 239 B
utils/animate 3.34 kB
utils/boundingRectToCircle 206 B
utils/cache 208 B
utils/camelCase 405 B
utils/clamp 98 B
utils/clamp01 114 B
utils/collideCircleCircle 129 B
utils/collideCircleRect 192 B
utils/collidePointCircle 128 B
utils/collidePointRect 122 B
utils/collideRectRect 128 B
utils/createEaseInOut 123 B
utils/createEaseOut 91 B
utils/createElement 635 B
utils/createLocalStorage 1.32 kB
utils/createLocalStorageProvider 296 B
utils/createMemoryStorageProvider 174 B
utils/createNoopProvider 128 B
utils/createRange 115 B
utils/createSessionStorage 1.32 kB
utils/createSessionStorageProvider 288 B
utils/createStorage 1.3 kB
utils/createUrlSearchParamsInHashProvider 461 B
utils/createUrlSearchParamsInHashStorage 1.35 kB
utils/createUrlSearchParamsProvider 429 B
utils/createUrlSearchParamsStorage 1.34 kB
utils/damp 106 B
utils/dashCase 404 B
utils/debounce 122 B
utils/domScheduler 310 B
utils/ease 519 B
utils/easeInCirc 285 B
utils/easeInCubic 287 B
utils/easeInExpo 286 B
utils/easeInOutCirc 288 B
utils/easeInOutCubic 289 B
utils/easeInOutExpo 288 B
utils/easeInOutQuad 288 B
utils/easeInOutQuart 289 B
utils/easeInOutQuint 289 B
utils/easeInOutSine 288 B
utils/easeInQuad 285 B
utils/easeInQuart 286 B
utils/easeInQuint 286 B
utils/easeInSine 285 B
utils/easeLinear 77 B
utils/easeOutCirc 286 B
utils/easeOutCubic 288 B
utils/easeOutExpo 286 B
utils/easeOutQuad 286 B
utils/easeOutQuart 286 B
utils/easeOutQuint 286 B
utils/easeOutSine 286 B
utils/endsWith 128 B
utils/fold 168 B
utils/getAncestorWhere 123 B
utils/getAncestorWhereUntil 148 B
utils/getComponentResolver 140 B
utils/getOffsetSizes 194 B
utils/hasWindow 88 B
utils/historyPush 524 B
utils/historyReplace 526 B
utils/inertiaFinalValue 169 B
utils/isArray 63 B
utils/isBoolean 78 B
utils/isDefined 75 B
utils/isDev 78 B
utils/isEmpty 206 B
utils/isEmptyString 108 B
utils/isFunction 79 B
utils/isNull 68 B
utils/isNumber 91 B
utils/isObject 108 B
utils/isString 77 B
utils/keyCodes 122 B
utils/lerp 84 B
utils/loadElement 220 B
utils/loadIframe 241 B
utils/loadImage 241 B
utils/loadLink 237 B
utils/loadScript 251 B
utils/localStorageProvider 839 B
utils/lowerCase 404 B
utils/map 93 B
utils/matrix 136 B
utils/mean 126 B
utils/memo 130 B
utils/memoize 228 B
utils/memoryStorageProvider 843 B
utils/nextFrame 179 B
utils/nextMicrotask 133 B
utils/nextTick 148 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 322 B
utils/pascalCase 407 B
utils/random 93 B
utils/randomInt 113 B
utils/randomItem 234 B
utils/removeClass 242 B
utils/removeStyle 243 B
utils/round 95 B
utils/saveActiveElement 92 B
utils/scrollTo 2.31 kB
utils/sessionStorageProvider 838 B
utils/smoothTo 476 B
utils/snakeCase 406 B
utils/spring 154 B
utils/startsWith 125 B
utils/throttle 125 B
utils/toggleClass 242 B
utils/transform 347 B
utils/transition 1010 B
utils/trapFocus 441 B
utils/tween 1.72 kB
utils/untrapFocus 120 B
utils/upperCase 404 B
utils/urlSearchParamsInHashProvider 845 B
utils/urlSearchParamsProvider 839 B
utils/useScheduler 309 B
utils/wait 103 B
utils/withLeadingCharacters 135 B
utils/withLeadingSlash 142 B
utils/withTrailingCharacters 135 B
utils/withTrailingSlash 142 B
utils/withoutLeadingCharacters 122 B
utils/withoutLeadingCharactersRecursive 165 B
utils/withoutLeadingSlash 133 B
utils/withoutTrailingCharacters 122 B
utils/withoutTrailingCharactersRecursive 165 B
utils/withoutTrailingSlash 133 B
utils/wrap 122 B
version 56 B
withBreakpointManager 1.54 kB
withBreakpointObserver 1.71 kB
withDrag 2.18 kB
withExtraConfig 163 B
withFreezedOptions 187 B
withGroup 455 B
withIntersectionObserver 303 B
withMountOnMediaQuery 393 B
withMountWhenInView 347 B
withMountWhenPrefersMotion 431 B
withMutation 1010 B
withName 109 B
withRelativePointer 1.29 kB
withResponsiveOptions 2.4 kB
withScrolledInView 3.05 kB

@studiometa/js-toolkit-v4

Export Size (gzip) Diff
BREAKPOINTS 778 B
Base 8.56 kB
DRAG_MODES 162 B
EVENTS 155 B
MOUNT_ATTRIBUTE 69 B
SWAP_MODES 129 B
children 244 B
createContext 472 B
createGroup 1.07 kB
createMemoryStorageProvider 1.21 kB
createService 640 B
createServiceMixin 1018 B
createStorage 2.3 kB
createUrlSearchParamsInHashProvider 1.21 kB
createUrlSearchParamsInHashStorage 2.34 kB
createUrlSearchParamsProvider 1.21 kB
defaultScheduler 1.5 kB
defineManifest 983 B
domUpdate 1.23 kB
fromMetaGlob 203 B
fromWebpackContext 131 B
getBreakpoints 776 B
inject 176 B
injectContext 675 B
injectContextSync 634 B
jsonSerializer 95 B
localStorageProvider 1.21 kB
memoryStorageProvider 1.21 kB
nextFrame 115 B
on 8.91 kB
perTarget 322 B
provide 182 B
provideContext 704 B
provideRootContext 748 B
read 126 B
sessionStorageProvider 1.21 kB
setBreakpoints 806 B
signal 925 B
subscribeContext 1.44 kB
toggle 177 B
until 172 B
urlSearchParamsInHashProvider 1.21 kB
urlSearchParamsProvider 1.21 kB
useBreakpoint 1.44 kB
useDrag 3.35 kB
useInView 1.43 kB
useKey 1.47 kB
useMediaQuery 1.06 kB
useMutation 1.4 kB
usePrefersReducedMotion 1.09 kB
useRaf 1.95 kB
useResize 1.43 kB
useScroll 2.67 kB
useScrollProgress 3.64 kB
useWindowScroll 2.66 kB
useWindowSize 1.43 kB
utils/DEFAULT_DAMP_FACTOR 109 B
utils/INERTIA_FRAME 97 B
utils/MAX_SPRING_RATIO 100 B
utils/SCROLL_ALIGNMENTS 117 B
utils/SCROLL_AXES 100 B
utils/TRANSFORM_PROPS 137 B
utils/TRANSITION_OPTIONS 132 B
utils/camelCase 449 B
utils/capitalize 119 B
utils/clamp 133 B
utils/clamp01 149 B
utils/clampDampFactor 157 B
utils/createEaseInOut 120 B
utils/createEaseOut 91 B
utils/createElement 638 B
utils/createRange 205 B
utils/damp 211 B
utils/debounce 121 B
utils/decayOver 162 B
utils/deepmerge 312 B
utils/easeInCirc 94 B
utils/easeInCubic 81 B
utils/easeInExpo 97 B
utils/easeInOutCirc 150 B
utils/easeInOutCubic 141 B
utils/easeInOutExpo 150 B
utils/easeInOutQuad 139 B
utils/easeInOutQuart 140 B
utils/easeInOutQuint 140 B
utils/easeInOutSine 156 B
utils/easeInQuad 80 B
utils/easeInQuart 81 B
utils/easeInQuint 81 B
utils/easeInSine 104 B
utils/easeLinear 77 B
utils/easeOutCirc 121 B
utils/easeOutCubic 111 B
utils/easeOutExpo 124 B
utils/easeOutQuad 110 B
utils/easeOutQuart 112 B
utils/easeOutQuint 111 B
utils/easeOutSine 132 B
utils/enterTransition 679 B
utils/fold 200 B
utils/getOffsetSizes 268 B
utils/historyPush 391 B
utils/historyReplace 392 B
utils/inertiaDecay 199 B
utils/inertiaFinalValue 187 B
utils/inertiaStep 232 B
utils/inertiaTimeConstant 178 B
utils/isBoolean 90 B
utils/isDefined 87 B
utils/isFunction 86 B
utils/isNull 78 B
utils/isNumber 103 B
utils/isObject 115 B
utils/isString 89 B
utils/kebabCase 421 B
utils/leaveTransition 679 B
utils/lerp 120 B
utils/loadImage 245 B
utils/loadLink 776 B
utils/loadScript 697 B
utils/lockScroll 565 B
utils/lowerCase 84 B
utils/map 128 B
utils/matrix 150 B
utils/mean 147 B
utils/memo 217 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 266 B
utils/pascalCase 434 B
utils/random 93 B
utils/randomInt 132 B
utils/randomItem 163 B
utils/round 130 B
utils/saveActiveElement 571 B
utils/scrollPosition 857 B
utils/scrollTo 1.86 kB
utils/setClassesOrStyles 218 B
utils/smoothTo 2.83 kB
utils/snakeCase 421 B
utils/spring 343 B
utils/throttle 151 B
utils/transform 286 B
utils/transition 577 B
utils/trapFocus 717 B
utils/untrapFocus 587 B
utils/upperCase 84 B
utils/wait 103 B
utils/withLeadingCharacters 142 B
utils/withLeadingSlash 152 B
utils/withTrailingCharacters 143 B
utils/withTrailingSlash 153 B
utils/withoutLeadingCharacters 127 B
utils/withoutLeadingCharactersRecursive 144 B
utils/withoutLeadingSlash 138 B
utils/withoutTrailingCharacters 129 B
utils/withoutTrailingCharactersRecursive 147 B
utils/withoutTrailingSlash 140 B
utils/wrap 154 B
viewTransition 1.66 kB
watchAttributes 2.02 kB
withDrag 4.03 kB
withInView 2.12 kB
withKey 2.15 kB
withMutation 2.09 kB
withPointer 2.52 kB
withResize 2.11 kB
withScrollProgress 4.34 kB
write 124 B

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Code Review

Risk: Low — No concrete defects were found in the reviewed source changes; the MR is safe to merge aside from any issues in files not opened here.

The MR introduces a shared attribute-namespace watcher, settles qualified attribute parsing around a single colon, and unifies event modifier parsing for Action and Track. It also adds live rebinding for Data bindings, updates responsive option handling, and exposes the new primitives through package entry points.

Reviewed: packages/v4/src/attribute-namespaces.ts, packages/v4/src/subpaths/watchAttributeNamespace.ts, packages/v4/src/attributes.ts, packages/v4/src/responsive-options.ts, packages/v4/src/subpaths/namespaceQualifier.ts, packages/v4/src/Base.ts, packages/v4/src/index.ts, packages/v4/src/diagnostic-contract.ts, packages/v4/migration/Data/DataBind.ts, packages/v4/migration/Action/Action.ts, packages/v4/migration/Action/ActionEvent.ts, packages/v4/migration/Track/AbstractTrack.ts, packages/v4/migration/Track/TrackEvent.ts, packages/v4/migration/event-modifiers.ts, packages/v4/package.json, scripts/lib/subpath-exports.js, and the added namespace and export specs. Not opened: the remaining changed/skipped diffs, including the documentation, migration reports and specs, package consumer test, and any changed files not listed above.

Notes:

  • The unreviewed files are the skipped diffs supplied under .code-review-skipped/; their correctness is not covered by this review.

Still open from earlier reviews (2 findings):

  • packages/v4/migration/event-modifiers.ts:98issue: Reject malformed timed modifier delays
  • packages/v4/src/attribute-namespaces.ts:84issue: Reject qualifiers containing a second colon

Review usage: 167,923 in (136,309 cached) / 2,323 out tokens — $0.0335 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 18f9193.

Previous review runs

Previous run archived 2026-08-19T11:32:09Z

Code Review

Risk: Medium — issues that should be addressed before merge.

The MR introduces a shared attribute-namespace watcher, settles namespace and qualifier parsing, and consolidates event modifier parsing across Action and Track. It also adds live rebinding for DataBind and updates the public exports and diagnostics contract.

1 issue found:

  • issuepackages/v4/src/attribute-namespaces.ts:84 — Reject qualifiers containing a second colon

Notes:

  • Reviewed: packages/v4/src/attribute-namespaces.ts, packages/v4/src/subpaths/watchAttributeNamespace.ts, packages/v4/src/subpaths/namespaceQualifier.ts, packages/v4/src/attributes.ts, packages/v4/src/responsive-options.ts, packages/v4/src/Base.ts, packages/v4/src/component-declarations.ts, packages/v4/src/index.ts, packages/v4/src/diagnostic-contract.ts, packages/v4/src/diagnostics.spec.ts, packages/v4/src/attribute-namespaces.spec.ts, packages/v4/src/attributes.spec.ts, packages/v4/src/exports.spec.ts, packages/v4/migration/event-modifiers.ts, packages/v4/migration/Data/DataBind.ts, packages/v4/migration/Action/Action.ts, packages/v4/migration/Track/AbstractTrack.ts, packages/v4/migration/Action/ActionEvent.ts, packages/v4/migration/Track/TrackEvent.ts, packages/v4/migration/index.ts, and scripts/lib/subpath-exports.js.
  • Not opened: the remaining files listed in the supplied skipped-files list, including the skipped design/rationale documents, migration specs and report, package metadata, package-consumer test, and the other skipped migration/source files.

Still open from earlier reviews (1 finding):

  • packages/v4/migration/event-modifiers.ts:91issue: Reject malformed timed modifier delays

Review usage: 88,870 in (47,893 cached) / 2,293 out tokens — $0.0349 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 389a7b8.

Previous run archived 2026-08-19T11:24:24Z

Code Review

Risk: Low — The change is safe to merge; no concrete defects were found in the reviewed implementation.

This MR establishes the unified attribute grammar, adds the shared namespace watcher, migrates DataBind, Action, and Track to it, and consolidates event-modifier parsing. The reviewed code also preserves responsive option handling while renaming the separator terminology.

Reviewed files: packages/v4/src/attribute-namespaces.ts, packages/v4/src/subpaths/watchAttributeNamespace.ts, packages/v4/src/subpaths/namespaceQualifier.ts, packages/v4/src/attributes.ts, packages/v4/src/responsive-options.ts, packages/v4/src/component-declarations.ts, packages/v4/src/Base.ts, packages/v4/src/index.ts, packages/v4/migration/Data/DataBind.ts, packages/v4/migration/Action/Action.ts, packages/v4/migration/Action/ActionEvent.ts, packages/v4/migration/Track/AbstractTrack.ts, packages/v4/migration/Track/TrackEvent.ts, packages/v4/migration/event-modifiers.ts, packages/v4/src/attribute-namespaces.spec.ts, and scripts/lib/subpath-exports.js. I did not open the remaining files listed under skipped_files.

Still open from earlier reviews (1 finding):

  • packages/v4/migration/event-modifiers.ts:91issue: Reject malformed timed modifier delays

Review usage: 82,316 in (58,557 cached) / 1,757 out tokens — $0.0230 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 0aaa2d1.

Previous run archived 2026-08-19T10:08:04Z

Code Review

Risk: Medium — issues that should be addressed before merge.

This change settles the attribute grammar, introduces a shared namespace watcher for Action, DataBind, and Track, and consolidates event modifier parsing. I reviewed packages/v4/src/attribute-namespaces.ts, packages/v4/src/subpaths/watchAttributeNamespace.ts, packages/v4/src/attributes.ts, packages/v4/src/Base.ts, packages/v4/src/component-declarations.ts, packages/v4/src/diagnostic-contract.ts, packages/v4/src/index.ts, packages/v4/src/responsive-options.ts, packages/v4/migration/Action/Action.ts, packages/v4/migration/Action/ActionEvent.ts, packages/v4/migration/Data/DataBind.ts, packages/v4/migration/Track/AbstractTrack.ts, packages/v4/migration/Track/TrackEvent.ts, packages/v4/migration/event-modifiers.ts, packages/v4/src/attribute-namespaces.spec.ts, and scripts/lib/subpath-exports.js. I did not open the remaining skipped files listed in the request, including the design/rationale/report files, package metadata, the other migration specs and barrels, the remaining source specs, packages/v4/src/dom-mutations.ts because no corresponding skipped diff was present, or the other skipped source diffs.

1 issue found:

  • issuepackages/v4/migration/event-modifiers.ts:91 — Reject malformed timed modifier delays

Review usage: 121,291 in (86,317 cached) / 1,999 out tokens — $0.0322 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 88d8d4b.

continue;
}

if (!MODIFIER_NAMES.includes(part)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Reject malformed timed modifier delays

Any part beginning with debounce or throttle is accepted as a timed modifier, and Number.parseInt can return NaN for inputs such as click.debounceoops or scroll.throttlex. The resulting handler is still enabled with a NaN timeout, which browsers treat as an immediate timer, so a malformed modifier silently changes event timing instead of being warned and ignored. Validate that the suffix is either empty or an integer before adding the modifier and delay.

Confidence: high.


Reviewed by @weareikko/code-review v0.9.5 for commit 88d8d4b.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.04%. Comparing base (e9d1e52) to head (18f9193).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #859   +/-   ##
=======================================
  Coverage   97.04%   97.04%           
=======================================
  Files         175      175           
  Lines        4535     4535           
  Branches     1323     1322    -1     
=======================================
  Hits         4401     4401           
  Misses        122      122           
  Partials       12       12           
Flag Coverage Δ
eslint-plugin-js-toolkit 94.43% <ø> (ø)
js-toolkit 97.93% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

v4 mount benchmarks

Base and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back.

A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner.

No benchmark moved beyond the noise floor.

Within noise (18)
Group Benchmark Base Head us / component Change
destroy 1000 flat components, one removal flat 2.10 ms 2.20 ms 2.20 +4.8%
destroy 5000 flat components, one removal flat 17.1 ms 13.7 ms 2.74 -19.9%
mount 1000 components, one insertion control — declared but unregistered 1.60 ms 1.70 ms 1.70 +6.2%
mount 1000 components, one insertion flat 14.4 ms 14.0 ms 14.00 -2.8%
mount 1000 components, one insertion in-view — one controller per element 25.8 ms 25.7 ms 25.70 -0.4%
mount 1000 components, one insertion nested 4 deep 10.4 ms 11.2 ms 11.20 +7.7%
mount 1000 components, one insertion realistic — 5 refs, 3 options, 4 handlers 57.1 ms 57.1 ms 57.10 -0.0%
mount 1000 components, one insertion responsive option — breakpoint cascade per mount 12.3 ms 13.3 ms 13.30 +8.1%
mount 1000 flat components, 1 vs 10 insertions 1 insertion 9.50 ms 9.10 ms 9.10 -4.2%
mount 1000 flat components, 1 vs 10 insertions 10 insertions 10.0 ms 9.70 ms 9.70 -3.0%
mount 5000 components, one insertion control — declared but unregistered 13.8 ms 14.0 ms 2.80 +1.4%
mount 5000 components, one insertion flat 53.4 ms 55.1 ms 11.02 +3.2%
mount 5000 components, one insertion in-view — one controller per element 125.2 ms 126.4 ms 25.28 +1.0%
mount 5000 components, one insertion nested 4 deep 53.4 ms 53.4 ms 10.68 +0.0%
mount 5000 components, one insertion realistic — 5 refs, 3 options, 4 handlers 263.5 ms 269.5 ms 53.90 +2.3%
mount 5000 components, one insertion responsive option — breakpoint cascade per mount 64.9 ms 66.1 ms 13.22 +1.8%
mount 5000 flat components, 1 vs 10 insertions 1 insertion 55.5 ms 54.9 ms 10.98 -1.1%
mount 5000 flat components, 1 vs 10 insertions 10 insertions 56.8 ms 56.7 ms 11.34 -0.2%

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

v3 mount benchmarks

Base and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back.

A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner.

No benchmark moved beyond the noise floor.

Within noise (10)
Group Benchmark Base Head us / component Change
destroy 1000 flat components — v3 v3 — destroy 15.3 ms 15.9 ms 15.90 +3.9%
destroy 1000 flat components — v4 v4 — destroy 2.60 ms 2.70 ms 2.70 +3.8%
swap 1000 components — v3 v3 — control 0.30 ms 0.30 ms 0.30 -0.0%
swap 1000 components — v3 v3 — flat 38.9 ms 39.4 ms 39.40 +1.3%
swap 1000 components — v3 v3 — nested 44.1 ms 46.1 ms 46.10 +4.5%
swap 1000 components — v3 v3 — realistic 120.1 ms 125.1 ms 125.10 +4.2%
swap 1000 components — v4 v4 — control 2.00 ms 2.00 ms 2.00 0.0%
swap 1000 components — v4 v4 — flat 16.6 ms 16.2 ms 16.20 -2.4%
swap 1000 components — v4 v4 — nested 14.9 ms 14.2 ms 14.20 -4.7%
swap 1000 components — v4 v4 — realistic 75.8 ms 73.3 ms 73.30 -3.3%

hasComponentAttribute() rebuilt the exact prefix check isInNamespace()
already expresses, one call site left over from before the grammar was
named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
`check:package` keeps its own copy of the root export count, in
`test/package-node-consumer.js`, deliberately separate from
`exports.spec.ts` because `npm test`'s vitest run does not exercise it.
Missed when the count moved to 84 for `watchAttributeNamespace` and
`namespaceQualifier`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9

const rebind = (attribute: string, value: string | null): void => {
// Release first, whatever comes next: a rewritten attribute must not leave
// its previous binding attached, which is the whole point of watching.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Reject qualifiers containing a second colon

namespaceQualifier() returns the entire suffix without validating it, so data-on:click:s is passed to the binder as qualifier click:s. With the open data-on namespace, Action consequently binds an event named click:s even though the settled grammar requires at most one colon and declares this spelling ill-formed. Validate and ignore or diagnose qualifiers containing QUALIFIER_SEPARATOR before invoking bind.

Confidence: high.


Reviewed by @weareikko/code-review v0.9.5 for commit 389a7b8.

Two findings from automated review, both confirmed against the code.

`namespaceQualifier()` returns everything after the first colon
unexamined, so `data-on:click:s` reached the binder as qualifier
`click:s`. With no declared vocabulary — `Action`'s case — nothing
stopped it: the settled grammar makes a second colon ill-formed, but
only `attributes.spec.ts` checked the invariant, not the primitive that
reads it. `watchAttributeNamespace()` now rejects a qualifier holding a
second `QUALIFIER_SEPARATOR` before it reaches a binder, warning once
through the same `attribute.unknown-qualifier` code the vocabulary check
already uses.

`parseEventDefinition()` matched a timed modifier on `startsWith`, so
`click.debounceoops` matched `debounce`, `Number.parseInt('oops', 10)`
produced `NaN`, and `delay('debounce')` returned `NaN` — not `undefined`,
so the `?? DEFAULT_DEBOUNCE_DELAY` fallback never ran. `setTimeout(fn,
NaN)` runs immediately in a browser, so a typo silently turned a
debounced listener into an unthrottled one. The prefix match becomes a
whole-token regex requiring the suffix to be digits or nothing, so a
malformed suffix falls through to the existing unknown-modifier warning
instead of parsing to `NaN`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
@titouanmathis
titouanmathis merged commit 8b8c9da into main Aug 19, 2026
16 of 17 checks passed
@titouanmathis
titouanmathis deleted the feat/v4-attribute-grammar branch August 19, 2026 11:52
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