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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/v4/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<namespace>[:<qualifier>[.<part>…]]
```

**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:<event>` is that case.
Expand Down
12 changes: 12 additions & 0 deletions packages/v4/RATIONALE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<ns>[-<subject>]:<qualifier>[.<part>…]` — 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
Expand Down
6 changes: 4 additions & 2 deletions packages/v4/migration/Action/Action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
66 changes: 30 additions & 36 deletions packages/v4/migration/Action/Action.ts
Original file line number Diff line number Diff line change
@@ -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:<event>[.<modifier>]` 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:<event>[.<modifier>]` 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: {
Expand All @@ -38,8 +40,8 @@ export class Action extends Base<ActionProps> {
},
};

/** Live bindings by the key that produced them, each holding its release. */
#bindings = new Map<string, () => 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;
Expand Down Expand Up @@ -67,23 +69,18 @@ export class Action extends Base<ActionProps> {
}

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;
};
}
Expand All @@ -102,10 +99,11 @@ export class Action extends Base<ActionProps> {

/** One `data-on:<event>` 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. */
Expand All @@ -118,23 +116,19 @@ export class Action extends Base<ActionProps> {
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();
}
}
37 changes: 15 additions & 22 deletions packages/v4/migration/Action/ActionEvent.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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<string, Base>;
Expand All @@ -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 = '->';

Expand All @@ -28,9 +29,9 @@ export class ActionEvent {
/** The event type to listen to. */
event: string;

modifiers: Modifier[];
modifiers: ReadonlySet<Modifier>;

debounceDelay = 100;
debounceDelay: number;

/** `Target Target(.selector)` — empty means "the action itself". */
targetDefinition: string;
Expand All @@ -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 = '';
Expand Down Expand Up @@ -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();
}

Expand All @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/v4/migration/Action/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading