tracked-instance 1.0.23 → 2.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/README.md CHANGED
@@ -70,6 +70,27 @@ useTrackedInstance(false)
70
70
  useTrackedInstance([1,2,3])
71
71
  ```
72
72
 
73
+ ### Custom equality with `equals` option
74
+
75
+ By default, values are compared with strict equality (`===`). You can override this with a custom `equals` function that is called for primitive leaf values.
76
+
77
+ A common use case is treating `null` and `""` as equivalent — for example, when a UI component (like Vuetify's text field with a clear button) sets a value to `null`, but the original data used `""`:
78
+
79
+ ```javascript
80
+ const { data, isDirty } = useTrackedInstance(
81
+ { comment: null },
82
+ { equals: (a, b) => (a ?? '') === (b ?? '') }
83
+ )
84
+
85
+ data.value.comment = '' // treated as equal to null
86
+ console.log(isDirty.value) // false
87
+
88
+ data.value.comment = 'hello'
89
+ console.log(isDirty.value) // true
90
+ ```
91
+
92
+ The `equals` function receives the two values being compared and should return `true` if they are considered equal. It is only called for primitive values — object and array fields are always compared by recursing into their properties.
93
+
73
94
  ### Real-world example
74
95
  [Try on playground](https://play.vuejs.org/#eNqNVc1u00AQfpXBl7RSapfCAVlJVGh7KEiloj36srEn8TbrtbU/aaMoz4DEjROvgYTEw/AC8AjM7tqpaarSm+d/vplv1uvobdPES4tRGo10rnhjQKOxzSSTvGpqZWBtNV4rli+wOJfaMJnjBmaqrmBggvqAt/pBJjOZ1yTBumCGDSEvmZxjceoFrk+5MqshKKQaQxA185YNjGG3yN46kwCGG4EpDAZDJ62QqRSkFcKLXF/aqeC6xCKFGRMavbpAw7jQKfgMAEkCuqytKGCKYBvqDOlzta3vvDaZ3Oy79sk5uACX3HAmwCGBW24ohYGKLRBmtaqgcFgy2SUJ7XJq5KVvomv8ukQ4ZWoBHySfl6aP4+jw8M0uDqPs4zCoIOampsBWASBZ5WqclIprUzclKrioBZMDh9l3qRHbJWgCBBK1A79kwqIOWQh5D37YnmZLPGmjxrC3D+NJKLpF64YS+zQURtGjJLCHeEOCwaoRNEOSAEYFX07a3VPv644HsNmMEmfzTqWCJHxNrTG1hONc8HwxziLPliya/Pr64/f3z/DJiaMkePlyFOOxHms7rbiJG4VLlIZCezgoQcA74rKxppvg8qCqCxTk6xH5rWVRZzWrBslk8I4aaMn0VJpY2mqKqsvm1ryTLLg8K12Xp8ePnXR5ifliWt/1Ez4jZUuuuCNV7Kj0X+Bt4jD7B75h+Pcp0oJrNhVYkO1Fu/LO2oIG+PPty0+4oi11JG0r3K/XCW673abLo0nYaOEPMx0lpPEWWvuE2NV7cjzDnNrxs8fJaBiFx+2gYk18o2tJz5/nd9YaiC7bM6MxPHjnnDGLSmManSaJlc1iHud1lez6dbdFFY2m25rx+YN6FNdwgepjYzjd3j91mRD17Xuv274KPsbt/BH9jSYauNYu3c2oJTWwtRmm5u6MnPns6sIvdmskaljH+yeMdHa1sK7H4PbOyoLa7vn5bs/9+LicX+uzO4NSd6Bco34a3j+L6J9z8gT0+3Zfxa+3U9z8BYQrOQM=)
75
96
  ```vue
@@ -156,7 +177,7 @@ console.log(isDirty.value) // true
156
177
  Add new item:
