@soundworks/core 5.6.0 → 5.7.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,4 +1,7 @@
1
- import { isPlainObject, isString } from '@ircam/sc-utils';
1
+ import {
2
+ isPlainObject,
3
+ isString,
4
+ } from '@ircam/sc-utils';
2
5
 
3
6
  import ParameterBag from './ParameterBag.js';
4
7
  import PromiseStore from './PromiseStore.js';
@@ -20,8 +23,13 @@ import {
20
23
  import {
21
24
  kStateManagerClient,
22
25
  kStateManagerDeleteState,
26
+ kPendingSharedStateConstructionData,
23
27
  } from './BaseStateManager.js';
24
28
 
29
+ import {
30
+ sanitizeOnUpdateParams,
31
+ } from './shared-state-utils.js';
32
+
25
33
  import warnings from './logs/warnings.js';
26
34
 
27
35
  // for testing purposes
@@ -31,10 +39,14 @@ export const kSharedStatePromiseStore = Symbol('soundworks:shared-state-promise-
31
39
  * Callback executed when updates are applied on a {@link SharedState}.
32
40
  *
33
41
  * @callback sharedStateOnUpdateCallback
34
- * @param {Object} newValues - Key / value pairs of the updates that have been
35
- * applied to the state.
36
- * @param {Object} oldValues - Key / value pairs of the updated params before
37
- * the updates has been applied to the state.
42
+ * @param {Object|any} newValues - Key / value pairs of the updates that have been
43
+ * applied to the state, or single value if the callback has been registered against
44
+ * a parameter name filter.
45
+ * @param {Object|any} oldValues - Key / value pairs of the updated params before
46
+ * the updates has been applied to the state, or single value if the callback has been
47
+ * registered against a parameter name filter.
48
+ * @param {sharedStateDeleteOnUpdateCallback} unsubscribe - Reference to unsubscribe
49
+ * function returned by the `SharedState#onUpdate` method.
38
50
  */
39
51
 
40
52
  /**
@@ -107,243 +119,197 @@ class SharedState {
107
119
  #client = null;
108
120
  #manager = null;
109
121
  #filter = null;
110
- // true is the state has been detached or deleted
111
122
  #detached = false;
112
- // only valid for owners
113
123
  #hasSiblings = false;
114
124
  #parameters = null;
115
125
  #onUpdateCallbacks = new Set();
116
126
  #onDetachCallbacks = new Set();
117
127
  #onDeleteCallbacks = new Set();
118
-
119
- constructor({
120
- stateId,
121
- instanceId,
122
- className,
123
- classDescription,
124
- isOwner,
125
- manager,
126
- initValues,
127
- filter,
128
- }) {
129
- this.#manager = manager;
130
- this.#client = manager[kStateManagerClient];
131
- this.#className = className;
132
- this.#id = stateId;
133
- this.#instanceId = instanceId;
134
- this.#isOwner = isOwner; // may be any node
135
- this.#filter = filter;
128
+ #waitForConditions = new Set();
129
+
130
+ constructor() {
131
+ // cf. BaseStateManager#buildSharedState to get the whole pattern
132
+ // rationale is to have a clean constructor API for future derived classes
133
+ this.#manager = globalThis[kPendingSharedStateConstructionData].manager;
134
+ this.#client = globalThis[kPendingSharedStateConstructionData].manager[kStateManagerClient];
135
+ this.#className = globalThis[kPendingSharedStateConstructionData].className;
136
+ this.#id = globalThis[kPendingSharedStateConstructionData].stateId;
137
+ this.#instanceId =globalThis[kPendingSharedStateConstructionData]. instanceId;
138
+ this.#isOwner = globalThis[kPendingSharedStateConstructionData].isOwner; // may be any node
139
+ this.#filter = globalThis[kPendingSharedStateConstructionData].filter;
136
140
 
137
141
  try {
138
- this.#parameters = new ParameterBag(classDescription, initValues);
142
+ this.#parameters = new ParameterBag(
143
+ globalThis[kPendingSharedStateConstructionData].classDescription,
144
+ globalThis[kPendingSharedStateConstructionData].initValues,
145
+ );
139
146
  } catch (err) {
140
- throw new Error(`Cannot construct 'SharedState': ${err.message}`);
147
+ throw new Error(`Cannot construct 'SharedState' (${this.#className}): ${err.message}`);
141
148
  }
142
149
 
143
150
  /** @private */
144
151
  this[kSharedStatePromiseStore] = new PromiseStore(this.constructor.name);
145
152
 
146
- // add listener for state updates
147
- this.#client.transport.addListener(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`, async (reqId, updates) => {
148
- const updated = await this.#commit(updates, true, true);
149
- this[kSharedStatePromiseStore].resolve(reqId, updated);
150
- });
153
+ this.#client.transport.addListener(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onUpdateResponse);
154
+ this.#client.transport.addListener(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`, this.#onUpdateAbort);
155
+ this.#client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onUpdateNotification);
156
+ this.#client.transport.addListener(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onDeleteNotification);
151
157
 
