From 91896efe62e57acd705ffee04a5981fd1f0f2189 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 11:47:30 +0200 Subject: [PATCH 1/6] docs(v4): rule the attribute grammar, one colon per attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/DESIGN.md | 44 ++++++++++++ packages/v4/RATIONALE.md | 12 ++++ packages/v4/src/Base.ts | 8 ++- packages/v4/src/attributes.spec.ts | 66 ++++++++++++++++-- packages/v4/src/attributes.ts | 81 ++++++++++++++++++++--- packages/v4/src/component-declarations.ts | 4 +- packages/v4/src/responsive-options.ts | 15 ++--- 7 files changed, 204 insertions(+), 26 deletions(-) diff --git a/packages/v4/DESIGN.md b/packages/v4/DESIGN.md index 1c4560d84..c30ea141e 100644 --- a/packages/v4/DESIGN.md +++ b/packages/v4/DESIGN.md @@ -373,6 +373,50 @@ A disconnected element receives `$destroy()` and keeps its instance for a later **The coalescing rule is written once.** Several writes to one attribute in a batch give one change, from the value before the first write to the value at the end of the batch. A write that ends where it started is not a change. `rememberPreviousValue()` and `isNetChange()` hold the rule. The comparison uses raw strings, so a breakpoint crossing and an attribute write are the same kind of event. +### The attribute grammar + +Every attribute the framework reads has one shape, and `attributes.ts` owns it: + +``` +data-[:[.…]] +``` + +**A namespace is a whole name, and it is one of two kinds.** A **fixed** namespace is written in a module — `data-component`, `data-mount`, `data-ref`, and in ui `data-on`, `data-track`, `data-bind`. A **generated** namespace comes from a declaration: every declared option owns the one `optionAttributeFor()` builds for it, so `columns` owns `data-option-columns` and `data-option-` is a namespace _family_ rather than a namespace. An option's name is therefore inside its namespace, never a qualifier of a shared one: `data-option-columns:s`, never `data-option:columns:s`. + +**The colon has one meaning: it selects one member of the vocabulary the namespace declares.** `data-option-columns:s` and `data-on:click` are not two meanings of the separator — they differ in what the namespace is, one already-declared option against an open family of bindings, and in both cases the colon picks one member. So **an attribute the framework reads holds at most one colon**, which is a rule you can check and `attributes.spec.ts` does. `data-on:click:s` is not an attribute: responsiveness belongs to declared options, the only vocabulary that is finite, ordered, and has a value to resolve through a cascade. + +**A dot splits the qualifier into parts, and core reads none of them.** Whether the first part names what a binding writes to (`data-bind:prop.value`) or modifies how it fires (`data-on:click.prevent`) is the business of whoever declared the namespace. Core reads the qualifier's _head_ only to check it against a vocabulary the caller handed over, and never past it. **Core owns when a declaration is re-parsed and how the attribute is observed; the namespace's owner owns what the string means.** + +**The kind of namespace decides the mechanism, and that is the point of the distinction.** What matters is whether the _whole set of names_ can be listed in advance, not whether the qualifier vocabulary is finite: + +| 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 why the page-wide filter stays precise and no option costs a second observer — the argument gap 33 made when it rejected `watchAttributes()` for responsive options. `data-bind` is the case that shows why "finite or open" is the wrong axis on its own: its six binding types are finite, but the class, property or attribute name after the dot is not, so the names cannot be listed and the namespace must be watched. Validating a finite head is an **independent** capability, available to a watched namespace and a registered one alike. + +### `watchAttributeNamespace()` + +The mechanism for a namespace whose names cannot be enumerated. Declare the prefix, hand over a binder, and get the per-element observation, the keyed bindings and the teardown: + +```js +mounted() { + return watchAttributeNamespace(this.$el, 'data-on', ({ qualifier, value }) => + new ActionEvent(this, qualifier, value).attach(), + ); +} +``` + +- **One binding per attribute, keyed by the name that produced it.** That is what lets one code path cover all three shapes a change takes: **added** attaches with nothing to release, **changed** releases then attaches, **removed** releases with nothing to attach. A memoised parse cannot express the middle one, and rewriting an attribute in place is not hypothetical — `swap({ mode: 'morph' })` does it, and so does any `data-bind:` template around the element. +- **The binder returns that binding's release**, or nothing when the declaration produced no binding. Returning nothing leaves nothing held, so a malformed value costs no bookkeeping. +- **Declaration order survives a rewrite.** The bindings are a `Map` keyed by attribute, and `set` on a key already there keeps its position, so a consumer applying its bindings in order is not reordered by an edit. +- **An optional finite vocabulary validates the qualifier's head.** Given one, an unknown head warns once per element and per name with `DIAGNOSTICS.attribute.unknownQualifier` and binds nothing, so `data-bind:prpo.value` stops being an attribute that silently does nothing. Omitted, the vocabulary is open and anything binds — which is the only honest answer for `data-on`, whose qualifiers are any DOM event. +- **It is built on `watchAttributes()`**, so its records join the one mutation engine's queue and are reported from the same batch: `whenDOMSettled()`, and therefore `swap()`, covers a namespaced declaration the way it covers a mount. +- `Base` has no wrapper and owns no cleanup. A component calls it from `mounted()` and returns its cleanup. + ### `watchAttributes()` `attributeFilter` takes exact names and the DOM has no wildcard, so the engine cannot see an attribute that the framework cannot name. `data-on:` is that case. diff --git a/packages/v4/RATIONALE.md b/packages/v4/RATIONALE.md index 9f414d9ac..f437e1de1 100644 --- a/packages/v4/RATIONALE.md +++ b/packages/v4/RATIONALE.md @@ -241,6 +241,18 @@ Measured from the previous `origin/main`, with esbuild tree shaking and minifica The package dry run moves from 282 files, 267,606 B packed and 847,465 B unpacked, to 284 files, 265,174 B packed and 840,221 B unpacked. +### Why the grammar was settled before a fifth parser was written + +Four families had grown the same shape — `data-[-]:[.…]` — with four independent parsers, one of them core's own. The evidence that it was one shape rather than four similar ones was not the shape, it was the duplication: `Action.mounted()` and `AbstractTrack.mounted()` were the same fifteen lines down to the justifying comments, written independently by two ports of two unrelated families, and `ActionEvent` and `TrackEvent` each split the same modifier vocabulary, the second being the first plus `throttle` — a superset, not a variant. Nobody could have unified them earlier: filter registration existed for options, and `watchAttributes()` only landed two rounds ago. + +**The thing that had to be decided first was whether the separators mean one thing each.** Each looked like it meant two. A colon introduced the subject being declared (`data-on:click`) or a variant of the thing on its left (`data-option-columns:s`); a dot introduced modifiers (`click.prevent`) or a name (`data-bind:prop.value`). Left unstated, a shared primitive would have had to honour both readings without being able to say which it was implementing. + +**The ruling is that the colon means one thing — pick one member of the namespace's vocabulary — and that the asymmetry is in what a namespace is, not in the separator.** A namespace is fixed (written in a module) or generated (one per declared option). `data-option-columns` is `columns`' own namespace, so the colon after it picks a breakpoint exactly as the colon after `data-on` picks an event. What follows is the 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` rather than a path walk. + +**Two alternatives were weighed and refused.** Aligning options onto a shared namespace — `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 one-colon invariant, and `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, which costs `dataset.optionColumns` for the base value, escaping in selectors, and a special character in every template that sets an option. Dropping the prefix entirely — `data-columns` — claims the whole `data-*` space: it collides with the framework's own fixed names (`Action` declares an option called `on`, so `data-on` would be both), 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. + +**The ruling earns its keep by deciding J2's mechanism, which the flat "finite or open" framing could not.** A generated namespace is enumerable _because_ it comes from a declaration — the names are `attribute × breakpoint` — so it is registered in the one `attributeFilter` and no option costs a second observer, which is the argument gap 33 already made when it rejected `watchAttributes()` for responsive options. A fixed namespace whose qualifiers are open cannot be enumerated, so it is watched per element. And `data-bind` is the case that settles the axis: its six binding types are finite while the name after the dot is not, so a finite vocabulary does **not** imply an enumerable name, and validation is an independent capability rather than a consequence of the mechanism. That is why `watchAttributeNamespace()` is the one mechanism this round adds, and why responsive options keep their own registration instead of being wrapped in a selector with a single caller on one side. + ## 4. Parents listen to child events ### Why the payload is one object diff --git a/packages/v4/src/Base.ts b/packages/v4/src/Base.ts index 85a2612a2..b2f10fcfc 100644 --- a/packages/v4/src/Base.ts +++ b/packages/v4/src/Base.ts @@ -1,4 +1,5 @@ import { + isInNamespace, isNetChange, NEGATED_RAW, negatedOptionAttributeFor, @@ -18,7 +19,6 @@ import { defaultScheduler, type ScheduledTask } from './scheduler.js'; import { activeBreakpoint, checkResponsiveAttributes, - isResponsiveAttribute, responsiveRawValue, watchBreakpoint, } from './responsive-options.js'; @@ -685,9 +685,11 @@ function buildOptions(instance: Base): { const rawValue = () => responsiveRawValue(attribute, activeBreakpoint(), fromElement, negated); const rawValueAt = (breakpoint: string, get: (attributeName: string) => string | null) => responsiveRawValue(attribute, breakpoint, get, negated); + // An option owns its generated namespace: the base name and every + // breakpoint-qualified spelling of it, on both the value and the negation. const owns = (attributeName: string) => - isResponsiveAttribute(attribute, attributeName) || - (negated !== undefined && isResponsiveAttribute(negated, attributeName)); + isInNamespace(attribute, attributeName) || + (negated !== undefined && isInNamespace(negated, attributeName)); readers.set(name, { attribute, rawValue, rawValueAt, owns, read }); Object.defineProperty(options, name, { diff --git a/packages/v4/src/attributes.spec.ts b/packages/v4/src/attributes.spec.ts index ac458192d..98f968eb9 100644 --- a/packages/v4/src/attributes.spec.ts +++ b/packages/v4/src/attributes.spec.ts @@ -3,14 +3,20 @@ import { COMPONENT_ATTRIBUTE, FRAMEWORK_ATTRIBUTES, isComponentAttribute, + isInNamespace, isNetChange, isOptionAttribute, MOUNT_ATTRIBUTE, + namespacedAttribute, + namespaceQualifier, + negatedOptionAttributeFor, OPTION_ATTRIBUTE_PREFIX, optionAttributeFor, + PART_SEPARATOR, + qualifierHead, REF_ATTRIBUTE, rememberPreviousValue, - RESPONSIVE_SEPARATOR, + QUALIFIER_SEPARATOR, } from './attributes.js'; describe('the framework attribute names', () => { @@ -21,7 +27,8 @@ describe('the framework attribute names', () => { expect(MOUNT_ATTRIBUTE).toBe('data-mount'); expect(REF_ATTRIBUTE).toBe('data-ref'); expect(OPTION_ATTRIBUTE_PREFIX).toBe('data-option-'); - expect(RESPONSIVE_SEPARATOR).toBe(':'); + expect(QUALIFIER_SEPARATOR).toBe(':'); + expect(PART_SEPARATOR).toBe('.'); }); it('names the three every page observes before a component registers', () => { @@ -29,6 +36,57 @@ describe('the framework attribute names', () => { }); }); +describe('the grammar', () => { + it('holds at most one colon in every attribute the framework reads', () => { + // The invariant, spelled as the check it is: a namespace is a whole name, a + // colon introduces one qualifier of it, and the parts of that qualifier are + // separated by dots. A second colon would mean a nested grammar. + const everySpelling = [ + COMPONENT_ATTRIBUTE, + MOUNT_ATTRIBUTE, + REF_ATTRIBUTE, + namespacedAttribute(COMPONENT_ATTRIBUTE, 's'), + optionAttributeFor('columns'), + namespacedAttribute(optionAttributeFor('columns'), 's'), + negatedOptionAttributeFor('trapFocus'), + namespacedAttribute(negatedOptionAttributeFor('trapFocus'), 's'), + // ui's namespaces answer to the same rule. + 'data-on:click.prevent.stop', + 'data-track:view.once', + 'data-bind:prop.value', + ]; + + for (const attribute of everySpelling) { + expect(attribute.split(QUALIFIER_SEPARATOR).length).toBeLessThanOrEqual(2); + } + }); + + it('reads a qualifier off a name, and only a qualified one', () => { + expect(namespaceQualifier('data-on', 'data-on:click.prevent')).toBe('click.prevent'); + expect(namespaceQualifier('data-option-columns', 'data-option-columns:s')).toBe('s'); + // The bare namespace declares nothing, so it carries no qualifier. + expect(namespaceQualifier('data-on', 'data-on')).toBeNull(); + expect(namespaceQualifier('data-on', 'data-once:click')).toBeNull(); + }); + + it('tells belonging to a namespace from carrying a qualifier', () => { + // `isInNamespace` answers the observer's question — is this name mine — + // where `namespaceQualifier` answers the binder's: what does it declare. + expect(isInNamespace('data-component', 'data-component')).toBe(true); + expect(isInNamespace('data-component', 'data-component:s')).toBe(true); + expect(isInNamespace('data-component', 'data-components')).toBe(false); + expect(isInNamespace('data-component', null)).toBe(false); + }); + + it('reads the head of a qualifier and nothing past it', () => { + expect(qualifierHead('click.prevent.stop')).toBe('click'); + expect(qualifierHead('prop.value')).toBe('prop'); + expect(qualifierHead('s')).toBe('s'); + // A name with its own dots stays one part as far as the head is concerned. + expect(qualifierHead('style.--custom-prop')).toBe('style'); + }); +}); + describe('optionAttributeFor', () => { it('kebab-cases the declared name', () => { expect(optionAttributeFor('columnCount')).toBe('data-option-column-count'); @@ -39,7 +97,7 @@ describe('optionAttributeFor', () => { describe('isOptionAttribute', () => { it('accepts an option at any breakpoint', () => { expect(isOptionAttribute('data-option-columns')).toBe(true); - expect(isOptionAttribute(`data-option-columns${RESPONSIVE_SEPARATOR}s`)).toBe(true); + expect(isOptionAttribute(`data-option-columns${QUALIFIER_SEPARATOR}s`)).toBe(true); }); it('rejects a near miss and an absent name', () => { @@ -52,7 +110,7 @@ describe('isOptionAttribute', () => { describe('isComponentAttribute', () => { it('accepts the plain declaration and its breakpoint-scoped spellings', () => { expect(isComponentAttribute('data-component')).toBe(true); - expect(isComponentAttribute(`data-component${RESPONSIVE_SEPARATOR}s`)).toBe(true); + expect(isComponentAttribute(`data-component${QUALIFIER_SEPARATOR}s`)).toBe(true); }); it('rejects a name which only starts like one', () => { diff --git a/packages/v4/src/attributes.ts b/packages/v4/src/attributes.ts index bef1a62b0..8cc2d0dbd 100644 --- a/packages/v4/src/attributes.ts +++ b/packages/v4/src/attributes.ts @@ -2,6 +2,25 @@ * How the framework spells its own attributes, and how it reads a batch of * writes to them. * + * One shape covers every attribute the framework reads: + * + * ``` + * data-[:[.…]] + * ``` + * + * A namespace is a whole name, and it is one of two kinds. A **fixed** one is + * written here or by whoever owns it — `data-component`, `data-mount`, + * `data-ref`, and in ui `data-on`, `data-track`, `data-bind`. A **generated** + * one comes from a declaration: every declared option owns the namespace + * {@link optionAttributeFor} builds for it, so `columns` owns + * `data-option-columns`. The kind decides the mechanism — a generated + * namespace's whole set of names is enumerable, so it is registered with the + * one observer, while a fixed namespace with open qualifiers can only be + * watched per element. See DESIGN.md §3. + * + * Both kinds take exactly one {@link QUALIFIER_SEPARATOR}, and neither reads + * past it: a qualifier's parts belong to whoever declared the namespace. + * * This module is deliberately a **leaf**: it imports nothing from core. The * mutation engine, the registry and `Base` all need the same names, and the * modules which used to own them sit downstream of the engine — the engine @@ -23,14 +42,63 @@ export const MOUNT_ATTRIBUTE = 'data-mount'; /** Names an element as a ref of the component which owns it. */ export const REF_ATTRIBUTE = 'data-ref'; -/** Every declared option is `data-option-` plus its kebab-cased name. */ +/** + * Every declared option is `data-option-` plus its kebab-cased name. + * + * This is a **namespace family**, not a namespace: the name after it is the + * option's own, so `columns` owns `data-option-columns` — see the grammar + * below. + */ export const OPTION_ATTRIBUTE_PREFIX = 'data-option-'; /** - * Separates an attribute from its breakpoint. A colon can never appear in a - * kebab-cased name, so `data-option-columns-s` stays unambiguous. + * Introduces one qualifier of the namespace on its left, and there is **at most + * one of these in an attribute the framework reads**. + * + * A colon can never appear in a kebab-cased name, so `data-option-columns:s` + * stays unambiguous where `data-option-columns-s` would not. + */ +export const QUALIFIER_SEPARATOR = ':'; + +/** + * Splits a qualifier into its parts. Core never reads them: what a part means + * belongs to whoever owns the namespace — a modifier for `data-on:click.prevent`, + * a property name for `data-bind:prop.value`. */ -export const RESPONSIVE_SEPARATOR = ':'; +export const PART_SEPARATOR = '.'; + +/** One qualified spelling: `data-on` + `click.prevent` → `data-on:click.prevent`. */ +export function namespacedAttribute(namespace: string, qualifier: string): string { + return `${namespace}${QUALIFIER_SEPARATOR}${qualifier}`; +} + +/** + * Whether a name belongs to a namespace, plainly or qualified. `data-component` + * and `data-component:s` both answer yes; `data-components` does not. + */ +export function isInNamespace(namespace: string, name: string | null): name is string { + return name === namespace || name?.startsWith(`${namespace}${QUALIFIER_SEPARATOR}`) === true; +} + +/** + * The qualifier a name carries within a namespace, or `null` when the name is + * outside it — the bare namespace included, since a namespace with nothing + * after the colon declares nothing. + */ +export function namespaceQualifier(namespace: string, name: string): string | null { + const prefix = `${namespace}${QUALIFIER_SEPARATOR}`; + return name.startsWith(prefix) ? name.slice(prefix.length) : null; +} + +/** + * The part of a qualifier which names the member: `click` of `click.prevent`, + * `prop` of `prop.value`. The one piece of a qualifier core reads, and only to + * check it against a vocabulary its owner declared. + */ +export function qualifierHead(qualifier: string): string { + const index = qualifier.indexOf(PART_SEPARATOR); + return index === -1 ? qualifier : qualifier.slice(0, index); +} /** * The names every page observes, before a single component declares an option. @@ -74,10 +142,7 @@ export function isOptionAttribute(attribute: string | null): attribute is string /** Whether a name declares components, plainly or scoped to a breakpoint. */ export function isComponentAttribute(attribute: string | null): attribute is string { - return ( - attribute === COMPONENT_ATTRIBUTE || - attribute?.startsWith(`${COMPONENT_ATTRIBUTE}${RESPONSIVE_SEPARATOR}`) === true - ); + return isInNamespace(COMPONENT_ATTRIBUTE, attribute); } /** diff --git a/packages/v4/src/component-declarations.ts b/packages/v4/src/component-declarations.ts index ff6bba2ba..4f9c755b8 100644 --- a/packages/v4/src/component-declarations.ts +++ b/packages/v4/src/component-declarations.ts @@ -1,4 +1,4 @@ -import { COMPONENT_ATTRIBUTE, RESPONSIVE_SEPARATOR } from './attributes.js'; +import { COMPONENT_ATTRIBUTE, QUALIFIER_SEPARATOR } from './attributes.js'; import { activeBreakpoint, responsiveAttributeNames, @@ -25,7 +25,7 @@ export function componentTokens(el: Element): Set { /** Whether an element carries any component spelling, including an invalid suffix. */ export function hasComponentAttribute(el: Element): boolean { - const prefix = `${COMPONENT_ATTRIBUTE}${RESPONSIVE_SEPARATOR}`; + const prefix = `${COMPONENT_ATTRIBUTE}${QUALIFIER_SEPARATOR}`; return el .getAttributeNames() .some((attribute) => attribute === COMPONENT_ATTRIBUTE || attribute.startsWith(prefix)); diff --git a/packages/v4/src/responsive-options.ts b/packages/v4/src/responsive-options.ts index f8da7e45c..5db89c5ed 100644 --- a/packages/v4/src/responsive-options.ts +++ b/packages/v4/src/responsive-options.ts @@ -3,7 +3,7 @@ * * Values are resolved on read and are not stored. */ -import { NEGATED_RAW, RESPONSIVE_SEPARATOR } from './attributes.js'; +import { NEGATED_RAW, namespaceQualifier, namespacedAttribute } from './attributes.js'; import { warnOnce } from './diagnostics.js'; import { registerDOMOptionAttributes, replaceDOMOptionAttributes } from './dom-mutations.js'; import { breakpointNames, onBreakpointsReplaced, useBreakpoint } from './services/breakpoint.js'; @@ -22,7 +22,7 @@ const responsiveOptionsState = /* @__PURE__ */ getSharedRuntimeSlot ({ scopedAttributes: memo((attribute: string): readonly string[] => - breakpointNames().map((name) => `${attribute}${RESPONSIVE_SEPARATOR}${name}`), + breakpointNames().map((name) => namespacedAttribute(attribute, name)), ), observed: new Set(), isReplacementListenerAttached: false, @@ -61,11 +61,6 @@ export function activeBreakpoint(): string { return useBreakpoint().props().name; } -/** Whether an attribute name belongs to this option at any breakpoint. */ -export function isResponsiveAttribute(attribute: string, name: string): boolean { - return name === attribute || name.startsWith(`${attribute}${RESPONSIVE_SEPARATOR}`); -} - /** * Resolve a raw option value by cascading from a breakpoint to the base attribute. * @@ -142,8 +137,10 @@ export function checkResponsiveAttributes(el: HTMLElement, attributes: readonly const names = breakpointNames(); for (const name of el.getAttributeNames()) { for (const attribute of attributes) { - const prefix = `${attribute}${RESPONSIVE_SEPARATOR}`; - if (name.startsWith(prefix) && !names.includes(name.slice(prefix.length))) { + // The qualifier of a generated namespace is one breakpoint and nothing + // else, so an unknown one is a typo rather than a part core cannot read. + const qualifier = namespaceQualifier(attribute, name); + if (qualifier !== null && !names.includes(qualifier)) { warnOnce( el, name, From 4528517a20ba527d2c6918862b09aa0fdd95b15b Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 11:47:56 +0200 Subject: [PATCH 2/6] feat(v4): one attribute-namespace primitive, consumed by three families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/migration/Action/Action.ts | 66 +++--- packages/v4/migration/Data/DataBind.spec.ts | 63 ++++++ packages/v4/migration/Data/DataBind.ts | 108 ++++++--- packages/v4/migration/Track/AbstractTrack.ts | 84 ++++--- packages/v4/package.json | 8 + packages/v4/src/attribute-namespaces.spec.ts | 205 ++++++++++++++++++ packages/v4/src/attribute-namespaces.ts | 149 +++++++++++++ packages/v4/src/diagnostic-contract.ts | 5 + packages/v4/src/diagnostics.spec.ts | 2 + packages/v4/src/exports.spec.ts | 19 +- packages/v4/src/index.ts | 8 +- .../v4/src/subpaths/namespaceQualifier.ts | 1 + .../src/subpaths/watchAttributeNamespace.ts | 1 + scripts/lib/subpath-exports.js | 12 + 14 files changed, 620 insertions(+), 111 deletions(-) create mode 100644 packages/v4/src/attribute-namespaces.spec.ts create mode 100644 packages/v4/src/attribute-namespaces.ts create mode 100644 packages/v4/src/subpaths/namespaceQualifier.ts create mode 100644 packages/v4/src/subpaths/watchAttributeNamespace.ts diff --git a/packages/v4/migration/Action/Action.ts b/packages/v4/migration/Action/Action.ts index f20b8403f..a7e169de9 100644 --- a/packages/v4/migration/Action/Action.ts +++ b/packages/v4/migration/Action/Action.ts @@ -1,17 +1,19 @@ import { Base, - watchAttributes, + namespaceQualifier, + watchAttributeNamespace, type BaseConfig, type BaseProps, type MountedReturn, } from '../../src/index.js'; import { ActionEvent } from './ActionEvent.js'; -/** The required prefix for virtual `on:[.]` options. */ -const ON_ATTRIBUTE_PREFIX = 'data-on:'; - -/** Binding key that cannot collide with hyphenated attribute names. */ -const OPTION_BINDING_KEY = 'options'; +/** + * The namespace of the virtual `on:[.]` declarations. Its + * qualifiers are any DOM event, so the set of names is open and the namespace + * is watched per element rather than registered. + */ +const ON_NAMESPACE = 'data-on'; export type ActionProps = BaseProps & { $options: { @@ -38,8 +40,8 @@ export class Action extends Base { }, }; - /** Live bindings by the key that produced them, each holding its release. */ - #bindings = new Map void>(); + /** The release of the binding built from the `on`/`target`/`effect` triple. */ + #releaseOptionBinding?: () => void; /** The `on`/`target`/`effect` values the option binding was built from. */ #optionSignature: string | null = null; @@ -67,23 +69,18 @@ export class Action extends Base { } mounted(): MountedReturn { - // Initial option hooks bind the option triple before `mounted()`. - for (const { name, value } of Array.from(this.$el.attributes)) { - this.#bind(name, this.#parseAttribute(name, value)); - } - - const stopWatchingAttributes = watchAttributes(this.$el, ({ name, value }) => { - if (name.startsWith(ON_ATTRIBUTE_PREFIX)) { - this.#bind(name, this.#parseAttribute(name, value)); - } - }); + // The option triple is bound by the option hooks, which run before + // `mounted()`; the namespace owns the attribute half. + const stopWatchingNamespace = watchAttributeNamespace( + this.$el, + ON_NAMESPACE, + ({ qualifier, value }) => new ActionEvent(this, qualifier, value).attach(), + ); return () => { - stopWatchingAttributes(); - for (const release of this.#bindings.values()) { - release(); - } - this.#bindings.clear(); + stopWatchingNamespace(); + this.#releaseOptionBinding?.(); + this.#releaseOptionBinding = undefined; this.#optionSignature = null; }; } @@ -102,10 +99,11 @@ export class Action extends Base { /** One `data-on:` attribute, or `null` for anything else. */ #parseAttribute(name: string, value: string | null): ActionEvent | null { - if (!name.startsWith(ON_ATTRIBUTE_PREFIX) || value === null) { + const qualifier = namespaceQualifier(ON_NAMESPACE, name); + if (qualifier === null || value === null) { return null; } - return new ActionEvent(this, name.slice(ON_ATTRIBUTE_PREFIX.length), value); + return new ActionEvent(this, qualifier, value); } /** The `on`/`target`/`effect` triple, or `null` when no effect is set. */ @@ -118,23 +116,19 @@ export class Action extends Base { return new ActionEvent(this, on, definition); } + /** + * The one binding the namespace cannot own: it is derived from three + * independently reported options rather than from one attribute, so it needs + * its own release and its own change test. + */ #bindOptions(): void { const { on, target, effect } = this.$options; - // Three independently reported options produce one binding. const signature = JSON.stringify([on, target, effect]); if (signature === this.#optionSignature) { return; } this.#optionSignature = signature; - this.#bind(OPTION_BINDING_KEY, this.#parseOptions()); - } - - /** Replace one keyed binding, releasing the previous listener first. */ - #bind(key: string, actionEvent: ActionEvent | null): void { - this.#bindings.get(key)?.(); - this.#bindings.delete(key); - if (actionEvent) { - this.#bindings.set(key, actionEvent.attach()); - } + this.#releaseOptionBinding?.(); + this.#releaseOptionBinding = this.#parseOptions()?.attach(); } } diff --git a/packages/v4/migration/Data/DataBind.spec.ts b/packages/v4/migration/Data/DataBind.spec.ts index 8c0e07ace..63771dcf1 100644 --- a/packages/v4/migration/Data/DataBind.spec.ts +++ b/packages/v4/migration/Data/DataBind.spec.ts @@ -226,6 +226,69 @@ describe('DataBind — the element half', () => { expect(el(root, '#b').getAttribute('aria-expanded')).toBe('false'); }); + it('follows a virtual binding rewritten in place', async () => { + const root = await render(` +
+ `); + + const bind = at(root, '#d', 'DataBind'); + bind.set('one'); + expect(el(root, '#d').textContent).toBe('was: one'); + + // The bindings used to be memoised on first read, so an attribute a morph + // or a `data-bind:if` template rewrote kept its first parse forever. The + // rewrite now applies the value already in force, with no `set()` needed. + el(root, '#d').setAttribute('data-bind:text', '`now: ${value}`'); + await settle(); + expect(el(root, '#d').textContent).toBe('now: one'); + + bind.set('two'); + expect(el(root, '#d').textContent).toBe('now: two'); + }); + + it('picks up a virtual binding added after mount, and drops a removed one', async () => { + const root = await render(` +
+ `); + + const bind = at(root, '#d', 'DataBind'); + const div = el(root, '#d'); + bind.set('on'); + expect(div.textContent).toBe('on'); + + div.setAttribute('data-bind:class.is-active', 'value === "on"'); + await settle(); + expect(div.classList.contains('is-active')).toBe(true); + + div.removeAttribute('data-bind:class.is-active'); + await settle(); + bind.set('off'); + // The removed declaration stops being applied; what it wrote is left alone, + // as a binding the element no longer declares has nothing to say about it. + expect(div.textContent).toBe('off'); + expect(div.classList.contains('is-active')).toBe(true); + }); + + it('warns for a binding type that names nothing', async () => { + const details: string[] = []; + document.addEventListener(EVENTS.diagnostic, (event) => { + const { detail } = event as CustomEvent<{ code: string; message: string }>; + details.push(detail.code); + event.preventDefault(); + }); + + const root = await render(` +
+ `); + + // The typo used to be an attribute that silently did nothing at all. + expect(details).toContain('attribute.unknown-qualifier'); + expect(at(root, '#d', 'DataBind').hasVirtualBindings).toBe(false); + }); + it('fails quietly when a virtual expression throws', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const root = await render(` diff --git a/packages/v4/migration/Data/DataBind.ts b/packages/v4/migration/Data/DataBind.ts index 47803a0ea..137c31627 100644 --- a/packages/v4/migration/Data/DataBind.ts +++ b/packages/v4/migration/Data/DataBind.ts @@ -3,6 +3,7 @@ import { defaultScheduler, domUpdate, subscribeContext, + watchAttributeNamespace, type BaseConfig, type BaseProps, } from '../../src/index.js'; @@ -42,9 +43,31 @@ function warn(...args: unknown[]): void { console.warn('[data]', ...args); } +/** + * The namespace a virtual binding is declared by. Its qualifier head is finite + * — the six binding types — while the name a `prop`, `attr`, `class` or `style` + * binding carries after the dot is open, so the set of names is **not** + * enumerable and the namespace is watched rather than registered. The finite + * head is still worth declaring: it is what turns `data-bind:prpo.value` into a + * warning instead of an attribute silently doing nothing. + */ +const BIND_NAMESPACE = 'data-bind'; + +/** The qualifier heads that take no name. */ +const SIMPLE_BINDINGS = ['text', 'if'] as const; + +/** The qualifier heads that name what they write to. */ +const NAMED_BINDINGS = ['prop', 'attr', 'class', 'style'] as const; + +const BIND_QUALIFIERS = [...SIMPLE_BINDINGS, ...NAMED_BINDINGS]; + +type SimpleBinding = (typeof SIMPLE_BINDINGS)[number]; + +type NamedBinding = (typeof NAMED_BINDINGS)[number]; + type VirtualBinding = - | { type: 'text' | 'if'; expression: string } - | { type: 'prop' | 'attr' | 'class' | 'style'; name: string; expression: string }; + | { type: SimpleBinding; expression: string } + | { type: NamedBinding; name: string; expression: string }; /** A two-way binding between an element and a named data group. */ /** @@ -75,7 +98,13 @@ export class DataBind /** Undoes `#connect()`. `undefined` while disconnected. @private */ #leaveGroup?: () => void; - #virtualBindings?: VirtualBinding[]; + /** + * Live bindings by the attribute that declared them. Kept in step with the + * element rather than memoised: a `data-bind:*` rewritten in place used to + * keep its first parse forever, which is the bug `watchAttributeNamespace()` + * exists to remove. + */ + #virtualBindings = new Map(); #virtualValue?: DataValue; @@ -180,35 +209,30 @@ export class DataBind } get virtualBindings(): VirtualBinding[] { - if (!this.#virtualBindings) { - this.#virtualBindings = []; - - for (const attribute of this.$el.attributes) { - const simpleMatch = /^data-bind:(text|if)$/.exec(attribute.name); - if (simpleMatch) { - this.#virtualBindings.push({ - type: simpleMatch[1] as 'text' | 'if', - expression: attribute.value, - }); - continue; - } - - const match = /^data-bind:(prop|attr|class|style)\.(.+)$/.exec(attribute.name); - if (match) { - this.#virtualBindings.push({ - type: match[1] as 'prop' | 'attr' | 'class' | 'style', - name: match[2], - expression: attribute.value, - }); - } - } - } - - return this.#virtualBindings; + return [...this.#virtualBindings.values()]; } get hasVirtualBindings(): boolean { - return this.virtualBindings.length > 0; + return this.#virtualBindings.size > 0; + } + + /** + * One `data-bind:[.]` qualifier. The head is validated by the + * namespace, so what is left here is the grammar's own rule: the first part + * names what a named binding writes to, and a head which takes no name + * carries no part. + * @private + */ + #parseQualifier(qualifier: string, expression: string): VirtualBinding | undefined { + const separator = qualifier.indexOf('.'); + const head = separator === -1 ? qualifier : qualifier.slice(0, separator); + const name = separator === -1 ? '' : qualifier.slice(separator + 1); + + if ((SIMPLE_BINDINGS as readonly string[]).includes(head)) { + return name ? undefined : { type: head as SimpleBinding, expression }; + } + + return name ? { type: head as NamedBinding, name, expression } : undefined; } get value(): DataValue { @@ -510,6 +534,27 @@ export class DataBind /** Follow the nearest registry; create the required root registry as fallback. */ mounted(): () => void { + // Before the registry: `dataKey`, `prop` and `get()` all branch on whether + // this element has virtual bindings, and `#connect()` reads them. + const stopWatchingNamespace = watchAttributeNamespace( + this.$el, + BIND_NAMESPACE, + ({ qualifier, value, attribute }) => { + const binding = this.#parseQualifier(qualifier, value); + if (!binding) { + return undefined; + } + this.#virtualBindings.set(attribute, binding); + // A rewritten declaration applies the value already in force. Nothing + // is in force during the initial scan, so the mount pays nothing. + if (this.#hasVirtualValue) { + this.#applyVirtualBindings(this.#virtualValue); + } + return () => this.#virtualBindings.delete(attribute); + }, + { qualifiers: BIND_QUALIFIERS, component: this.$config.name }, + ); + const unsubscribe = subscribeContext(this.$el, DataRegistryContext, (registry) => { this.#registry = registry; this.#connect(); @@ -527,6 +572,9 @@ export class DataBind if (!this.#registry) { resolveDataRegistry(this.$el); } - return unsubscribe; + return () => { + unsubscribe(); + stopWatchingNamespace(); + }; } } diff --git a/packages/v4/migration/Track/AbstractTrack.ts b/packages/v4/migration/Track/AbstractTrack.ts index eaece6ba3..c310d992e 100644 --- a/packages/v4/migration/Track/AbstractTrack.ts +++ b/packages/v4/migration/Track/AbstractTrack.ts @@ -1,20 +1,23 @@ import { Base, defaultScheduler, - watchAttributes, + namespaceQualifier, + watchAttributeNamespace, type BaseConfig, type BaseProps, type MountedReturn, type ScheduledTask, - type Unsubscribe, } from '../../src/index.js'; import { deepmerge } from '../../src/utils/deepmerge.js'; import { TrackContext } from './TrackContext.js'; import { TRACK_PSEUDO_EVENTS, TrackEvent } from './TrackEvent.js'; import { warn } from './utils.js'; -/** The attribute prefix one `TrackEvent` is declared by. */ -const TRACK_ATTRIBUTE_PREFIX = 'data-track:'; +/** + * The namespace one `TrackEvent` is declared by. Its qualifiers are any DOM + * event plus the two pseudo-events, so the set of names is open. + */ +const TRACK_NAMESPACE = 'data-track'; export type AbstractTrackProps = BaseProps & { $refs: { @@ -65,9 +68,6 @@ export class AbstractTrack extends Base(); - /** The deferred `mounted` dispatches, cancelled if the cycle ends first. */ #deferred = new Set>(); @@ -153,22 +153,14 @@ export class AbstractTrack extends Base, event?: Event): void {} mounted(): MountedReturn { - for (const { name, value } of Array.from(this.$el.attributes)) { - this.#bind(name, this.#parseAttribute(name, value)); - } - - const stopWatchingAttributes = watchAttributes(this.$el, ({ name, value }) => { - if (name.startsWith(TRACK_ATTRIBUTE_PREFIX)) { - this.#bind(name, this.#parseAttribute(name, value)); - } - }); + const stopWatchingNamespace = watchAttributeNamespace( + this.$el, + TRACK_NAMESPACE, + ({ value, attribute }) => this.#bind(attribute, value), + ); return () => { - stopWatchingAttributes(); - for (const release of this.#bindings.values()) { - release(); - } - this.#bindings.clear(); + stopWatchingNamespace(); for (const task of this.#deferred) { task.cancel(); } @@ -180,42 +172,48 @@ export class AbstractTrack extends Base` attribute, or `null` for anything else. */ #parseAttribute(name: string, value: string | null): TrackEvent | null { - if (!name.startsWith(TRACK_ATTRIBUTE_PREFIX) || value === null) { + const qualifier = namespaceQualifier(TRACK_NAMESPACE, name); + if (qualifier === null || value === null) { return null; } try { - return new TrackEvent( - this, - name.slice(TRACK_ATTRIBUTE_PREFIX.length), - parseEventValue(value), - ); + return new TrackEvent(this, qualifier, parseEventValue(value)); } catch (error) { warn(`Invalid JSON in ${name}:`, error); return null; } } - /** Replace one keyed binding. */ - #bind(key: string, trackEvent: TrackEvent | null): void { - this.#bindings.get(key)?.(); - this.#bindings.delete(key); + /** Attach one declaration and return its release, or nothing if it is malformed. */ + #bind(attribute: string, value: string): (() => void) | undefined { + const trackEvent = this.#parseAttribute(attribute, value); if (!trackEvent) { - return; + return undefined; } - this.#bindings.set(key, trackEvent.attach()); - - if (trackEvent.event === TRACK_PSEUDO_EVENTS.MOUNTED) { - // Run after queued mounts and cancel if this mount cycle ends first. - const task = defaultScheduler.background(() => { - this.#deferred.delete(task); - if (this.$isMounted) { - trackEvent.trigger(); - } - }); - this.#deferred.add(task); + const release = trackEvent.attach(); + + if (trackEvent.event !== TRACK_PSEUDO_EVENTS.MOUNTED) { + return release; } + + // Run after queued mounts and cancel if this mount cycle ends first — or if + // the declaration is rewritten before the task runs, which is why the + // cancel belongs to this binding's release rather than to the mount's. + const task = defaultScheduler.background(() => { + this.#deferred.delete(task); + if (this.$isMounted) { + trackEvent.trigger(); + } + }); + this.#deferred.add(task); + + return () => { + this.#deferred.delete(task); + task.cancel(); + release(); + }; } } diff --git a/packages/v4/package.json b/packages/v4/package.json index 53b807807..bd98dedd0 100644 --- a/packages/v4/package.json +++ b/packages/v4/package.json @@ -20,10 +20,18 @@ "import": "./dist/utils/index.js" }, "./package.json": "./package.json", + "./watchAttributeNamespace": { + "types": "./dist/subpaths/watchAttributeNamespace.d.ts", + "import": "./dist/subpaths/watchAttributeNamespace.js" + }, "./MOUNT_ATTRIBUTE": { "types": "./dist/subpaths/MOUNT_ATTRIBUTE.d.ts", "import": "./dist/subpaths/MOUNT_ATTRIBUTE.js" }, + "./namespaceQualifier": { + "types": "./dist/subpaths/namespaceQualifier.d.ts", + "import": "./dist/subpaths/namespaceQualifier.js" + }, "./Base": { "types": "./dist/subpaths/Base.d.ts", "import": "./dist/subpaths/Base.js" diff --git a/packages/v4/src/attribute-namespaces.spec.ts b/packages/v4/src/attribute-namespaces.spec.ts new file mode 100644 index 000000000..1855ba169 --- /dev/null +++ b/packages/v4/src/attribute-namespaces.spec.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { watchAttributeNamespace } from './attribute-namespaces.js'; +import { EVENTS } from './events.js'; +import { resetDom, settle } from './test-utils.js'; +import type { ToolkitDiagnosticDetail } from './diagnostic-contract.js'; + +const cleanups = new Set<() => void>(); + +/** One element in the document, so the watcher has something to observe. */ +function element(html: string): HTMLElement { + const host = document.createElement('div'); + host.innerHTML = html; + const el = host.firstElementChild as HTMLElement; + document.body.append(host); + return el; +} + +/** A binder recording what it was handed, and what was released. */ +function recorder() { + const bound: string[] = []; + const released: string[] = []; + return { + bound, + released, + bind: ({ + qualifier, + value, + attribute, + }: { + qualifier: string; + value: string; + attribute: string; + }) => { + bound.push(`${attribute}|${qualifier}|${value}`); + return () => released.push(attribute); + }, + }; +} + +function watched( + el: Element, + namespace: string, + bind: Parameters[2], + options?: Parameters[3], +): () => void { + const cleanup = watchAttributeNamespace(el, namespace, bind, options); + cleanups.add(cleanup); + return cleanup; +} + +afterEach(async () => { + for (const cleanup of cleanups) { + cleanup(); + } + cleanups.clear(); + vi.restoreAllMocks(); + await resetDom(); +}); + +describe('watchAttributeNamespace', () => { + it('binds the declarations present on subscription, in document order', () => { + const el = element('
'); + const record = recorder(); + watched(el, 'data-on', record.bind); + + // `data-other` is on the element and outside the namespace, so it is not a + // declaration however the unfiltered observer reports it. + expect(record.bound).toEqual([ + 'data-on:click|click|a', + 'data-on:input.debounce|input.debounce|b', + ]); + }); + + it('ignores the bare namespace, which declares nothing', () => { + const el = element('
'); + const record = recorder(); + watched(el, 'data-on', record.bind); + + expect(record.bound).toEqual([]); + }); + + it('releases then rebinds one attribute rewritten in place', async () => { + const el = element('
'); + const record = recorder(); + watched(el, 'data-on', record.bind); + + el.setAttribute('data-on:click', 'second'); + await settle(); + + expect(record.bound).toEqual(['data-on:click|click|first', 'data-on:click|click|second']); + expect(record.released).toEqual(['data-on:click']); + }); + + it('releases an attribute that is removed, and binds one that is added', async () => { + const el = element('
'); + const record = recorder(); + watched(el, 'data-on', record.bind); + + el.removeAttribute('data-on:click'); + await settle(); + expect(record.released).toEqual(['data-on:click']); + + el.setAttribute('data-on:input', 'b'); + await settle(); + expect(record.bound).toEqual(['data-on:click|click|a', 'data-on:input|input|b']); + }); + + it('leaves the declaration order alone when one of them is rewritten', async () => { + const el = element('
'); + const order: string[] = []; + watched(el, 'data-on', ({ attribute }) => { + order.push(attribute); + return undefined; + }); + + // Rewriting the first must not move it behind the second: a `Map` keyed by + // attribute keeps the position a key already had, and consumers which apply + // their bindings in order depend on it. + el.setAttribute('data-on:click', 'c'); + await settle(); + + expect(order).toEqual(['data-on:click', 'data-on:input', 'data-on:click']); + }); + + it('releases every binding and stops watching on cleanup', async () => { + const el = element('
'); + const record = recorder(); + const cleanup = watched(el, 'data-on', record.bind); + + cleanup(); + expect(record.released).toEqual(['data-on:click', 'data-on:input']); + + el.setAttribute('data-on:click', 'c'); + await settle(); + expect(record.bound).toHaveLength(2); + + // The cleanup is idempotent, as `watchAttributes()`' is. + cleanup(); + expect(record.released).toEqual(['data-on:click', 'data-on:input']); + }); + + it('holds nothing for a declaration the binder refused', async () => { + const el = element('
'); + const released: string[] = []; + watched(el, 'data-on', ({ value, attribute }) => + value === 'bad' ? undefined : () => released.push(attribute), + ); + + el.setAttribute('data-on:click', 'good'); + await settle(); + el.setAttribute('data-on:click', 'bad'); + await settle(); + + // Only the one binding that was built is released. + expect(released).toEqual(['data-on:click']); + }); + + describe('a declared vocabulary', () => { + it('binds a known head and warns once for an unknown one', async () => { + const el = element('
'); + const details: ToolkitDiagnosticDetail[] = []; + document.addEventListener(EVENTS.diagnostic, (event) => { + details.push((event as CustomEvent).detail); + event.preventDefault(); + }); + const record = recorder(); + watched(el, 'data-bind', record.bind, { + qualifiers: ['text', 'if', 'prop'], + component: 'DataBind', + }); + + expect(record.bound).toEqual(['data-bind:text|text|a']); + expect(details).toHaveLength(1); + expect(details[0].code).toBe('attribute.unknown-qualifier'); + expect(details[0].severity).toBe('warning'); + expect(details[0].component).toBe('DataBind'); + expect(details[0].message).toContain('prpo'); + + // Once per element and per name, whatever the value is rewritten to. + el.setAttribute('data-bind:prpo.value', 'c'); + await settle(); + expect(details).toHaveLength(1); + }); + + it('validates the head only, so the name after the dot stays open', () => { + const el = element( + '
', + ); + const record = recorder(); + watched(el, 'data-bind', record.bind, { qualifiers: ['class', 'style', 'prop'] }); + + // A finite head does not make the whole name enumerable, which is why + // this namespace is watched rather than registered with the one observer. + expect(record.bound).toHaveLength(3); + }); + + it('binds anything when no vocabulary is declared', () => { + const el = element('
'); + const record = recorder(); + watched(el, 'data-on', record.bind); + + expect(record.bound).toHaveLength(2); + }); + }); +}); diff --git a/packages/v4/src/attribute-namespaces.ts b/packages/v4/src/attribute-namespaces.ts new file mode 100644 index 000000000..32f9afca8 --- /dev/null +++ b/packages/v4/src/attribute-namespaces.ts @@ -0,0 +1,149 @@ +/** + * The mechanism half of the attribute grammar: one namespace, watched on one + * element, rebound whenever one of its attributes changes. + * + * `attributes.ts` owns the grammar — what a namespace is, where the colon goes, + * and that a qualifier's parts belong to whoever declared the namespace. This + * module owns the other half of the split: **core decides when to re-parse and + * how the attribute is observed, and the caller decides what the string means.** + * + * Which mechanism a namespace gets follows from whether its whole set of names + * is enumerable, not from whether its qualifier vocabulary is finite. A + * declared option's names are `attribute × breakpoint` — finite, so they are + * registered with the one document observer and no second observer exists. A + * namespace whose names cannot be listed in advance is this module's case: + * `data-on:`, and `data-bind:class.`, whose + * qualifier head is finite while its tail is not. See DESIGN.md §3. + */ + +import { namespaceQualifier, qualifierHead } from './attributes.js'; +import { warnOnce } from './diagnostics.js'; +import { watchAttributes } from './dom-mutations.js'; + +/** One declaration read off an element, as {@link watchAttributeNamespace} reports it. */ +export interface AttributeNamespaceDeclaration { + /** Everything after the colon: `click.prevent`, `prop.value`, `view.once`. */ + qualifier: string; + /** The attribute's value. Never `null` — an absent attribute releases instead. */ + value: string; + /** The whole attribute name, which is the key the binding is held under. */ + attribute: string; +} + +/** + * Build whatever one declaration means, and return its release. Returning + * nothing says the declaration produced no binding — a malformed value, say — + * and leaves nothing to release. + */ +export type AttributeNamespaceBinder = ( + declaration: AttributeNamespaceDeclaration, +) => (() => void) | void; + +export interface AttributeNamespaceOptions { + /** + * The qualifier heads this namespace declares. Given, a head outside the set + * warns once and binds nothing; omitted, the vocabulary is open and anything + * binds. This is independent of the mechanism: a finite head does not make a + * name enumerable, which is why `data-bind` is watched and validated at once. + */ + qualifiers?: readonly string[]; + /** The component name carried by the warning, when there is one. */ + component?: string; +} + +/** + * Bind every `:` attribute of one element, and keep the + * bindings in step with the element. + * + * The declarations present now are bound on subscription; afterwards each + * attribute of the namespace is its own binding, keyed by the name that + * produced it, so one `#bind` covers all three shapes a change can take — + * **added** attaches with nothing to release, **changed** releases then + * attaches, **removed** releases with nothing to attach. This is what a + * memoised parse cannot do, and rewriting an attribute in place is not + * hypothetical: `swap({ mode: 'morph' })` does it, and so does any + * `data-bind:` template around the element. + * + * @param el The element whose attributes are read and watched. + * @param namespace The namespace, without its colon: `data-on`, `data-track`. + * @param bind Called once per declaration; returns that binding's release. + * @param options An optional finite head vocabulary, and a component name. + * @returns An idempotent cleanup releasing every binding and stopping the watch. + */ +export function watchAttributeNamespace( + el: Element, + namespace: string, + bind: AttributeNamespaceBinder, + { qualifiers, component }: AttributeNamespaceOptions = {}, +): () => void { + /** Live bindings by the attribute that produced them, each holding its release. */ + const bindings = new Map void>(); + + 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. + bindings.get(attribute)?.(); + const qualifier = namespaceQualifier(namespace, attribute); + const release = + value === null || + qualifier === null || + !isDeclared(el, namespace, attribute, qualifier, qualifiers, component) + ? undefined + : bind({ qualifier, value, attribute }); + + if (release) { + // `set` on a key already there keeps its position, so the order the + // element declares its bindings in survives a rewrite. + bindings.set(attribute, release); + } else { + bindings.delete(attribute); + } + }; + + for (const { name, value } of Array.from(el.attributes)) { + rebind(name, value); + } + + // The watcher covers every attribute of the element, framework names + // included, so the namespace is what narrows it — and a removal keeps the + // name it was declared under, so one test covers all three shapes. + const stopWatchingAttributes = watchAttributes(el, ({ name, value }) => { + if (namespaceQualifier(namespace, name) !== null) { + rebind(name, value); + } + }); + + return () => { + stopWatchingAttributes(); + for (const release of bindings.values()) { + release(); + } + bindings.clear(); + }; +} + +/** Whether a qualifier names a member of the vocabulary, warning once if not. */ +function isDeclared( + el: Element, + namespace: string, + attribute: string, + qualifier: string, + qualifiers: readonly string[] | undefined, + component: string | undefined, +): boolean { + if (qualifiers === undefined) { + return true; + } + const head = qualifierHead(qualifier); + if (qualifiers.includes(head)) { + return true; + } + warnOnce( + el, + attribute, + 'attribute.unknown-qualifier', + `\`${attribute}\` declares nothing: \`${head}\` names no \`${namespace}\` binding — known names: ${qualifiers.join(', ')}.`, + { component, target: el }, + ); + return false; +} diff --git a/packages/v4/src/diagnostic-contract.ts b/packages/v4/src/diagnostic-contract.ts index 79142b30b..a37d9917f 100644 --- a/packages/v4/src/diagnostic-contract.ts +++ b/packages/v4/src/diagnostic-contract.ts @@ -1,3 +1,7 @@ +const attribute = Object.freeze({ + unknownQualifier: 'attribute.unknown-qualifier', +} as const); + const callback = Object.freeze({ signalFailed: 'callback.signal-failed', contextSubscriptionFailed: 'callback.context-subscription-failed', @@ -69,6 +73,7 @@ const storage = Object.freeze({ /** Stable codes carried by toolkit diagnostics. */ export const DIAGNOSTICS = Object.freeze({ + attribute, callback, component, event, diff --git a/packages/v4/src/diagnostics.spec.ts b/packages/v4/src/diagnostics.spec.ts index 1b4cc642c..d10d8e702 100644 --- a/packages/v4/src/diagnostics.spec.ts +++ b/packages/v4/src/diagnostics.spec.ts @@ -11,6 +11,7 @@ import { EVENTS } from './events.js'; describe('diagnostics', () => { it('exposes exact deeply frozen stable codes', () => { expect(DIAGNOSTICS).toEqual({ + attribute: { unknownQualifier: 'attribute.unknown-qualifier' }, callback: { signalFailed: 'callback.signal-failed', contextSubscriptionFailed: 'callback.context-subscription-failed', @@ -58,6 +59,7 @@ describe('diagnostics', () => { } expectTypeOf().toEqualTypeOf<'warning' | 'error'>(); expectTypeOf().toEqualTypeOf< + | 'attribute.unknown-qualifier' | 'callback.signal-failed' | 'callback.context-subscription-failed' | 'callback.context-teardown-failed' diff --git a/packages/v4/src/exports.spec.ts b/packages/v4/src/exports.spec.ts index c138c1937..706ad2866 100644 --- a/packages/v4/src/exports.spec.ts +++ b/packages/v4/src/exports.spec.ts @@ -18,7 +18,10 @@ import { useMutation, usePointer, useScrollProgress, + watchAttributeNamespace, watchAttributes, + type AttributeNamespaceBinder, + type AttributeNamespaceDeclaration, withDrag, withInView, withKey, @@ -96,6 +99,10 @@ import watchAttributesFromSubpath, { type AttributeChange as SubpathAttributeChange, type AttributeWatcher as SubpathAttributeWatcher, } from '@studiometa/js-toolkit-v4/watchAttributes'; +import watchAttributeNamespaceFromSubpath, { + watchAttributeNamespace as namedWatchAttributeNamespaceFromSubpath, + type AttributeNamespaceBinder as SubpathAttributeNamespaceBinder, +} from '@studiometa/js-toolkit-v4/watchAttributeNamespace'; import diagnosticsFromSubpath, { DIAGNOSTICS as namedDiagnosticsFromSubpath, } from '@studiometa/js-toolkit-v4/DIAGNOSTICS'; @@ -173,7 +180,7 @@ describe('the package entry points', () => { it('keeps the framework on the root entry, without the utils or removed exports', async () => { expect(typeof Base).toBe('function'); const root = (await import('@studiometa/js-toolkit-v4')) as Record; - expect(Object.keys(root)).toHaveLength(82); + expect(Object.keys(root)).toHaveLength(84); expect(root.clamp).toBeUndefined(); expect(root.smoothTo).toBeUndefined(); for (const removed of [ @@ -204,6 +211,16 @@ describe('the package entry points', () => { expectTypeOf().toEqualTypeOf<(change: AttributeChange) => void>(); }); + it('exports the attribute-namespace primitive from root and subpath, not Base', () => { + expect(watchAttributeNamespaceFromSubpath).toBe(watchAttributeNamespace); + expect(namedWatchAttributeNamespaceFromSubpath).toBe(watchAttributeNamespace); + expect(Base.prototype).not.toHaveProperty('$watchAttributeNamespace'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + (declaration: AttributeNamespaceDeclaration) => (() => void) | void + >(); + }); + it('exports the optional context subscription helper from root and subpath', () => { expect(subscribeContextFromSubpath).toBe(subscribeContext); expect(namedSubscribeContextFromSubpath).toBe(subscribeContext); diff --git a/packages/v4/src/index.ts b/packages/v4/src/index.ts index d8e1b6bb5..1a1e30fb8 100644 --- a/packages/v4/src/index.ts +++ b/packages/v4/src/index.ts @@ -1,4 +1,10 @@ -export { MOUNT_ATTRIBUTE } from './attributes.js'; +export { + watchAttributeNamespace, + type AttributeNamespaceBinder, + type AttributeNamespaceDeclaration, + type AttributeNamespaceOptions, +} from './attribute-namespaces.js'; +export { MOUNT_ATTRIBUTE, namespaceQualifier } from './attributes.js'; export { Base, type BaseConfig, diff --git a/packages/v4/src/subpaths/namespaceQualifier.ts b/packages/v4/src/subpaths/namespaceQualifier.ts new file mode 100644 index 000000000..c78fb4ee6 --- /dev/null +++ b/packages/v4/src/subpaths/namespaceQualifier.ts @@ -0,0 +1 @@ +export { namespaceQualifier, namespaceQualifier as default } from '../attributes.js'; diff --git a/packages/v4/src/subpaths/watchAttributeNamespace.ts b/packages/v4/src/subpaths/watchAttributeNamespace.ts new file mode 100644 index 000000000..497e7f044 --- /dev/null +++ b/packages/v4/src/subpaths/watchAttributeNamespace.ts @@ -0,0 +1 @@ +export { watchAttributeNamespace, watchAttributeNamespace as default, type AttributeNamespaceBinder, type AttributeNamespaceDeclaration, type AttributeNamespaceOptions } from '../attribute-namespaces.js'; diff --git a/scripts/lib/subpath-exports.js b/scripts/lib/subpath-exports.js index e356e114a..0cb90f1e2 100644 --- a/scripts/lib/subpath-exports.js +++ b/scripts/lib/subpath-exports.js @@ -180,6 +180,18 @@ const COMPANION_TYPES = new Map([ resolve(dirname(new URL(import.meta.url).pathname), '../../packages/v4/src/dom-mutations.ts'), new Map([['watchAttributes', ['AttributeChange', 'AttributeWatcher']]]), ], + [ + resolve( + dirname(new URL(import.meta.url).pathname), + '../../packages/v4/src/attribute-namespaces.ts', + ), + new Map([ + [ + 'watchAttributeNamespace', + ['AttributeNamespaceBinder', 'AttributeNamespaceDeclaration', 'AttributeNamespaceOptions'], + ], + ]), + ], ]); /** From 88d8d4ba43f5088f96f4010214abb8afaa68b6b7 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 11:48:06 +0200 Subject: [PATCH 3/6] refactor(v4): one modifier parser in ui, and one modifier vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/migration/Action/Action.spec.ts | 6 +- packages/v4/migration/Action/ActionEvent.ts | 37 +++---- packages/v4/migration/Action/index.ts | 2 +- packages/v4/migration/REPORT.md | 8 ++ .../v4/migration/Track/TrackEvent.spec.ts | 49 ++++++--- packages/v4/migration/Track/TrackEvent.ts | 76 ++++--------- packages/v4/migration/Track/index.ts | 4 - packages/v4/migration/event-modifiers.ts | 104 ++++++++++++++++++ packages/v4/migration/index.ts | 3 + 9 files changed, 189 insertions(+), 100 deletions(-) create mode 100644 packages/v4/migration/event-modifiers.ts diff --git a/packages/v4/migration/Action/Action.spec.ts b/packages/v4/migration/Action/Action.spec.ts index ab25e0bba..48996c4fc 100644 --- a/packages/v4/migration/Action/Action.spec.ts +++ b/packages/v4/migration/Action/Action.spec.ts @@ -90,11 +90,13 @@ describe('ActionEvent — parsing and the effect evaluator', () => { const plain = new ActionEvent(action, 'click.prevent.stop', 'target'); expect(plain.event).toBe('click'); - expect(plain.modifiers).toEqual(['prevent', 'stop']); + expect([...plain.modifiers]).toEqual(['prevent', 'stop']); + // A bare `debounce` reads 100 here, where `Track` reads 300 from the one + // shared parser: the delay is the family's, the vocabulary is not. expect(plain.debounceDelay).toBe(100); const debounced = new ActionEvent(action, 'scroll.debounce300', 'target'); - expect(debounced.modifiers).toEqual(['debounce']); + expect([...debounced.modifiers]).toEqual(['debounce']); expect(debounced.debounceDelay).toBe(300); }); diff --git a/packages/v4/migration/Action/ActionEvent.ts b/packages/v4/migration/Action/ActionEvent.ts index 6d8481b63..1fcfcab39 100644 --- a/packages/v4/migration/Action/ActionEvent.ts +++ b/packages/v4/migration/Action/ActionEvent.ts @@ -1,4 +1,5 @@ import { getInstances, type Base } from '../../src/index.js'; +import { MODIFIERS, parseEventDefinition, type Modifier } from '../event-modifiers.js'; import { getEffect, type EffectFunction } from './expression.js'; /** @@ -7,7 +8,8 @@ import { getEffect, type EffectFunction } from './expression.js'; */ const TARGET_REGEX = /([a-zA-Z]+)(\((.*)\))?/; -export type Modifier = 'prevent' | 'stop' | 'once' | 'passive' | 'capture' | 'debounce'; +/** What a bare `debounce` means here. `Track` reads the same modifier at 300. */ +const DEFAULT_DEBOUNCE_DELAY = 100; /** A resolved target: one entry, keyed by the component's name. */ export type ActionTarget = Record; @@ -18,7 +20,6 @@ function warn(...args: unknown[]): void { /** One runtime event binding from an attribute or the option triple. */ export class ActionEvent { - static modifierSeparator = '.'; static targetSeparator = ' '; static effectSeparator = '->'; @@ -28,9 +29,9 @@ export class ActionEvent { /** The event type to listen to. */ event: string; - modifiers: Modifier[]; + modifiers: ReadonlySet; - debounceDelay = 100; + debounceDelay: number; /** `Target Target(.selector)` — empty means "the action itself". */ targetDefinition: string; @@ -47,19 +48,11 @@ export class ActionEvent { */ constructor(action: Base, eventDefinition: string, effectDefinition: string) { this.action = action; - const [event, ...modifiers] = eventDefinition.split(ActionEvent.modifierSeparator); - this.event = event; - const processedModifiers: Modifier[] = []; - for (const modifier of modifiers) { - if (modifier.startsWith('debounce')) { - processedModifiers.push('debounce'); - this.debounceDelay = Number.parseInt(modifier.replace('debounce', '') || '100', 10); - } else { - processedModifiers.push(modifier as Modifier); - } - } - this.modifiers = processedModifiers; + const { event, modifiers, delay } = parseEventDefinition(eventDefinition); + this.event = event; + this.modifiers = modifiers; + this.debounceDelay = delay(MODIFIERS.DEBOUNCE) ?? DEFAULT_DEBOUNCE_DELAY; let effect = effectDefinition; let targetDefinition = ''; @@ -126,10 +119,10 @@ export class ActionEvent { handleEvent(event: Event): void { const { modifiers } = this; - if (modifiers.includes('prevent')) { + if (modifiers.has(MODIFIERS.PREVENT)) { event.preventDefault(); } - if (modifiers.includes('stop')) { + if (modifiers.has(MODIFIERS.STOP)) { event.stopPropagation(); } @@ -138,7 +131,7 @@ export class ActionEvent { const effect = getEffect(this.effectDefinition, [...instances.keys()]); const { targets } = this; - if (modifiers.includes('debounce')) { + if (modifiers.has(MODIFIERS.DEBOUNCE)) { clearTimeout(this.#debounceTimer); this.#debounceTimer = window.setTimeout(() => { this.executeEffect(targets, effect, event, instances); @@ -186,9 +179,9 @@ export class ActionEvent { attach(): () => void { const { modifiers } = this; const off = this.action.$on(this.event, (event) => this.handleEvent(event), { - capture: modifiers.includes('capture'), - once: modifiers.includes('once'), - passive: modifiers.includes('passive'), + capture: modifiers.has(MODIFIERS.CAPTURE), + once: modifiers.has(MODIFIERS.ONCE), + passive: modifiers.has(MODIFIERS.PASSIVE), }); return () => { diff --git a/packages/v4/migration/Action/index.ts b/packages/v4/migration/Action/index.ts index 8fb1c85db..55f14d153 100644 --- a/packages/v4/migration/Action/index.ts +++ b/packages/v4/migration/Action/index.ts @@ -4,6 +4,6 @@ */ export { Action, type ActionProps } from './Action.js'; -export { ActionEvent, type ActionTarget, type Modifier } from './ActionEvent.js'; +export { ActionEvent, type ActionTarget } from './ActionEvent.js'; export { EFFECT_ARGUMENTS, getEffect, type EffectFunction } from './expression.js'; export { Target } from './Target.js'; diff --git a/packages/v4/migration/REPORT.md b/packages/v4/migration/REPORT.md index 6713e1a9e..38a99daa8 100644 --- a/packages/v4/migration/REPORT.md +++ b/packages/v4/migration/REPORT.md @@ -827,6 +827,14 @@ Added on review of this round, and the first finding is the state it found: **no 43. **A frame subscriber runs in the read phase, and a write from it is silent.** `useRaf()` fans its subscribers out inside `defaultScheduler.read()`, so that a measurement can precede the writes of the same frame, and the service's own contract is that a callback **returns** the function which mutates — `RafHook.ticked?(props): void | RafRender`. DESIGN.md §7 states it. Two of the fifteen families broke it anyway, independently: `Cursor` wrote its transform straight from the frame hook, and `Carousel` read the wrapper's `scrollLeft` **and** wrote `--carousel-progress` in the same read phase, which is exactly the interleaving the phases exist to prevent. Nothing fails, nothing warns, and no spec can see it — the pixels are right, the layout work is not. Fixed in both: `Carousel.ticked()` returns its write, and `Cursor.render()` carries `@write`, which is the same thing for a component whose loop is now inside `smoothTo()`. An audit of the other thirteen families found no third case. **Ask:** a lint rule in `@studiometa/eslint-plugin-js-toolkit` — a DOM write inside `ticked()`, or inside a callback passed to a service `subscribe()`, is mechanically detectable, and it is the only kind of check that reaches a mistake no test can fail on. +44. **Four families had grown the same declarative attribute shape with four independent parsers, one of them core's. Ruled, and consolidated to one mechanism plus one vocabulary.** The shape is `data-[-]:[.…]` and the evidence that it was one shape rather than four similar ones was the duplication, not the syntax: `Action.mounted()` and `AbstractTrack.mounted()` were the same fifteen lines down to the justifying comments, written independently by two ports of two unrelated families, while `ActionEvent` and `TrackEvent` each split the same modifier vocabulary — Track's being Action's plus `throttle`, a superset rather than a variant. `Data` was the same family one generation behind, memoising its `data-bind:*` parse so an attribute rewritten in place kept its first reading forever, which is the exact bug `watchAttributes()` was built to fix and which the other two already consumed. Nobody could have unified them earlier: filter registration existed for options and `watchAttributes()` landed two rounds ago. + + **What had to be settled first is whether the separators mean one thing each**, because 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.** 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`). 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, while putting a colon on every option costs `dataset.optionColumns`, selector escaping, and a special character in every template. 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()` would stop being able to tell an undeclared option from an attribute that was never ours. + + **The ruling then decides the mechanism, which the "finite or open" framing could not.** A generated namespace is enumerable _because_ it comes from a declaration — its names are `attribute × breakpoint` — so it is registered in the one `attributeFilter` and costs no second observer, which is the argument gap 33 already made when it rejected `watchAttributes()` for responsive options. A fixed namespace with open qualifiers cannot be enumerated and is watched per element. **`data-bind` is the case that settles the axis**: its six binding types are finite while the class, property or attribute name after the dot is not, so a finite vocabulary does not imply an enumerable name, and validation is an independent capability rather than a consequence of the mechanism. So responsive options keep their own registration — one caller, whose cascade, negation and `setBreakpoints()` replacement generalise to nothing — and what core adds is the one mechanism the other three needed. + + **`watchAttributeNamespace(el, namespace, bind, options?)`** absorbs the identical block: 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. `Action` and `AbstractTrack` each lose their scan, their watcher, their `Map` 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, with three specs on it — and the 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. **`parseEventDefinition()` in `migration/event-modifiers.ts`** is the other half: one frozen `MODIFIERS` object, one parser, and the `Modifier`/`TrackModifier` barrel collision gone. The **defaults** are what actually differed between the two implementations, so the parser reports only the delay an author wrote and each family keeps its own fallback (`Action` debounces at 100, `Track` at 300), and an unknown modifier warns instead of being pushed through as if it were real. See DESIGN.md §3, "The attribute grammar", and RATIONALE.md. + ## What came out better | | v3 | v4 | diff --git a/packages/v4/migration/Track/TrackEvent.spec.ts b/packages/v4/migration/Track/TrackEvent.spec.ts index 8d73279a8..c382fb0e2 100644 --- a/packages/v4/migration/Track/TrackEvent.spec.ts +++ b/packages/v4/migration/Track/TrackEvent.spec.ts @@ -1,8 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registerComponents } from '../../src/index.js'; -import { resetDom, settle } from '../../src/test-utils.js'; +import { getInstance, resetDom, settle } from '../../src/test-utils.js'; +import { parseEventDefinition } from '../event-modifiers.js'; import { Track } from './Track.js'; -import { parseEventDefinition, resolveDetailPlaceholders } from './TrackEvent.js'; +import { resolveDetailPlaceholders } from './TrackEvent.js'; registerComponents(Track); @@ -32,21 +33,37 @@ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe('parseEventDefinition', () => { it('splits an event from its modifiers', () => { - expect(parseEventDefinition('click.prevent.stop')).toEqual({ - event: 'click', - modifiers: ['prevent', 'stop'], - debounceDelay: 0, - throttleDelay: 0, - }); + const { event, modifiers } = parseEventDefinition('click.prevent.stop'); + expect(event).toBe('click'); + expect([...modifiers]).toEqual(['prevent', 'stop']); }); - it('reads the delay out of a debounce or throttle modifier, with a default', () => { - expect(parseEventDefinition('input.debounce500')).toMatchObject({ - modifiers: ['debounce'], - debounceDelay: 500, - }); - expect(parseEventDefinition('input.debounce')).toMatchObject({ debounceDelay: 300 }); - expect(parseEventDefinition('scroll.throttle')).toMatchObject({ throttleDelay: 16 }); + it('reports only the delay an author wrote, leaving the fallback to the caller', () => { + expect(parseEventDefinition('input.debounce500').delay('debounce')).toBe(500); + // A bare modifier carries no number, so each family keeps its own default: + // `Track` reads 300 here and `Action` reads 100 from the same modifier. + expect(parseEventDefinition('input.debounce').delay('debounce')).toBeUndefined(); + expect(parseEventDefinition('scroll.throttle').delay('throttle')).toBeUndefined(); + expect(parseEventDefinition('scroll.throttle200').delay('throttle')).toBe(200); + expect(parseEventDefinition('click.prevent').delay('debounce')).toBeUndefined(); + }); + + it('warns for a modifier that names nothing instead of binding it', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { event, modifiers } = parseEventDefinition('click.prevnet.stop'); + expect(event).toBe('click'); + // The typo is dropped; the modifiers that parsed still apply. + expect([...modifiers]).toEqual(['stop']); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0].join(' ')).toContain('prevnet'); + spy.mockRestore(); + }); + + it('applies the family default through the bound declaration', async () => { + const el = await render('
'); + const [trackEvent] = getInstance(el, 'Track').trackEvents; + expect(trackEvent.debounceDelay).toBe(300); + expect(trackEvent.throttleDelay).toBe(16); }); }); diff --git a/packages/v4/migration/Track/TrackEvent.ts b/packages/v4/migration/Track/TrackEvent.ts index 658189a68..4784c4812 100644 --- a/packages/v4/migration/Track/TrackEvent.ts +++ b/packages/v4/migration/Track/TrackEvent.ts @@ -1,24 +1,14 @@ import { useInView } from '../../src/index.js'; import type { Unsubscribe } from '../../src/index.js'; import { throttle } from '../../src/utils/timing.js'; +import { MODIFIERS, parseEventDefinition, type Modifier } from '../event-modifiers.js'; import type { AbstractTrack } from './AbstractTrack.js'; -export type Modifier = - | 'prevent' - | 'stop' - | 'once' - | 'passive' - | 'capture' - | 'debounce' - | 'throttle' - | 'detail'; - -export interface ParsedEvent { - event: string; - modifiers: Modifier[]; - debounceDelay: number; - throttleDelay: number; -} +/** What a bare `debounce` means here. `Action` reads the same modifier at 100. */ +const DEFAULT_DEBOUNCE_DELAY = 300; + +/** What a bare `throttle` means: about one frame. */ +const DEFAULT_THROTTLE_DELAY = 16; /** Synthetic event names that do not map to DOM events. */ export const TRACK_PSEUDO_EVENTS = { @@ -30,29 +20,6 @@ export const TRACK_PSEUDO_EVENTS = { export type TrackPseudoEvent = (typeof TRACK_PSEUDO_EVENTS)[keyof typeof TRACK_PSEUDO_EVENTS]; -/** Parse definitions such as `click.prevent.stop` or `input.debounce500`. */ -export function parseEventDefinition(eventDefinition: string): ParsedEvent { - const [event, ...rawModifiers] = eventDefinition.split('.'); - - let debounceDelay = 0; - let throttleDelay = 0; - const modifiers: Modifier[] = []; - - for (const mod of rawModifiers) { - if (mod.startsWith('debounce')) { - modifiers.push('debounce'); - debounceDelay = Number.parseInt(mod.replace('debounce', '') || '300', 10); - } else if (mod.startsWith('throttle')) { - modifiers.push('throttle'); - throttleDelay = Number.parseInt(mod.replace('throttle', '') || '16', 10); - } else { - modifiers.push(mod as Modifier); - } - } - - return { event, modifiers, debounceDelay, throttleDelay }; -} - /** * Resolve `$detail.*` placeholders in an arbitrary value, descending into both * objects and arrays so nested payload placeholders are resolved too. @@ -99,7 +66,7 @@ function getNestedValue(obj: Record, path: string): unknown { export class TrackEvent { track: AbstractTrack; event: string; - modifiers: Modifier[]; + modifiers: ReadonlySet; data: Record; debounceDelay: number; throttleDelay: number; @@ -116,23 +83,22 @@ export class TrackEvent { this.track = track; this.data = data; - const { event, modifiers, debounceDelay, throttleDelay } = - parseEventDefinition(eventDefinition); + const { event, modifiers, delay } = parseEventDefinition(eventDefinition); this.event = event; this.modifiers = modifiers; - this.debounceDelay = debounceDelay; - this.throttleDelay = throttleDelay; + this.debounceDelay = delay(MODIFIERS.DEBOUNCE) ?? DEFAULT_DEBOUNCE_DELAY; + this.throttleDelay = delay(MODIFIERS.THROTTLE) ?? DEFAULT_THROTTLE_DELAY; // Own the debounce timer so release can cancel it. const dispatch = (domEvent?: Event) => this.handleEvent(domEvent); - if (modifiers.includes('debounce')) { + if (modifiers.has(MODIFIERS.DEBOUNCE)) { this.#handler = (domEvent?: Event) => { clearTimeout(this.#debounceTimer); - this.#debounceTimer = setTimeout(() => dispatch(domEvent), debounceDelay); + this.#debounceTimer = setTimeout(() => dispatch(domEvent), this.debounceDelay); }; - } else if (modifiers.includes('throttle')) { - this.#handler = throttle(dispatch, throttleDelay); + } else if (modifiers.has(MODIFIERS.THROTTLE)) { + this.#handler = throttle(dispatch, this.throttleDelay); } else { this.#handler = dispatch; } @@ -149,11 +115,11 @@ export class TrackEvent { return; } - if (event && modifiers.includes('prevent')) { + if (event && modifiers.has(MODIFIERS.PREVENT)) { event.preventDefault(); } - if (event && modifiers.includes('stop')) { + if (event && modifiers.has(MODIFIERS.STOP)) { event.stopPropagation(); } @@ -166,7 +132,7 @@ export class TrackEvent { ? (event.detail as Record) : {}; - finalData = modifiers.includes('detail') + finalData = modifiers.has(MODIFIERS.DETAIL) ? { ...data, ...detail } : resolveDetailPlaceholders(data, detail); } @@ -214,7 +180,7 @@ export class TrackEvent { // make an impression unreachable for an element taller than the // viewport, whose ratio can never approach a non-zero threshold. this.#handler(); - if (modifiers.includes('once')) { + if (modifiers.has(MODIFIERS.ONCE)) { // Hoisted because the callback can run during `subscribe()`. unsubscribe?.(); unsubscribe = undefined; @@ -225,9 +191,9 @@ export class TrackEvent { } return track.$on(event, this.#handler, { - capture: modifiers.includes('capture'), - once: modifiers.includes('once'), - passive: modifiers.includes('passive'), + capture: modifiers.has(MODIFIERS.CAPTURE), + once: modifiers.has(MODIFIERS.ONCE), + passive: modifiers.has(MODIFIERS.PASSIVE), }); } } diff --git a/packages/v4/migration/Track/index.ts b/packages/v4/migration/Track/index.ts index 62b9f0879..ac9f92daf 100644 --- a/packages/v4/migration/Track/index.ts +++ b/packages/v4/migration/Track/index.ts @@ -4,11 +4,7 @@ export { TrackContext, type TrackContextProps } from './TrackContext.js'; export { TrackEvent, TRACK_PSEUDO_EVENTS, - parseEventDefinition, resolveDetailPlaceholders, - // Avoid the `ActionEvent.Modifier` barrel-export collision. - type Modifier as TrackModifier, - type ParsedEvent, type TrackPseudoEvent, } from './TrackEvent.js'; export { TrackShopify, type TrackShopifyProps } from './TrackShopify.js'; diff --git a/packages/v4/migration/event-modifiers.ts b/packages/v4/migration/event-modifiers.ts new file mode 100644 index 000000000..a06e7e349 --- /dev/null +++ b/packages/v4/migration/event-modifiers.ts @@ -0,0 +1,104 @@ +/** + * The modifier vocabulary of a `:[.…]` declaration, + * and the one parser that reads it. + * + * This is ui's half of the attribute grammar, and it stays in ui deliberately: + * core owns when a declaration is re-parsed and how the attribute is observed, + * while what the parts of a qualifier mean belongs to whoever declared the + * namespace. `prevent` and `throttle200` are product vocabulary, not framework + * concepts — see DESIGN.md §3. + * + * `Action` and `Track` had one of these each, written independently, and the + * second was the first plus `throttle` — a superset, not a variant. The + * **defaults** are what differed for real (`Action` debounces at 100 ms, + * `Track` at 300), so this parser reports only the delay an author actually + * wrote and leaves the fallback to the caller. + */ + +/** Every modifier a declaration may carry. */ +export const MODIFIERS = Object.freeze({ + /** `event.preventDefault()` before the effect runs. */ + PREVENT: 'prevent', + /** `event.stopPropagation()` before the effect runs. */ + STOP: 'stop', + /** Listen once, then release. */ + ONCE: 'once', + /** Register the listener as passive. */ + PASSIVE: 'passive', + /** Register the listener on the capture phase. */ + CAPTURE: 'capture', + /** Delay until the declaration stops firing. Takes a delay in milliseconds. */ + DEBOUNCE: 'debounce', + /** Fire at most once per interval. Takes an interval in milliseconds. */ + THROTTLE: 'throttle', + /** Merge a `CustomEvent` detail into the payload instead of resolving placeholders. */ + DETAIL: 'detail', +} as const); + +export type Modifier = (typeof MODIFIERS)[keyof typeof MODIFIERS]; + +/** The modifiers which carry a number: `debounce500`, `throttle200`. */ +const TIMED_MODIFIERS = [MODIFIERS.DEBOUNCE, MODIFIERS.THROTTLE] as const; + +const MODIFIER_NAMES: readonly string[] = Object.values(MODIFIERS); + +export interface ParsedEventDefinition { + /** The event name, which is everything before the first dot. */ + event: string; + /** The modifiers written, in declaration order and without duplicates. */ + modifiers: ReadonlySet; + /** + * The delay an author wrote on a timed modifier, in milliseconds, or + * `undefined` when the modifier was bare or absent. Each family keeps its own + * fallback, which is why this is not defaulted here. A property rather than a + * method, so a caller can destructure it. + */ + delay: (modifier: Modifier) => number | undefined; +} + +function warn(...args: unknown[]): void { + console.warn('[event]', ...args); +} + +/** + * Split an event definition into its event and its modifiers. + * + * @param definition The qualifier of one declaration: `click.prevent.stop`, + * `input.debounce500`, `view.once`. + * @example + * ```js + * const { event, modifiers, delay } = parseEventDefinition('input.debounce500'); + * // event: 'input', modifiers: Set { 'debounce' }, delay('debounce'): 500 + * ``` + */ +export function parseEventDefinition(definition: string): ParsedEventDefinition { + const [event, ...parts] = definition.split('.'); + const modifiers = new Set(); + const delays = new Map(); + + for (const part of parts) { + const timed = TIMED_MODIFIERS.find((modifier) => part.startsWith(modifier)); + + if (timed) { + modifiers.add(timed); + const written = part.slice(timed.length); + if (written) { + delays.set(timed, Number.parseInt(written, 10)); + } + continue; + } + + if (!MODIFIER_NAMES.includes(part)) { + // Unknown modifiers used to be pushed through as if they were real, so a + // typo silently did nothing on a listener that still bound. + warn( + `\`${part}\` in \`${definition}\` names no modifier — known names: ${MODIFIER_NAMES.join(', ')}.`, + ); + continue; + } + + modifiers.add(part as Modifier); + } + + return { event, modifiers, delay: (modifier) => delays.get(modifier) }; +} diff --git a/packages/v4/migration/index.ts b/packages/v4/migration/index.ts index 1ae7d3146..181adb409 100644 --- a/packages/v4/migration/index.ts +++ b/packages/v4/migration/index.ts @@ -16,3 +16,6 @@ export * from './ScrollAnimation/index.js'; export * from './Slider/index.js'; export * from './Track/index.js'; export * from './Transition/index.js'; + +/** Shared vocabulary, not a family: what the parts of a declaration mean. */ +export * from './event-modifiers.js'; From 0aaa2d1040562b0a160c8872657d197410aa210d Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 12:06:53 +0200 Subject: [PATCH 4/6] refactor(v4): fold hasComponentAttribute into isInNamespace 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) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/src/component-declarations.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/v4/src/component-declarations.ts b/packages/v4/src/component-declarations.ts index 4f9c755b8..7f2f7fff8 100644 --- a/packages/v4/src/component-declarations.ts +++ b/packages/v4/src/component-declarations.ts @@ -1,4 +1,4 @@ -import { COMPONENT_ATTRIBUTE, QUALIFIER_SEPARATOR } from './attributes.js'; +import { COMPONENT_ATTRIBUTE, isInNamespace } from './attributes.js'; import { activeBreakpoint, responsiveAttributeNames, @@ -25,10 +25,7 @@ export function componentTokens(el: Element): Set { /** Whether an element carries any component spelling, including an invalid suffix. */ export function hasComponentAttribute(el: Element): boolean { - const prefix = `${COMPONENT_ATTRIBUTE}${QUALIFIER_SEPARATOR}`; - return el - .getAttributeNames() - .some((attribute) => attribute === COMPONENT_ATTRIBUTE || attribute.startsWith(prefix)); + return el.getAttributeNames().some((attribute) => isInNamespace(COMPONENT_ATTRIBUTE, attribute)); } /** Whether an element carries a scoped spelling from the configured breakpoint set. */ From 389a7b8e0c87cc3e99bbe4b8270047478d2f8217 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 13:23:16 +0200 Subject: [PATCH 5/6] fix(v4): update the packed-consumer export count to 84 `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) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/test/package-node-consumer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/v4/test/package-node-consumer.js b/packages/v4/test/package-node-consumer.js index 4fa0888ee..753934dba 100644 --- a/packages/v4/test/package-node-consumer.js +++ b/packages/v4/test/package-node-consumer.js @@ -58,7 +58,7 @@ assert.equal(createGroup, toolkit.createGroup); assert.equal(createGroupDefault, createGroup); // Keep in step with the same count in `src/exports.spec.ts`. This one runs // under `check:package`, which `npm test` does not cover. -assert.equal(Object.keys(toolkit).length, 82); +assert.equal(Object.keys(toolkit).length, 84); assert.equal(toolkit.ToolkitErrorDetail, undefined); assert.equal(toolkit.ToolkitErrorStage, undefined); // The stateless web storage adapters are exported as instances only. From 18f91930ae6285f0b7cfcd9329c0313cde05420b Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 19 Aug 2026 13:30:53 +0200 Subject: [PATCH 6/6] fix(v4): reject a second colon and a malformed timed delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- .../v4/migration/Track/TrackEvent.spec.ts | 13 ++++++++ packages/v4/migration/event-modifiers.ts | 13 ++++++-- packages/v4/src/attribute-namespaces.spec.ts | 25 +++++++++++++++ packages/v4/src/attribute-namespaces.ts | 31 ++++++++++++++++++- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/v4/migration/Track/TrackEvent.spec.ts b/packages/v4/migration/Track/TrackEvent.spec.ts index c382fb0e2..39e56cb96 100644 --- a/packages/v4/migration/Track/TrackEvent.spec.ts +++ b/packages/v4/migration/Track/TrackEvent.spec.ts @@ -59,6 +59,19 @@ describe('parseEventDefinition', () => { spy.mockRestore(); }); + it('rejects a malformed timed delay instead of parsing it to NaN', () => { + // `debounceoops` used to match on the `debounce` prefix alone, so its + // suffix went through `Number.parseInt` and produced `NaN` — a timeout + // browsers run immediately rather than warn about. + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { modifiers, delay } = parseEventDefinition('click.debounceoops'); + expect([...modifiers]).toEqual([]); + expect(delay('debounce')).toBeUndefined(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0].join(' ')).toContain('debounceoops'); + spy.mockRestore(); + }); + it('applies the family default through the bound declaration', async () => { const el = await render('
'); const [trackEvent] = getInstance(el, 'Track').trackEvents; diff --git a/packages/v4/migration/event-modifiers.ts b/packages/v4/migration/event-modifiers.ts index a06e7e349..07cd80b8b 100644 --- a/packages/v4/migration/event-modifiers.ts +++ b/packages/v4/migration/event-modifiers.ts @@ -40,6 +40,9 @@ export type Modifier = (typeof MODIFIERS)[keyof typeof MODIFIERS]; /** The modifiers which carry a number: `debounce500`, `throttle200`. */ const TIMED_MODIFIERS = [MODIFIERS.DEBOUNCE, MODIFIERS.THROTTLE] as const; +/** A timed modifier's name, then only digits or nothing — never `debounceoops`. */ +const TIMED_MODIFIER_PATTERN = new RegExp(`^(${TIMED_MODIFIERS.join('|')})(\\d*)$`); + const MODIFIER_NAMES: readonly string[] = Object.values(MODIFIERS); export interface ParsedEventDefinition { @@ -77,11 +80,15 @@ export function parseEventDefinition(definition: string): ParsedEventDefinition const delays = new Map(); for (const part of parts) { - const timed = TIMED_MODIFIERS.find((modifier) => part.startsWith(modifier)); + // Matched as a whole, not by prefix: `part.startsWith('debounce')` would + // also accept `debounceoops`, whose suffix `Number.parseInt` turns into + // `NaN` — a timeout browsers run immediately rather than warn about. + const timedMatch = TIMED_MODIFIER_PATTERN.exec(part); - if (timed) { + if (timedMatch) { + const timed = timedMatch[1] as Modifier; + const written = timedMatch[2]; modifiers.add(timed); - const written = part.slice(timed.length); if (written) { delays.set(timed, Number.parseInt(written, 10)); } diff --git a/packages/v4/src/attribute-namespaces.spec.ts b/packages/v4/src/attribute-namespaces.spec.ts index 1855ba169..580828b60 100644 --- a/packages/v4/src/attribute-namespaces.spec.ts +++ b/packages/v4/src/attribute-namespaces.spec.ts @@ -202,4 +202,29 @@ describe('watchAttributeNamespace', () => { expect(record.bound).toHaveLength(2); }); }); + + it('rejects a second colon instead of binding it as part of the qualifier', async () => { + // `namespaceQualifier()` returns everything after the first colon + // unexamined, so an open namespace with no declared vocabulary must reject + // the second separator itself — nothing else stands between this and + // `Action` binding a literal `click:s` DOM event. + const el = element('
'); + const details: ToolkitDiagnosticDetail[] = []; + document.addEventListener(EVENTS.diagnostic, (event) => { + details.push((event as CustomEvent).detail); + event.preventDefault(); + }); + const record = recorder(); + watched(el, 'data-on', record.bind); + + expect(record.bound).toEqual([]); + expect(details).toHaveLength(1); + expect(details[0].code).toBe('attribute.unknown-qualifier'); + + // Once per element and per name, not re-triggered by a rewrite of the value. + el.setAttribute('data-on:click:s', 'b'); + await settle(); + expect(record.bound).toEqual([]); + expect(details).toHaveLength(1); + }); }); diff --git a/packages/v4/src/attribute-namespaces.ts b/packages/v4/src/attribute-namespaces.ts index 32f9afca8..5f9ca5fe5 100644 --- a/packages/v4/src/attribute-namespaces.ts +++ b/packages/v4/src/attribute-namespaces.ts @@ -16,7 +16,7 @@ * qualifier head is finite while its tail is not. See DESIGN.md §3. */ -import { namespaceQualifier, qualifierHead } from './attributes.js'; +import { namespaceQualifier, qualifierHead, QUALIFIER_SEPARATOR } from './attributes.js'; import { warnOnce } from './diagnostics.js'; import { watchAttributes } from './dom-mutations.js'; @@ -87,6 +87,7 @@ export function watchAttributeNamespace( const release = value === null || qualifier === null || + !isWellFormed(el, attribute, qualifier, component) || !isDeclared(el, namespace, attribute, qualifier, qualifiers, component) ? undefined : bind({ qualifier, value, attribute }); @@ -122,6 +123,34 @@ export function watchAttributeNamespace( }; } +/** + * Whether a qualifier holds the grammar's one colon and no more, warning once + * if not. `namespaceQualifier()` returns everything after the first colon + * unexamined, so `data-on:click:s` reaches here as the qualifier `click:s` — + * a second declaration's separator leaking into the first — and an open + * namespace with no declared vocabulary would otherwise bind it as though + * `click:s` were an event name instead of the ill-formed attribute J1 settled + * it to be. + */ +function isWellFormed( + el: Element, + attribute: string, + qualifier: string, + component: string | undefined, +): boolean { + if (!qualifier.includes(QUALIFIER_SEPARATOR)) { + return true; + } + warnOnce( + el, + attribute, + 'attribute.unknown-qualifier', + `\`${attribute}\` declares nothing: a qualifier holds one colon at most, and this one holds a second.`, + { component, target: el }, + ); + return false; +} + /** Whether a qualifier names a member of the vocabulary, warning once if not. */ function isDeclared( el: Element,