jssm 5.138.0 → 5.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,222 @@
1
1
  import { css, LitElement, html } from 'lit';
2
2
  import { sm } from 'jssm';
3
3
 
4
+ /**
5
+ * Walk a dotted path into a value. Used by the `data.path.to.field`
6
+ * variant of {@link resolve_binding}. Returns `undefined` whenever the
7
+ * traversal would dereference a non-object, missing field, or `null` —
8
+ * matching the natural "missing data" semantics rather than throwing.
9
+ *
10
+ * ```typescript
11
+ * walk_path({ a: { b: 7 } }, 'a.b'); // => 7
12
+ * walk_path({ a: { b: 7 } }, 'a.c'); // => undefined
13
+ * walk_path({ a: { b: 7 } }, 'a.b.c'); // => undefined (7 is not an object)
14
+ * walk_path(undefined, 'a'); // => undefined
15
+ * walk_path({ a: null }, 'a.b'); // => undefined (null is not an object)
16
+ * walk_path({ a: 1 }, ''); // => { a: 1 } (empty path = identity)
17
+ * ```
18
+ *
19
+ * @param obj - The root value to traverse.
20
+ * @param path - Dotted path of property names, e.g. `"a.b.c"`.
21
+ * @returns The terminal value, or `undefined` if any step fails.
22
+ */
23
+ function walk_path(obj, path) {
24
+ if (path.length === 0) {
25
+ return obj;
26
+ }
27
+ let cur = obj;
28
+ for (const part of path.split('.')) {
29
+ if (cur === null || typeof cur !== 'object') {
30
+ return undefined;
31
+ }
32
+ cur = cur[part];
33
+ }
34
+ return cur;
35
+ }
36
+ /**
37
+ * Resolve a `<jssm-bind>` / `data-jssm-bind` expression against a live
38
+ * machine. Throws on any unknown expression — bindings fail fast at
39
+ * install time rather than silently producing `undefined` strings in the
40
+ * DOM.
41
+ *
42
+ * Recognized expressions:
43
+ *
44
+ * | Expression | Resolves to |
45
+ * | ---------------- | --------------------------------------------- |
46
+ * | `data` | `machine.data()` |
47
+ * | `data.a.b.c` | dotted-path traversal into `machine.data()` |
48
+ * | `state` | `machine.state()` |
49
+ * | `terminal` | `machine.is_terminal()` |
50
+ * | `complete` | `machine.is_complete()` |
51
+ * | `legal-actions` | `machine.list_exit_actions().join(' ')` |
52
+ *
53
+ * ```typescript
54
+ * resolve_binding(m, 'state'); // current state name
55
+ * resolve_binding(m, 'data.username'); // typed-data subfield
56
+ * resolve_binding(m, 'wat'); // throws
57
+ * ```
58
+ *
59
+ * @param m - The machine whose state/data is being projected.
60
+ * @param expr - The binding expression text (raw attribute value).
61
+ * @returns The resolved value, typed `unknown` since each expression
62
+ * yields a different shape.
63
+ *
64
+ * @throws Error - When `expr` is not a recognized binding form.
65
+ */
66
+ function resolve_binding(m, expr) {
67
+ switch (expr) {
68
+ case 'state': return m.state();
69
+ case 'terminal': return m.is_terminal();
70
+ case 'complete': return m.is_complete();
71
+ case 'legal-actions': return m.list_exit_actions().map(a => String(a)).join(' ');
72
+ case 'data': return m.data();
73
+ default:
74
+ if (expr.startsWith('data.')) {
75
+ return walk_path(m.data(), expr.slice(5));
76
+ }
77
+ throw new Error(`<jssm-bind>: unknown binding expression "${expr}"`);
78
+ }
79
+ }
80
+ /**
81
+ * Apply a resolved binding value to an element's target property. The
82
+ * `target` selector follows the rules documented in #645:
83
+ *
84
+ * - `textContent` (or omitted) sets `el.textContent` to the value coerced
85
+ * with `String()`.
86
+ * - Any string starting with `data-` is treated as an attribute name and
87
+ * set via `setAttribute`, value coerced with `String()`.
88
+ * - Any other string is assigned directly as a property of the element
89
+ * (no coercion) — supports `value`, `disabled`, `hidden`, `checked`,
90
+ * and the documented power-user escape hatch.
91
+ *
92
+ * ```typescript
93
+ * set_on_element(span, 'textContent', 7); // span.textContent = '7'
94
+ * set_on_element(input, 'value', 'hi'); // input.value = 'hi'
95
+ * set_on_element(button, 'disabled', true); // button.disabled = true
96
+ * set_on_element(div, 'data-current', 'red'); // setAttribute('data-current', 'red')
97
+ * ```
98
+ *
99
+ * @param el - The element to update.
100
+ * @param target - Target property name, possibly a `data-*` attribute.
101
+ * @param value - The resolved value to assign.
102
+ */
103
+ function set_on_element(el, target, value) {
104
+ if (target.startsWith('data-')) {
105
+ el.setAttribute(target, String(value));
106
+ }
107
+ else if (target === 'textContent') {
108
+ el.textContent = String(value);
109
+ }
110
+ else {
111
+ // Power-user escape hatch — assigns value as-is so booleans hit
112
+ // properties like `disabled`/`hidden`/`checked` with the correct
113
+ // semantics rather than being coerced to a string.
114
+ el[target] = value;
115
+ }
116
+ }
117
+ /**
118
+ * Discover every binding declaration under `host` and install live
119
+ * subscriptions that refresh them on every machine transition. Returns
120
+ * a list of unsubscribe callbacks so the host's `disconnectedCallback`
121
+ * can tear them all down.
122
+ *
123
+ * Two surface forms are recognized:
124
+ *
125
+ * 1. Inline attribute — any descendant with `data-jssm-bind="<expr>"`.
126
+ * Optional `data-jssm-bind-to="<target>"` chooses the target property
127
+ * (defaults to `textContent`).
128
+ *
129
+ * 2. Dedicated tag — direct-child `<jssm-bind>` configuration tags with
130
+ * `selector="<css>"` and `source="<expr>"` attributes, plus an
131
+ * optional `target="<target>"` (also defaulting to `textContent`).
132
+ * The `selector` is scoped to `host`'s descendants.
133
+ *
134
+ * Each binding is painted once immediately (using the machine's current
135
+ * state) and then re-painted on every `transition` event.
136
+ *
137
+ * ```typescript
138
+ * // typical install during <jssm-instance>.connectedCallback:
139
+ * const unsubs = install_bindings(this, this.machine);
140
+ * this._unsubs.push(...unsubs);
141
+ * ```
142
+ *
143
+ * @param host - The host element whose descendants carry the bindings.
144
+ * @param machine - The machine whose state/data is being projected.
145
+ * @returns A flat array of unsubscribe callbacks, one per installed
146
+ * subscription.
147
+ *
148
+ * @throws Error - When any binding expression is unrecognized
149
+ * (propagated from {@link resolve_binding}).
150
+ * @throws Error - When a `<jssm-bind>` tag is missing its `selector`
151
+ * or `source` attribute.
152
+ */
153
+ function install_bindings(host, machine) {
154
+ var _a, _b;
155
+ const unsubs = [];
156
+ // Form 1: inline `data-jssm-bind` on descendants.
157
+ const inline_nodes = host.querySelectorAll('[data-jssm-bind]');
158
+ for (const el of Array.from(inline_nodes)) {
159
+ const expr = el.dataset.jssmBind;
160
+ const target = (_a = el.dataset.jssmBindTo) !== null && _a !== void 0 ? _a : 'textContent';
161
+ const apply = () => {
162
+ set_on_element(el, target, resolve_binding(machine, expr));
163
+ };
164
+ apply();
165
+ unsubs.push(machine.on('transition', apply));
166
+ }
167
+ // Form 2: dedicated `<jssm-bind>` configuration tags. Only direct
168
+ // children are considered configuration tags for THIS host — nested
169
+ // `<jssm-instance>` children would have their own bindings handled by
170
+ // their own component.
171
+ const config_tags = host.querySelectorAll(':scope > jssm-bind');
172
+ for (const tag of Array.from(config_tags)) {
173
+ const selector = tag.getAttribute('selector');
174
+ const expr = tag.getAttribute('source');
175
+ const target = (_b = tag.getAttribute('target')) !== null && _b !== void 0 ? _b : 'textContent';
176
+ if (selector === null || selector.length === 0) {
177
+ throw new Error('<jssm-bind>: missing required "selector" attribute');
178
+ }
179
+ if (expr === null || expr.length === 0) {
180
+ throw new Error('<jssm-bind>: missing required "source" attribute');
181
+ }
182
+ const targets = host.querySelectorAll(selector);
183
+ for (const el of Array.from(targets)) {
184
+ const apply = () => {
185
+ set_on_element(el, target, resolve_binding(machine, expr));
186
+ };
187
+ apply();
188
+ unsubs.push(machine.on('transition', apply));
189
+ }
190
+ }
191
+ return unsubs;
192
+ }
193
+ /**
194
+ * `<jssm-bind>` configuration tag. The element itself is invisible —
195
+ * it carries `selector`, `source`, and optional `target` attributes
196
+ * that the parent `<jssm-instance>` reads during its connection
197
+ * lifecycle to wire up a machine-to-DOM binding.
198
+ *
199
+ * Registering it as a `LitElement` (rather than leaving it as a generic
200
+ * unknown tag) gives it a stable upgrade timing, a `display: none`
201
+ * default style, and a proper place in the custom-elements registry so
202
+ * `customElements.get('jssm-bind')` resolves.
203
+ *
204
+ * @element jssm-bind
205
+ * @attribute selector - CSS selector for the target element(s), scoped to the host.
206
+ * @attribute source - Binding expression (see {@link resolve_binding}).
207
+ * @attribute target - Target property name; defaults to `textContent`.
208
+ */
209
+ class JssmBind extends LitElement {
210
+ /**
211
+ * No-op render. The tag's purpose is purely declarative
212
+ * configuration; it must not contribute any DOM to the page.
213
+ */
214
+ render() {
215
+ return null;
216
+ }
217
+ }
218
+ JssmBind.styles = css `:host { display: none; }`;
219
+
4
220
  const VALID_KINDS = new Set([
5
221
  'hook',
6
222
  'named',
@@ -64,7 +280,7 @@ function make_hook_proxy(ctx, machine) {
64
280
  * @param debug_id - Identifier appended to the synthetic sourceURL.
65
281
  * @returns The compiled handler.
66
282
  */
67
- function compile_inline_body(body, debug_id) {
283
+ function compile_inline_body$1(body, debug_id) {
68
284
  const annotated = `//# sourceURL=jssm-hook:${debug_id}\n${body}`;
69
285
  const ctor = Function;
70
286
  return new ctor('m', annotated);
@@ -79,7 +295,7 @@ function compile_inline_body(body, debug_id) {
79
295
  * @returns The resolved handler.
80
296
  * @throws Error - If no callable of that name is found in either location.
81
297
  */
82
- function resolve_named_handler(name, registry) {
298
+ function resolve_named_handler$1(name, registry) {
83
299
  if (registry !== undefined) {
84
300
  const registered = registry.get(name);
85
301
  if (registered !== undefined) {
@@ -139,8 +355,8 @@ function parse_hook_element(el, debug_id, registry) {
139
355
  throw new Error('<jssm-hook>: must specify either handler="name" attribute or an inline body');
140
356
  }
141
357
  const user_handler = handler_attr !== null
142
- ? resolve_named_handler(handler_attr, registry)
143
- : compile_inline_body(body_text, debug_id);
358
+ ? resolve_named_handler$1(handler_attr, registry)
359
+ : compile_inline_body$1(body_text, debug_id);
144
360
  const kind = normalize_hook_kind(el.getAttribute('kind'));
145
361
  // Convert null → undefined so downstream descriptors omit absent keys.
146
362
  const from = (_a = el.getAttribute('from')) !== null && _a !== void 0 ? _a : undefined;
@@ -203,6 +419,177 @@ function build_hook_descriptor(spec, wrapped) {
203
419
  return base;
204
420
  }
205
421
 
422
+ /**
423
+ * Allow-list of event names accepted by `<jssm-on event="...">`. Must stay
424
+ * in sync with the `JssmEventName` union in `jssm_types.ts` (the library's
425
+ * `machine.on(...)` event API, added in #638). Validating here gives the
426
+ * declarative wiring a clear "unknown event name" error at the WC layer
427
+ * instead of relying on a downstream library throw whose message would
428
+ * mention `machine.on(...)` rather than the offending tag.
429
+ */
430
+ const JSSM_ON_EVENT_NAMES = new Set([
431
+ 'transition',
432
+ 'rejection',
433
+ 'action',
434
+ 'entry',
435
+ 'exit',
436
+ 'terminal',
437
+ 'complete',
438
+ 'error',
439
+ 'data-change',
440
+ 'override',
441
+ 'timeout',
442
+ 'hook-registration',
443
+ 'hook-removal'
444
+ ]);
445
+ /**
446
+ * Parse a `<jssm-on>` element into a validated {@link ParsedJssmOn}
447
+ * record. Centralized so the declarative-tag logic is testable without
448
+ * spinning up the full `<jssm-instance>` lifecycle.
449
+ *
450
+ * Validation rules (per #643):
451
+ * - `event` is required and must be in {@link JSSM_ON_EVENT_NAMES}.
452
+ * - Either a `handler="name"` attribute or non-empty `textContent`
453
+ * must be supplied, but not both.
454
+ * - `state` is only meaningful for `event="entry"` / `event="exit"`;
455
+ * it's silently ignored on other events.
456
+ * - `from` / `to` are only meaningful for `event="transition"`; they
457
+ * are silently ignored on other events. Both → AND (a specific
458
+ * edge). Neither → unfiltered.
459
+ *
460
+ * ```typescript
461
+ * const el = document.createElement('jssm-on');
462
+ * el.setAttribute('event', 'entry');
463
+ * el.setAttribute('state', 'paid');
464
+ * el.setAttribute('handler', 'onPaid');
465
+ * parse_jssm_on_element(el);
466
+ * // => { event: 'entry', handler_name: 'onPaid', inline_body: undefined,
467
+ * // once: false, name: undefined, filter: { state: 'paid' } }
468
+ * ```
469
+ *
470
+ * @param el - The `<jssm-on>` element to parse.
471
+ * @returns A validated {@link ParsedJssmOn} record.
472
+ * @throws If `event` is missing, unknown, both handler forms are
473
+ * supplied, or neither handler form is supplied.
474
+ */
475
+ function parse_jssm_on_element(el) {
476
+ const event_attr = el.getAttribute('event');
477
+ if (event_attr === null || event_attr.trim().length === 0) {
478
+ throw new Error('<jssm-on>: missing required `event` attribute');
479
+ }
480
+ const event = event_attr.trim();
481
+ if (!JSSM_ON_EVENT_NAMES.has(event)) {
482
+ throw new Error(`<jssm-on>: unknown event "${event}"`);
483
+ }
484
+ const handler_attr = el.getAttribute('handler');
485
+ const handler_name = (handler_attr !== null && handler_attr.trim().length > 0)
486
+ ? handler_attr.trim()
487
+ : undefined;
488
+ // textContent on a connected HTMLElement is always a string, so the
489
+ // historical `?? ''` fallback never executed. Use a direct cast here
490
+ // and let test cases that supply a literal `null` (defensive coverage)
491
+ // hit the `=== null` branch instead — that branch is reachable via
492
+ // Object.defineProperty in tests, where the `??` form would be a dead
493
+ // operator.
494
+ const body_text = el.textContent;
495
+ const inline_body = (body_text !== null && body_text.trim().length > 0) ? body_text : undefined;
496
+ if (handler_name !== undefined && inline_body !== undefined) {
497
+ throw new Error('<jssm-on>: specify handler="name" OR inline body, not both');
498
+ }
499
+ if (handler_name === undefined && inline_body === undefined) {
500
+ throw new Error('<jssm-on>: must specify handler="name" or an inline body');
501
+ }
502
+ const once_attr = el.hasAttribute('once');
503
+ const name_attr = el.getAttribute('name');
504
+ const name = (name_attr !== null && name_attr.trim().length > 0) ? name_attr.trim() : undefined;
505
+ // Build the filter, but only honour attributes that apply to this event.
506
+ // Unknown filter attributes for an event are silently ignored, matching
507
+ // the documented semantics in the issue.
508
+ let filter;
509
+ if (event === 'entry' || event === 'exit') {
510
+ const state_attr = el.getAttribute('state');
511
+ if (state_attr !== null && state_attr.length > 0) {
512
+ filter = { state: state_attr };
513
+ }
514
+ }
515
+ else if (event === 'transition') {
516
+ const from_attr = el.getAttribute('from');
517
+ const to_attr = el.getAttribute('to');
518
+ const candidate = {};
519
+ if (from_attr !== null && from_attr.length > 0) {
520
+ candidate.from = from_attr;
521
+ }
522
+ if (to_attr !== null && to_attr.length > 0) {
523
+ candidate.to = to_attr;
524
+ }
525
+ if (Object.keys(candidate).length > 0) {
526
+ filter = candidate;
527
+ }
528
+ }
529
+ return {
530
+ event,
531
+ handler_name,
532
+ inline_body,
533
+ once: once_attr,
534
+ name,
535
+ filter
536
+ };
537
+ }
538
+ /**
539
+ * Optional global registry that `<jssm-on>` (and, later, `<jssm-hook>`)
540
+ * consult first when resolving a `handler="name"` attribute. Consumers
541
+ * register named handlers here in a strict-CSP environment where a stray
542
+ * `globalThis[name]` isn't acceptable. Falls through to `globalThis[name]`
543
+ * if the registry has no entry.
544
+ *
545
+ * Intentionally a `Map<string, Function>` rather than a class with methods,
546
+ * so consumers can use any of `.get`, `.set`, `.delete`, `.clear` directly
547
+ * without a thin wrapper API.
548
+ */
549
+ const jssm_handler_registry = new Map();
550
+ /**
551
+ * Resolve a named handler from the registry, then from `globalThis`.
552
+ * Throws if neither lookup finds a function — earlier failure here is
553
+ * better than a delayed "is not a function" at first event delivery.
554
+ *
555
+ * @param name - The handler name as supplied by `handler="..."`.
556
+ * @returns The resolved function.
557
+ * @throws If no function is registered under `name`.
558
+ */
559
+ function resolve_named_handler(name) {
560
+ const from_registry = jssm_handler_registry.get(name);
561
+ if (typeof from_registry === 'function') {
562
+ return from_registry;
563
+ }
564
+ const from_global = globalThis[name];
565
+ if (typeof from_global === 'function') {
566
+ return from_global;
567
+ }
568
+ throw new Error(`<jssm-on>: handler "${name}" not found in registry or globalThis`);
569
+ }
570
+ /**
571
+ * Compile an inline-body string into a handler function whose single
572
+ * parameter is `e` (the event detail object). Uses the same dynamic
573
+ * `Function(...)` constructor that browsers use internally for inline
574
+ * event-handler attributes such as `<a onclick="...">`; the input here
575
+ * is consumer-authored markup, never network data, so the surface is
576
+ * exactly that of an inline event-handler attribute and the same CSP
577
+ * caveats apply (strict CSP without `'unsafe-eval'` blocks it). A
578
+ * `//# sourceURL=jssm-on:N` pragma is appended so devtools stack traces
579
+ * point at a meaningful name.
580
+ *
581
+ * @param body - The inline JS body (function body, not full function).
582
+ * @param source_id - A short identifier for the sourceURL pragma.
583
+ * @returns The compiled handler.
584
+ */
585
+ function compile_inline_body(body, source_id) {
586
+ const wrapped = `${body}\n//# sourceURL=jssm-on:${source_id}`;
587
+ // The Function constructor is intentional here — see the docblock above
588
+ // for the rationale and the CSP caveat. Equivalent to how browsers wire
589
+ // up inline event handlers; the input is consumer-authored markup.
590
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
591
+ return new Function('e', wrapped); // skipcq: JS-0086
592
+ }
206
593
  /**
207
594
  * Resolve a `<jssm-instance>`'s FSL source from the three legal channels:
208
595
  * the `fsl=""` attribute, a child `<script type="text/fsl">`, and the
@@ -318,44 +705,33 @@ class JssmInstance extends LitElement {
318
705
  * connection.
319
706
  */
320
707
  this._machine = undefined;
708
+ /**
709
+ * Live unsubscribe callbacks for #645 `<jssm-bind>` / `data-jssm-bind`
710
+ * projections. Every entry must be invoked exactly once during
711
+ * {@link disconnectedCallback}.
712
+ */
713
+ this._unsubs = [];
714
+ /**
715
+ * Unsubscribe callbacks for every `machine.on(...)` / `machine.once(...)`
716
+ * subscription installed from a `<jssm-on>` child during
717
+ * `connectedCallback`. Walked in `disconnectedCallback`.
718
+ */
719
+ this._on_unsubscribes = [];
321
720
  /**
322
721
  * Per-instance registry of named hook handlers consulted before
323
722
  * `globalThis` when resolving `<jssm-hook handler="name">`.
324
- *
325
- * Initialized to an empty `Map`; consumers may populate it before the
326
- * element connects to provide handlers without polluting global scope —
327
- * useful for module-scoped SPAs where strict CSP blocks inline-body hooks.
328
- *
329
- * @see {@link parse_hook_element}
330
723
  */
331
724
  this.registry = new Map();
332
725
  /**
333
- * Descriptors for hooks this WC installed at connect time, used in
334
- * `disconnectedCallback` to call `remove_hook` for each so the underlying
335
- * machine doesn't leak handlers when the element is detached.
336
- *
337
- * Captured at install time because `remove_hook` matches by descriptor
338
- * shape (not handler identity), and we need to record the wrapped handler
339
- * we passed to `set_hook` to undo the registration cleanly. Stored as
340
- * `unknown[]` and cast at the call site because jssm's `HookDescription`
341
- * is a discriminated union whose discriminator is only known at runtime.
726
+ * Descriptors for hooks this WC installed at connect time.
342
727
  */
343
728
  this._installed_hooks = [];
344
729
  /**
345
- * Counter used to give each compiled inline-body hook a unique debug id
346
- * for its `//# sourceURL=jssm-hook:N` annotation. Per-instance so that
347
- * multiple `<jssm-instance>` elements on a page don't share numbering.
730
+ * Counter for compiled inline-body hook debug ids.
348
731
  */
349
732
  this._hook_debug_counter = 0;
350
733
  /**
351
- * Records every DOM listener installed by `<jssm-action>` / `data-jssm-action`
352
- * discovery so {@link disconnectedCallback} can remove each one with the
353
- * same handler reference originally passed to `addEventListener`.
354
- *
355
- * Listeners installed via the dedicated `<jssm-action>` tag form may target
356
- * elements outside the host (its `selector` is resolved against the host,
357
- * but matching elements live in the document tree), so cleanup must be
358
- * explicit — relying on the host's GC is not sufficient.
734
+ * DOM listeners installed by `<jssm-action>` / `data-jssm-action` discovery.
359
735
  */
360
736
  this._action_listeners = [];
361
737
  }
@@ -424,9 +800,54 @@ class JssmInstance extends LitElement {
424
800
  // and dispatch DOM CustomEvents from this element.
425
801
  // #641: <jssm-hook> declarative discovery.
426
802
  this._install_declarative_hooks();
427
- // TODO #643: <jssm-on> discovery happens here.
803
+ // #643: <jssm-on> declarative event observation.
804
+ this._install_jssm_on_children();
805
+ // #645: discover <jssm-bind> tags and `data-jssm-bind` descendants,
806
+ // install live machine-to-DOM projections.
807
+ this._unsubs.push(...install_bindings(this, this._machine));
808
+ // #640: <jssm-action> DOM event → machine action wiring.
428
809
  this._discover_jssm_actions();
429
- // TODO #645: <jssm-bind> discovery happens here.
810
+ }
811
+ /**
812
+ * Discover direct-child `<jssm-on>` elements and install their
813
+ * subscriptions on the owned machine. Per #643:
814
+ *
815
+ * - Direct children only (`:scope > jssm-on`). Deeper nesting is the
816
+ * responsibility of a future MutationObserver-driven v2.
817
+ * - Each `<jssm-on>` is parsed by {@link parse_jssm_on_element}, which
818
+ * enforces the form / event-name / filter rules.
819
+ * - Handlers come from {@link resolve_named_handler} (form A) or
820
+ * {@link compile_inline_body} (form B), and the result is installed
821
+ * via `machine.on(...)` or `machine.once(...)` depending on the
822
+ * element's `once` attribute.
823
+ * - Every returned unsubscribe is tracked in {@link _on_unsubscribes}
824
+ * so {@link disconnectedCallback} can release them all.
825
+ *
826
+ * Called once from `connectedCallback` after the machine has been
827
+ * constructed. Any error thrown by parsing or resolution propagates
828
+ * out so it surfaces via jsdom's error event (matching the rest of
829
+ * `<jssm-instance>`'s "fail loud at connect" policy).
830
+ */
831
+ _install_jssm_on_children() {
832
+ const machine = this._machine;
833
+ const on_nodes = this.querySelectorAll(':scope > jssm-on');
834
+ let index = 0;
835
+ for (const el of Array.from(on_nodes)) {
836
+ index += 1;
837
+ const parsed = parse_jssm_on_element(el);
838
+ const handler = parsed.handler_name !== undefined
839
+ ? resolve_named_handler(parsed.handler_name)
840
+ : compile_inline_body(parsed.inline_body, String(index));
841
+ // Argument shape: machine.on(name, handler) when no filter, or
842
+ // machine.on(name, filter, handler) when filtered. Same for once.
843
+ // `as any` collapses the per-event detail typing — the WC is a
844
+ // schema-erased entry point and the type-safety belongs upstream.
845
+ const subscribe = parsed.once ? machine.once.bind(machine) : machine.on.bind(machine);
846
+ const unsubscribe = parsed.filter === undefined
847
+ ? subscribe(parsed.event, handler)
848
+ : subscribe(parsed.event, parsed.filter, handler);
849
+ this._on_unsubscribes.push(unsubscribe);
850
+ }
430
851
  }
431
852
  /**
432
853
  * Discover every direct-child `<jssm-hook>` element and install each
@@ -434,17 +855,6 @@ class JssmInstance extends LitElement {
434
855
  * adapter that lets user code write `m.data = ...` and return `false` to
435
856
  * cancel — see {@link make_hook_proxy} and the issue (#641) doc-comment
436
857
  * for the full contract.
437
- *
438
- * Direct children only (the `:scope > jssm-hook` selector) so that nested
439
- * `<jssm-instance>` elements don't have their child hooks installed on
440
- * the outer machine.
441
- *
442
- * Tracks every installed descriptor in `_installed_hooks` so that
443
- * `disconnectedCallback` can remove them on detach.
444
- *
445
- * @throws Error - On a malformed `<jssm-hook>` (mutual-exclusion violation,
446
- * unknown kind, unresolved name, or jssm's own missing-key
447
- * errors from `set_hook`).
448
858
  */
449
859
  _install_declarative_hooks() {
450
860
  const machine = this._machine;
@@ -454,35 +864,26 @@ class JssmInstance extends LitElement {
454
864
  const spec = parse_hook_element(el, debug_id, this.registry);
455
865
  const wrapped = wrap_user_handler(spec, machine);
456
866
  const desc = build_hook_descriptor(spec, wrapped);
457
- // `desc` is shaped from runtime kind discrimination; jssm's typed
458
- // `HookDescription` is a static discriminated union that TS can't
459
- // unify with our runtime-built object, hence the cast.
460
867
  machine.set_hook(desc);
461
868
  this._installed_hooks.push(desc);
462
869
  }
463
870
  }
464
871
  /**
465
872
  * Prefix used in synthetic `//# sourceURL=jssm-hook:<prefix><n>` annotations
466
- * for inline-body hooks compiled by this element. Includes the element's
467
- * `id` when present so multi-instance pages can tell sources apart in
468
- * devtools.
873
+ * for inline-body hooks compiled by this element.
469
874
  */
470
875
  _hook_id_prefix() {
471
876
  const host_id = this.getAttribute('id');
472
877
  return host_id !== null && host_id.length > 0 ? `${host_id}-` : '';
473
878
  }
474
879
  /**
475
- * Lifecycle hook. Removes every hook this WC installed via
476
- * `<jssm-hook>` discovery so the underlying machine doesn't leak handlers
477
- * when the element detaches. Called automatically by the browser; the
478
- * machine itself is not destroyed (consumers can reuse it).
479
- *
480
- * Future tickets #638/#643/#645 will extend this to drop other
481
- * subscriptions / listeners installed by their respective tags.
880
+ * Lifecycle hook. Cleans up everything the WC installed at connect: hook
881
+ * registrations from `<jssm-hook>`, event subscriptions from `<jssm-on>`,
882
+ * and DOM listeners from `<jssm-action>` / `data-jssm-action`.
482
883
  */
483
884
  disconnectedCallback() {
484
885
  super.disconnectedCallback();
485
- // TODO #638: unsubscribe from machine.on(...) handlers.
886
+ // TODO #638: unsubscribe from any direct machine.on(...) handlers added by host.
486
887
  // #641: remove installed hooks.
487
888
  if (this._machine !== undefined) {
488
889
  const machine = this._machine;
@@ -491,10 +892,20 @@ class JssmInstance extends LitElement {
491
892
  }
492
893
  }
493
894
  this._installed_hooks = [];
494
- // TODO #643/#645: remove installed listeners / bindings.
495
- // Remove every listener installed during `<jssm-action>` / `data-jssm-action`
496
- // discovery. Using the original handler reference ensures `removeEventListener`
497
- // actually unbinds — anonymous re-creation here would silently leak.
895
+ // #643: release every subscription installed from a <jssm-on> child.
896
+ for (const off of this._on_unsubscribes) {
897
+ try {
898
+ off();
899
+ }
900
+ catch ( /* swallow — cleanup must not throw past us */_a) { /* swallow — cleanup must not throw past us */ }
901
+ }
902
+ this._on_unsubscribes = [];
903
+ // #645: tear down every live binding.
904
+ for (const off of this._unsubs) {
905
+ off();
906
+ }
907
+ this._unsubs = [];
908
+ // #640: remove DOM listeners installed via <jssm-action> / data-jssm-action.
498
909
  for (const entry of this._action_listeners) {
499
910
  entry.target.removeEventListener(entry.event, entry.handler);
500
911
  }
@@ -502,30 +913,12 @@ class JssmInstance extends LitElement {
502
913
  }
503
914
  /**
504
915
  * Wire DOM events to machine actions, using the two declarative forms from
505
- * issue #640:
506
- *
507
- * 1. Inline attribute form: every descendant of the host carrying a
508
- * `data-jssm-action="<name>"` attribute receives a listener on the
509
- * event named by `data-jssm-event` (default `click`).
510
- * 2. Dedicated tag form: each direct `<jssm-action>` child of the host
511
- * supplies a CSS `selector` (scoped to the host), an `action`, and an
512
- * optional `event` (default `click`); every matching descendant
513
- * receives a listener configured by the tag's attributes.
514
- *
515
- * Both forms support optional `from-state` guards (dispatch only when the
516
- * machine's current state matches), `from-property` data extraction (pass
517
- * the source element's named property as the action's data argument), and
518
- * `prevent-default` / `stop-propagation` modifiers.
519
- *
520
- * Every installed listener is recorded in {@link _action_listeners} so
521
- * {@link disconnectedCallback} can detach them cleanly.
916
+ * issue #640. Both forms support optional `from-state` guards,
917
+ * `from-property` data extraction, and `prevent-default` /
918
+ * `stop-propagation` modifiers.
522
919
  */
523
920
  _discover_jssm_actions() {
524
921
  var _a, _b, _c, _d;
525
- // Inline attribute form: `[data-jssm-action]` descendants. Per the
526
- // ticket, we scan the host's light DOM (not the shadow tree, which is
527
- // owned by us) and skip any element living inside a `<jssm-action>` tag
528
- // — those tags are pure data markup, never the source of an event.
529
922
  const inline_targets = Array.from(this.querySelectorAll('[data-jssm-action]')).filter(el => el.closest('jssm-action') === null);
530
923
  for (const el of inline_targets) {
531
924
  this._install_action_listener({
@@ -538,16 +931,11 @@ class JssmInstance extends LitElement {
538
931
  stop_propagation: 'jssmStopPropagation' in el.dataset,
539
932
  });
540
933
  }
541
- // Dedicated tag form: direct `<jssm-action>` children of the host.
542
- // `:scope >` keeps a nested `<jssm-instance>`'s actions from being
543
- // claimed by an outer host.
544
934
  const tags = this.querySelectorAll(':scope > jssm-action');
545
935
  for (const tag of Array.from(tags)) {
546
936
  const selector = tag.getAttribute('selector');
547
937
  const action_name = tag.getAttribute('action');
548
938
  if (selector === null || action_name === null) {
549
- // Required attrs missing — skip, but don't throw: a malformed tag
550
- // shouldn't break the rest of the host's wiring.
551
939
  continue;
552
940
  }
553
941
  const event_name = (_b = tag.getAttribute('event')) !== null && _b !== void 0 ? _b : 'click';
@@ -571,18 +959,7 @@ class JssmInstance extends LitElement {
571
959
  }
572
960
  /**
573
961
  * Attach one DOM listener that translates a DOM event into a
574
- * `machine.action(...)` call, honoring the configured modifiers. The
575
- * listener is recorded in {@link _action_listeners} so it can be removed
576
- * on disconnect.
577
- *
578
- * @param config - Listener configuration.
579
- * @param config.source - Element to attach the listener to.
580
- * @param config.event_name - DOM event to listen for.
581
- * @param config.action_name - Action to dispatch on the machine.
582
- * @param config.from_state - If set, only fire when `machine.state() === from_state`.
583
- * @param config.from_property - If set, pass `source[from_property]` as the action's data argument.
584
- * @param config.prevent_default - If true, call `e.preventDefault()` before checking the guard.
585
- * @param config.stop_propagation - If true, call `e.stopPropagation()` before checking the guard.
962
+ * `machine.action(...)` call, honoring the configured modifiers.
586
963
  */
587
964
  _install_action_listener(config) {
588
965
  const handler = (e) => {
@@ -592,7 +969,6 @@ class JssmInstance extends LitElement {
592
969
  if (config.stop_propagation) {
593
970
  e.stopPropagation();
594
971
  }
595
- // Guard: skip dispatch when the machine isn't in the required state.
596
972
  if (config.from_state !== undefined && this.state() !== config.from_state) {
597
973
  return;
598
974
  }
@@ -696,4 +1072,4 @@ JssmInstance.properties = {
696
1072
  fsl: { type: String, reflect: false },
697
1073
  };
698
1074
 
699
- export { JssmInstance, resolve_fsl_source };
1075
+ export { JSSM_ON_EVENT_NAMES, JssmInstance, compile_inline_body, jssm_handler_registry, parse_jssm_on_element, resolve_fsl_source, resolve_named_handler };