152
- // retrieve values but do not propagate to subscriptions
153
- this.#client.transport.addListener(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`, async (reqId, updates) => {
154
- const updated = await this.#commit(updates, false, true);
155
- this[kSharedStatePromiseStore].resolve(reqId, updated);
156
- });
158
+ if (this.#isOwner) {
159
+ this.#client.transport.addListener(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onDeleteResponse);
160
+ this.#client.transport.addListener(`${DELETE_ERROR}-${this.#id}`, this.#onDeleteError);
161
+ this.#client.transport.addListener(`${HAS_SIBLINGS_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onHasSiblingsNotification);
162
+ } else {
163
+ this.#client.transport.addListener(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onDetachResponse);
164
+ this.#client.transport.addListener(`${DETACH_ERROR}-${this.#id}`, this.#onDetachError);
165
+ }
166
+ }
157
167
 
158
- this.#client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, async (updates) => {
159
- // https://github.com/collective-soundworks/soundworks/issues/18
160
- //
161
- // # note: 2002-10-03
162
- //
163
- // `setTimeout(async () => this.#commit(updates, true, false));`
164
- // appears to be the only way to push the update commit in the next event
165
- // cycle so that `attach` can resolve before the update notification is
166
- // actually dispatched. The alternative:
167
- // `Promise.resolve().then(() => this.#commit(updates, true, false))``
168
- // does not behave as expected...
169
- //
170
- // However this breaks the reliability of:
171
- // ```
172
- // /* given a0, a1 and a2 being 3 similar attached states */
173
- // await state.set({ int: i });
174
- //
175
- // assert.equal(a0.get('int'), i);
176
- // assert.equal(a1.get('int'), i);
177
- // assert.equal(a2.get('int'), i);
178
- // ```
179
- // which is far more important than the edge case reported in the issue
180
- // therefore this wont be fixed for now
181
- this.#commit(updates, true, false);
182
- });
168
+ #cleanup() {
169
+ this.#detached = true;
170
+ this.#manager[kStateManagerDeleteState](this.#id);
171
+
172
+ this.#client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`);
173
+ this.#client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
174
+ this.#client.transport.removeAllListeners(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`);
175
+ this.#client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
183
176
 
184
- // ---------------------------------------------
185
- // state has been deleted by its creator or the class has been deleted
186
- // ---------------------------------------------
187
- this.#client.transport.addListener(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, async () => {
188
- this.#detached = true;
177
+ if (this.#isOwner) {
178
+ this.#client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`);
179
+ this.#client.transport.removeAllListeners(`${DELETE_ERROR}-${this.#id}-${this.#instanceId}`);
180
+ this.#client.transport.removeAllListeners(`${HAS_SIBLINGS_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
181
+ } else {
182
+ this.#client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`);
183
+ this.#client.transport.removeAllListeners(`${DETACH_ERROR}-${this.#id}-${this.#instanceId}`);
184
+ }
185
+ }
189
186
 
