ember-data-model-fragments 7.0.2 → 8.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.
@@ -1,10 +1,42 @@
1
1
  import { assert } from '@ember/debug';
2
2
  import { typeOf } from '@ember/utils';
3
3
  import { isArray } from '@ember/array';
4
- import { diffArray } from '@ember-data/model/-private';
5
4
  import { recordIdentifierFor } from '@ember-data/store';
5
+ import { dependencySatisfies, macroCondition } from '@embroider/macros';
6
6
  import { getActualFragmentType, isFragment } from '../fragment';
7
7
  import isInstanceOfType from '../util/instance-of-type';
8
+ import fragmentCacheFor from '../util/fragment-cache';
9
+
10
+ /**
11
+ * Simple array diff implementation.
12
+ * Returns an object with `firstChangeIndex` indicating where the arrays first differ.
13
+ * Returns `{ firstChangeIndex: null }` if arrays are identical.
14
+ *
15
+ * @param {Array} oldArray
16
+ * @param {Array} newArray
17
+ * @returns {Object} Object with firstChangeIndex property
18
+ */
19
+ function diffArray(oldArray, newArray) {
20
+ if (oldArray === newArray) {
21
+ return { firstChangeIndex: null };
22
+ }
23
+
24
+ if (!oldArray || !newArray) {
25
+ return { firstChangeIndex: 0 };
26
+ }
27
+
28
+ if (oldArray.length !== newArray.length) {
29
+ return { firstChangeIndex: Math.min(oldArray.length, newArray.length) };
30
+ }
31
+
32
+ for (let i = 0; i < oldArray.length; i++) {
33
+ if (oldArray[i] !== newArray[i]) {
34
+ return { firstChangeIndex: i };
35
+ }
36
+ }
37
+
38
+ return { firstChangeIndex: null };
39
+ }
8
40
 
