@soundworks/core 4.0.0-alpha.2 → 4.0.0-alpha.21

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,4 +1,6 @@
1
+ import { isPlainObject } from '@ircam/sc-utils';
1
2
  import ParameterBag from './ParameterBag.js';
3
+ import PromiseStore from './PromiseStore.js';
2
4
  import {
3
5
  DELETE_REQUEST,
4
6
  DELETE_RESPONSE,
@@ -12,29 +14,44 @@ import {
12
14
  UPDATE_ABORT,
13
15
  UPDATE_NOTIFICATION,
14
16
  } from './constants.js';
15
- import {
16
- storeRequestPromise,
17
- resolveRequest,
18
- rejectRequest,
19
- } from './promise-store.js';
20
17
 
21
- class BaseSharedState {
22
- constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues = {}) {
23
- this.id = id;
24
- this.remoteId = remoteId;
25
- this.schemaName = schemaName;
18
+ // @todo - replace isOwner by `type`:
19
+ // enum {
20
+ // 'owner'
21
+ // 'attached'
22
+ // 'collection-controlller'
23
+ // 'collection-attached'
24
+ // }
26
25
 
26
+ class BaseSharedState {
27
+ constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues, filter) {
28
+ /** @private */
29
+ this._id = id;
30
+ /** @private */
31
+ this._remoteId = remoteId;
32
+ /** @private */
33
+ this._schemaName = schemaName;
27
34
  /** @private */
28
35
  this._isOwner = isOwner; // may be the server or any client
29
36
  /** @private */
30
37
  this._client = client;
31
38
  /** @private */
32
39
  this._manager = manager;
40
+ /** @private */
41
+ this._filter = filter;
42
+
43
+ /**
44
+ * true is the state has been detached or deleted
45
+ * @private
46
+ */
47
+ this._detached = false;
48
+ /* @private */
49
+ this._promiseStore = new PromiseStore(this.constructor.name);
33
50
 
34
51
  try {
35
52
  /** @private */
36
53
  this._parameters = new ParameterBag(schema, initValues);
37
- } catch(err) {
54
+ } catch (err) {
38
55
  console.error(err.stack);
39
56
 
40
57
  throw new Error(`Error creating or attaching state "${schemaName}" w/ values:\n
@@ -51,13 +68,13 @@ ${JSON.stringify(initValues, null, 2)}`);
51
68
  // add listener for state updates
52
69
  this._client.transport.addListener(`${UPDATE_RESPONSE}-${this.id}-${this.remoteId}`, async (reqId, updates, context) => {
53
70
  const updated = await this._commit(updates, context, true, true);
54
- resolveRequest(reqId, updated);
71
+ this._promiseStore.resolve(reqId, updated);
55
72
  });
56
73
 
57
74
  // retrieve values but do not propagate to subscriptions
58
75
  this._client.transport.addListener(`${UPDATE_ABORT}-${this.id}-${this.remoteId}`, async (reqId, updates, context) => {
59
76
  const updated = await this._commit(updates, context, false, true);
60
- resolveRequest(reqId, updated);
77
+ this._promiseStore.resolve(reqId, updated);
61
78
  });
62
79
 
63
80
  this._client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.id}-${this.remoteId}`, async (updates, context) => {
@@ -87,70 +104,121 @@ ${JSON.stringify(initValues, null, 2)}`);
87
104
  });
88
105
 
89
106
  // ---------------------------------------------
90
- // DELETE initiated by creator, or schema deleted
107
+ // state has been deleted by its creator or the schema has been deleted
91
108
  // ---------------------------------------------