190
- this.#manager[kStateManagerDeleteState](this.#id);
191
- this.#clearTransport();
187
+ #onUpdateResponse = async (reqId, updates) => {
188
+ const updated = await this.#commit(updates, true, true);
189
+ this[kSharedStatePromiseStore].resolve(reqId, updated);
190
+ this.#checkPendingWaitForConditions();
191
+ };
192
192
 
193
- for (let callback of this.#onDetachCallbacks) {
194
- try {
195
- await callback();
196
- } catch (err) {
197
- console.error(err.message);
198
- }
199
- }
193
+ #onUpdateAbort = async (reqId, updates) => {
194
+ const updated = await this.#commit(updates, false, true);
195
+ this[kSharedStatePromiseStore].resolve(reqId, updated);
196
+ this.#checkPendingWaitForConditions();
197
+ };
200
198
 
201
- if (this.#isOwner) {
202
- for (let callback of this.#onDeleteCallbacks) {
203
- await callback();
204
- }
199
+ #onUpdateNotification = updates => {
200
+ this.#commit(updates, true, false);
201
+ this.#checkPendingWaitForConditions();
202
+ };
203
+
204
+ #onDetachResponse = async (reqId) => {
205
+ this.#cleanup();
206
+
207
+ for (let callback of this.#onDetachCallbacks) {
208
+ try {
209
+ await callback();
210
+ } catch (err) {
211
+ console.log(err.message);
205
212
  }
213
+ }
206
214
 
207
- this.#onDetachCallbacks.clear();
208
- this.#onDeleteCallbacks.clear();
209
- this[kSharedStatePromiseStore].flush();
210
- });
215
+ this.#onDetachCallbacks.clear();
216
+ this.#onDeleteCallbacks.clear();
217
+ this[kSharedStatePromiseStore].resolve(reqId, this);
218
+ this[kSharedStatePromiseStore].flush();
219
+ };
211
220
 
221
+ #onDetachError = (reqId, msg) => {
222
+ this.#onDetachCallbacks.clear();
223
+ this.#onDeleteCallbacks.clear();
224
+ this[kSharedStatePromiseStore].reject(reqId, msg);
225
+ this[kSharedStatePromiseStore].flush();
226
+ };
212
227
 
213
- if (this.#isOwner) {
214
- // ---------------------------------------------
215
- // the creator has called `.delete()`
216
- // ---------------------------------------------
217
- this.#client.transport.addListener(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`, async (reqId) => {
218
- this.#manager[kStateManagerDeleteState](this.#id);
219
- this.#clearTransport();
220
-
221
- for (let callback of this.#onDetachCallbacks) {
222
- try {
223
- await callback();
224
- } catch (err) {
225
- console.log(err.message);
226
- }
227
- }
228
+ #onDeleteResponse = async (reqId) => {
229
+ this.#cleanup();
228
230
 
229
- for (let callback of this.#onDeleteCallbacks) {
230
- try {
231
- await callback();
232
- } catch (err) {
233
- console.log(err.message);
234
- }
235
- }
231
+ for (let callback of this.#onDetachCallbacks) {
232
+ try {
233
+ await callback();
234
+ } catch (err) {
235
+ console.log(err.message);
236
+ }
237
+ }
236
238
 
237
- this.#onDetachCallbacks.clear();
238
- this.#onDeleteCallbacks.clear();
239
- this[kSharedStatePromiseStore].resolve(reqId, this);
240
- this[kSharedStatePromiseStore].flush();
241
- });
239
+ for (let callback of this.#onDeleteCallbacks) {
240
+ try {
241
+ await callback();
242
+ } catch (err) {
243
+ console.log(err.message);
244
+ }
245
+ }
242
246
 
243
- this.#client.transport.addListener(`${DELETE_ERROR}-${this.#id}`, (reqId, msg) => {
244
- this[kSharedStatePromiseStore].reject(reqId, msg);
245
- });
247
+ this.#onDetachCallbacks.clear();
248
+ this.#onDeleteCallbacks.clear();
249
+ this[kSharedStatePromiseStore].resolve(reqId, this);
250
+ this[kSharedStatePromiseStore].flush();
251
+ };
246
252
 