157
178
  ```javascript
158
179
  const addedItem = add({name: 'Taras'})
159
- console.log(addedItem) // {instance: TrackedInstance<{name: 'Taras'}>, isRemoved: false, isNew: true, meta: {}}}
180
+ console.log(addedItem) // {instance: TrackedInstance<{name: 'Taras'}>, isRemoved: false, isNew: true, meta: undefined}
160
181
  ```
161
182
  Add new item in specific position:
162
183
  ```javascript
@@ -259,27 +280,61 @@ console.log(items.value[0].meta.isValidName.value) // false
259
280
  ```
260
281
 
261
282
  # Documentation
262
- ## TrackedInstance
263
- - **data** - tracked data
264
- - **changeData** - includes only modified fields from data, considers nested objects and arrays
265
- - **isDirty** - weather instance has some changes
266
- - **loadData** - rewrite data and clear dirty state
267
- - **reset** - rollback changes at the last point when the instance was not isDirty
283
+ ## useTrackedInstance(initialData?, options?)
268
284
 
269
- ## Collection
270
- - **items** - array of `CollectionItem`
271
- - **isDirty** - weather collection includes some changes (add/remove/change)
272
- - **add** - add new item
273
- - **remove** - soft remove item by index. Soft removed items should be deleted permanently after load data. Can be reverted by reset. If passed second param isHardRemove can be deleted permanently.
274
- - **loadData** - accepts array of data for each item. Rewrite each instance data and clear dirty state
275
- - **reset** - rollback changes at the last point when the instance was not isDirty
285
+ ```typescript
286
+ useTrackedInstance<Data>(initialData?: Data, options?: TrackedInstanceOptions): TrackedInstance<Data>
287
+ ```
288
+
289
+ ### Options
290
+
291
+ | Option | Type | Description |
292
+ |--------|------|-------------|
293
+ | `equals` | `(a: unknown, b: unknown) => boolean` | Custom equality function for primitive leaf values. Replaces default `===`. |
294
+
295
+ ### TrackedInstance
296
+
297
+ - **data** — reactive reference to the current data. Mutate it directly to track changes.
298
+ - **changedData** — only the modified fields, deeply nested. `undefined` when nothing has changed.
299
+ - **isDirty** — `true` when any field differs from the original.
300
+ - **loadData(newData)** — replace data and clear dirty state (new baseline).
301
+ - **reset()** — revert all changes back to the last `loadData()` baseline.
302
+
303
+ ## useCollection(createItemMeta?, options?)
304
+
305
+ ```typescript
306
+ useCollection<Item, Meta>(
307
+ createItemMeta?: (instance: TrackedInstance<Item>) => Meta,
308
+ options?: TrackedInstanceOptions,
309
+ ): Collection<Item, Meta>
310
+ ```
311
+
312
+ The `options` object (including `equals`) is passed to every `TrackedInstance` created inside the collection — both from `loadData()` and `add()`.
313
+
314
+ ```javascript
315
+ const { items, isDirty } = useCollection(
316
+ () => undefined,
317
+ { equals: (a, b) => (a ?? '') === (b ?? '') }
318
+ )
319
+ ```
320
+
321
+ ### Collection
322
+
323
+ - **items** — reactive array of `CollectionItem`.
324
+ - **isDirty** — `true` if any item is dirty, new, or soft-removed.
325
+ - **add(item, index?)** — add a new item (marked `isNew`). Appended to end by default.
326
+ - **remove(index, isHardRemove?)** — soft-remove by default (`isRemoved = true`). Pass `true` to hard-delete from array.
327
+ - **loadData(items)** — replace all items and clear dirty state.
328
+ - **reset()** — remove new items, restore soft-removed items, and reset all instance data.
329
+
330
+ ### CollectionItem
276
331
 