92
- this._client.transport.addListener(`${DELETE_NOTIFICATION}-${this.id}-${this.remoteId}`, () => {
109
+ this._client.transport.addListener(`${DELETE_NOTIFICATION}-${this.id}-${this.remoteId}`, async () => {
93
110
  this._manager._statesById.delete(this.id);
94
111
  this._clearTransport();
95
112
 
96
- this._onDetachCallbacks.forEach(callback => callback());
97
- this._onDeleteCallbacks.forEach(callback => callback());
113
+ for (let callback of this._onDetachCallbacks) {
114
+ try {
115
+ await callback();
116
+ } catch (err) {
117
+ console.error(err.message);
118
+ }
119
+ }
120
+
121
+ if (this._isOwner) {
122
+ for (let callback of this._onDeleteCallbacks) {
123
+ await callback();
124
+ }
125
+ }
98
126
 
99
- this._clearDetach();
127
+ this._onDetachCallbacks.clear();
128
+ this._onDeleteCallbacks.clear();
129
+ this._promiseStore.flush();
100
130
  });
101
131
 
102
132
 
103
133
  if (this._isOwner) {
104
134
  // ---------------------------------------------
105
- // DELETE (can only delete if creator)
135
+ // the creator has called `.delete()`
106
136
  // ---------------------------------------------
107
- this._client.transport.addListener(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`, (reqId) => {
137
+ this._client.transport.addListener(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`, async (reqId) => {
108
138
  this._manager._statesById.delete(this.id);
109
139
  this._clearTransport();
110
140
 
111
- this._onDetachCallbacks.forEach(callback => callback());
112
- this._onDeleteCallbacks.forEach(callback => callback());
141
+ for (let callback of this._onDetachCallbacks) {
142
+ await callback();
143
+ }
144
+
145
+ for (let callback of this._onDeleteCallbacks) {
146
+ await callback();
147
+ }
113
148
 
114
- this._clearDetach();
115
- resolveRequest(reqId, this);
149
+ this._onDetachCallbacks.clear();
150
+ this._onDeleteCallbacks.clear();
151
+ this._promiseStore.resolve(reqId, this);
152
+ this._promiseStore.flush();
116
153
  });
117
154
 
118
155
  this._client.transport.addListener(`${DELETE_ERROR}-${this.id}`, (reqId, msg) => {
119
- rejectRequest(reqId, msg);
156
+ this._promiseStore.reject(reqId, msg);
120
157
  });
121
158
 
122
159
  } else {
123
160
  // ---------------------------------------------
124
- // DETACH
161
+ // the attached node has called `.detach()`
125
162
  // ---------------------------------------------
126
- this._client.transport.addListener(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`, (reqId) => {
163
+ this._client.transport.addListener(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`, async (reqId) => {
127
164
  this._manager._statesById.delete(this.id);
128
165
  this._clearTransport();
129
166
 
130
- this._onDetachCallbacks.forEach(func => func());
167
+ for (let callback of this._onDetachCallbacks) {
168
+ await callback();
169
+ }
131
170
 
132
- this._clearDetach();
133
- resolveRequest(reqId, this);
171
+ this._onDetachCallbacks.clear();
172
+ this._onDeleteCallbacks.clear();
173
+ this._promiseStore.resolve(reqId, this);
174
+ this._promiseStore.flush();
134
175
  });
135
176
 
136
177
  // the state does not exists anymore in the server (should not happen)
137
178
  this._client.transport.addListener(`${DETACH_ERROR}-${this.id}`, (reqId, msg) => {
138
- rejectRequest(reqId, msg);
179
+ this._onDetachCallbacks.clear();
180
+ this._onDeleteCallbacks.clear();
181
+ this._promiseStore.reject(reqId, msg);
182
+ this._promiseStore.flush();
139
183
  });
140
-
141
184
  }
142
185
  }
143
186
 
144
- /** @private */
145
- _clearDetach() {
146
- this._onDetachCallbacks.clear();
147
- this._onDeleteCallbacks.clear();
148
-
149
- // Monkey patch detach so it throws if called twice. Doing nothing blocks
150
- // the process on a second `detach` call as the Promise never resolves
151
- this.detach = () => {
152
- throw new Error(`[stateManager] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
153
- };
187
+ /**
188
+ * Id of the state
189
+ * @type {Number}
190
+ * @readonly
191
+ */
192
+ get id() {
193
+ return this._id;
194
+ }
195
+
196
+ /**
197
+ * Unique id of the state for the current node
198
+ * @readonly
199
+ * @type {Number}
200
+ * @private
201
+ */
202
+ get remoteId() {
203
+ return this._remoteId;
204
+ }
205
+
206
+ /**
207
+ * Name of the schema
208
+ * @type {String}
209
+ * @readonly
210
+ */
211
+ get schemaName() {
212
+ return this._schemaName;
213
+ }
214
+
215
+ /**
216
+ * Indicates if the node is the owner of the state
217
+ * @type {Boolean}
218
+ * @readonly
219
+ */
220
+ get isOwner() {
221
+ return this._isOwner;
154
222
  }
155
223
 
156
224
  /** @private */
@@ -158,9 +226,16 @@ ${JSON.stringify(initValues, null, 2)}`);
158
226
  // remove listeners
159
227
  this._client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.id}-${this.remoteId}`);
160
228
  this._client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.id}-${this.remoteId}`);
161
- this._client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`);
229
+ this._client.transport.removeAllListeners(`${UPDATE_ABORT}-${this.id}-${this.remoteId}`);
162
230
  this._client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.id}-${this.remoteId}`);