247
- this.#client.transport.addListener(`${HAS_SIBLINGS_NOTIFICATION}-${this.#id}-${this.#instanceId}`, hasSiblings => {
248
- this.#hasSiblings = hasSiblings;
249
- });
253
+ #onDeleteError = (reqId, msg) => {
254
+ this[kSharedStatePromiseStore].reject(reqId, msg);
255
+ };
250
256
 
251
- } else {
252
- // ---------------------------------------------
253
- // the attached node has called `.detach()`
254
- // ---------------------------------------------
255
- this.#client.transport.addListener(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`, async (reqId) => {
256
- this.#manager[kStateManagerDeleteState](this.#id);
257
- this.#clearTransport();
258
-
259
- for (let callback of this.#onDetachCallbacks) {
260
- try {
261
- await callback();
262
- } catch (err) {
263
- console.log(err.message);
264
- }
265
- }
257
+ #onDeleteNotification = async () => {
258
+ this.#cleanup();
266
259
 
267
- this.#onDetachCallbacks.clear();
268
- this.#onDeleteCallbacks.clear();
269
- this[kSharedStatePromiseStore].resolve(reqId, this);
270
- this[kSharedStatePromiseStore].flush();
271
- });
260
+ for (let callback of this.#onDetachCallbacks) {
261
+ try {
262
+ await callback();
263
+ } catch (err) {
264
+ console.error(err.message);
265
+ }
266
+ }
272
267
 
273
- // the state does not exists anymore in the server (should not happen)
274
- this.#client.transport.addListener(`${DETACH_ERROR}-${this.#id}`, (reqId, msg) => {
275
- this.#onDetachCallbacks.clear();
276
- this.#onDeleteCallbacks.clear();
277
- this[kSharedStatePromiseStore].reject(reqId, msg);
278
- this[kSharedStatePromiseStore].flush();
279
- });
268
+ if (this.#isOwner) {
269
+ for (let callback of this.#onDeleteCallbacks) {
270
+ await callback();
271
+ }
280
272
  }
281
- }
282
273
 
283
- /**
284
- * Id of the state
285
- * @type {Number}
286
- */
287
- get id() {
288
- return this.#id;
289
- }
274
+ this.#onDetachCallbacks.clear();
275
+ this.#onDeleteCallbacks.clear();
276
+ this[kSharedStatePromiseStore].flush();
277
+ };
290
278
 
291
- /**
292
- * Name of the underlying {@link SharedState} class.
293
- * @type {String}
294
- */
295
- get className() {
296
- return this.#className;
297
- }
279
+ #onHasSiblingsNotification = hasSiblings => {
280
+ this.#hasSiblings = hasSiblings;
281
+ };
298
282
 
299
- /**
300
- * @deprecated Use {@link SharedState#className} instead.
301
- */
302
- get schemaName() {
303
- warnings.deprecated('SharedState#schemaName', 'SharedState#className', '4.0.0-alpha.29');
304
- return this.className;
305
- }
283
+ #checkParamNameAgainstFilters(paramName) {
284
+ if (this.#filter === null) {
285
+ return;
286
+ }
306
287
 
307
- /**
308
- * Indicates if the node is the owner of the state, i.e. if it created the state.
309
- * @type {Boolean}
310
- */
311
- get isOwner() {
312
- return this.#isOwner;
313
- }
288
+ const { whiteList, blackList } = this.#filter;
314
289
 