277
332
  ```typescript
278
- interface CollectionItem {
279
- instance: TrackedInstance
280
- isRemoved: Ref<boolean>
281
- isNew: Ref<boolean> //weather is new instance. Field can be changed manually or changed in loadData in second argument
282
- meta: Record<string, any>
283
- remove: (isHardRemove?: boolean) => void
333
+ interface CollectionItem<Item, Meta = undefined> {
334
+ instance: TrackedInstance<Item> // the tracked instance for this item
335
+ isRemoved: Ref<boolean> // true after soft remove
336
+ isNew: Ref<boolean> // true for items added via add(), false for items loaded via loadData()
337
+ meta: Meta // custom metadata from createItemMeta(), undefined by default
338
+ remove: (isHardRemove?: boolean) => void // shortcut: removes self from collection
284
339
  }
285
340
  ```
package/dist/index.mjs CHANGED
@@ -1535,7 +1535,7 @@ var setOriginalDataValue = (originalData, path) => {
1535
1535
  const lastItem = path.at(-1);
1536
1536
  originalDataTarget[lastItem.property] = lastItem.target[lastItem.property];
1537
1537
  };
1538
- var snapshotValueToOriginalData = (originalData, path, value) => {
1538
+ var snapshotValueToOriginalData = (originalData, path, value, equals) => {
1539
1539
  const pathAsString = path.map((i) => i.property);
1540
1540
  const valueInOriginalData = get_default(originalData, pathAsString);
1541
1541
  const markRemovedFieldsAsUndefined = (valueInOriginalData2, oldValue2) => {
@@ -1555,7 +1555,8 @@ var snapshotValueToOriginalData = (originalData, path, value) => {
1555
1555
  snapshotValueToOriginalData(
1556
1556
  originalData,
1557
1557
  path.concat({ target: oldValue2 || value, property: key }),
1558
- void 0
1558
+ void 0,
1559
+ equals
1559
1560
  );
1560
1561
  }
1561
1562
  };
@@ -1564,7 +1565,7 @@ var snapshotValueToOriginalData = (originalData, path, value) => {
1564
1565
  if (isObject2(value) && (isObject2(valueInOriginalData) || isObject2(oldValue))) {
1565
1566
  markRemovedFieldsAsUndefined(valueInOriginalData, oldValue);
1566
1567
  for (const key of Object.keys(value)) {
1567
- snapshotValueToOriginalData(originalData, path.concat({ target: oldValue || value, property: key }), value[key]);
1568
+ snapshotValueToOriginalData(originalData, path.concat({ target: oldValue || value, property: key }), value[key], equals);
1568
1569
  }
1569
1570
  } else if (Array.isArray(value) && (valueInOriginalData instanceof ArrayInOriginalData || Array.isArray(oldValue))) {
1570
1571
  markRemovedFieldsAsUndefined(valueInOriginalData, oldValue);
@@ -1572,20 +1573,24 @@ var snapshotValueToOriginalData = (originalData, path, value) => {
1572
1573
  snapshotValueToOriginalData(
1573
1574
  originalData,
1574
1575
  path.concat({ target: oldValue || value, property: key.toString() }),
1575
- value[key]
1576
+ value[key],
1577
+ equals
1576
1578
  );
1577
1579
  }
1578
1580
  } else {
1581
+ const isEqual = equals ? equals(oldValue, value) : oldValue === value;
1582
+ const isEqualToOriginal = equals ? equals(valueInOriginalData, value) : valueInOriginalData === value;
1579
1583
  if (!has_default(originalData, pathAsString)) {
1580
- if (oldValue !== value) {
1584
+ if (!isEqual) {
1581
1585
  setOriginalDataValue(originalData, path);
1582
1586
  }
1583
- } else if (valueInOriginalData === value) {
1587
+ } else if (isEqualToOriginal) {
1584
1588
  unset_default(originalData, pathAsString);
1585
1589
  }
1586
1590
  }
1587
1591
  };
1588
- function useTrackedInstance(initialData) {
1592
+ function useTrackedInstance(initialData, options) {
1593
+ const { equals } = options ?? {};
1589
1594
  const _originalData = createNestedRef({}, (path) => ({
1590
1595
  deleteProperty(target, property) {
1591
1596
  const result = Reflect.deleteProperty(target, property);
@@ -1623,13 +1628,13 @@ function useTrackedInstance(initialData) {
1623
1628
  triggerChangingArrayItems();
1624
1629
  }
1625
1630
  } else {
1626
- snapshotValueToOriginalData(_originalData.value, path, value);
1631
+ snapshotValueToOriginalData(_originalData.value, path, value, equals);
1627
1632
  }
1628
1633
  return Reflect.set(target, property, cloneDeep(value), receiver);
1629
1634
  },
1630
1635
  deleteProperty(target, property) {
1631
1636
  const path = parentThree.concat({ target, property });
1632
- snapshotValueToOriginalData(_originalData.value, path, void 0);
1637
+ snapshotValueToOriginalData(_originalData.value, path, void 0, equals);
1633
1638
  return Reflect.deleteProperty(target, property);
1634
1639
  }
1635
1640
  }));
@@ -1660,7 +1665,7 @@ function useTrackedInstance(initialData) {
1660
1665
  _originalData.value = {};
1661
1666
  };
1662
1667
  const reset = () => {
1663
- const updatedData = JSON.parse(JSON.stringify(_data.value));
1668
+ const updatedData = cloneDeep(_data.value);
1664
1669
  for (const [path, value] of iterateObject(_originalData.value, { includeParent: true })) {
1665
1670
  if (value instanceof ArrayInOriginalData) {
1666
1671
  set_default(updatedData, path.concat("length"), value.length);
@@ -1686,7 +1691,11 @@ function useTrackedInstance(initialData) {
1686
1691
 
1687
1692
  // src/collection.ts
1688
1693
  import { computed as computed2, markRaw, ref } from "vue";
1689
- var useCollection = (createItemMeta = () => void 0) => {
1694
+ var useCollection = (options) => {
1695
+ const {
1696
+ createItemMeta = () => void 0,
1697
+ ...instanceOptions
1698
+ } = options ?? {};
1690
1699
  const items = ref([]);
1691
1700
  const isDirty = computed2(
1692
1701
  () => items.value.some(({
@@ -1696,7 +1705,7 @@ var useCollection = (createItemMeta = () => void 0) => {
1696
1705
  }) => instance.isDirty.value || isNew.value || isRemoved.value)
1697
1706
  );
1698
1707
  const createItem = (item, isNew) => {
1699
- const instance = useTrackedInstance(item);
1708
+ const instance = useTrackedInstance(item, instanceOptions);
1700
1709
  const collectionItem = markRaw({
1701
1710
  isRemoved: ref(false),
1702
1711
  isNew: ref(isNew),
@@ -1,18 +1,43 @@
1
1
  import { ComputedRef, Raw, Ref } from 'vue';
2
- import { TrackedInstance } from './tracked-instance';
2
+ import { TrackedInstance, TrackedInstanceOptions } from './tracked-instance';
3
3
  export type CollectionItem<Item, Meta = undefined> = Raw<{
4
4
  instance: TrackedInstance<Item>;
5
+ /** Arbitrary metadata attached to this item, produced by CollectionOptions.createItemMeta. */
5
6
  meta: Meta;
7
+ /** True when the item has been soft-deleted via remove(). */
6
8
  isRemoved: Ref<boolean>;
9
+ /** True for items added via add() after the last loadData() call. */
7
10
  isNew: Ref<boolean>;
11
+ /** Removes this item from the collection. Shortcut for calling collection.remove(index). */
8
12
  remove: (isHardRemoved?: boolean) => void;
9
13
  }>;
10
14
  export interface Collection<Item, Meta = undefined> {
11
15
  items: Ref<CollectionItem<Item, Meta>[]>;
16
+ /** True when any item is modified, newly added, or soft-deleted. */
12
17
  isDirty: ComputedRef<boolean>;
13
- add: (item: Item, afterIndex?: number) => CollectionItem<Item, Meta>;
18
+ /** Adds an item to the collection. Inserts at the end by default; pass `index` to insert elsewhere. */
19
+ add: (item: Item, index?: number) => CollectionItem<Item, Meta>;
20
+ /** Soft-deletes an item by index (sets isRemoved). Pass isHardRemove=true to splice immediately. */
14
21
  remove: (index: number, isHardRemove?: boolean) => void;
22
+ /** Replaces all items and clears the dirty state. The loaded items become the new baseline. */
15
23
  loadData: (items: Item[]) => void;
24
+ /** Reverts all changes: drops new items, restores removed items, resets modified fields. */
16
25
  reset: () => void;
17
26
  }
18
- export declare const useCollection: <Item = any, Meta = undefined>(createItemMeta?: (instance: TrackedInstance<Item>) => Meta) => Collection<Item, Meta>;
27
+ export interface CollectionOptions<Item, Meta = undefined> extends TrackedInstanceOptions {
28
+ /**
29
+ * Factory called when a collection item is created (via loadData or add).
30
+ * Use it to attach arbitrary metadata to each item — UI flags, sub-forms, derived state —
31
+ * that lives alongside the tracked instance but is not part of the tracked data.
32
+ * Receives the newly created TrackedInstance so the meta can reference reactive instance fields.
33
+ */
34
+ createItemMeta?: (instance: TrackedInstance<Item>) => Meta;
35
+ }
36
+ /**
37
+ * Creates a reactive collection of TrackedInstance items.
38
+ *
39
+ * Tracks additions, removals, and field-level modifications across all items.
40
+ * Each item is wrapped with markRaw to prevent Vue from making the collection item
41
+ * itself deeply reactive — only instance.data, isRemoved, and isNew carry reactivity.
42
+ */
43
+ export declare const useCollection: <Item = any, Meta = undefined>(options?: CollectionOptions<Item, Meta>) => Collection<Item, Meta>;
@@ -1,4 +1,4 @@
1
- export type { TrackedInstance } from './tracked-instance';
2
- export type { Collection, CollectionItem } from './collection';
1
+ export type { TrackedInstance, TrackedInstanceOptions } from './tracked-instance';
2
+ export type { Collection, CollectionItem, CollectionOptions } from './collection';
3
3
  export { useTrackedInstance } from './tracked-instance';
4
4
  export { useCollection } from './collection';
@@ -1,11 +1,27 @@
1
1
  import { Ref } from 'vue';
2
2
  import { DeepPartial } from './utils';
3
3
  export interface TrackedInstance<Data> {
4
+ /** Reactive reference to the current (possibly modified) data. */
4
5
  data: Ref<Data>;
6
+ /** True when at least one field differs from the value at the last loadData() call. */
5
7
  isDirty: Ref<boolean>;
8
+ /** Partial object containing only the fields that have changed since the last loadData(). */
6
9
  changedData: Ref<DeepPartial<Data>>;
10
+ /** Replaces the current data and clears the dirty state. The new value becomes the new baseline. */
7
11
  loadData: (newData: Data) => void;
12
+ /** Reverts all changes, restoring data to the state at the last loadData() call. */
8
13
  reset: () => void;
9
14
  }
10
- export declare function useTrackedInstance<Data = any>(): TrackedInstance<Data | undefined>;
11
- export declare function useTrackedInstance<Data>(value: Data): TrackedInstance<Data>;
15
+ export interface TrackedInstanceOptions {
16
+ /**
17
+ * Custom equality function for comparing primitive values.
18
+ * When provided, replaces the default strict equality (===) check.
19
+ * Called only for primitive leaf values (strings, numbers, booleans, null, undefined).
20
+ *
21
+ * @example treat null and empty string as equal
22
+ * equals: (a, b) => (a ?? '') === (b ?? '')
23
+ */
24
+ equals?: (a: unknown, b: unknown) => boolean;
25
+ }
26
+ export declare function useTrackedInstance<Data = any>(value?: undefined, options?: TrackedInstanceOptions): TrackedInstance<Data | undefined>;
27
+ export declare function useTrackedInstance<Data>(value: Data, options?: TrackedInstanceOptions): TrackedInstance<Data>;
@@ -1,16 +1,52 @@
1
+ /**
2
+ * Recursively makes all properties of T optional.
3
+ * Arrays use element-level DeepPartial rather than making the array itself optional,
4
+ * which allows sparse array diffs (e.g. only index 2 changed).
5
+ */
1
6
  export type DeepPartial<Value> = Value extends object ? Value extends Array<infer ArrayValue> ? Array<DeepPartial<ArrayValue>> : {
2
7
  [Property in keyof Value]?: DeepPartial<Value[Property]>;
3
8
  } : Value;
9
+ /**
10
+ * Represents one segment in the path from the root proxy to the currently accessed node.
11
+ * Accumulated as Proxy `get` traps are traversed, then passed to `set`/`deleteProperty`
12
+ * handlers so they can reconstruct the full property path for _originalData bookkeeping.
13
+ */
4
14
  export interface NestedProxyPathItem {
5
15
  target: Record<string, any>;
6
16
  property: string;
7
17
  receiver?: Record<string, any>;
8
18
  }
19
+ /**
20
+ * Returns true only for plain objects — intentionally excludes Array, Date, File, Map,
21
+ * and Set so they are treated as atomic leaf values rather than being traversed.
22
+ */
9
23
  export declare const isObject: (value: unknown) => boolean;
10
24
  export declare const isEmpty: (value: object) => boolean;
25
+ /**
26
+ * Depth-first generator that walks an object tree, yielding [path, value] pairs.
27
+ *
28
+ * By default it descends into plain objects (via isObject). Supply `goDeepCondition`
29
+ * to override — e.g. to also descend into ArrayInOriginalData entries.
30
+ * When `includeParent` is true, intermediate nodes are yielded before their children,
31
+ * which is needed for reset() to handle ArrayInOriginalData length restoration.
32
+ */
11
33
  export declare const iterateObject: (source: Record<string, any>, params?: {
12
34
  goDeepCondition?: (path: string[], value: any) => boolean;
13
35
  includeParent?: boolean;
14
36
  }) => Generator<[string[], any], void, any>;
37
+ /**
38
+ * Creates a Vue customRef whose value is a deeply nested Proxy tree.
39
+ *
40
+ * Every nested object/array returned by a `get` is itself wrapped in a new Proxy,
41
+ * so mutations at any depth trigger Vue's reactivity system via the root `track`/`trigger`
42
+ * pair. The `handler` factory receives the full path from the root to the current node,
43
+ * allowing callers to intercept `set` and `deleteProperty` with complete path context.
44
+ */
15
45
  export declare const createNestedRef: <Source extends Record<string, any>>(source: Source, handler: <InnerSource extends Record<string, any>>(path: NestedProxyPathItem[]) => ProxyHandler<InnerSource>) => import("vue").Ref<Source, Source>;
46
+ /**
47
+ * Deep-clones a value while preserving special types:
48
+ * - Date → new Date instance with the same timestamp
49
+ * - File → same reference (Files are immutable browser objects and cannot be meaningfully cloned)
50
+ * All other types delegate to lodash cloneDeepWith for recursive cloning.
51
+ */
16
52
  export declare const cloneDeep: (inputValue: any) => any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracked-instance",
3
- "version": "1.0.23",
3
+ "version": "2.0.0",
4
4
  "description": "Build large forms and track all changes",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -10,6 +10,12 @@
10
10
  },
11
11
  "main": "./dist/index.mjs",
12
12
  "types": "./dist/types/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/index.mjs",
16
+ "types": "./dist/types/index.d.ts"
17
+ }
18
+ },
13
19
  "files": [
14
20
  "dist"
15
21
  ],