jssm 5.141.1 → 5.141.2

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.
package/README.md CHANGED
@@ -18,10 +18,10 @@ Please edit the file it's derived from, instead: `./src/md/readme_base.md`
18
18
 
19
19
 
20
20
 
21
- * Generated for version 5.141.1 at 6/1/2026, 7:35:45 PM
21
+ * Generated for version 5.141.2 at 6/2/2026, 7:18:20 AM
22
22
 
23
23
  -->
24
- # jssm 5.141.1
24
+ # jssm 5.141.2
25
25
 
26
26
  [**Try the live editor**](https://stonecypher.github.io/jssm-viz-demo/graph_explorer.html) ·
27
27
  [Documentation](https://stonecypher.github.io/jssm/docs/) ·
@@ -281,7 +281,7 @@ That decision shows up everywhere downstream:
281
281
  or run `npm run benny` against your own machine.
282
282
 
283
283
  - **More thoroughly tested than any other JavaScript state-machine
284
- library.** 6,458 tests at 100.0% line coverage
284
+ library.** 6,473 tests at 100.0% line coverage
285
285
  ([report](https://coveralls.io/github/StoneCypher/jssm)), plus
286
286
  fuzz testing via `fast-check`, with parser test data across ten natural
287
287
  languages and Emoji.
@@ -414,11 +414,11 @@ If your contribution is missing here, please open an issue.
414
414
 
415
415
  <br/>
416
416
 
417
- ***6,458 tests***, run 57,245 times.
417
+ ***6,473 tests***, run 57,260 times.
418
418
 
419
- - 5,945 specs with 100.0% coverage
419
+ - 5,960 specs with 100.0% coverage
420
420
  - 513 fuzz tests with 3.4% coverage
421
- - 5,555 TypeScript lines - 1.2 tests per line, 10.3 generated tests per line
421
+ - 5,566 TypeScript lines - 1.2 tests per line, 10.3 generated tests per line
422
422
 
423
423
  [![Actions Status](https://github.com/StoneCypher/jssm/workflows/Node%20CI/badge.svg)](https://github.com/StoneCypher/jssm/actions)
424
424
  [![NPM version](https://img.shields.io/npm/v/jssm.svg)](https://www.npmjs.com/package/jssm)
@@ -21327,7 +21327,7 @@ var constants = /*#__PURE__*/Object.freeze({
21327
21327
  * Useful for runtime diagnostics and for embedding in serialized machine
21328
21328
  * snapshots so that deserializers can detect version-skew.
21329
21329
  */
21330
- const version = "5.141.1";
21330
+ const version = "5.141.2";
21331
21331
 
21332
21332
  // whargarbl lots of these return arrays could/should be sets
21333
21333
  const { state_name_chars, state_name_first_chars, action_label_chars } = constants;
@@ -21622,6 +21622,7 @@ class Machine {
21622
21622
  this._timeout_target_time = undefined;
21623
21623
  this._after_mapping = new Map();
21624
21624
  this._event_handlers = new Map();
21625
+ this._event_listener_count = 0;
21625
21626
  this._firing_error = false;
21626
21627
  // consolidate the state declarations
21627
21628
  if (state_declaration) {
@@ -22978,12 +22979,28 @@ class Machine {
22978
22979
  }
22979
22980
  for (const entry of set) {
22980
22981
  if (entry.handler === handler) {
22981
- set.delete(entry);
22982
+ this._unsubscribe_entry(set, entry);
22982
22983
  return true;
22983
22984
  }
22984
22985
  }
22985
22986
  return false;
22986
22987
  }
22988
+ /**
22989
+ * Remove one event-subscription entry from its set and keep
22990
+ * {@link Machine._event_listener_count} in sync. The count is decremented
22991
+ * only when the entry was actually present, so calling a stale unsubscribe
22992
+ * closure (or removing an already-fired `once` entry) is idempotent and
22993
+ * cannot drive the count negative.
22994
+ *
22995
+ * @param set The per-event-name subscription set.
22996
+ * @param entry The entry to remove.
22997
+ * @internal
22998
+ */
22999
+ _unsubscribe_entry(set, entry) {
23000
+ if (set.delete(entry)) {
23001
+ this._event_listener_count--;
23002
+ }
23003
+ }
22987
23004
  /**
22988
23005
  * Shared registration core used by {@link Machine.on} and
22989
23006
  * {@link Machine.once}. Normalizes the optional filter argument and
@@ -23012,7 +23029,8 @@ class Machine {
23012
23029
  }
23013
23030
  const entry = { handler, filter, once };
23014
23031
  set.add(entry);
23015
- return () => { set.delete(entry); };
23032
+ this._event_listener_count++;
23033
+ return () => { this._unsubscribe_entry(set, entry); };
23016
23034
  }
23017
23035
  /**
23018
23036
  * Dispatch an event to every registered subscriber in registration
@@ -23052,7 +23070,7 @@ class Machine {
23052
23070
  // gets removed and so re-entrant `on` calls during the handler see
23053
23071
  // the post-removal state.
23054
23072
  if (entry.once) {
23055
- set.delete(entry);
23073
+ this._unsubscribe_entry(set, entry);
23056
23074
  }
23057
23075
  try {
23058
23076
  entry.handler(detail);
@@ -23861,15 +23879,25 @@ class Machine {
23861
23879
  newState = newStateOrAction;
23862
23880
  }
23863
23881
  }
23864
- const hook_args = {
23865
- data: this._data,
23866
- action: fromAction,
23867
- from: this._state,
23868
- to: newState,
23869
- next_data: newData,
23870
- forced: wasForced,
23871
- trans_type
23872
- };
23882
+ // hook_args is read only inside the `_has_hooks` / `_has_post_hooks`
23883
+ // blocks below. Skip building it for hook-free machines (every
23884
+ // chain/dense/hub/messy benchmark shape) so the hot path stops allocating
23885
+ // a 7-field object it never reads. The NonNullable cast keeps the type
23886
+ // unchanged for all downstream uses without introducing an impossible
23887
+ // (uncoverable) branch; the value is only dereferenced under the guards
23888
+ // that imply it was built. #670
23889
+ const hook_args_obj = (this._has_hooks || this._has_post_hooks)
23890
+ ? {
23891
+ data: this._data,
23892
+ action: fromAction,
23893
+ from: this._state,
23894
+ to: newState,
23895
+ next_data: newData,
23896
+ forced: wasForced,
23897
+ trans_type
23898
+ }
23899
+ : undefined;
23900
+ const hook_args = hook_args_obj;
23873
23901
  // 'action' event fires when an action is attempted, regardless of whether
23874
23902
  // it ultimately succeeds — matches the issue spec for observation events.
23875
23903
  if (wasAction) {
@@ -24155,46 +24183,53 @@ class Machine {
24155
24183
  this._post_everything_hook(Object.assign(Object.assign({}, hook_args), { hook_name: 'post everything' }));
24156
24184
  }
24157
24185
  }
24158
- // observation events: fire after the state has been committed and all
24159
- // hooks (pre and post) have completed, matching the semantics that
24160
- // events observe rather than intercept (#638).
24161
- const newData_after = this._data;
24162
- this._fire('exit', {
24163
- state: fromState,
24164
- to: newState,
24165
- action: fromAction,
24166
- data: newData_after
24167
- });
24168
- this._fire('transition', {
24169
- from: fromState,
24170
- to: newState,
24171
- action: fromAction,
24172
- data: newData_after,
24173
- next_data: newData,
24174
- trans_type,
24175
- forced: wasForced
24176
- });
24177
- this._fire('entry', {
24178
- state: newState,
24179
- from: fromState,
24180
- action: fromAction,
24181
- data: newData_after
24182
- });
24183
- if (oldData !== newData_after) {
24184
- this._fire('data-change', {
24186
+ // Observation events (#638) fire after the state is committed. Each call
24187
+ // builds a detail literal at the call site, so guard the whole block on a
24188
+ // live subscription count: with zero listeners (the common hot-path case,
24189
+ // and every benchmark shape) we skip all of these allocations entirely.
24190
+ // Read after pre-hooks, so a listener a pre-hook installed is still seen.
24191
+ // ('action' above and 'rejection' on the invalid path are intentionally
24192
+ // NOT under this gate — they fire regardless, and `_fire` itself no-ops
24193
+ // cheaply when that specific event has no subscribers.) #670
24194
+ if (this._event_listener_count !== 0) {
24195
+ const newData_after = this._data;
24196
+ this._fire('exit', {
24197
+ state: fromState,
24198
+ to: newState,
24199
+ action: fromAction,
24200
+ data: newData_after
24201
+ });
24202
+ this._fire('transition', {
24185
24203
  from: fromState,
24186
24204
  to: newState,
24187
24205
  action: fromAction,
24188
- old_data: oldData,
24189
- new_data: newData_after,
24190
- cause: 'transition'
24206
+ data: newData_after,
24207
+ next_data: newData,
24208
+ trans_type,
24209
+ forced: wasForced
24191
24210
  });
24192
- }
24193
- if (this.state_is_terminal(newState)) {
24194
- this._fire('terminal', { state: newState, data: newData_after });
24195
- }
24196
- if (this.state_is_complete(newState)) {
24197
- this._fire('complete', { state: newState, data: newData_after });
24211
+ this._fire('entry', {
24212
+ state: newState,
24213
+ from: fromState,
24214
+ action: fromAction,
24215
+ data: newData_after
24216
+ });
24217
+ if (oldData !== newData_after) {
24218
+ this._fire('data-change', {
24219
+ from: fromState,
24220
+ to: newState,
24221
+ action: fromAction,
24222
+ old_data: oldData,
24223
+ new_data: newData_after,
24224
+ cause: 'transition'
24225
+ });
24226
+ }
24227
+ if (this.state_is_terminal(newState)) {
24228
+ this._fire('terminal', { state: newState, data: newData_after });
24229
+ }
24230
+ if (this.state_is_complete(newState)) {
24231
+ this._fire('complete', { state: newState, data: newData_after });
24232
+ }
24198
24233
  }
24199
24234
  // possibly re-establish new 'after' clause
24200
24235
  this.auto_set_state_timeout();
package/dist/cdn/viz.js CHANGED
@@ -21352,7 +21352,7 @@ var constants = /*#__PURE__*/Object.freeze({
21352
21352
  * Useful for runtime diagnostics and for embedding in serialized machine
21353
21353
  * snapshots so that deserializers can detect version-skew.
21354
21354
  */
21355
- const version = "5.141.1";
21355
+ const version = "5.141.2";
21356
21356
 
21357
21357
  // whargarbl lots of these return arrays could/should be sets
21358
21358
  const { state_name_chars, state_name_first_chars, action_label_chars } = constants;
@@ -21647,6 +21647,7 @@ class Machine {
21647
21647
  this._timeout_target_time = undefined;
21648
21648
  this._after_mapping = new Map();
21649
21649
  this._event_handlers = new Map();
21650
+ this._event_listener_count = 0;
21650
21651
  this._firing_error = false;
21651
21652
  // consolidate the state declarations
21652
21653
  if (state_declaration) {
@@ -23003,12 +23004,28 @@ class Machine {
23003
23004
  }
23004
23005
  for (const entry of set) {
23005
23006
  if (entry.handler === handler) {
23006
- set.delete(entry);
23007
+ this._unsubscribe_entry(set, entry);
23007
23008
  return true;
23008
23009
  }
23009
23010
  }
23010
23011
  return false;
23011
23012
  }
23013
+ /**
23014
+ * Remove one event-subscription entry from its set and keep
23015
+ * {@link Machine._event_listener_count} in sync. The count is decremented
23016
+ * only when the entry was actually present, so calling a stale unsubscribe
23017
+ * closure (or removing an already-fired `once` entry) is idempotent and
23018
+ * cannot drive the count negative.
23019
+ *
23020
+ * @param set The per-event-name subscription set.
23021
+ * @param entry The entry to remove.
23022
+ * @internal
23023
+ */
23024
+ _unsubscribe_entry(set, entry) {
23025
+ if (set.delete(entry)) {
23026
+ this._event_listener_count--;
23027
+ }
23028
+ }
23012
23029
  /**
23013
23030
  * Shared registration core used by {@link Machine.on} and
23014
23031
  * {@link Machine.once}. Normalizes the optional filter argument and
@@ -23037,7 +23054,8 @@ class Machine {
23037
23054
  }
23038
23055
  const entry = { handler, filter, once };
23039
23056
  set.add(entry);
23040
- return () => { set.delete(entry); };
23057
+ this._event_listener_count++;
23058
+ return () => { this._unsubscribe_entry(set, entry); };
23041
23059
  }
23042
23060
  /**
23043
23061
  * Dispatch an event to every registered subscriber in registration
@@ -23077,7 +23095,7 @@ class Machine {
23077
23095
  // gets removed and so re-entrant `on` calls during the handler see
23078
23096
  // the post-removal state.
23079
23097
  if (entry.once) {
23080
- set.delete(entry);
23098
+ this._unsubscribe_entry(set, entry);
23081
23099
  }
23082
23100
  try {
23083
23101
  entry.handler(detail);
@@ -23886,15 +23904,25 @@ class Machine {
23886
23904
  newState = newStateOrAction;
23887
23905
  }
23888
23906
  }
23889
- const hook_args = {
23890
- data: this._data,
23891
- action: fromAction,
23892
- from: this._state,
23893
- to: newState,
23894
- next_data: newData,
23895
- forced: wasForced,
23896
- trans_type
23897
- };
23907
+ // hook_args is read only inside the `_has_hooks` / `_has_post_hooks`
23908
+ // blocks below. Skip building it for hook-free machines (every
23909
+ // chain/dense/hub/messy benchmark shape) so the hot path stops allocating
23910
+ // a 7-field object it never reads. The NonNullable cast keeps the type
23911
+ // unchanged for all downstream uses without introducing an impossible
23912
+ // (uncoverable) branch; the value is only dereferenced under the guards
23913
+ // that imply it was built. #670
23914
+ const hook_args_obj = (this._has_hooks || this._has_post_hooks)
23915
+ ? {
23916
+ data: this._data,
23917
+ action: fromAction,
23918
+ from: this._state,
23919
+ to: newState,
23920
+ next_data: newData,
23921
+ forced: wasForced,
23922
+ trans_type
23923
+ }
23924
+ : undefined;
23925
+ const hook_args = hook_args_obj;
23898
23926
  // 'action' event fires when an action is attempted, regardless of whether
23899
23927
  // it ultimately succeeds — matches the issue spec for observation events.
23900
23928
  if (wasAction) {
@@ -24180,46 +24208,53 @@ class Machine {
24180
24208
  this._post_everything_hook(Object.assign(Object.assign({}, hook_args), { hook_name: 'post everything' }));
24181
24209
  }
24182
24210
  }
24183
- // observation events: fire after the state has been committed and all
24184
- // hooks (pre and post) have completed, matching the semantics that
24185
- // events observe rather than intercept (#638).
24186
- const newData_after = this._data;
24187
- this._fire('exit', {
24188
- state: fromState,
24189
- to: newState,
24190
- action: fromAction,
24191
- data: newData_after
24192
- });
24193
- this._fire('transition', {
24194
- from: fromState,
24195
- to: newState,
24196
- action: fromAction,
24197
- data: newData_after,
24198
- next_data: newData,
24199
- trans_type,
24200
- forced: wasForced
24201
- });
24202
- this._fire('entry', {
24203
- state: newState,
24204
- from: fromState,
24205
- action: fromAction,
24206
- data: newData_after
24207
- });
24208
- if (oldData !== newData_after) {
24209
- this._fire('data-change', {
24211
+ // Observation events (#638) fire after the state is committed. Each call
24212
+ // builds a detail literal at the call site, so guard the whole block on a
24213
+ // live subscription count: with zero listeners (the common hot-path case,
24214
+ // and every benchmark shape) we skip all of these allocations entirely.
24215
+ // Read after pre-hooks, so a listener a pre-hook installed is still seen.
24216
+ // ('action' above and 'rejection' on the invalid path are intentionally
24217
+ // NOT under this gate — they fire regardless, and `_fire` itself no-ops
24218
+ // cheaply when that specific event has no subscribers.) #670
24219
+ if (this._event_listener_count !== 0) {
24220
+ const newData_after = this._data;
24221
+ this._fire('exit', {
24222
+ state: fromState,
24223
+ to: newState,
24224
+ action: fromAction,
24225
+ data: newData_after
24226
+ });
24227
+ this._fire('transition', {
24210
24228
  from: fromState,
24211
24229
  to: newState,
24212
24230
  action: fromAction,
24213
- old_data: oldData,
24214
- new_data: newData_after,
24215
- cause: 'transition'
24231
+ data: newData_after,
24232
+ next_data: newData,
24233
+ trans_type,
24234
+ forced: wasForced
24216
24235
  });
24217
- }
24218
- if (this.state_is_terminal(newState)) {
24219
- this._fire('terminal', { state: newState, data: newData_after });
24220
- }
24221
- if (this.state_is_complete(newState)) {
24222
- this._fire('complete', { state: newState, data: newData_after });
24236
+ this._fire('entry', {
24237
+ state: newState,
24238
+ from: fromState,
24239
+ action: fromAction,
24240
+ data: newData_after
24241
+ });
24242
+ if (oldData !== newData_after) {
24243
+ this._fire('data-change', {
24244
+ from: fromState,
24245
+ to: newState,
24246
+ action: fromAction,
24247
+ old_data: oldData,
24248
+ new_data: newData_after,
24249
+ cause: 'transition'
24250
+ });
24251
+ }
24252
+ if (this.state_is_terminal(newState)) {
24253
+ this._fire('terminal', { state: newState, data: newData_after });
24254
+ }
24255
+ if (this.state_is_complete(newState)) {
24256
+ this._fire('complete', { state: newState, data: newData_after });
24257
+ }
24223
24258
  }
24224
24259
  // possibly re-establish new 'after' clause
24225
24260
  this.auto_set_state_timeout();