315
- /**
316
- * Indicates if the node as siblings, i.e. if several versions of the same state
317
- * exists on the network
318
- * Always return true if the state instance is attached
319
- * @type {Boolean}
320
- */
321
- get hasSiblings() {
322
- if (this.#detached) {
323
- return false;
290
+ if (whiteList && !whiteList.includes(paramName)) {
291
+ throw new Error(`Parameter '${paramName}' is not in white list`);
292
+ } else if (blackList && blackList.includes(paramName)) {
293
+ throw new Error(`Parameter '${paramName}' is in black list`);
324
294
  }
295
+ }
325
296
 
326
- if (!this.isOwner) {
327
- return true;
297
+ #sanitizeValuesAgainstFilters(values) {
298
+ if (this.#filter === null) {
299
+ return values;
328
300
  }
329
301
 
330
- return this.#hasSiblings;
331
- }
332
-
333
- #clearTransport() {
334
- // remove listeners
335
- this.#client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`);
336
- this.#client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
337
- this.#client.transport.removeAllListeners(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`);
338
- this.#client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
302
+ const { whiteList, blackList } = this.#filter;
339
303
 
340
- if (this.#isOwner) {
341
- this.#client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`);
342
- this.#client.transport.removeAllListeners(`${DELETE_ERROR}-${this.#id}-${this.#instanceId}`);
343
- } else {
344
- this.#client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`);
345
- this.#client.transport.removeAllListeners(`${DETACH_ERROR}-${this.#id}-${this.#instanceId}`);
304
+ for (let name in values) {
305
+ if (whiteList && !whiteList.includes(name)) {
306
+ delete values[name];
307
+ } else if (blackList && blackList.includes(name)) {
308
+ delete values[name];
309
+ }
346
310
  }
311
+
312
+ return values;
347
313
  }
348
314
 
349
315
  async #commit(updates, propagate = true, initiator = false) {
@@ -375,19 +341,14 @@ class SharedState {
375
341
  oldValues[name] = oldValue;
376
342
  }
377
343
 
378
- // if the `UPDATE_REQUEST` as been aborted by the server, do not propagate
379
- let promises = [];
380
-
344
+ // do not propagate if the `UPDATE_REQUEST` as been aborted by the server.
381
345
  if (propagate && Object.keys(newValues).length > 0) {
382
- this.#onUpdateCallbacks.forEach(listener => {
383
- promises.push(listener(newValues, oldValues));
384
- });
346
+ const callbackPromises = this.#executeUpdateCallbacks(newValues, oldValues);
347
+ // on a given client, `await state.set(update)` resolves after all
348
+ // update callbacks have themselves resolved
349
+ await Promise.all(callbackPromises);
385
350
  }
386
351
 
387
- // on a given client, `await state.set(update)` resolves after all
388
- // update callbacks have themselves resolved
389
- await Promise.all(promises);
390
-
391
352
  // reset events to null after propagation of all listeners
