From eca54cd56f4831ed258f5a0d32dcf6724aa2f3e0 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 17 Sep 2026 01:10:10 +0200 Subject: [PATCH 1/4] feat(action): add a reserved `mounted` pseudo-event Let HTML run an Action once its element and the components sharing it are mounted, without binding the public component lifecycle DOM event. `Track` already deferred its own `mounted` declaration that way. Lift the scheduling into `utils/mounted-event.ts` and have both families use it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu --- packages/docs/reference/items/Action/index.md | 21 +++ .../docs/reference/items/Action/js-api.md | 30 +++- .../items/Action/stories/mounted/app.js | 4 + .../items/Action/stories/mounted/app.twig | 8 ++ packages/tests/Action/Action.spec.ts | 128 ++++++++++++++++++ packages/ui/src/Action/Action.ts | 34 ++++- packages/ui/src/Action/ActionEvent.ts | 21 ++- packages/ui/src/Track/AbstractTrack.ts | 29 +--- packages/ui/src/Track/TrackEvent.ts | 5 +- packages/ui/src/utils/mounted-event.ts | 41 ++++++ 10 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 packages/docs/reference/items/Action/stories/mounted/app.js create mode 100644 packages/docs/reference/items/Action/stories/mounted/app.twig create mode 100644 packages/ui/src/utils/mounted-event.ts diff --git a/packages/docs/reference/items/Action/index.md b/packages/docs/reference/items/Action/index.md index f1d3011b..96319d67 100644 --- a/packages/docs/reference/items/Action/index.md +++ b/packages/docs/reference/items/Action/index.md @@ -107,6 +107,27 @@ The `Target` component is a companion of the `Action` component that can be used +### Running an action on mount + +The reserved [`mounted`](./js-api.md#reserved-events) event runs the effect once, after the element and the components sharing it are mounted. It lets HTML drive an initial call without every component gaining its own option for it. + + + + + + +:::code-group + +<<< ./stories/mounted/app.twig +<<< ./stories/mounted/app.js + +::: + + + ### Listening to multiple events The advanced HTML [option `on:[.]`](./js-api.md#on-event-modifier) can be used to listen to multiple events on a single `Action` component. diff --git a/packages/docs/reference/items/Action/js-api.md b/packages/docs/reference/items/Action/js-api.md index 8c75ea64..efdfed26 100644 --- a/packages/docs/reference/items/Action/js-api.md +++ b/packages/docs/reference/items/Action/js-api.md @@ -34,6 +34,32 @@ Modifiers can be chained with a `.` as separator: ``` +#### Reserved events + +`mounted` is a reserved name rather than a DOM event. It runs the effect once per mount cycle, after the current mount batch has settled, so the effect can target a component mounted on the same element: + + +```html {3} +
+
+``` + + +No listener is bound for it, so a lifecycle event bubbling from a descendant that mounts later never runs it again. Unmounting before the deferred effect runs cancels it, and remounting starts exactly one new one. + +The effect receives `undefined` for its `event` argument, which is what the modifiers reading an event have to work with: + +| Modifier | With `mounted` | +| ----------------------------- | ------------------------------------------------------- | +| `.debounce` / `.debounce` | Applies — the effect runs that many milliseconds later. | +| `.prevent` / `.stop` | Ignored — there is no event to cancel or to stop. | +| `.once` | Ignored — the effect already runs once per mount cycle. | +| `.capture` / `.passive` | Ignored — they configure a listener, and none is bound. | + +Any other name binds a DOM event of that name. + ### `target` - Type: `string` @@ -116,7 +142,7 @@ Defines a small piece of JavaScript executed in the context of the current targe - `this` (`HTMLElement`): the current element - `ctx` (`Record`): the current targeted component in an object with a uniq key being its name set in the static `config.name` property and the value being the component instance -- `event` (`Event`): the event that triggered the action +- `event` (`Event | undefined`): the event that triggered the action, `undefined` for [`mounted`](#reserved-events) - `target` (`Base`): a direct reference to the current targeted component - `action` (`Base`): a direct reference to the current action component - `$el` (`HTMLElement`): a direct reference to the targeted element @@ -223,7 +249,7 @@ The pattern described above with multiple components as targets is an advanced p - Type: `string` - Format: `[[()] -> ]` -Combines the [`on`](#on), [`target`](#target) and [`effect`](#effect) options into a single attribute. Attaches multiple events to a single `Action` component. +Combines the [`on`](#on), [`target`](#target) and [`effect`](#effect) options into a single attribute. Attaches multiple events to a single `Action` component. It reads the same event names and modifiers as the `on` option, [`mounted`](#reserved-events) included. ```html {3} +
+ `); + const foo = at(root, '#foo', 'Foo'); + await settle(); + + expect(foo.calls).toEqual([['ready']]); + }); + + it('reaches a component mounted on its own element', async () => { + // The point of the deferral: at bind time the co-located `Foo` is not + // resolvable yet, so the effect could not name it. + const root = await mount(` + + `); + await settle(); + + expect(at(root, '#action', 'Foo').calls).toEqual([['co-located']]); + }); + + it('binds no DOM listener at all', async () => { + const root = await mount('
'); + const action = at(root, '#action', 'Action'); + const spy = vi.spyOn(action.$el, 'addEventListener'); + + const release = new ActionEvent(action, 'mounted', 'target').attach(); + + expect(spy).not.toHaveBeenCalled(); + release(); + spy.mockRestore(); + }); + + it('ignores a `mounted` event bubbling from a descendant', async () => { + const root = await mount(` + +
+ `); + const foo = at(root, '#foo', 'Foo'); + await settle(); + expect(foo.calls).toHaveLength(1); + + (root.querySelector('#child') as HTMLElement).dispatchEvent( + new CustomEvent('mounted', { bubbles: true }), + ); + (root.querySelector('#child') as HTMLElement).dispatchEvent( + new CustomEvent('js-toolkit:component:mounted', { bubbles: true }), + ); + await settle(); + + expect(foo.calls).toHaveLength(1); + }); + + it('cancels the deferred effect when the action unmounts first', async () => { + const root = await mount('
'); + const foo = at(root, '#foo', 'Foo'); + + const host = document.createElement('div'); + host.innerHTML = ``; + document.body.append(host); + host.innerHTML = ''; + await settle(); + await settle(); + + expect(foo.calls).toHaveLength(0); + host.remove(); + }); + + it('starts exactly one new effect on a remount', async () => { + const root = await mount(` + +
+ `); + const action = at(root, '#action', 'Action'); + const foo = at(root, '#foo', 'Foo'); + await settle(); + expect(foo.calls).toHaveLength(1); + + action.$unmount(); + action.$mount(); + await settle(); + + expect(foo.calls).toHaveLength(2); + }); + + it('runs the effect with no event', async () => { + const root = await mount(` + +
+ `); + const foo = at(root, '#foo', 'Foo'); + await settle(); + + expect(foo.calls).toEqual([['undefined']]); + }); + + it('applies the debounce modifier to the deferred effect', async () => { + const root = await mount(` + +
+ `); + const foo = at(root, '#foo', 'Foo'); + await settle(); + expect(foo.calls).toHaveLength(0); + + await wait(300); + expect(foo.calls).toHaveLength(1); + }); + + it('works through the `on` option too', async () => { + const root = await mount(` + +
+ `); + const foo = at(root, '#foo', 'Foo'); + await settle(); + + expect(foo.calls).toEqual([['from-option']]); + }); +}); + describe('Action — the lifecycle', () => { it('releases its listeners when the element leaves the DOM', async () => { const root = await mount(` diff --git a/packages/ui/src/Action/Action.ts b/packages/ui/src/Action/Action.ts index 512ec711..13c2727a 100644 --- a/packages/ui/src/Action/Action.ts +++ b/packages/ui/src/Action/Action.ts @@ -2,6 +2,7 @@ import { Base } from '@studiometa/js-toolkit/Base'; import { namespaceQualifier } from '@studiometa/js-toolkit/namespaceQualifier'; import { watchAttributeNamespace } from '@studiometa/js-toolkit/watchAttributeNamespace'; import type { BaseConfig, BaseProps, MountedReturn } from '@studiometa/js-toolkit'; +import { MOUNTED_EVENT, whenMounted } from '../utils/mounted-event.js'; import { ActionEvent } from './ActionEvent.js'; /** @@ -70,7 +71,7 @@ export class Action extends Base { const stopWatchingNamespace = watchAttributeNamespace( this.$el, ON_NAMESPACE, - ({ qualifier, value }) => new ActionEvent(this, qualifier, value).attach(), + ({ qualifier, value }) => this.__attach(new ActionEvent(this, qualifier, value)), ); return () => { @@ -128,7 +129,36 @@ export class Action extends Base { } this.__optionSignature = signature; this.__releaseOptionBinding?.(); - this.__releaseOptionBinding = this.__parseOptions()?.attach(); + const actionEvent = this.__parseOptions(); + this.__releaseOptionBinding = actionEvent ? this.__attach(actionEvent) : undefined; + } + + /** + * Attach one binding and return its release. + * + * Both halves of the component go through here — the `data-on:*` namespace + * and the option triple — because the reserved `mounted` pseudo-event is a + * property of the declaration, not of where it was written. It binds no + * listener: the effect is posted to the background lane instead, so it runs + * once the batch has settled and can reach a component that mounts on the + * same element. The cancel belongs to this binding, so rewriting the + * declaration or unmounting before the lane drains drops the pending effect. + * + * @private + */ + __attach(actionEvent: ActionEvent): () => void { + const release = actionEvent.attach(); + + if (actionEvent.event !== MOUNTED_EVENT) { + return release; + } + + const cancel = whenMounted(this, () => actionEvent.handleEvent()); + + return () => { + cancel(); + release(); + }; } } diff --git a/packages/ui/src/Action/ActionEvent.ts b/packages/ui/src/Action/ActionEvent.ts index 50a3d75f..4383be13 100644 --- a/packages/ui/src/Action/ActionEvent.ts +++ b/packages/ui/src/Action/ActionEvent.ts @@ -1,6 +1,7 @@ import { getMountedInstances } from '@studiometa/js-toolkit/getMountedInstances'; import type { Base } from '@studiometa/js-toolkit'; import { MODIFIERS, parseEventDefinition, type Modifier } from '../utils/event-modifiers.js'; +import { MOUNTED_EVENT } from '../utils/mounted-event.js'; import { getEffect, type EffectFunction } from './expression.js'; /** @@ -112,14 +113,18 @@ export class ActionEvent { /** * Apply the modifiers that live in the handler body, then run the effect. + * + * The event is optional because the reserved `mounted` pseudo-event has + * none: the modifiers reading it then have nothing to act on, and the effect + * receives `undefined` for its `event` argument. */ - handleEvent(event: Event): void { + handleEvent(event?: Event): void { const { modifiers } = this; - if (modifiers.has(MODIFIERS.PREVENT)) { + if (event && modifiers.has(MODIFIERS.PREVENT)) { event.preventDefault(); } - if (modifiers.has(MODIFIERS.STOP)) { + if (event && modifiers.has(MODIFIERS.STOP)) { event.stopPropagation(); } @@ -145,7 +150,7 @@ export class ActionEvent { executeEffect( targets: ActionTarget[], effect: EffectFunction, - event: Event, + event?: Event, instances: Map = this.instances, ): void { const { action } = this; @@ -177,6 +182,14 @@ export class ActionEvent { /** Bind the event and return a release that also cancels pending debounce. */ attach(): () => void { const { modifiers } = this; + + if (this.event === MOUNTED_EVENT) { + // Nothing to bind: `Action` triggers it once the mount batch has settled. + // A DOM listener would also catch the lifecycle events of descendants + // mounting later, which is exactly what the pseudo-event exists to avoid. + return () => clearTimeout(this.__debounceTimer); + } + const off = this.action.$on(this.event, (event) => this.handleEvent(event), { capture: modifiers.has(MODIFIERS.CAPTURE), once: modifiers.has(MODIFIERS.ONCE), diff --git a/packages/ui/src/Track/AbstractTrack.ts b/packages/ui/src/Track/AbstractTrack.ts index 3aea2dc9..dcda07ef 100644 --- a/packages/ui/src/Track/AbstractTrack.ts +++ b/packages/ui/src/Track/AbstractTrack.ts @@ -1,11 +1,11 @@ import { Base } from '@studiometa/js-toolkit/Base'; -import { defaultScheduler } from '@studiometa/js-toolkit/defaultScheduler'; import { namespaceQualifier } from '@studiometa/js-toolkit/namespaceQualifier'; import { watchAttributeNamespace } from '@studiometa/js-toolkit/watchAttributeNamespace'; -import type { BaseConfig, BaseProps, MountedReturn, ScheduledTask } from '@studiometa/js-toolkit'; +import type { BaseConfig, BaseProps, MountedReturn } from '@studiometa/js-toolkit'; import { deepmerge } from '@studiometa/js-toolkit/utils/deepmerge'; +import { MOUNTED_EVENT, whenMounted } from '../utils/mounted-event.js'; import { TrackContext } from './TrackContext.js'; -import { TRACK_PSEUDO_EVENTS, TrackEvent } from './TrackEvent.js'; +import { TrackEvent } from './TrackEvent.js'; /** * The namespace one `TrackEvent` is declared by. Its qualifiers are any DOM @@ -62,9 +62,6 @@ export class AbstractTrack extends Base>(); - /** Resolved once per mount cycle. */ __payload: Record | null = null; @@ -155,10 +152,6 @@ export class AbstractTrack extends Base { stopWatchingNamespace(); - for (const task of this.__deferred) { - task.cancel(); - } - this.__deferred.clear(); this.__payload = null; this.__context = null; }; @@ -191,24 +184,14 @@ export class AbstractTrack extends Base { - this.__deferred.delete(task); - if (this.$isMounted) { - trackEvent.trigger(); - } - }); - this.__deferred.add(task); + const cancel = whenMounted(this, () => trackEvent.trigger()); return () => { - this.__deferred.delete(task); - task.cancel(); + cancel(); release(); }; } diff --git a/packages/ui/src/Track/TrackEvent.ts b/packages/ui/src/Track/TrackEvent.ts index b4802c4f..734c559d 100644 --- a/packages/ui/src/Track/TrackEvent.ts +++ b/packages/ui/src/Track/TrackEvent.ts @@ -2,6 +2,7 @@ import { useInView } from '@studiometa/js-toolkit/useInView'; import type { Unsubscribe } from '@studiometa/js-toolkit'; import { throttle } from '@studiometa/js-toolkit/utils/throttle'; import { MODIFIERS, parseEventDefinition, type Modifier } from '../utils/event-modifiers.js'; +import { MOUNTED_EVENT } from '../utils/mounted-event.js'; import type { AbstractTrack } from './AbstractTrack.js'; /** What a bare `debounce` means here. `Action` reads the same modifier at 100. */ @@ -13,7 +14,7 @@ const DEFAULT_THROTTLE_DELAY = 16; /** Synthetic event names that do not map to DOM events. */ export const TRACK_PSEUDO_EVENTS = { /** Fires once the component and its context have settled. */ - MOUNTED: 'mounted', + MOUNTED: MOUNTED_EVENT, /** Fires when the element enters the viewport. */ VIEW: 'view', } as const; @@ -164,7 +165,7 @@ export class TrackEvent { __bind(): Unsubscribe { const { event, modifiers, track } = this; - if (event === TRACK_PSEUDO_EVENTS.MOUNTED) { + if (event === MOUNTED_EVENT) { // Nothing to bind: `AbstractTrack` triggers it once the DOM has settled. return () => {}; } diff --git a/packages/ui/src/utils/mounted-event.ts b/packages/ui/src/utils/mounted-event.ts new file mode 100644 index 00000000..c62d575c --- /dev/null +++ b/packages/ui/src/utils/mounted-event.ts @@ -0,0 +1,41 @@ +/** + * The reserved `mounted` pseudo-event, and the scheduling both families that + * declare it share. + * + * `Action` and `Track` both let HTML react to their own mount. A component + * lifecycle DOM event cannot express that: it bubbles, so a descendant + * mounting later fires the declaration again, and it arrives while the batch + * is still running, so a component sharing the element may not be resolvable + * yet. `mounted` is therefore a name the families reserve rather than an event + * they listen to — nothing is bound, and the work is posted to the background + * lane instead, which runs once the batch has settled. + */ + +import { defaultScheduler } from '@studiometa/js-toolkit/defaultScheduler'; +import type { Base, Unsubscribe } from '@studiometa/js-toolkit'; + +/** The event name both families reserve. It binds no DOM listener. */ +export const MOUNTED_EVENT = 'mounted'; + +/** + * Run `callback` once the mount batch has settled, and return its cancel. + * + * The cancel belongs to the binding that asked for the work rather than to the + * mount, so a declaration rewritten before the task runs cancels its own + * pending call. Releasing every binding is what ends a mount cycle, so an + * unmount cancels through the same path; `$isMounted` is checked as well, + * because the component can also be unmounted by the time the lane drains. + * + * @param component The component whose mount cycle the work belongs to. + * @param callback The work to run once the batch has settled. + * @returns A cancel for the pending work, safe to call after it has run. + */ +export function whenMounted(component: Base, callback: () => void): Unsubscribe { + const task = defaultScheduler.background(() => { + if (component.$isMounted) { + callback(); + } + }); + + return () => task.cancel(); +} From 6daa16b50237df8d6b3d4a8f2d411afe7082ce09 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 17 Sep 2026 01:10:42 +0200 Subject: [PATCH 2/4] feat(track): resolve `$event` paths and read DOM-backed data per dispatch Placeholders now resolve against the whole event, so `$event.target.dataset.type` works on a native event and `$event.detail.response.headers.x-count` on a component event. `$detail.x` is rewritten to the path `detail.x` walked from the same root. The inherited context, the payload script and the payload option are read when the event fires, so a partial DOM update changes what the next dispatch sends. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu --- packages/docs/reference/items/Track/index.md | 27 ++++- packages/docs/reference/items/Track/js-api.md | 43 ++++++- .../Track/stories/basic/event-paths.twig | 31 +++++ packages/tests/Track/Track.spec.ts | 110 +++++++++++++++++- packages/tests/Track/TrackEvent.spec.ts | 97 ++++++++++++++- packages/tests/Track/TrackFetch.spec.ts | 99 ++++++++++++++++ packages/ui/src/Track/AbstractTrack.ts | 23 ++-- packages/ui/src/Track/TrackEvent.ts | 96 ++++++++++----- packages/ui/src/Track/index.ts | 2 +- 9 files changed, 467 insertions(+), 61 deletions(-) create mode 100644 packages/docs/reference/items/Track/stories/basic/event-paths.twig create mode 100644 packages/tests/Track/TrackFetch.spec.ts diff --git a/packages/docs/reference/items/Track/index.md b/packages/docs/reference/items/Track/index.md index 20fa4635..3ab2d6de 100644 --- a/packages/docs/reference/items/Track/index.md +++ b/packages/docs/reference/items/Track/index.md @@ -110,9 +110,11 @@ Wrap a section in `TrackContext` to provide data inherited by every descendant ` -### Custom events +### Event data -Track a `CustomEvent` emitted by third-party scripts and pull values from its `detail` with the `$detail.*` placeholder syntax: +Pull values off the event that triggered the dispatch with `$event.` placeholders. `$event` is the whole event, so the same syntax reads a `CustomEvent` detail and a native event property; `$detail.` is the shortcut for `$event.detail.`. + +The story below tracks a `CustomEvent` emitted by a third-party script: +And this one reads a native click, where the value lives on the element rather than in a detail: + + + + + + +:::code-group + +<<< ./stories/basic/event-paths.twig +<<< ./stories/basic/app.js + +::: + + + ### Multiple events Declare several `data-track:*` attributes on one element to track independent events, each with its own payload and modifiers: @@ -169,5 +190,5 @@ The provider is chosen by the component name, so switching destinations is a one `TrackShopify` uses the payload's `event` value as the published event name. Shopify recommends namespacing custom events (e.g. `my_app:add_to_cart`). To send to another destination, extend `Track` and override its [`dispatch()`](./js-api.md#providers) method. ::: warning -Payloads are serialised into the DOM (attribute or ` + + + + `); + const track = getInstance(root.querySelector('button'), 'Track')!; + + (root.querySelector('button') as HTMLButtonElement).click(); + expect(lastPush()).toEqual({ page_type: 'home', event: 'cta' }); + + await swap( + root.querySelector('#host') as Element, + `
+ + +
`, + { mode: SWAP_MODES.MORPH }, + ); + + // The same instance, so nothing re-read the context by remounting. + expect(getInstance(root.querySelector('button'), 'Track')).toBe(track); + + (root.querySelector('button') as HTMLButtonElement).click(); + expect(lastPush()).toEqual({ page_type: 'search', event: 'cta' }); + }); + + it('reads a morphed payload script on the next dispatch', async () => { + const root = await mount(` +
+ +
+ `); + const track = getInstance(root.querySelector('button'), 'Track')!; + + (root.querySelector('button') as HTMLButtonElement).click(); + expect(lastPush()).toEqual({ event: 'cta', source: 'before' }); + + await swap( + root.querySelector('#host') as Element, + ``, + { mode: SWAP_MODES.MORPH }, + ); + + expect(getInstance(root.querySelector('button'), 'Track')).toBe(track); + + (root.querySelector('button') as HTMLButtonElement).click(); + expect(lastPush()).toEqual({ event: 'cta', source: 'after' }); + }); + + it('reads a rewritten payload option on the next dispatch', async () => { + const root = await mount( + ``, + ); + const button = root.querySelector('button') as HTMLButtonElement; + + button.click(); + expect(lastPush()).toEqual({ event: 'cta', source: 'before' }); + + button.setAttribute('data-option-payload', '{"source": "after"}'); + await settle(); + + button.click(); + expect(lastPush()).toEqual({ event: 'cta', source: 'after' }); + }); + + it('keeps the context < payload < event precedence when the sources change', async () => { + const root = await mount(` +
+ +
+ `); + const button = root.querySelector('button') as HTMLButtonElement; + + button.click(); + expect(lastPush()).toEqual({ + value: 'event', + from_context: true, + from_payload: true, + event: 'x', + }); + + (root.querySelector('[data-component="TrackContext"]') as HTMLElement).setAttribute( + 'data-option-context', + '{"value": "context", "from_context": "rewritten"}', + ); + await settle(); + + button.click(); + expect(lastPush()).toEqual({ + value: 'event', + from_context: 'rewritten', + from_payload: true, + event: 'x', + }); + }); +}); + describe('Track — malformed declarations', () => { it('drops an event whose JSON cannot be parsed, without throwing', async () => { const log = captureDiagnostics(); diff --git a/packages/tests/Track/TrackEvent.spec.ts b/packages/tests/Track/TrackEvent.spec.ts index 1d21f62a..2bc01841 100644 --- a/packages/tests/Track/TrackEvent.spec.ts +++ b/packages/tests/Track/TrackEvent.spec.ts @@ -3,7 +3,7 @@ import { getInstance, registerComponents } from '@studiometa/js-toolkit'; import { captureDiagnostics, mount, resetDom } from '@studiometa/js-toolkit/test'; import { parseEventDefinition } from '#private/utils/event-modifiers.js'; import { Track } from '#private/Track/Track.js'; -import { resolveDetailPlaceholders } from '#private/Track/TrackEvent.js'; +import { resolveEventPlaceholders } from '#private/Track/TrackEvent.js'; registerComponents(Track); @@ -80,18 +80,103 @@ describe('parseEventDefinition', () => { }); }); -describe('resolveDetailPlaceholders', () => { +describe('resolveEventPlaceholders', () => { + const event = new CustomEvent('x', { detail: { email: 'a@b.c', user: { name: 'John' } } }); + it('resolves a dotted path and leaves everything else alone', () => { expect( - resolveDetailPlaceholders( - { event: 'e', email: '$detail.email', name: '$detail.user.name', kept: 1 }, - { email: 'a@b.c', user: { name: 'John' } }, + resolveEventPlaceholders( + { event: 'e', email: '$event.detail.email', name: '$event.detail.user.name', kept: 1 }, + event, ), ).toEqual({ event: 'e', email: 'a@b.c', name: 'John', kept: 1 }); }); + + it('resolves `$detail.*` to the same value as `$event.detail.*`', () => { + expect(resolveEventPlaceholders({ name: '$detail.user.name' }, event)).toEqual( + resolveEventPlaceholders({ name: '$event.detail.user.name' }, event), + ); + }); + + it('reaches an array element through a numeric segment', () => { + const arrayEvent = new CustomEvent('x', { + detail: { request: { searchParams: { genre: ['rock', 'jazz'] } } }, + }); + + expect( + resolveEventPlaceholders( + { + first: '$event.detail.request.searchParams.genre.0', + second: '$detail.request.searchParams.genre.1', + }, + arrayEvent, + ), + ).toEqual({ first: 'rock', second: 'jazz' }); + }); + + it('resolves a missing path to `undefined`', () => { + expect( + resolveEventPlaceholders({ missing: '$event.detail.nope.deeper', kept: 'x' }, event), + ).toEqual({ missing: undefined, kept: 'x' }); + }); + + it('resolves every placeholder to `undefined` with no event at all', () => { + expect(resolveEventPlaceholders({ a: '$event.type', b: '$detail.email' })).toEqual({ + a: undefined, + b: undefined, + }); + }); + + it('descends through nested objects and arrays', () => { + expect( + resolveEventPlaceholders( + { ecommerce: { items: [{ id: '$detail.user.name' }, { id: 'literal' }] } }, + event, + ), + ).toEqual({ ecommerce: { items: [{ id: 'John' }, { id: 'literal' }] } }); + }); }); -describe('TrackEvent — custom events', () => { +describe('TrackEvent — event paths', () => { + it('resolves `$event.*` placeholders against the whole event', async () => { + const el = await render( + `
`, + ); + + el.dispatchEvent( + new CustomEvent('form-submitted', { detail: { email: 'test@example.com' } }), + ); + + expect(lastPush()).toEqual({ + event: 'form_submitted', + type: 'form-submitted', + email: 'test@example.com', + }); + }); + + it('resolves `$event.target.*` on a native event', async () => { + const el = await render( + `
`, + ); + + el.dispatchEvent(new Event('click')); + + expect(lastPush()).toEqual({ event: 'click', type: 'cta' }); + }); + + it('resolves `$event.detail.*` on a component lifecycle event', async () => { + const el = await render( + `
`, + ); + + // What `$emit()` builds: a bubbling `CustomEvent` whose detail is the payload. + el.dispatchEvent( + new CustomEvent('custom-lifecycle', { bubbles: true, detail: { id: 'abc' } }), + ); + + expect(lastPush()).toEqual({ event: 'lifecycle', id: 'abc' }); + }); + it('resolves `$detail.*` placeholders from the event detail', async () => { const el = await render( `
`, diff --git a/packages/tests/Track/TrackFetch.spec.ts b/packages/tests/Track/TrackFetch.spec.ts new file mode 100644 index 00000000..e1ae337c --- /dev/null +++ b/packages/tests/Track/TrackFetch.spec.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getInstance, registerComponents } from '@studiometa/js-toolkit'; +import { mount, resetDom } from '@studiometa/js-toolkit/test'; +import { Fetch } from '#private/Fetch/Fetch.js'; +import { Track } from '#private/Track/Track.js'; + +/** + * The scenario both `$event` paths exist for: a `Fetch` announces its own + * lifecycle as plain data, and a `Track` declared on the same element reads + * that data by path, with no code between the two. + */ + +registerComponents(Fetch, Track); + +const originalFetch = window.fetch; +const originalHref = window.location.href; + +beforeEach(() => { + window.dataLayer = []; +}); + +afterEach(async () => { + window.fetch = originalFetch; + window.history.replaceState({}, '', originalHref); + await resetDom(); +}); + +function lastPush(): Record | undefined { + return window.dataLayer?.at(-1); +} + +describe('Track — reading a Fetch lifecycle event by path', () => { + it('resolves a response header, the request and a search param off `fetch-update-after`', async () => { + window.fetch = vi.fn( + async () => + new Response('
new
', { + headers: { 'x-search-result-count': '42' }, + }), + ) as unknown as typeof fetch; + + const root = await mount(` + +
old
+ `); + const fetchInstance = getInstance(root.querySelector('#search'), 'Fetch')!; + + await fetchInstance.fetch('/search?genre=rock&genre=jazz'); + + expect(root.querySelector('#results')?.textContent).toBe('new'); + expect(lastPush()).toEqual({ + event: 'content_search_results', + result_count: '42', + status: 200, + method: 'GET', + genre: 'rock', + missing: undefined, + }); + }); + + it('resolves the same value through the `$detail` shorthand', async () => { + window.fetch = vi.fn( + async () => + new Response('
new
', { + headers: { 'x-search-result-count': '7' }, + }), + ) as unknown as typeof fetch; + + const root = await mount(` + +
old
+ `); + const fetchInstance = getInstance(root.querySelector('#search'), 'Fetch')!; + + await fetchInstance.fetch('/search'); + + expect(lastPush()).toEqual({ + event: 'content_search_results', + long: '7', + short: '7', + }); + }); +}); diff --git a/packages/ui/src/Track/AbstractTrack.ts b/packages/ui/src/Track/AbstractTrack.ts index dcda07ef..46178a0a 100644 --- a/packages/ui/src/Track/AbstractTrack.ts +++ b/packages/ui/src/Track/AbstractTrack.ts @@ -62,11 +62,6 @@ export class AbstractTrack extends Base | null = null; - - __context: Record | null = null; - /** Every current `data-track:*` declaration on the element. */ get trackEvents(): TrackEvent[] { const trackEvents: TrackEvent[] = []; @@ -115,16 +110,18 @@ export class AbstractTrack extends Base { - this.__payload ??= deepmerge(this.scriptPayload, this.optionPayload); - return this.__payload; + return deepmerge(this.scriptPayload, this.optionPayload); } - /** The merged context of the ancestor chain. */ + /** The merged context of the ancestor chain, resolved per dispatch. */ get context(): Record { - this.__context ??= this.$closest('TrackContext')?.context ?? {}; - return this.__context; + return this.$closest('TrackContext')?.context ?? {}; } /** @@ -150,11 +147,7 @@ export class AbstractTrack extends Base this.__bind(attribute, value), ); - return () => { - stopWatchingNamespace(); - this.__payload = null; - this.__context = null; - }; + return stopWatchingNamespace; } /** One `data-track:` attribute, or `null` for anything else. */ diff --git a/packages/ui/src/Track/TrackEvent.ts b/packages/ui/src/Track/TrackEvent.ts index 734c559d..33420381 100644 --- a/packages/ui/src/Track/TrackEvent.ts +++ b/packages/ui/src/Track/TrackEvent.ts @@ -21,48 +21,82 @@ export const TRACK_PSEUDO_EVENTS = { export type TrackPseudoEvent = (typeof TRACK_PSEUDO_EVENTS)[keyof typeof TRACK_PSEUDO_EVENTS]; +/** The placeholder root resolving against the whole event. */ +const EVENT_PREFIX = '$event.'; + +/** The placeholder root resolving against `event.detail`. */ +const DETAIL_PREFIX = '$detail.'; + /** - * Resolve `$detail.*` placeholders in an arbitrary value, descending into both - * objects and arrays so nested payload placeholders are resolved too. + * Walk a dotted path from a root value. + * + * Every segment is read as a key, so a numeric one reaches an array element: + * `request.searchParams.genre.0`. Descending into anything that is not an + * object — a primitive, `undefined`, a root that is not there — yields + * `undefined`, which is what a path naming data the event does not carry + * should resolve to. */ -function resolveDetailValue(value: unknown, detail: Record): unknown { - if (typeof value === 'string' && value.startsWith('$detail.')) { - return getNestedValue(detail, value.slice(8)); +function resolvePath(root: unknown, path: string): unknown { + return path.split('.').reduce((current: unknown, key) => { + if (current && typeof current === 'object') { + return (current as Record)[key]; + } + return undefined; + }, root); +} + +/** + * Resolve the placeholders of one value, descending into both objects and + * arrays so nested payload placeholders are resolved too. + * + * `$detail.x` is rewritten to the path `detail.x` walked from the event rather + * than resolved by a second code path, so the two roots can never disagree. + */ +function resolveValue(value: unknown, event?: Event): unknown { + if (typeof value === 'string') { + if (value.startsWith(EVENT_PREFIX)) { + return resolvePath(event, value.slice(EVENT_PREFIX.length)); + } + + if (value.startsWith(DETAIL_PREFIX)) { + return resolvePath(event, `detail.${value.slice(DETAIL_PREFIX.length)}`); + } + + return value; } if (Array.isArray(value)) { - return value.map((item) => resolveDetailValue(item, detail)); + return value.map((item) => resolveValue(item, event)); } if (value && typeof value === 'object') { - return resolveDetailPlaceholders(value as Record, detail); + return resolveEventPlaceholders(value as Record, event); } return value; } -export function resolveDetailPlaceholders( +/** + * Resolve every `$event.*` and `$detail.*` placeholder of a declared payload + * against the event that triggered it. + * + * The resolver knows nothing about who emitted the event: it walks paths, and + * an emitter that carries plain data is what makes a path reachable. With no + * event, every placeholder resolves to `undefined`. + */ +export function resolveEventPlaceholders( data: Record, - detail: Record, + event?: Event, ): Record { const result: Record = {}; for (const [key, value] of Object.entries(data)) { - result[key] = resolveDetailValue(value, detail); + result[key] = resolveValue(value, event); } return result; } -function getNestedValue(obj: Record, path: string): unknown { - return path.split('.').reduce((current: unknown, key) => { - if (current && typeof current === 'object') { - return (current as Record)[key]; - } - return undefined; - }, obj); -} - /** One bound `data-track:` declaration. */ export class TrackEvent { track: AbstractTrack; @@ -124,18 +158,18 @@ export class TrackEvent { event.stopPropagation(); } - // A non-object detail (0, false, '', …) is an empty detail, so placeholders - // resolve to `undefined` instead of leaking the literal `$detail.*` string. - let finalData = data; - if (event instanceof CustomEvent) { - const detail = - event.detail && typeof event.detail === 'object' - ? (event.detail as Record) - : {}; - - finalData = modifiers.has(MODIFIERS.DETAIL) - ? { ...data, ...detail } - : resolveDetailPlaceholders(data, detail); + // Merging a detail wholesale stays a `CustomEvent` affair — a native event + // has none — while paths resolve against whatever event arrived, including + // none at all for the `mounted` pseudo-event. + let finalData: Record; + if (modifiers.has(MODIFIERS.DETAIL)) { + const detail = event instanceof CustomEvent ? (event.detail as unknown) : undefined; + finalData = + detail && typeof detail === 'object' + ? { ...data, ...(detail as Record) } + : data; + } else { + finalData = resolveEventPlaceholders(data, event); } track.send(finalData, event); diff --git a/packages/ui/src/Track/index.ts b/packages/ui/src/Track/index.ts index ac9f92da..397dd0d4 100644 --- a/packages/ui/src/Track/index.ts +++ b/packages/ui/src/Track/index.ts @@ -4,7 +4,7 @@ export { TrackContext, type TrackContextProps } from './TrackContext.js'; export { TrackEvent, TRACK_PSEUDO_EVENTS, - resolveDetailPlaceholders, + resolveEventPlaceholders, type TrackPseudoEvent, } from './TrackEvent.js'; export { TrackShopify, type TrackShopifyProps } from './TrackShopify.js'; From f24d6954afb9ed7fbfb87497acb42ee2fbd940b6 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 17 Sep 2026 01:11:35 +0200 Subject: [PATCH 3/4] docs(changelog): add the 648 and 652 entries Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66988baa..cff4b1ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **Fetch:** add the `historyMode` option and keep the `src` separation on popstate ([#656](https://github.com/studiometa/ui/pull/656)) - **Fetch:** report `fetch.file-not-uploaded` when a file control cannot be sent as a file ([#656](https://github.com/studiometa/ui/pull/656)) +- **Action:** add the reserved `mounted` pseudo-event ([#658](https://github.com/studiometa/ui/pull/658)) +- **Track:** add `$event.` placeholders, with `$detail.` as the shortcut for `$event.detail.` ([#658](https://github.com/studiometa/ui/pull/658)) ### Changed - ⚠️ **Fetch:** replace the `url` and `requestInit` event payload fields with one progressive lifecycle detail ([#657](https://github.com/studiometa/ui/pull/657)) - ⚠️ **Fetch:** drop the raw `Response` from the `fetch-response` payload ([#657](https://github.com/studiometa/ui/pull/657)) +- **Track:** read the inherited context and both payload sources at dispatch time instead of caching them per mount cycle ([#658](https://github.com/studiometa/ui/pull/658)) +- **Track:** rename `resolveDetailPlaceholders` to `resolveEventPlaceholders` ([#658](https://github.com/studiometa/ui/pull/658)) ### Fixed From 368f135d89ae6f2bafdd7ff4ba414620be212cb9 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 17 Sep 2026 12:15:13 +0200 Subject: [PATCH 4/4] test(action): assert the mounted effects by outcome, not by timing The debounce spec asserted a moment at which the effect had not run yet, which a machine slower than the delay reaches too late. Assert the gap between an undebounced and a debounced declaration instead: a timer cannot fire early, so the gap is the delay however loaded the machine is. Poll for every effect that has run, since the deferred work lands in the background lane a single settle() is generous about rather than deterministic. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu --- packages/tests/Action/Action.spec.ts | 68 ++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/tests/Action/Action.spec.ts b/packages/tests/Action/Action.spec.ts index 70f44e09..bdaea879 100644 --- a/packages/tests/Action/Action.spec.ts +++ b/packages/tests/Action/Action.spec.ts @@ -7,7 +7,7 @@ import { SWAP_MODES, type BaseConfig, } from '@studiometa/js-toolkit'; -import { captureDiagnostics, mount, resetDom, settle } from '@studiometa/js-toolkit/test'; +import { captureDiagnostics, mount, resetDom, settle, waitFor } from '@studiometa/js-toolkit/test'; import { Dialog } from '#private/Dialog/Dialog.js'; import { Action } from '#private/Action/Action.js'; import { ActionEvent } from '#private/Action/ActionEvent.js'; @@ -484,12 +484,29 @@ describe('Action — the component', () => { }); describe('Action — the `mounted` pseudo-event', () => { + /** + * The deferred effect lands in the scheduler's background lane, which a + * single `settle()` is generous about rather than deterministic — the helper + * says so itself. Every assertion that the effect **has** run therefore polls + * for it. Only an assertion that it has **not** run is made after a settle, + * and only where something observable proves the lane already drained. + */ + function ran(foo: Foo, count: number): Promise { + return waitFor(() => foo.calls.length === count, { + message: `Expected ${count} mounted effect(s), got ${foo.calls.length}.`, + timeout: 3000, + }); + } + it('runs once after the mount batch has settled', async () => { const root = await mount(`
`); const foo = at(root, '#foo', 'Foo'); + + await ran(foo, 1); + // Nothing queues a second one behind the first. await settle(); expect(foo.calls).toEqual([['ready']]); @@ -501,9 +518,11 @@ describe('Action — the `mounted` pseudo-event', () => { const root = await mount(` `); - await settle(); + const foo = at(root, '#action', 'Foo'); - expect(at(root, '#action', 'Foo').calls).toEqual([['co-located']]); + await ran(foo, 1); + + expect(foo.calls).toEqual([['co-located']]); }); it('binds no DOM listener at all', async () => { @@ -526,8 +545,7 @@ describe('Action — the `mounted` pseudo-event', () => {
`); const foo = at(root, '#foo', 'Foo'); - await settle(); - expect(foo.calls).toHaveLength(1); + await ran(foo, 1); (root.querySelector('#child') as HTMLElement).dispatchEvent( new CustomEvent('mounted', { bubbles: true }), @@ -545,13 +563,17 @@ describe('Action — the `mounted` pseudo-event', () => { const foo = at(root, '#foo', 'Foo'); const host = document.createElement('div'); - host.innerHTML = ``; + host.innerHTML = ``; document.body.append(host); - host.innerHTML = ''; - await settle(); + // Replaced in the same tick, so the first declaration never survives to run. + host.innerHTML = ``; + + // The control is what makes the absence an absence: its effect is queued + // behind the doomed one, so once it has run the lane has drained past both. + await ran(foo, 1); await settle(); - expect(foo.calls).toHaveLength(0); + expect(foo.calls).toEqual([['control']]); host.remove(); }); @@ -562,11 +584,11 @@ describe('Action — the `mounted` pseudo-event', () => { `); const action = at(root, '#action', 'Action'); const foo = at(root, '#foo', 'Foo'); - await settle(); - expect(foo.calls).toHaveLength(1); + await ran(foo, 1); action.$unmount(); action.$mount(); + await ran(foo, 2); await settle(); expect(foo.calls).toHaveLength(2); @@ -579,7 +601,8 @@ describe('Action — the `mounted` pseudo-event', () => {
`); const foo = at(root, '#foo', 'Foo'); - await settle(); + + await ran(foo, 1); expect(foo.calls).toEqual([['undefined']]); }); @@ -587,15 +610,23 @@ describe('Action — the `mounted` pseudo-event', () => { it('applies the debounce modifier to the deferred effect', async () => { const root = await mount(` + data-on:mounted="Foo -> target.fn('immediate', performance.now())" + data-on:mounted.debounce200="Foo -> target.fn('debounced', performance.now())">
`); const foo = at(root, '#foo', 'Foo'); - await settle(); - expect(foo.calls).toHaveLength(0); - await wait(300); - expect(foo.calls).toHaveLength(1); + await ran(foo, 2); + + // The delay is asserted as the gap between the two effects, never as a + // moment at which the debounced one has not run yet: a loaded machine can + // spend longer than the delay reaching such a moment, while a timer cannot + // fire early. Both declarations are deferred by the same lane and the + // undebounced one is queued first, so the gap is the delay itself. + const [immediate, debounced] = foo.calls; + expect(immediate[0]).toBe('immediate'); + expect(debounced[0]).toBe('debounced'); + expect((debounced[1] as number) - (immediate[1] as number)).toBeGreaterThanOrEqual(190); }); it('works through the `on` option too', async () => { @@ -605,7 +636,8 @@ describe('Action — the `mounted` pseudo-event', () => {
`); const foo = at(root, '#foo', 'Foo'); - await settle(); + + await ran(foo, 1); expect(foo.calls).toEqual([['from-option']]); });