9
41
  /**
10
42
  * Behavior for single fragment attributes
@@ -429,6 +461,14 @@ export default class FragmentStateManager {
429
461
  return this.__storeWrapper._store;
430
462
  }
431
463
 
464
+ get cache() {
465
+ return fragmentCacheFor(this.store);
466
+ }
467
+
468
+ get innerCache() {
469
+ return this.cache.__innerCache;
470
+ }
471
+
432
472
  _identifierFor(fragmentOrRecord) {
433
473
  // Use recordIdentifierFor for records/fragments that already exist
434
474
  // This is the correct way to get an identifier from an instantiated record
@@ -441,36 +481,53 @@ export default class FragmentStateManager {
441
481
 
442
482
  _getBehaviors(identifier) {
443
483
  let behaviors = this.__behaviors.get(identifier.lid);
444
- if (!behaviors) {
445
- behaviors = Object.create(null);
446
- const definitions = this.__storeWrapper
447
- .getSchemaDefinitionService()
448
- .attributesDefinitionFor(identifier);
449
- for (const [key, definition] of Object.entries(definitions)) {
450
- if (!definition.isFragment) {
451
- continue;
452
- }
453
- switch (definition.kind) {
454
- case 'fragment-array':
455
- behaviors[key] = new FragmentArrayBehavior(
456
- this,
457
- identifier,
458
- definition,
459
- );
460
- break;
461
- case 'fragment':
462
- behaviors[key] = new FragmentBehavior(this, identifier, definition);
463
- break;
464
- case 'array':
465
- behaviors[key] = new ArrayBehavior(this, identifier, definition);
466
- break;
467
- default:
468
- assert(`Unsupported fragment type: ${definition.kind}`);
469
- break;
470
- }
484
+ if (behaviors) {
485
+ return behaviors;
486
+ }
487
+
488
+ behaviors = Object.create(null);
489
+
490
+ const definitions = this.__storeWrapper
491
+ .getSchemaDefinitionService()
492
+ .attributesDefinitionFor(identifier);
493
+
494
+ for (const [key, definition] of Object.entries(definitions)) {
495
+ // Support both metadata formats:
496
+ // - ember-data 4.12: definition.isFragment (direct property on meta)
497
+ // - ember-data 4.13: definition.options.isFragment (transformed by FragmentSchemaService)
498
+ const isFragmentAttr =
499
+ definition.isFragment || definition.options?.isFragment;
500
+
501
+ if (!isFragmentAttr) {
502
+ continue;
503
+ }
504
+
505
+ // Get the fragment kind from the appropriate location:
506
+ // - ember-data 4.12: definition.kind (original location)
507
+ // - ember-data 4.13: definition.options.fragmentKind (preserved by FragmentSchemaService)
508
+ const fragmentKind = definition.options?.fragmentKind || definition.kind;
509
+
510
+ switch (fragmentKind) {
511
+ case 'fragment-array':
512
+ behaviors[key] = new FragmentArrayBehavior(
513
+ this,
514
+ identifier,
515
+ definition,
516
+ );
517
+ break;
518
+ case 'fragment':
519
+ behaviors[key] = new FragmentBehavior(this, identifier, definition);
520
+ break;
521
+ case 'array':
522
+ behaviors[key] = new ArrayBehavior(this, identifier, definition);
523
+ break;
524
+ default:
525
+ assert(`Unsupported fragment type: ${fragmentKind}`);
526
+ break;
471
527
  }
472
- this.__behaviors.set(identifier.lid, behaviors);
473
528
  }
529
+
530
+ this.__behaviors.set(identifier.lid, behaviors);
474
531
  return behaviors;
475
532
  }
476
533
 
@@ -621,6 +678,119 @@ export default class FragmentStateManager {
621
678
  });
622
679
  }
623
680
 
681
+ /**
682
+ * Returns true if the given attribute value is, or contains, an instantiated
683
+ * Fragment. Used by FragmentCache.clientDidCreate to route fragment-instance
684
+ * initialization (from `store.createRecord(type, { key: fragmentInstance })`)
685
+ * through the adopt-fragment path instead of pushFragmentData (which only
686
+ * accepts raw object/null canonical data).
687
+ */
688
+ valueContainsFragmentInstance(value) {
689
+ if (value == null) {
690
+ return false;
691
+ }
692
+ if (isFragment(value)) {
693
+ return true;
694
+ }
695
+ if (isArray(value)) {
696
+ return value.some((item) => isFragment(item));
697
+ }
698
+ return false;
699
+ }
700
+
701
+ /**
702
+ * Adopt an existing fragment instance (or array of fragment instances) for the
703
+ * given owner identifier + key. This is used by the client-created path
704
+ * (`createRecord(...props)`) when consumers pass already-instantiated
705
+ * fragments rather than raw object data.
706
+ *
707
+ * Returns the canonical fragment identifier value to be stored in the
708
+ * fragment data map (a single identifier for `fragment` kind, an array of
709
+ * identifiers for `fragment-array` kind).
710
+ *
711
+ * Asserts when the provided value does not match the fragment kind/type
712
+ * declared on the owner's schema.
713
+ */
714
+ adoptFragmentForKey(ownerIdentifier, key, value) {
715
+ const behaviors = this._getBehaviors(ownerIdentifier);
716
+ const behavior = behaviors[key];
717
+ assert(
718
+ `Attribute '${key}' for model '${ownerIdentifier.type}' must be a fragment`,
719
+ behavior != null,
720
+ );
721
+
722
+ const definition = behavior.definition;
723
+ const isArrayBehavior = behavior instanceof FragmentArrayBehavior;
724
+ const isSingleFragmentBehavior = behavior instanceof FragmentBehavior;
725
+
726
+ // Only single-fragment and fragment-array behaviors support adopting
727
+ // fragment instances. Plain array behavior should never receive a
728
+ // fragment instance.
729
+ if (!isSingleFragmentBehavior && !isArrayBehavior) {
730
+ return undefined;
731
+ }
732
+
733
+ if (isSingleFragmentBehavior) {
734
+ if (value === null) {
735
+ return null;
736
+ }
737
+ if (!isFragment(value)) {
738
+ return undefined;
739
+ }
740
+ assert(
741
+ `The value provided for fragment attribute '${key}' must be a '${definition.modelName}' fragment`,
742
+ isInstanceOfType(
743
+ this.__storeWrapper._store.modelFor(definition.modelName),
744
+ value,
745
+ ),
746
+ );
747
+ const fragmentIdentifier = this._identifierFor(value);
748
+ this.setFragmentOwner(fragmentIdentifier, ownerIdentifier, key);
749
+ return fragmentIdentifier;
750
+ }
751
+
752
+ // fragment-array behavior
753
+ if (value === null) {
754
+ return null;
755
+ }
756
+ if (!isArray(value)) {
757
+ return undefined;
758
+ }
759
+ // Only treat the value as fragment-instance input if at least one entry is
760
+ // a fragment instance. Otherwise let the regular pushData path handle it
761
+ // (raw object array).
762
+ if (!value.some((item) => isFragment(item))) {
763
+ return undefined;
764
+ }
765
+ return value.map((item) => {
766
+ if (isFragment(item)) {
767
+ assert(
768
+ `The value provided for fragment array attribute '${key}' can only include '${definition.modelName}' fragments`,
769
+ isInstanceOfType(
770
+ this.__storeWrapper._store.modelFor(definition.modelName),
771
+ item,
772
+ ),
773
+ );
774
+ const fragmentIdentifier = this._identifierFor(item);
775
+ this.setFragmentOwner(fragmentIdentifier, ownerIdentifier, key);
776
+ return fragmentIdentifier;
777
+ }
778
+ // Raw object entry mixed in with fragments - create a new fragment for it.
779
+ return this._newFragmentIdentifier(ownerIdentifier, definition, item);
780
+ });
781
+ }
782
+
783
+ /**
784
+ * Set canonical fragment data for a key on an owner identifier. Used by the
785
+ * client-created path after adopting fragment instances so subsequent
786
+ * getFragment/getCurrentState calls find the canonical value without going
787
+ * through pushData (which only accepts raw object/null canonical data).
788
+ */
789
+ setCanonicalFragmentValue(identifier, key, value) {
790
+ const fragmentData = this._getFragmentDataMap(identifier);
791
+ fragmentData[key] = value;
792
+ }
793
+
624
794
  _newFragmentIdentifierForKey(identifier, key, attributes) {
625
795
  const behaviors = this._getBehaviors(identifier);
626
796
  const behavior = behaviors[key];
@@ -645,27 +815,21 @@ export default class FragmentStateManager {
645
815
  const fragmentIdentifier =
646
816
  this.store.identifierCache.createIdentifierForNewRecord({ type });
647
817
  this.setFragmentOwner(fragmentIdentifier, ownerIdentifier, definition.name);
648
- // Initialize the fragment in the cache - use clientDidCreate to set up
649
- // the record's internal state, then upsert to set canonical data
650
- this.store.cache.__innerCache.clientDidCreate(fragmentIdentifier, {});
651
- // Push the attributes to the inner cache
818
+ this.innerCache.clientDidCreate(fragmentIdentifier, {});
819
+
652
820
  if (attributes) {
653
- this.store.cache.__innerCache.upsert(
654
- fragmentIdentifier,
655
- { attributes },
656
- false,
657
- );
821
+ this.innerCache.upsert(fragmentIdentifier, { attributes }, false);
658
822
  }
823
+
659
824
  // Process any nested fragment attributes
660
825
  this.pushFragmentData(fragmentIdentifier, { attributes }, false);
661
826
  return fragmentIdentifier;
662
827
  }
663
828
 
664
829
  hasChangedAttributes(identifier) {
665
- // Check both fragment state and inner cache state
666
830
  return (
667
831
  this.hasChangedFragments(identifier) ||
668
- this.store.cache.__innerCache.hasChangedAttrs(identifier)
832
+ this.innerCache.hasChangedAttrs(identifier)
669
833
  );
670
834
  }
671
835
 
@@ -675,7 +839,7 @@ export default class FragmentStateManager {
675
839
  // Explicitly return boolean to ensure false instead of undefined
676
840
  return Boolean(
677
841
  (fragments && Object.keys(fragments).length > 0) ||
678
- (inFlight && Object.keys(inFlight).length > 0),
842
+ (inFlight && Object.keys(inFlight).length > 0),
679
843
  );
680
844
  }
681
845
 
@@ -684,14 +848,16 @@ export default class FragmentStateManager {
684
848
  const definitions = this.__storeWrapper
685
849
  .getSchemaDefinitionService()
686
850
  .attributesDefinitionFor(identifier);
687
- const cache = this.store.cache.__innerCache;
851
+ const cache = this.innerCache;
688
852
 
689
853
  // Get changed attrs from inner cache to find original values for dirty attrs
690
854
  const changedAttrs = cache.changedAttrs(identifier);
691
855
 
692
856
  // Get canonical values for all attributes
693
857
  for (const [key, definition] of Object.entries(definitions)) {
694
- if (definition.isFragment) {
858
+ const isFragmentAttr =
859
+ definition.isFragment || definition.options?.isFragment;
860
+ if (isFragmentAttr) {
695
861
  // Fragment attributes - use behavior's canonicalState
696
862
  const behaviors = this._getBehaviors(identifier);
697
863
  const behavior = behaviors[key];
@@ -723,14 +889,16 @@ export default class FragmentStateManager {
723
889
 
724
890
  // Get current values for all attributes
725
891
  for (const [key, definition] of Object.entries(definitions)) {
726
- if (definition.isFragment) {
892
+ const isFragmentAttr =
893
+ definition.isFragment || definition.options?.isFragment;
894
+ if (isFragmentAttr) {
727
895
  // Fragment attributes - use behavior's currentState
728
896
  const behaviors = this._getBehaviors(identifier);
729
897
  const behavior = behaviors[key];
730
898
  result[key] = behavior.currentState(this.getFragment(identifier, key));
731
899
  } else {
732
900
  // Regular attributes - get from inner cache
733
- const cache = this.store.cache.__innerCache;
901
+ const cache = this.innerCache;
734
902
  result[key] = cache.getAttr(identifier, key);
735
903
  }
736
904
  }
@@ -796,7 +964,7 @@ export default class FragmentStateManager {
796
964
  return changedKeys;
797
965
  }
798
966
 
799
- pushFragmentData(identifier, data, calculateChange) {
967
+ pushFragmentData(identifier, data, calculateChange, shouldNotify = true) {
800
968
  let changedFragmentKeys;
801
969
  const behaviors = this._getBehaviors(identifier);
802
970
  const subFragmentsToProcess = [];
@@ -823,23 +991,28 @@ export default class FragmentStateManager {
823
991
  newCanonicalFragments[key] = behavior.pushData(current, canonical);
824
992
  });
825
993
 
826
- if (calculateChange) {
827
- changedFragmentKeys = this._changedFragmentKeys(
828
- identifier,
829
- newCanonicalFragments,
830
- );
831
- }
994
+ // Always calculate which keys changed so we can notify observers
995
+ changedFragmentKeys = this._changedFragmentKeys(
996
+ identifier,
997
+ newCanonicalFragments,
998
+ );
832
999
 
833
1000
  Object.assign(fragmentData, newCanonicalFragments);
834
- changedFragmentKeys?.forEach((key) => {
835
- // Notify the storeWrapper that the fragment attribute changed
836
- this.__storeWrapper.notifyChange(identifier, 'attributes', key);
837
- const arrayCache = this.__fragmentArrayCache.get(identifier.lid);
838
- arrayCache?.[key]?.notify();
839
- });
1001
+
1002
+ if (shouldNotify) {
1003
+ // Notify the storeWrapper that fragment attributes changed when this is
1004
+ // an update to existing state. For clientDidCreate initialization we
1005
+ // skip notification so initial props don't look like post-consumption
1006
+ // mutations during first render in WarpDrive 5.8.
1007
+ changedFragmentKeys.forEach((key) => {
1008
+ this.__storeWrapper.notifyChange(identifier, 'attributes', key);
1009
+ const arrayCache = this.__fragmentArrayCache.get(identifier.lid);
1010
+ arrayCache?.[key]?.notify();
1011
+ });
1012
+ }
840
1013
  }
841
1014
 
842
- return changedFragmentKeys || [];
1015
+ return calculateChange ? changedFragmentKeys || [] : [];
843
1016
  }
844
1017
 
845
1018
  willCommitFragments(identifier) {
@@ -968,6 +1141,16 @@ export default class FragmentStateManager {
968
1141
  }
969
1142
 
970
1143
  unloadFragments(identifier) {
1144
+ // Guard against store being destroyed during teardown
1145
+ if (this.store.isDestroying || this.store.isDestroyed) {
1146
+ // Just clear our internal data structures
1147
+ this.__fragments.delete(identifier.lid);
1148
+ this.__inFlightFragments.delete(identifier.lid);
1149
+ this.__fragmentData.delete(identifier.lid);
1150
+ this.__fragmentArrayCache.delete(identifier.lid);
1151
+ return;
1152
+ }
1153
+
971
1154
  const behaviors = this._getBehaviors(identifier);
972
1155
  const fragments = this.__fragments.get(identifier.lid) || {};
973
1156
  const inFlight = this.__inFlightFragments.get(identifier.lid) || {};
@@ -1059,14 +1242,38 @@ export default class FragmentStateManager {
1059
1242
 
1060
1243
  _notifyStateChange(identifier, key) {
1061
1244
  this.__storeWrapper.notifyChange(identifier, 'attributes', key);
1245
+ if (macroCondition(dependencySatisfies('ember-data', '<5.8.0'))) {
1246
+ // 5.8+ already wires cache notifications into reactivity, and directly notifying
1247
+ // the record can trip mutation-after-consumption assertions during initial render.
1248
+ if (key) {
1249
+ try {
1250
+ const record = this.store._instanceCache.getRecord(identifier);
1251
+ if (record && typeof record.notifyPropertyChange === 'function') {
1252
+ record.notifyPropertyChange(key);
1253
+ // Also clear any cached value by directly removing from Ember's cache
1254
+ // This is needed because computed setters cache their return value
1255
+ // and notifyPropertyChange alone may not clear it in all Ember versions
1256
+ const meta = record.constructor.metaForProperty?.(key);
1257
+ if (meta) {
1258
+ // Force the computed property to re-evaluate by clearing its cache entry
1259
+ const cache = record['__ember_meta__']?.peekCache?.(key);
1260
+ if (cache !== undefined) {
1261
+ record['__ember_meta__']?.deleteFromCache?.(key);
1262
+ }
1263
+ }
1264
+ }
1265
+ } catch {
1266
+ // Record may not be instantiated yet
1267
+ }
1268
+ }
1269
+ }
1062
1270
  }
1063
1271
 
1064
1272
  // Fragment lifecycle methods using cache API directly
1065
1273
  _fragmentPushData(identifier, data) {
1066
1274
  // Push data to the cache for the fragment
1067
1275
  if (data?.attributes) {
1068
- // Use the inner cache's upsert for the fragment's own attributes
1069
- const cache = this.store.cache.__innerCache;
1276
+ const cache = this.innerCache;
1070
1277
  cache.upsert(identifier, data, false);
1071
1278
  // Notify that attributes changed so computed properties are invalidated
1072
1279
  for (const key of Object.keys(data.attributes)) {
@@ -1079,14 +1286,16 @@ export default class FragmentStateManager {
1079
1286
 
1080
1287
  _fragmentWillCommit(identifier) {
1081
1288
  // Capture the current attribute values before commit - these are what will be committed
1082
- const innerCache = this.store.cache.__innerCache;
1289
+ const innerCache = this.innerCache;
1083
1290
  const definitions = this.__storeWrapper
1084
1291
  .getSchemaDefinitionService()
1085
1292
  .attributesDefinitionFor(identifier);
1086
1293
 
1087
1294
  const inFlightValues = {};
1088
1295
  for (const [key, definition] of Object.entries(definitions)) {
1089
- if (!definition.isFragment) {
1296
+ const isFragmentAttr =
1297
+ definition.isFragment || definition.options?.isFragment;
1298
+ if (!isFragmentAttr) {
1090
1299
  // getAttr returns dirty value if exists, else canonical
1091
1300
  inFlightValues[key] = innerCache.getAttr(identifier, key);
1092
1301
  }
@@ -1095,7 +1304,7 @@ export default class FragmentStateManager {
1095
1304
 
1096
1305
  // Signal to cache that fragment is being committed
1097
1306
  this.willCommitFragments(identifier);
1098
- this.store.cache.__innerCache.willCommit(identifier);
1307
+ innerCache.willCommit(identifier);
1099
1308
  }
1100
1309
 
1101
1310
  _fragmentDidCommit(identifier, data) {
@@ -1113,7 +1322,7 @@ export default class FragmentStateManager {
1113
1322
  // 4. Rollback to clear in-flight state
1114
1323
  // 5. Upsert the committed values as new canonical
1115
1324
  // 6. Re-apply any new dirty changes that were made during in-flight
1116
- const innerCache = this.store.cache.__innerCache;
1325
+ const innerCache = this.innerCache;
1117
1326
 
1118
1327
  // Get schema for non-fragment attributes
1119
1328
  const definitions = this.__storeWrapper
@@ -1127,7 +1336,9 @@ export default class FragmentStateManager {
1127
1336
  // Get current values (may include new dirty changes made during in-flight)
1128
1337
  const currentValues = {};
1129
1338
  for (const [key, definition] of Object.entries(definitions)) {
1130
- if (!definition.isFragment) {
1339
+ const isFragmentAttr =
1340
+ definition.isFragment || definition.options?.isFragment;
1341
+ if (!isFragmentAttr) {
1131
1342
  currentValues[key] = innerCache.getAttr(identifier, key);
1132
1343
  }
1133
1344
  }
@@ -1140,7 +1351,9 @@ export default class FragmentStateManager {
1140
1351
  const newDirtyAttrs = {};
1141
1352
 
1142
1353
  for (const [key, definition] of Object.entries(definitions)) {
1143
- if (definition.isFragment) continue;
1354
+ const isFragmentAttr =
1355
+ definition.isFragment || definition.options?.isFragment;
1356
+ if (isFragmentAttr) continue;
1144
1357
 
1145
1358
  // Determine the new canonical value
1146
1359
  const canonicalValue =
@@ -1199,13 +1412,13 @@ export default class FragmentStateManager {
1199
1412
  _fragmentRollbackAttributes(identifier) {
1200
1413
  // Rollback fragment attributes
1201
1414
  this.rollbackFragments(identifier);
1202
- this.store.cache.__innerCache.rollbackAttrs(identifier);
1415
+ this.innerCache.rollbackAttrs(identifier);
1203
1416
  }
1204
1417
 
1205
1418
  _fragmentCommitWasRejected(identifier) {
1206
1419
  // Signal that commit was rejected
1207
1420
  this.commitWasRejectedFragments(identifier);
1208
- this.store.cache.__innerCache.commitWasRejected(identifier);
1421
+ this.innerCache.commitWasRejected(identifier);
1209
1422
  }
1210
1423
 
1211
1424
  _fragmentUnloadRecord(identifier) {
@@ -1223,7 +1436,7 @@ export default class FragmentStateManager {
1223
1436
  // Fragment may already be unloaded or destroyed
1224
1437
  // Fall back to just clearing the inner cache
1225
1438
  try {
1226
- this.store.cache.__innerCache.unloadRecord(identifier);
1439
+ this.innerCache.unloadRecord(identifier);
1227
1440
  } catch {
1228
1441
  // May already be unloaded
1229
1442
  }