392
353
  for (let name in newValues) {
393
354
  const { event } = this.#parameters.getDescription(name);
@@ -400,6 +361,98 @@ class SharedState {
400
361
  return newValues;
401
362
  }
402
363
 
364
+ #executeUpdateCallbacks(newValues, oldValues) {
365
+ let promises = [];
366
+
367
+ this.#onUpdateCallbacks.forEach(listenerPayload => {
368
+ let somePromise = this.#executeUpdateCallback(listenerPayload, newValues, oldValues);
369
+ promises.push(somePromise);
370
+ });
371
+
372
+ return promises;
373
+ }
374
+
375
+ #executeUpdateCallback(listenerPayload, newValues, oldValues) {
376
+ const { paramName, listener, unsubscribe } = listenerPayload;
377
+
378
+ if (paramName === null) {
379
+ return listener(newValues, oldValues, unsubscribe);
380
+ } else if (paramName in newValues) {
381
+ return listener(newValues[paramName], oldValues[paramName], unsubscribe);
382
+ }
383
+ }
384
+
385
+ #checkPendingWaitForConditions() {
386
+ const values = this.getValuesUnsafe();
387
+
388
+ this.#waitForConditions.forEach(payload => {
389
+ const { resolve, condition, timeoutId } = payload;
390
+ let match = true;
391
+
392
+ for (let [key, value] of Object.entries(condition)) {
393
+ if (values[key] !== value) {
394
+ match = false;
395
+ }
396
+ }
397
+
398
+ if (match) {
399
+ clearTimeout(timeoutId);
400
+ this.#waitForConditions.delete(payload);
401
+ resolve();
402
+ }
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Id of the state
408
+ * @type {Number}
409
+ */
410
+ get id() {
411
+ return this.#id;
412
+ }
413
+
414
+ /**
415
+ * Name of the underlying {@link SharedState} class.
416
+ * @type {String}
417
+ */
418
+ get className() {
419
+ return this.#className;
420
+ }
421
+
422
+ /**
423
+ * @deprecated Use {@link SharedState#className} instead.
424
+ */
425
+ get schemaName() {
426
+ warnings.deprecated('SharedState#schemaName', 'SharedState#className', '4.0.0-alpha.29');
427
+ return this.className;
428
+ }
429
+
430
+ /**
431
+ * Indicates if the node is the owner of the state, i.e. if it created the state.
432
+ * @type {Boolean}
433
+ */
434
+ get isOwner() {
435
+ return this.#isOwner;
436
+ }
437
+
438
+ /**
439
+ * Indicates if the node as siblings, i.e. if several versions of the same state
440
+ * exists on the network
441
+ * Always return true if the state instance is attached
442
+ * @type {Boolean}
443
+ */
444
+ get hasSiblings() {
445
+ if (this.#detached) {
446
+ return false;
447
+ }
448
+
449
+ if (!this.isOwner) {
450
+ return true;
451
+ }
452
+
453
+ return this.#hasSiblings;
454
+ }
455
+
403
456
  /**
404
457
  * Return the underlying {@link SharedStateClassDescription} or the
405
458
  * {@link SharedStateParameterDescription} if `paramName` is given.
@@ -420,14 +473,6 @@ class SharedState {
420
473
  }
421
474
  }
422
475
 
423
- /**
424
- * @deprecated Use {@link SharedState#getDescription} instead.
425
- */
426
- getSchema(paramName = null) {
427
- warnings.deprecated('SharedState#getSchema', 'SharedState#getDescription', '4.0.0-alpha.29');
428
- return this.getDescription(paramName);
429
- }
430
-
431
476
  /**
432
477
  * Update the values of the state.
433
478
  *
@@ -512,6 +557,12 @@ class SharedState {
512
557
  let syncedOnUpdate = false;
513
558
 
514
559
  for (let name in updates) {
560
+ // Throw early if some param is blacklisted or not whitelisted
561
+ try {
562
+ this.#checkParamNameAgainstFilters(name);
563
+ } catch (err) {
564
+ throw new DOMException(`Cannot execute 'set' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
565
+ }
515
566
  // Try to coerce value now, so that eventual errors are triggered early
516
567
  // on the node requesting the update and not only on the server side.
517
568
  try {
@@ -521,13 +572,6 @@ class SharedState {
521
572
  throw new TypeError(`Cannot execute 'set' on SharedState (${this.#className}): ${err.message}`);
522
573
  }
523
574
 
524
- // Make sure that given name is in filter white list (if any)
525
- if (this.#filter !== null) {
526
- if (!this.#filter.includes(name)) {
527
- throw new DOMException(`Cannot execute 'set' on SharedState (${this.#className}): Parameter '${name}' is not in white list`, 'NotSupportedError');
528
- }
529
- }
530
-
531
575
  // ### `immediate` modifier behavior
532
576
  //
533
577
  // If immediate=true
@@ -593,9 +637,9 @@ class SharedState {
593
637
  }
594
638
  }
595
639
 
596
- // params that trigger a synced onUpdate call: immediate, local, non-acknowledged
640
+ // params that trigger a synced onUpdate call are: immediate, local, non-acknowledged
597
641
  if (syncedOnUpdate) {
598
- this.#onUpdateCallbacks.forEach(listener => listener(newValues, oldValues));
642
+ this.#executeUpdateCallbacks(newValues, oldValues);
599
643
  }
600
644
 
601
645
  // if we only have local params, we can resolve immediately
@@ -642,10 +686,11 @@ class SharedState {
642
686
  throw new ReferenceError(`Cannot execute 'get' on SharedState (${this.#className}): Parameter '${name}' is not defined`);
643
687
  }
644
688
 
645
- if (this.#filter !== null) {
646
- if (!this.#filter.includes(name)) {
647
- throw new DOMException(`Cannot execute 'get' on SharedState (${this.#className}): Parameter '${name}' is not in white list`, 'NotSupportedError');
648
- }
689
+ // Throw early if some param is blacklisted or not whitelisted
690
+ try {
691
+ this.#checkParamNameAgainstFilters(name);
692
+ } catch (err) {
693
+ throw new DOMException(`Cannot execute 'get' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
649
694
  }
650
695
 
651
696
  return this.#parameters.get(name);
@@ -671,10 +716,11 @@ class SharedState {
671
716
  throw new ReferenceError(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): Parameter '${name}' is not defined`);
672
717
  }
673
718
 
674
- if (this.#filter !== null) {
675
- if (!this.#filter.includes(name)) {
676
- throw new DOMException(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): Parameter '${name}' is not in white list`, 'NotSupportedError');
677
- }
719
+ // Throw early if some param is blacklisted or not whitelisted
720
+ try {
721
+ this.#checkParamNameAgainstFilters(name);
722
+ } catch (err) {
723
+ throw new DOMException(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
678
724
  }
679
725
 
680
726
  return this.#parameters.getUnsafe(name);
@@ -691,16 +737,7 @@ class SharedState {
691
737
  */
