Skip to content

feat(action,track): add a reserved mounted pseudo-event and generic $event paths - #658

Open
titouanmathis wants to merge 3 commits into
feat/v2-fetch-lifecycle-detailfrom
feat/v2-declarative-event-paths
Open

titouanmathis wants to merge 3 commits into
feat/v2-fetch-lifecycle-detailfrom
feat/v2-declarative-event-paths

Conversation

@titouanmathis

Copy link
Copy Markdown
Contributor

Stacks on #657, which stacks on #656. Review the last two commits only until those land; GitHub retargets this PR as the stack merges.

#648 — a reserved mounted pseudo-event for Action

data-on:mounted 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:

<form data-component="Fetch Action" data-on:mounted="Fetch(#content-search) -> target.fetch()"></form>

It binds no DOM listener, 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. Both halves of the component go through it: the data-on:* namespace and the on/target/effect option triple.

The effect receives undefined for its event argument. debounce applies; prevent, stop, once, capture and passive have nothing to act on and are documented as ignored.

Where the shared scheduling lives

packages/ui/src/utils/mounted-event.ts, next to event-modifiers.ts. It holds the reserved name and whenMounted(component, callback), which posts the work to defaultScheduler.background, guards on $isMounted, and returns its cancel. AbstractTrack.__bind() and the new Action.__attach() both use it.

Track's __deferred set is gone rather than moved: watchAttributeNamespace's cleanup releases every binding, and each binding's release already cancels its own task, so the set cancelled nothing the release had not. Behaviour is unchanged — the existing Track specs cover the cancel-on-unmount and remount paths.

#652 — generic $event paths, and fresh DOM-backed data

$event.<path> resolves against the whole event, so a native event works too:

<button data-track:click='{"event": "cta_click", "type": "$event.target.dataset.type"}'></button>

Resolution no longer requires a CustomEvent. The .detail modifier, which merges the detail wholesale, stays CustomEvent-only, because a native event has none.

$detail is kept as sugar, as the maintainer asked. It is a path rewrite, not a second code path: $detail.x becomes the path detail.x walked from the event, so the two roots can never disagree. That rewrite also removed the special case for a non-object detail — descending into a primitive already yields undefined, which is the same answer the special case gave. The change is therefore non-breaking, so nothing goes in the migration guide.

The resolver walks objects and arrays, reads a numeric segment as an array index (request.searchParams.genre.0), recurses through nested objects and arrays in the declared payload, returns undefined for a missing path and for every placeholder when there is no event at all, and knows nothing about Fetch or any other emitter.

resolveDetailPlaceholders is renamed resolveEventPlaceholders, updated in packages/ui/src/Track/index.ts. It is not in the package root barrel, so public-contracts.ts and the barrel snapshot are unaffected.

Fresh DOM-backed data

AbstractTrack no longer caches __payload and __context. The inherited TrackContext, the payload script and the payload option are read when the event fires, so a Fetch that morphs a JSON script or a context attribute changes what the next dispatch sends, with no remount. Merge precedence is unchanged.

Tests

packages/tests/Action/Action.spec.ts — runs once after mount; reaches a component on its own element; binds no listener; ignores a mounted and a js-toolkit:component:mounted event bubbling from a descendant; cancels on unmount; one new effect per remount; undefined event; debounce; the on option.

packages/tests/Track/TrackEvent.spec.ts$event.* on a CustomEvent, a native event and a component event; $detail.* equal to $event.detail.*; numeric segments; missing path; no event; nested objects and arrays.

packages/tests/Track/Track.spec.ts — a morphed TrackContext script, a morphed payload script and a rewritten payload option each affect the next dispatch on the same instance; precedence unchanged when a source changes.

packages/tests/Track/TrackFetch.spec.ts — a real Fetch update read through data-track:fetch-update-after, resolving a response header, the request method, a repeated search param by index, and a missing header.

npm run test (1037 passed), npm run lint, npm run manifest:check and npm run docs:build all pass.

Closes #648
Closes #652

🤖 Generated with Claude Code

https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu

titouanmathis and others added 3 commits September 17, 2026 01:10
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu
…atch

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMSCm41fm3g7chxD728vAu
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.32%. Comparing base (ae61c0e) to head (f24d695).

Additional details and impacted files
@@                        Coverage Diff                        @@
##             feat/v2-fetch-lifecycle-detail     #658   +/-   ##
=================================================================
  Coverage                             86.32%   86.32%           
  Complexity                              145      145           
=================================================================
  Files                                    20       20           
  Lines                                   746      746           
  Branches                                 88       88           
=================================================================
  Hits                                    644      644           
  Misses                                   95       95           
  Partials                                  7        7           
Flag Coverage Δ
unittests 86.32% <ø> (ø)

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant