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 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 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/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..46178a0a 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,14 +62,6 @@ export class AbstractTrack extends Base>(); - - /** Resolved once per mount cycle. */ - __payload: Record | null = null; - - __context: Record | null = null; - /** Every current `data-track:*` declaration on the element. */ get trackEvents(): TrackEvent[] { const trackEvents: TrackEvent[] = []; @@ -118,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 ?? {}; } /** @@ -153,15 +147,7 @@ export class AbstractTrack extends Base this.__bind(attribute, value), ); - return () => { - stopWatchingNamespace(); - for (const task of this.__deferred) { - task.cancel(); - } - this.__deferred.clear(); - this.__payload = null; - this.__context = null; - }; + return stopWatchingNamespace; } /** One `data-track:` attribute, or `null` for anything else. */ @@ -191,24 +177,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..33420381 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,55 +14,89 @@ 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; 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; @@ -123,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); @@ -164,7 +199,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/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'; 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(); +}