692
738
  getValues() {
693
739
  const values = this.#parameters.getValues();
694
-
695
- if (this.#filter !== null) {
696
- for (let name in values) {
697
- if (!this.#filter.includes(name)) {
698
- delete values[name];
699
- }
700
- }
701
- }
702
-
703
- return values;
740
+ return this.#sanitizeValuesAgainstFilters(values);
704
741
  }
705
742
 
706
743
  /**
@@ -718,16 +755,7 @@ class SharedState {
718
755
  */
719
756
  getValuesUnsafe() {
720
757
  const values = this.#parameters.getValuesUnsafe();
721
-
722
- if (this.#filter !== null) {
723
- for (let name in values) {
724
- if (!this.#filter.includes(name)) {
725
- delete values[name];
726
- }
727
- }
728
- }
729
-
730
- return values;
758
+ return this.#sanitizeValuesAgainstFilters(values);
731
759
  }
732
760
 
733
761
  /**
@@ -808,9 +836,43 @@ class SharedState {
808
836
  }
809
837
  }
810
838
 
839
+ /**
840
+ * Subscribe to any updates in the state.
841
+ *
842
+ * @overload
843
+ * @param {sharedStateOnUpdateCallback} callback
844
+ * Callback to execute when an update is applied on the state.
845
+ * @param {Boolean} [executeListener=false] - Execute the callback immediately
846
+ * with current state values. Note that `oldValues` will be set to `{}`.
847
+ * @returns {sharedStateDeleteOnUpdateCallback}
848
+ * @example
849
+ * state.onUpdate(updates => {
850
+ * // ...
851
+ * });
852
+ */
853
+ /**
854
+ * Subscribe to updates in the state filtered by a given parameter name.
855
+ *
856
+ * @overload
857
+ * @param {SharedStateParameterName} paramName
858
+ * @param {sharedStateOnUpdateCallback} callback
859
+ * Callback to execute when an update is applied on the state.
860
+ * @param {Boolean} [executeListener=false] - Execute the callback immediately
861
+ * with current state values. Note that `oldValues` will be set to `{}`.
862
+ * @returns {sharedStateDeleteOnUpdateCallback}
863
+ * @example
864
+ * state.onUpdate('my-param', myValue => {
865
+ * // ...
866
+ * });
867
+ */
811
868
  /**
812
869
  * Subscribe to state updates.
813
870
  *
871
+ * Alternative signatures:
872
+ * - `state.onUpdate(callback, executeListener)`
873
+ * - `state.onUpdate(paramName, callback, executeListener)`
874
+ *
875
+ * @param {SharedStateParameterName} paramName
814
876
  * @param {sharedStateOnUpdateCallback} callback
815
877
  * Callback to execute when an update is applied on the state.
816
878
  * @param {Boolean} [executeListener=false] - Execute the callback immediately
@@ -828,8 +890,13 @@ class SharedState {
828
890
  * // later
829
891
  * unsubscribe();
830
892
  */
