@converse/skeletor 3.1.6 → 4.0.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.
package/package.json CHANGED
@@ -39,7 +39,7 @@
39
39
  "contributors": [
40
40
  "JC Brand"
41
41
  ],
42
- "version": "3.1.6",
42
+ "version": "4.0.0",
43
43
  "engines": {
44
44
  "node": ">=22.0.0"
45
45
  },
package/src/collection.ts CHANGED
@@ -4,10 +4,18 @@ import groupBy from 'lodash-es/groupBy';
4
4
  import isFunction from 'lodash-es/isFunction';
5
5
  import isString from 'lodash-es/isString';
6
6
  import keyBy from 'lodash-es/keyBy';
7
+ import matches from 'lodash-es/matches';
7
8
  import sortBy from 'lodash-es/sortBy';
8
9
  import { EventEmitterObject } from './eventemitter';
9
10
  import PersistentStorage from './storage';
10
- import { getResolveablePromise, getStorage, getSyncMethod, warnBrowserStorageDeprecation, wrapError } from './helpers';
11
+ import {
12
+ bridgeSyncToPromise,
13
+ getResolveablePromise,
14
+ getStorage,
15
+ getSyncMethod,
16
+ warnBrowserStorageDeprecation,
17
+ wrapError,
18
+ } from './helpers';
11
19
  import { ensureUnloadListener } from './autosync';
12
20
  import { Model } from './model';