163
- this._client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`);
231
+
232
+ if (this._isOwner) {
233
+ this._client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`);
234
+ this._client.transport.removeAllListeners(`${DELETE_ERROR}-${this.id}-${this.remoteId}`);
235
+ } else {
236
+ this._client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`);
237
+ this._client.transport.removeAllListeners(`${DETACH_ERROR}-${this.id}-${this.remoteId}`);
238
+ }
164
239
  }
165
240
 
166
241
  /** @private */
@@ -255,85 +330,222 @@ ${JSON.stringify(initValues, null, 2)}`);
255
330
  * const updates = await state.set({ myParam: Math.random() });
256
331
  */
257
332
  async set(updates, context = null) {
258
- // handle immediate option
259
- const immediateNewValues = {};
260
- const immediateOldValues = {};
333
+ if (this._detached) {
334
+ return;
335
+ }
336
+
337
+ if (!isPlainObject(updates)) {
338
+ throw new TypeError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as first parameter`);
339
+ }
340
+
341
+ if (context !== null && !isPlainObject(context)) {
342
+ throw new TypeError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as second parameter`);
343
+ }
344
+
345
+ const newValues = {};
346
+ const oldValues = {};
347
+ const localParams = {};
348
+ const sharedParams = {};
349
+ let hasLocalParam = false;
350
+ let hasSharedParam = false;
351
+ let forwardParams = undefined;
261
352
  let propagateNow = false;
262
353
 