831
- onUpdate(listener, executeListener = false) {
832
- this.#onUpdateCallbacks.add(listener);
893
+ onUpdate(...args) {
894
+ const { paramName, listener, executeListener } = sanitizeOnUpdateParams(this, ...args);
895
+
896
+ const listenerPayload = { paramName, listener };
897
+ this.#onUpdateCallbacks.add(listenerPayload);
898
+ // create unsubscribe and add to payload, to pass it back to the listeners as argument
899
+ listenerPayload.unsubscribe = () => this.#onUpdateCallbacks.delete(listenerPayload);
833
900
 
834
901
  if (executeListener === true) {
835
902
  const currentValues = this.getValues();
@@ -843,12 +910,10 @@ class SharedState {
843
910
  }
844
911
  }
845
912
 
846
- listener(currentValues, {});
913
+ this.#executeUpdateCallback(listenerPayload, currentValues, {});
847
914
  }
848
915
 
849
- return () => {
850
- this.#onUpdateCallbacks.delete(listener);
851
- };
916
+ return listenerPayload.unsubscribe;
852
917
  }
853
918
 
854
919
  /**
@@ -879,6 +944,67 @@ class SharedState {
879
944
  this.#onDeleteCallbacks.add(callback);
880
945
  return () => this.#onDeleteCallbacks.delete(callback);
881
946
  }
947
+
948
+ /**
949
+ * Wait for a given condition in the state of the SharedState instance.
950
+ * If the state watches the condition when `waitFor` is called, the promise is
951
+ * resolved immediately.
952
+ *
953
+ * @note - mark as private until tested in real-world
954
+ *
955
+ * @private
956
+ * @param {object} condition - Condition to be meet in the state for the
957
+ * returned promise to resolve.
958
+ * @param {object} condition - Timeout (in milliseconds) that trigger the rejection
959
+ * of the returned promise.
960
+ * @return {Promise}
961
+ */
962
+ waitFor(condition, timeout = null) {
963
+ if (!isPlainObject(condition)) {
964
+ throw new TypeError(`Cannot execute 'waitFor' on SharedState: argument 0 must be an object`);
965
+ }
966
+
967
+ if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
968
+ throw new TypeError(`Cannot execute 'waitFor' on SharedState: optional argument 1 must be a finite strictly positive number`);
969
+ }
970
+
971
+ // @todo - check condition keys are not filtered, this could never resolve
972
+ const descriptionKeys = Object.keys(this.getDescription());
973
+ const conditionKeys = Object.keys(condition);
974
+ const diff = conditionKeys.filter(key => !descriptionKeys.includes(key));
975
+
976
+ if (diff.length > 0) {
977
+ throw new ReferenceError(`Cannot execute 'waitFor' on SharedState: condition contains keys (${diff.join(', ')}) that are not declared in class description`);
978
+ }
979
+
980
+ const { promise, resolve, reject } = Promise.withResolvers();
981
+ const payload = { resolve, condition };
982
+
983
+ if (timeout !== null) {
984
+ payload.timeoutId = setTimeout(() => {
985
+ this.#waitForConditions.delete(payload);
986
+ reject();
987
+ }, timeout);
988
+ }
989
+
990
+ this.#waitForConditions.add(payload);
991
+ this.#checkPendingWaitForConditions();
992
+
993
+ return promise;
994
+ }
995
+
996
+ // ---------------------------------------------------------------------------
997
+ // DEPRECATED
998
+ // ---------------------------------------------------------------------------
999
+
1000
+ /**
1001
+ * @deprecated Use {@link SharedState#getDescription} instead.
1002
+ */
1003
+ getSchema(paramName = null) {
1004
+ warnings.deprecated('SharedState#getSchema', 'SharedState#getDescription', '4.0.0-alpha.29');
1005
+ return this.getDescription(paramName);
1006
+ }
1007
+
882
1008
  }
883
1009
 
884
1010
  export default SharedState;