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