263
354
  for (let name in updates) {
264
- // throw early (client-side and not only server-side) if parameter is undefined
265
- if (!this._parameters.has(name)) {
266
- throw new ReferenceError(`[stateManager] Cannot set value of undefined parameter "${name}"`);
355
+ // Try to coerce value early, so that eventual errors are triggered early
356
+ // on the node requesting the update, and not only on the server side
357
+ // This throws if name does not exists
358
+ this._parameters.coerceValue(name, updates[name]);
359
+
360
+ // Check that name is in filter list, if any
361
+ if (this._filter !== null) {
362
+ if (!this._filter.includes(name)) {
363
+ throw new DOMException(`[SharedState] State "${this.schemaName}": cannot set parameter '${name}', parameter is not in filter list`, 'NotSupportedError');
364
+ }
267
365
  }
268
366
 
269
- // @note: general idea...
270
- // if immediate=true
367
+ // `immediate` option behavior
368
+ //
369
+ // If immediate=true
271
370
  // - call listeners if value changed
272
371
  // - go through normal server path
273
372
  // - retrigger only if response from server is different from current value
274
- // if immediate=true && (filterChange=false || event=true)
373
+ // If immediate=true && (filterChange=false || event=true)
275
374
  // - call listeners with value regarless it changed
276
375
  // - go through normal server path
277
376
  // - if the node is initiator of the update (UPDATE_RESPONSE), (re-)check
278
377
  // to prevent execute the listeners twice
279
378
 
280
- const { immediate, filterChange, event } = this._parameters.getSchema(name);
379
+ // `local` option behavior
380
+ //
381
+ // - If the `updates` object only contains local variables, we can call the
382
+ // update listeners and return a fulfilled promise immediately
383
+ // - If parameters that require network communication are present, we call the
384
+ // update callback onces with the local payload, then we need to wait for the server
385
+ // response, call update listeners with server response and resolve promise with
386
+ // the full payload, i.e. reintegrating the local params in the resolve payload
387
+
388
+ const { local, immediate, filterChange, event } = this._parameters.getSchema(name);
281
389
 
282
- if (immediate) {
390
+ if (immediate || local) {
283
391
  const oldValue = this._parameters.get(name);
284
392
  const [newValue, changed] = this._parameters.set(name, updates[name]);
285
393
 
394
+ // prepare data for immediate propagation of listeners
286
395
  if (changed || filterChange === false || event) {
287
- immediateOldValues[name] = oldValue;
288
- immediateNewValues[name] = newValue;
396
+ oldValues[name] = oldValue;
397
+ newValues[name] = newValue;
289
398
  propagateNow = true;
290
399
  }
291
400
  }
401
+
402
+ // define params that must go through network or not
403
+ if (local) {
404
+ hasLocalParam = true;
405
+ // get sanitize value for fulfilling promise
406
+ localParams[name] = this._parameters.get(name);
407
+ } else {
408
+ // note that immediate are shared params too
409
+ hasSharedParam = true;
410
+ sharedParams[name] = updates[name];
411
+ }
292
412
  }
293
413
 
414
+ // propagate immediate params if changed
294
415
  if (propagateNow) {
295
- this._onUpdateCallbacks.forEach(listener => listener(immediateNewValues, immediateOldValues, context));
416
+ this._onUpdateCallbacks.forEach(listener => listener(newValues, oldValues, context));
296
417
  }
297
418
 
419
+ // check if we can resolve immediately or if we need to go through network
420
+ if (hasLocalParam) {
421
+ if (!hasSharedParam) {
422
+ return Promise.resolve(localParams);
423
+ } else {
424
+ // store local params to fulfill promise with all values, see PromiseStore
425
+ forwardParams = localParams;
426
+ }
427
+ }
428
+
429
+ // override updates to be shared on network without local params
430
+ updates = sharedParams;
431
+
298
432
  // go through server-side normal behavior
299
433
  return new Promise((resolve, reject) => {
300
- const reqId = storeRequestPromise(resolve, reject);
434
+ const reqId = this._promiseStore.add(resolve, reject, 'update-request', forwardParams);
301
435
  this._client.transport.emit(`${UPDATE_REQUEST}-${this.id}-${this.remoteId}`, reqId, updates, context);
302
436
  });
303
437
  }
304
438
 
305
439
  /**
306
- * Get the value of a paramter of the state.
440
+ * Get the value of a parameter of the state. If the parameter is of `any` type,
441
+ * a deep copy is returned.
307
442
  *
308
443
  * @param {string} name - Name of the param.
309
444
  * @throws Throws if `name` does not correspond to an existing field
310
445
  * of the state.
311
446
  * @return {mixed}
312
447
  * @example
313
- * const value = state.get('name');
448
+ * const value = state.get('paramName');
314
449
  */
315
450
  get(name) {
451
+ if (!this._parameters.has(name)) {
452
+ throw new ReferenceError(`[SharedState] State "${this.schemaName}": Cannot get value of undefined parameter "${name}"`);
453
+ }
454
+
455
+ if (this._filter !== null) {
456
+ if (!this._filter.includes(name)) {
457
+ throw new DOMException(`[SharedState] State "${this.schemaName}": cannot get parameter '${name}', parameter is not in filter list`, 'NotSupportedError');
458
+ }
459
+ }
460
+
316
461
  return this._parameters.get(name);
317
462
  }
318
463
 
464
+ /**
465
+ * Similar to `get` but returns a reference to the underlying value in case of
466
+ * `any` type. May be usefull if the underlying value is big (e.g. sensors
467
+ * recordings, etc.) and deep cloning expensive. Be aware that if changes are
468
+ * made on the returned object, the state of your application will become
469
+ * inconsistent.
470
+ *
471
+ * @param {string} name - Name of the param.
472
+ * @throws Throws if `name` does not correspond to an existing field
473
+ * of the state.
474
+ * @return {mixed}
475
+ * @example
476
+ * const value = state.getUnsafe('paramName');
477
+ */
478
+ getUnsafe(name) {
479
+ if (!this._parameters.has(name)) {
480
+ throw new ReferenceError(`[SharedState] State "${this.schemaName}": Cannot get value of undefined parameter "${name}"`);
481
+ }
482
+
483
+ if (this._filter !== null) {
484
+ if (!this._filter.includes(name)) {
485
+ throw new DOMException(`[SharedState] State "${this.schemaName}": cannot get parameter '${name}', parameter is not in filter list`, 'NotSupportedError');
486
+ }
487
+ }
488
+
489
+ return this._parameters.getUnsafe(name);
490
+ }
491
+
319
492
  /**
320
493
  * Get all the key / value pairs of the state.
321
494
  *
495
+ * If a parameter is of `any` type, a deep copy is made.
496
+ *
322
497
  * @return {object}
323
498
  * @example
324
499
  * const values = state.getValues();
325
500
  */
326
501
  getValues() {
327
- return this._parameters.getValues();
502
+ const values = this._parameters.getValues();
503
+
504
+ if (this._filter !== null) {
505
+ for (let name in values) {
506
+ if (!this._filter.includes(name)) {
507
+ delete values[name];
508
+ }
509
+ }
510
+ }
511
+
512
+ return values;
328
513
  }
329
514
 
330
515
  /**
331
- * Get the schema from which the state has been created.
516
+ * Get all the key / value pairs of the state.
517
+ *
518
+ * Similar to `getValues` but returns a reference to the underlying value in
519
+ * case of `any` type. May be usefull if the underlying value is big (e.g.
520
+ * sensors recordings, etc.) and deep cloning expensive. Be aware that if
521
+ * changes are made on the returned object, the state of your application will
522
+ * become inconsistent.
332
523
  *
333
- * @param {string} [name=null] - If given, returns only the definition corresponding
334
- * to the given param name.
524
+ * @return {object}
525
+ * @example
526
+ * const values = state.getValues();
527
+ */
528
+ getValuesUnsafe() {
529
+ const values = this._parameters.getValuesUnsafe();
530
+
531
+ if (this._filter !== null) {
532
+ for (let name in values) {
533
+ if (!this._filter.includes(name)) {
534
+ delete values[name];
535
+ }
536
+ }
537
+ }
538
+
539
+ return values;
540
+ }
541
+
542
+ /**
543
+ * Definition of schema from which the state has been created.
544
+ *
545
+ * @param {string} [name=null] - If given, returns only the definition
546
+ * corresponding to the given param name.
335
547
  * @throws Throws if `name` does not correspond to an existing field
336
- * of the state.
548
+ * of the schema.
337
549
  * @return {object}
338
550
  * @example
339
551
  * const schema = state.getSchema();
@@ -367,7 +579,7 @@ ${JSON.stringify(initValues, null, 2)}`);
367
579
 
368
580
  /**
369
581
  * Detach from the state. If the client is the creator of the state, the state
370
- * is deleted and all attached nodes get notified
582
+ * is deleted and all attached nodes get notified.
371
583
  *
372
584
  * @example
373
585
  * const state = await client.state.attach('globals');
@@ -375,16 +587,21 @@ ${JSON.stringify(initValues, null, 2)}`);
375
587
  * await state.detach();
376
588
  */
377
589
  async detach() {
590
+ if (this._detached) {
591
+ throw new Error(`[SharedState] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
592
+ }
593
+
594
+ this._detached = true; // mark detached early
378
595
  this._onUpdateCallbacks.clear();
379
596
 
380
597
  if (this._isOwner) {
381
598
  return new Promise((resolve, reject) => {
382
- const reqId = storeRequestPromise(resolve, reject);
599
+ const reqId = this._promiseStore.add(resolve, reject, 'delete-request');
383
600
  this._client.transport.emit(`${DELETE_REQUEST}-${this.id}-${this.remoteId}`, reqId);
384
601
  });
385
602
  } else {
386
603
  return new Promise((resolve, reject) => {
387
- const reqId = storeRequestPromise(resolve, reject);
604
+ const reqId = this._promiseStore.add(resolve, reject, 'detach-request');
388
605
  this._client.transport.emit(`${DETACH_REQUEST}-${this.id}-${this.remoteId}`, reqId);
389
606
  });
390
607
  }
@@ -392,9 +609,11 @@ ${JSON.stringify(initValues, null, 2)}`);
392
609
 
393
610
  /**
394
611
  * Delete the state. Only the creator/owner of the state can use this method.
395
- * All nodes attached to the state will be deteched, triggering the `onDetach`
396
- * callback. The creator of the state will also have its `onDelete` callback
397
- * triggered.
612
+ *
613
+ * All nodes attached to the state will be detached, triggering any registered
614
+ * `onDetach` callbacks. The creator of the state will also have its own `onDelete`
615
+ * callback triggered. The local `onDeatch` and `onDelete` callbacks will be
616
+ * executed *before* the returned Promise resolves
398
617
  *
399
618
  * @throws Throws if the method is called by a node which is not the owner of
400
619
  * the state.
@@ -405,16 +624,20 @@ ${JSON.stringify(initValues, null, 2)}`);
405
624
  */
406
625
  async delete() {
407
626
  if (this._isOwner) {
627
+ if (this._detached) {
628
+ throw new Error(`[SharedState] State "${this.schemaName} (${this.id})" already deleted, cannot delete twice`);
629
+ }
630
+
408
631
  return this.detach();
409
632
  } else {
410
- throw new Error(`[stateManager] can delete state "${this.schemaName}", only owner of the state (i.e. the node that "create[d]" it) can delete it`);
633
+ throw new Error(`[SharedState] Cannot delete state "${this.schemaName}", only the owner of the state (i.e. the node that created it) can delete the state. Use "detach" instead.`);
411
634
  }
412
635
  }
413
636
 
414
637
  /**
415
638
  * Subscribe to state updates.
416
639
  *
417
- * @param {client.SharedState~onUpdateCallback|client.SharedState~onUpdateCallback} callback
640
+ * @param {client.SharedState~onUpdateCallback|server.SharedState~onUpdateCallback} callback
418
641
  * Callback to execute when an update is applied on the state.
419
642
  * @param {Boolean} [executeListener=false] - Execute the callback immediately
420
643
  * with current state values. (`oldValues` will be set to `{}`, and `context` to `null`)
@@ -447,10 +670,11 @@ ${JSON.stringify(initValues, null, 2)}`);
447
670
  }
448
671
 
449
672
  /**
450
- * Register a function to execute when detaching from the state
673
+ * Register a function to execute when detaching from the state. The function
674
+ * will be executed before the `detach` promise resolves.
451
675
  *
452
- * @param {Function} callback - callback to execute when detaching from the state.
453
- * wether the client as called `detach`, or the state has been deleted by its
676
+ * @param {Function} callback - Callback to execute when detaching from the state.
677
+ * Whether the client as called `detach`, or the state has been deleted by its
454
678
  * creator.
455
679
  */
456
680
  onDetach(callback) {
@@ -460,9 +684,10 @@ ${JSON.stringify(initValues, null, 2)}`);
460
684
 
461
685
  /**
462
686
  * Register a function to execute when the state is deleted. Only called if the
463
- * node was the creator of the state. Is called after `onDetach`
687
+ * node was the creator of the state. Is called after `onDetach` and executed
688
+ * before the `delete` Promise resolves.
464
689
  *
465
- * @param {Function} callback - callback to execute when the state is deleted.
690
+ * @param {Function} callback - Callback to execute when the state is deleted.
466
691
  */
467
692
  onDelete(callback) {
468
693
  this._onDeleteCallbacks.add(callback);