13
21
  import {
@@ -26,6 +34,24 @@ import {
26
34
  const setOptions = { add: true, remove: true, merge: true };
27
35
  const addOptions = { add: true, remove: false };
28
36
 
37
+ /**
38
+ * Normalizes the predicate forms accepted by the query methods into one
39
+ * `(model) => value` function. A function is used as-is, a string reads that
40
+ * attribute, and an attributes object becomes a lodash `matches` matcher.
41
+ *
42
+ * The matcher is built here, once per scan. Calling `Model#matches` per model
43
+ * instead rebuilds the match data for every model visited, which made
44
+ * `findWhere` an order of magnitude slower than a hand-written predicate.
45
+ */
46
+ function toModelPredicate<T extends Model>(
47
+ pred: ((model: T) => any) | string | Partial<ModelAttributes>,
48
+ ): (model: T) => any {
49
+ if (isFunction(pred)) return pred as (model: T) => any;
50
+ if (isString(pred)) return (m: T) => m.get(pred);
51
+ const matcher = matches(pred);
52
+ return (m: T) => matcher(m.attributes);
53
+ }
54
+
29
55
  /**
30
56
  * @public
31
57
  * If models tend to represent a single row of data, a Collection is
@@ -394,20 +420,14 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
394
420
  }
395
421
 
396
422
  filter(callback: ((model: T) => boolean) | string | Partial<ModelAttributes>, thisArg?: any): T[] {
397
- return this.models.filter(
398
- isFunction(callback)
399
- ? (callback as (model: T) => boolean)
400
- : (m) => m.matches(callback as Partial<ModelAttributes>),
401
- thisArg,
402
- );
423
+ return this.models.filter(toModelPredicate(callback), thisArg);
403
424
  }
404
425
 
405
426
  every(pred: ((attrs: ModelAttributes) => boolean) | Options): boolean {
406
427
  if (isFunction(pred)) {
407
- return this.models.map((m) => m.attributes).every(pred as (attrs: ModelAttributes) => boolean);
408
- } else {
409
- return this.models.every((m) => m.matches(pred));
428
+ return this.models.every((m) => (pred as (attrs: ModelAttributes) => boolean)(m.attributes));
410
429
  }
430
+ return this.models.every(toModelPredicate(pred));
411
431
  }
412
432
 
413
433
  difference(values: T[]): T[] {
@@ -428,19 +448,13 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
428
448
 
429
449
  some(pred: ((attrs: ModelAttributes) => boolean) | Options): boolean {
430
450
  if (isFunction(pred)) {
431
- return this.models.map((m) => m.attributes).some(pred as (attrs: ModelAttributes) => boolean);
432
- } else {
433
- return this.models.some((m) => m.matches(pred));
451
+ return this.models.some((m) => (pred as (attrs: ModelAttributes) => boolean)(m.attributes));
434
452
  }
453
+ return this.models.some(toModelPredicate(pred));
435
454
  }
436
455
 
437
- sortBy(iteratee: string | ((model: T) => any)): T[] {
438
- return sortBy(
439
- this.models,
440
- isFunction(iteratee)
441
- ? iteratee
442
- : (m: T) => (isString(iteratee) ? m.get(iteratee as string) : m.matches(iteratee as Partial<ModelAttributes>)),
443
- );
456
+ sortBy(iteratee: string | ((model: T) => any) | Partial<ModelAttributes>): T[] {
457
+ return sortBy(this.models, toModelPredicate(iteratee));
444
458
  }
445
459
 
446
460
  isEmpty(): boolean {
@@ -468,11 +482,11 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
468
482
  }
469
483
 
470
484
  countBy(f: string | ((model: T) => string) | Partial<ModelAttributes>): Record<string, number> {
471
- return countBy(this.models, isFunction(f) ? f : (m) => (isString(f) ? m.get(f) : m.matches(f)));
485
+ return countBy(this.models, toModelPredicate(f));
472
486
  }
473
487
 
474
- groupBy(pred: string | ((model: T) => string | number)): Record<string, T[]> {
475
- return groupBy(this.models, isFunction(pred) ? pred : (m) => (isString(pred) ? m.get(pred) : m.matches(pred)));
488
+ groupBy(pred: string | ((model: T) => string | number) | Partial<ModelAttributes>): Record<string, T[]> {
489
+ return groupBy(this.models, toModelPredicate(pred));
476
490
  }
477
491
 
478
492
  indexOf(model: T, fromIndex?: number): number {
@@ -480,12 +494,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
480
494
  }
481
495
 
482
496
  findLastIndex(pred: ((model: T) => boolean) | string | Partial<ModelAttributes>, fromIndex?: number): number {
483
- return this.models.findLastIndex(
484
- isFunction(pred)
485
- ? (pred as (model: T) => boolean)
486
- : (m) => (isString(pred) ? m.get(pred as string) : m.matches(pred as Partial<ModelAttributes>)),
487
- fromIndex,
488
- );
497
+ return this.models.findLastIndex(toModelPredicate(pred), fromIndex);
489
498
  }
490
499
 
491
500
  lastIndexOf(model: T, fromIndex?: number): number {
@@ -493,11 +502,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
493
502
  }
494
503
 
495
504
  findIndex(pred: ((model: T) => boolean) | string | Partial<ModelAttributes>): number {
496
- return this.models.findIndex(
497
- isFunction(pred)
498
- ? (pred as (model: T) => boolean)
499
- : (m) => (isString(pred) ? m.get(pred as string) : m.matches(pred as Partial<ModelAttributes>)),
500
- );
505
+ return this.models.findIndex(toModelPredicate(pred));
501
506
  }
502
507
 
503
508
  last(): T | undefined {
@@ -514,12 +519,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
514
519
  }
515
520
 
516
521
  map<U>(cb: string | ((model: T) => U) | Partial<ModelAttributes>, thisArg?: any): U[] {
517
- return this.models.map(
518
- isFunction(cb)
519
- ? (cb as (model: T) => U)
520
- : (m) => (isString(cb) ? m.get(cb as string) : m.matches(cb as Partial<ModelAttributes>)),
521
- thisArg,
522
- );
522
+ return this.models.map(toModelPredicate(cb), thisArg);
523
523
  }
524
524
 
525
525
  reduce<U = T>(callback: (accumulator: U, model: T, index: number, array: T[]) => U, initialValue?: U): U | T {
@@ -579,10 +579,7 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
579
579
  }
580
580
 
581
581
  find(predicate: ((model: T) => boolean) | Partial<ModelAttributes> | string, fromIndex?: number): T | undefined {
582
- const pred = isFunction(predicate)
583
- ? (predicate as (model: T) => boolean)
584
- : (m: T) => m.matches(predicate as Partial<ModelAttributes>);
585
- return this.models.find(pred, fromIndex);
582
+ return this.models.find(toModelPredicate(predicate), fromIndex);
586
583
  }
587
584
 
588
585
  /**
@@ -627,47 +624,22 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
627
624
  const collection = this;
628
625
  const promise = options.promise ? getResolveablePromise() : undefined;
629
626
 
630
- // Tracks whether sync invoked one of the callbacks. A sync may instead
631
- // settle its returned promise; the `.then`/`.catch` below bridges that to
632
- // the callbacks so the fetch promise never hangs. The flag is set
633
- // synchronously inside each callback (before sync's promise resolves), so
634
- // it's reliable by the time those handlers run.
635
- let settled = false;
636
-
637
627
  options.success = function (resp: any) {
638
- settled = true;
639
628
  const method = options.reset ? 'reset' : 'set';
640
629
  // fromStorage prevents individual model set() calls from triggering auto-save on reads
641
630
  collection[method](resp, Object.assign({}, options, { fromStorage: true }));
642
631
  if (success) success.call(options.context, collection, resp, options);
643
- promise && promise.resolve();
632
+ // Fire 'sync' before resolving, as `Model.fetch`/`save` do, so a throwing
633
+ // listener rejects the promise instead of being lost.
644
634
  collection.trigger('sync', collection, resp, options);
635
+ promise && promise.resolve();
645
636
  };
646
637
  wrapError(this, options, promise);
647
- if (promise) {
648
- const onError = options.error;
649
- options.error = (resp: any) => {
650
- settled = true;
651
- onError(resp);
652
- };
653
- }
654
638
 
655
- const result = this.sync('read', this, options);
656
- if (promise) {
657
- Promise.resolve(result).then(
658
- // A sync that resolves its promise without invoking options.success:
659
- // treat the resolved value as the response, so the collection is still
660
- // populated and the promise settles instead of hanging.
661
- (value) => {
662
- if (!settled) options.success(value);
663
- },
664
- // A sync that rejects instead of invoking options.error; settling
665
- // twice is a harmless no-op.
666
- (e) => promise.reject(e),
667
- );
668
- return promise;
669
- }
670
- return result;
639
+ // With `promise`, the sync's outcome is bridged to it however the sync
640
+ // reports it: through the callbacks, or through the promise it returns.
641
+ const result = bridgeSyncToPromise(options, promise, () => this.sync('read', this, options));
642
+ return promise ?? result;
671
643
  }
672
644
 
673
645
  /**
@@ -681,8 +653,19 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
681
653
  const return_promise = options.promise;
682
654
  const promise = return_promise && getResolveablePromise();
683
655
 
656
+ // `_prepareModel` reports attributes that fail validation by firing
657
+ // `'invalid'` and returning null. With `promise`, capture that error so a
658
+ // rejected promise is handed back rather than `null`.
659
+ let validationError: any;
660
+ const captureInvalid = (_c: unknown, err: any) => (validationError = err);
661
+ if (return_promise) this.on('invalid', captureInvalid);
684
662
  const preparedModel = this._prepareModel(model, options);
685
- if (!preparedModel) return null;
663
+ if (return_promise) this.off('invalid', captureInvalid);
664
+ if (!preparedModel) {
665
+ if (!return_promise) return null;
666
+ promise.reject(validationError);
667
+ return promise;
668
+ }
686
669
 
687
670
  if (!wait) this.add(preparedModel, options);
688
671
 
@@ -706,8 +689,15 @@ export class Collection<T extends Model = Model> extends EventEmitterObject {
706
689
  return_promise && promise.reject(e);
707
690
  };
708
691
 
709
- preparedModel.save(null, Object.assign(options, { 'promise': false }));
692
+ // `options.promise` is passed through, so `save` installs its own bridge
693
+ // from the sync's returned promise to its callbacks: a sync that resolves
694
+ // without calling them still reaches the wired `options.success` above,
695
+ // which resolves our promise with the model. Only a rejection that bypassed
696
+ // the callbacks (a rejecting sync, or a throwing `'sync'`/`'error'`
697
+ // listener) still needs bridging to ours.
698
+ const result = preparedModel.save(null, options);
710
699
  if (return_promise) {
700
+ Promise.resolve(result).catch((e) => promise.reject(e));
711
701
  return promise;
712
702
  } else {
713
703
  return preparedModel;
package/src/helpers.ts CHANGED
@@ -28,6 +28,15 @@ export function guid(): string {
28
28
  // Helpers
29
29
  // -------
30
30
 
31
+ /**
32
+ * Whether `value` is a thenable (a promise, or a promise-like object such as
33
+ * a jqXHR). Decides whether a `sync` return value can be bridged to the
34
+ * promise that `fetch`/`save` hand back.
35
+ */
36
+ export function isThenable(value: any): value is PromiseLike<any> {
37
+ return value != null && typeof value.then === 'function';
38
+ }
39
+
31
40
  // Helper function to correctly set up the prototype chain for subclasses.
32
41
  // Similar to `goog.inherits`, but uses a hash of prototype properties and
33
42
  // class properties to be extended.
@@ -131,6 +140,68 @@ export function wrapError(
131
140
  };
132
141
  }
133
142
 
143
+ /**
144
+ * Run a `sync` and make sure `promise` settles from its outcome, however the
145
+ * sync reports it.
146
+ *
147
+ * A `sync` may invoke `options.success`/`options.error` (the callback
148
+ * contract), or settle the promise it returns without calling them: a
149
+ * promise-only custom sync, or an async sync whose promise rejects because a
150
+ * `'sync'`/`'error'` listener threw after the callbacks ran. `options.success`
151
+ * and `options.error` must already be wired to resolve/reject `promise`. This
152
+ * wraps them to record that they ran, calls `run()`, and bridges a thenable
153
+ * result to them so the promise never hangs:
154
+ *
155
+ * - fulfilment with the callbacks unrun is delivered to `options.success`, so
156
+ * the response is still merged into the model or collection;
157
+ * - rejection with the callbacks unrun is delivered to `options.error`, so the
158
+ * caller's `error` callback and the `'error'` event still fire;
159
+ * - rejection after the callbacks ran rejects `promise` directly: a no-op if
160
+ * they already settled it, and the only way to settle it if a listener threw
161
+ * before they could;
162
+ * - a throw from either delivery (a throwing `success`/`error` callback or
163
+ * listener) rejects `promise` instead of surfacing as an unhandled rejection.
164
+ *
165
+ * A non-thenable result (a callback-only sync that returns nothing, or an
166
+ * XHR) is left alone: such a sync settles `promise` through its callbacks, and
167
+ * bridging it would fire `success` on the next microtask, before the sync is
168
+ * done. Without a `promise`, `run()` is called as is.
169
+ */
170
+ export function bridgeSyncToPromise(options: any, promise: ResolveablePromise | undefined, run: () => any): any {
171
+ if (!promise) return run();
172
+
173
+ let settled = false;
174
+ const success = options.success;
175
+ const error = options.error;
176
+ options.success = (...args: any[]) => {
177
+ settled = true;
178
+ return success && success(...args);
179
+ };
180
+ options.error = (...args: any[]) => {
181
+ settled = true;
182
+ return error && error(...args);
183
+ };
184
+
185
+ const result = run();
186
+ if (isThenable(result)) {
187
+ Promise.resolve(result)
188
+ .then(
189
+ (value) => {
190
+ if (!settled) options.success(value);
191
+ },
192
+ (e) => {
193
+ if (settled) {
194
+ promise.reject(e);
195
+ } else {
196
+ options.error(e);
197
+ }
198
+ },
199
+ )
200
+ .catch((e) => promise.reject(e));
201
+ }
202
+ return result;
203
+ }
204
+
134
205
  // Map from CRUD to HTTP for our default `sync` implementation.
135
206
  const methodMap: Record<string, string> = {
136
207
  create: 'POST',
@@ -183,21 +254,42 @@ export function getSyncMethod(model: Model | Collection<any>): typeof sync & { _
183
254
  }
184
255
 
185
256
  /**
186
- * Override this function to change the manner in which Backbone persists
187
- * models to the server. You will be passed the type of request, and the
188
- * model in question. By default makes a `fetch()` API call
189
- * to the model's `url()`.
190
- *
191
- * Some possible customizations could be:
192
- *
193
- * - Use `setTimeout` to batch rapid-fire updates into a single request.
194
- * - Persist models via WebSockets instead of Ajax.
195
- * - Persist models to browser storage
257
+ * The error the default `sync` hands to `options.error`, and rejects with, for
258
+ * a response outside the 2xx range.
259
+ * @public
196
260
  */
261
+ export class HTTPError extends Error {
262
+ readonly status: number;
263
+ readonly response: Response;
264
+
265
+ constructor(response: Response, method: string, url: string) {
266
+ const statusText = response.statusText ? ` ${response.statusText}` : '';
267
+ super(`${method} ${url} failed with HTTP ${response.status}${statusText}`);
268
+ this.name = 'HTTPError';
269
+ this.status = response.status;
270
+ this.response = response;
271
+ }
272
+ }
273
+
197
274
  /**
275
+ * The default `sync`: a JSON request over `fetch()` to the model's `url()`.
276
+ * Override it (or `Model.sync`/`Collection.sync`) to change how models are
277
+ * persisted, for instance to batch rapid-fire updates with `setTimeout`, to
278
+ * talk over WebSockets, or to write to browser storage.
279
+ *
280
+ * It honours both contracts a `sync` may use. On a 2xx response the JSON body
281
+ * (`null` when the body is empty) is passed to `options.success` and the
282
+ * returned promise resolves with it. On a non-2xx response, a network failure
283
+ * or an unparsable body, `options.error` receives the error (an `HTTPError`
284
+ * for the first) and the promise rejects with it.
285
+ *
286
+ * `options.data` is sent as query parameters on GET, which may not carry a
287
+ * body, and as a JSON body otherwise. For create, update and patch the body
288
+ * defaults to `options.attrs` or the model's `toJSON()`.
198
289
  * @public
199
290
  */
200
291
  export function sync(method: SyncOperation, model: Model | Collection<any>, options: SyncOptions = {}): Promise<any> {
292
+ const type = methodMap[method];
201
293
  let data = options.data;
202
294
 
203
295
  // Ensure that we have the appropriate request data.
@@ -205,22 +297,40 @@ export function sync(method: SyncOperation, model: Model | Collection<any>, opti
205
297
  data = options.attrs || model.toJSON();
206
298
  }
207
299
 
208
- const type = methodMap[method];
209
- const params: RequestInit & { success?: any; error?: any } = {
210
- method: type,
211
- body: data ? JSON.stringify(data) : '',
212
- headers: {
213
- 'Content-Type': 'application/json',
214
- },
215
- success: options.success,
216
- error: options.error,
217
- };
300
+ let url: string = options.url || result(model, 'url') || urlError();
301
+ const params: RequestInit = { method: type };
302
+ if (data != null) {
303
+ if (type === 'GET') {
304
+ url += (url.includes('?') ? '&' : '?') + new URLSearchParams(data).toString();
305
+ } else {
306
+ params.body = JSON.stringify(data);
307
+ params.headers = { 'Content-Type': 'application/json' };
308
+ }
309
+ }
218
310
 
219
- const url = options.url || result(model, 'url') || urlError();
220
311
  const xhr = fetch(url, params);
221
- if (options) {
222
- options.xhr = xhr;
223
- }
312
+ options.xhr = xhr;
224
313
  model.trigger('request', model, xhr, { ...params, xhr });
225
- return xhr;
314
+
315
+ return Promise.resolve(xhr)
316
+ .then(async (response) => {
317
+ if (!response.ok) throw new HTTPError(response, type, url);
318
+ const text = await response.text();
319
+ return text ? JSON.parse(text) : null;
320
+ })
321
+ .then(
322
+ // The callbacks run before the returned promise settles, so a caller that
323
+ // bridges this promise (`Model.fetch`/`save`) sees the outcome as already
324
+ // reported. A throw from `success` (a `'sync'` listener, say) is not
325
+ // routed to `error`: it rejects the promise, which is what the bridge
326
+ // expects once the callbacks have run.
327
+ (resp) => {
328
+ options.success?.(resp, options);
329
+ return resp;
330
+ },
331
+ (e) => {
332
+ options.error?.(e);
333
+ throw e;
334
+ },
335
+ );
226
336
  }
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { EventEmitter } from './eventemitter';
3
3
  import PersistentStorage from './storage';
4
4
  import { Collection } from './collection';
5
5
  import { Model } from './model';
6
- import { sync } from './helpers';
6
+ import { sync, HTTPError } from './helpers';
7
7
 
8
8
  /**
9
9
  * @public
@@ -13,6 +13,7 @@ interface SkeletorType {
13
13
  EventEmitter: typeof EventEmitter;
14
14
  Model: typeof Model;
15
15
  sync: typeof sync;
16
+ HTTPError: typeof HTTPError;
16
17
  VERSION?: string;
17
18
  noConflict?: () => SkeletorType;
18
19
  }
@@ -25,6 +26,7 @@ const skeletor: SkeletorType = {
25
26
  EventEmitter,
26
27
  Model,
27
28
  sync,
29
+ HTTPError,
28
30
  };
29
31
 
30
32
  // Establish the root object, `window` (`self`) in the browser, or `global` on the server.
@@ -59,7 +61,7 @@ export default skeletor;
59
61
  * @public
60
62
  */
61
63
  const BrowserStorage = PersistentStorage;
62
- export { noConflict, Collection, EventEmitter, Model, PersistentStorage, BrowserStorage, sync };
64
+ export { noConflict, Collection, EventEmitter, Model, PersistentStorage, BrowserStorage, sync, HTTPError };
63
65
  export { getStorage } from './helpers';
64
66
 
65
67
  export type { StorageDriver, StoreType } from './drivers/types';
package/src/model.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ bridgeSyncToPromise,
2
3
  getResolveablePromise,
3
4
  getStorage,
4
5
  getSyncMethod,
@@ -14,7 +15,7 @@ import has from 'lodash-es/has';
14
15
  import invert from 'lodash-es/invert';
15
16
  import isEmpty from 'lodash-es/isEmpty';
16
17
  import isEqual from 'lodash-es/isEqual';
17
- import iteratee from 'lodash-es/iteratee';
18
+ import isMatch from 'lodash-es/isMatch';
18
19
  import omit from 'lodash-es/omit';
19
20
  import pick from 'lodash-es/pick';
20
21
  import result from 'lodash-es/result';
@@ -317,10 +318,19 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
317
318
  }
318
319
 
319
320
  /**
320
- * Special-cased proxy to lodash's `matches` method.
321
+ * Returns `true` if the model's attributes satisfy `attrs`.
322
+ *
323
+ * An attributes object is compared with lodash's `isMatch`: a partial, deep
324
+ * comparison, so `{ form: { type: 'hidden' } }` matches a `form` attribute
325
+ * that has `type: 'hidden'` alongside other keys. A function is called with
326
+ * the attributes and its result coerced to a boolean.
327
+ *
328
+ * To scan a collection, prefer `Collection#find`, `filter` or `where`. They
329
+ * build their matcher once per scan instead of once per model.
321
330
  */
322
- matches(attrs: Partial<T>): boolean {
323
- return !!iteratee(attrs)(this.attributes);
331
+ matches(attrs: Partial<T> | ((attrs: T) => any)): boolean {
332
+ if (typeof attrs === 'function') return !!attrs(this.attributes);
333
+ return isMatch(this.attributes, attrs);
324
334
  }
325
335
 
326
336
  /**
@@ -511,48 +521,26 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
511
521
  const success = options.success;
512
522
  const promise = options.promise ? getResolveablePromise() : undefined;
513
523
 
514
- // Tracks whether sync invoked one of the callbacks. A sync may instead
515
- // settle its returned promise; the `.then`/`.catch` below bridges that to
516
- // the callbacks so the fetch promise never hangs. The flag is set
517
- // synchronously inside each callback (before sync's promise resolves), so
518
- // it's reliable by the time those handlers run.
519
- let settled = false;
520
-
521
524
  options.success = (resp: any) => {
522
- settled = true;
523
525
  const serverAttrs = options.parse ? this.parse(resp, options) : resp;
524
526
  // fromStorage prevents the set() from triggering auto-save on reads
525
- if (!this.set(serverAttrs, Object.assign({}, options, { fromStorage: true }))) return false;
527
+ if (!this.set(serverAttrs, Object.assign({}, options, { fromStorage: true }))) {
528
+ // The fetched attributes failed validation (`'invalid'` has fired).
529
+ // Settle the promise with the error rather than leave it pending.
530
+ promise && promise.reject(this.validationError);
531
+ return false;
532
+ }
526
533
  if (success) success.call(options.context, this, resp, options);
527
534
  this.trigger('sync', this, resp, options);
528
535
  promise && promise.resolve(resp);
529
536
  };
530
537
 
531
538
  wrapError(this, options, promise);
532
- if (promise) {
533
- const onError = options.error;
534
- options.error = (resp: any) => {
535
- settled = true;
536
- onError(resp);
537
- };
538
- }
539
539
 
540
- const result = this.sync('read', this, options);
541
- if (promise) {
542
- Promise.resolve(result).then(
543
- // A sync that resolves its promise without invoking options.success:
544
- // treat the resolved value as the response, so the model is still
545
- // merged and the promise settles instead of hanging.
546
- (value) => {
547
- if (!settled) options.success(value);
548
- },
549
- // A sync that rejects instead of invoking options.error; settling
550
- // twice is a harmless no-op.
551
- (e) => promise.reject(e),
552
- );
553
- return promise;
554
- }
555
- return result;
540
+ // With `promise`, the sync's outcome is bridged to it however the sync
541
+ // reports it: through the callbacks, or through the promise it returns.
542
+ const result = bridgeSyncToPromise(options, promise, () => this.sync('read', this, options));
543
+ return promise ?? result;
556
544
  }
557
545
 
558
546
  /**
@@ -574,15 +562,19 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
574
562
  options = Object.assign({ validate: true, parse: true }, options);
575
563
  const wait = options.wait;
576
564
  const return_promise = options.promise;
577
- const promise = return_promise && getResolveablePromise();
565
+ const promise = return_promise ? getResolveablePromise() : undefined;
578
566
 
579
567
  // If we're not waiting and attributes exist, save acts as
580
568
  // `set(attr).save(null, opts)` with validation. Otherwise, check if
581
569
  // the model will be valid when the attributes, if any, are set.
582
- if (attrs && !wait) {
583
- if (!this.set(attrs, options)) return false;
584
- } else if (!this._validate(attrs || {}, options)) {
585
- return false;
570
+ const valid = attrs && !wait ? !!this.set(attrs, options) : this._validate(attrs || {}, options);
571
+ if (!valid) {
572
+ // Nothing is synced (`'invalid'` has fired). With `promise`, hand back a
573
+ // rejected promise rather than `false`, so the promise contract holds
574
+ // for every outcome.
575
+ if (!promise) return false;
576
+ promise.reject(this.validationError);
577
+ return promise;
586
578
  }
587
579
 
588
580
  // After a successful server-side save, the client is (optionally)
@@ -596,7 +588,12 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
596
588
  this.attributes = attributes;
597
589
  let serverAttrs = options.parse ? this.parse(resp, options) : resp;
598
590
  if (wait) serverAttrs = Object.assign({}, attrs, serverAttrs);
599
- if (serverAttrs && !this.set(serverAttrs, options)) return false;
591
+ if (serverAttrs && !this.set(serverAttrs, options)) {
592
+ // The server attributes failed validation (`'invalid'` has fired).
593
+ // Settle the promise with the error rather than leave it pending.
594
+ return_promise && promise.reject(this.validationError);
595
+ return false;
596
+ }
600
597
  if (success) success.call(options.context, this, resp, options);
601
598
  this.trigger('sync', this, resp, options);
602
599
  return_promise && promise.resolve();
@@ -614,16 +611,14 @@ export class Model<T extends ModelAttributes = ModelAttributes> extends EventEmi
614
611
 
615
612
  const method = this.isNew() ? 'create' : options.patch ? 'patch' : 'update';
616
613
  if (method === 'patch' && !options.attrs) options.attrs = attrs;
617
- const xhr = this.sync(method, this, options);
614
+ // With `promise`, the sync's outcome is bridged to it however the sync
615
+ // reports it: through the callbacks, or through the promise it returns.
616
+ const xhr = bridgeSyncToPromise(options, promise, () => this.sync(method, this, options));
618
617
 
619
618
  // Restore attributes.
620
619
  this.attributes = attributes;
621
620
 
622
- if (return_promise) {
623
- return promise;
624
- } else {
625
- return xhr;
626
- }
621
+ return promise ?? xhr;
627
622
  }
628
623
 
629
624
  /**