tracked-instance 1.0.23 โ†’ 2.0.1

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
@@ -1,285 +1,218 @@
1
- # Tracked instance
1
+ # tracked-instance
2
+
2
3
  <a href="https://www.npmjs.com/package/tracked-instance"><img src="https://img.shields.io/npm/v/tracked-instance.svg?sanitize=true" alt="Version"></a>
3
4
 
4
- # ๐Ÿš€ Features
5
- - ๐Ÿ•ถ Track what changed in your form
6
- - ๐ŸŒŽ Send on backend only fields which changed
7
- - ๐Ÿ“ฆ Build multiple requests only for items that have been changed/removed/added
8
- - ๐Ÿฆพ Type Strong: Written in TypeScript
5
+ > Track form changes in Vue 3 and send only modified fields to the backend โ€” no more diffing payloads by hand.
6
+
7
+ ```js
8
+ const {data, changedData, isDirty} = useTrackedInstance({name: 'Jack', age: 30})
9
+
10
+ data.value.name = 'John'
11
+
12
+ changedData.value // { name: 'John' } โ† only what changed
13
+ isDirty.value // true
14
+
15
+ data.value.name = 'Jack' // revert
16
+ changedData.value // undefined โ† back to clean
17
+ isDirty.value // false
18
+ ```
19
+
20
+ ## Install
9
21
 
10
- # Description
11
- Build large forms and send all requests in one take.
12
- Combination of useTrackedInstance and useCollection can manage very large form with entities which deeply related each other.
13
- You can control what data should be sent to the server so that only what has changed is sent.
14
- Tracked instance is not so much about managing forms, but about building and optimizing queries.
22
+ ```bash
23
+ npm i tracked-instance
24
+ ```
15
25
 
16
- # Install
17
- > npm i tracked-instance
26
+ Supports **Vue 3** only.
18
27
 
19
- # Support
20
- Supports Vue 3.x only
28
+ ---
21
29
 
22
- # Usage
30
+ ## useTrackedInstance &nbsp;ยท&nbsp; [โ–ถ Try on playground](https://play.vuejs.org/#eNqtVttuGzcQ/ZWpglYyoJuV1AnWstI29kPS1jFivxTQC3eXq2XEJQmSK1mQDeQPArQFigIF+tZvKFCgH+MfaD+hQ+5FK8WXFg1sCEvOkHPOcOaQ69aXSvUXOW0FrbGJNFMWDLW5Ak7E7GjasmbamkwFy5TUFtagaQLXkGiZQRuXtRum3NALTaI5jV8KY4mIaO1pi/keKw24bCrsSlH4Vi4YhSNYTwUAiwMQeRZS7UaWWU4DMFYzMXMTK0p05QBX+MG5X2XO8pAzk1JcHkrJKcHdAWKmaWQlLvGbAwiSbe13PRX4PxWRRFhIICaWdCFKkTmNj/2AmWOm7aoLXJJySlNMEDI7uoXw2NOZdGo2+90Gk3bbjyoanPvhFvyEcEP9dAN9Cbzdhms0Xe85zBWeu0JdpBSOiZ7D14LNUtuMPBoOn30Y2er8zsAvUs2MlSrFvJ9KrIwmkiJ7zJyTBeYV04Il0vE80FwYOTH2nAp7RlYOd+EzfkMjqeNxcR5dyMVcyKWYlCc76bjfRgRDFq5QiFmJCDp7cDQpi6aM3F8QnjsPx8QZdqLW9lfnr0/7imhDO/6zAMCSVadx9IX3HsYHIEvCLAi6hDOsZobrtIuOZXDBMipz29FdeDYcFt71ybhyKre5FafPkS/B8aBoPWw0HFiaKU4sxRHAOGYLLNkVp9iMMTNoWQUJp5eHMCMqGD1R+JVIYXsJyRhfBYYI0zNUs+QQFIljjBjsH6hL38ZFH2zt6fYK9r3V2dCaPp6cxEg4c7U8HuCwWohGTkLKJxeuysahrhehhQmVW1j0MhlT7sA6+r4cpy1wve7UhF5aHFWxlyy2abA/HH6Kk4MNgkERxQUoguyE/w7r+L7o/UIjKhCu7DcYSoH5/yhQGXyj/Js8VE3Vdx310fPxUPRGo29iRymN5qHEwmiEAth4Phw9zK2VAr6IOIvmuKWXRtwvwEIlIacxzn1SSihW2M3Pf/z1+3t447zGg2JxHfkzERp1eOfWrvvv2BmururmatQxwHq9kaXn0C6+bt791gbUtL9//f5PwClaCr5BTcNWrDhvoRsPsGP+Q/tsIJxbbOQdbuhjFBEQVHusIZLcCW5FCME+ogdP6WjkoT4aPSX0YIgAt+iVBOslNz+9RxF1iYo3nHD5zS8/ePGMmwwdKYeiUWTbjR5OGmqI+ZjAzbsfYZkSCzNqDaqfsGAlWLxoUG4WVAf1WqVxriQX4gU50zIXcfAo+dz9NXRp5NQrxFuA6p4mMctNgFJ1CBkTvZS6iys4GHrtQqoNPPAc+XaEtKk729Kw5/iNBxi8yaNSU+wJliCenVthK6NI+hs0F9xU4RA47g2Xu7hRkpBk/0FuBZXdC3GDuzqMrUtguwKr7y2nVrdVPMR6GVH9t0YKfNH5+3FaGvAhV7+EUHl2XmTOOG2l1ioTDAa5UPNZP5LZ4EO/6uWEEa3Bqzlhs514uE4xTvVrZRle3VtxCedy+crP1S8Ov8ap0S3zbw2mzEE7c+KCZTZt1TZLNJZiYT45P/VaWhtRAHN39dxjRCGSPHcYC7ev8CARdsPPo33p04dnemFOLi0VpiJVPTTcW8h543v4xT3UN3Af95/UWbz+B3cQyP8=)
23
31
 
24
- ## Tracked instance
32
+ Track changes to a single object, primitive, or array.
25
33
 
26
- Track everything what was changed
34
+ ```js
35
+ import {useTrackedInstance} from 'tracked-instance'
27
36
 
28
- ```javascript
29
37
  const {data, changedData, isDirty, loadData, reset} = useTrackedInstance({
30
38
  name: 'Jack',
31
- isActive: false
39
+ isActive: false,
32
40
  })
33
41
  ```
34
- Do some changes and see only changed field in changedData.
35
- Then set previous value and see what changedData is empty.
36
- That guaranty what you always get real changes
37
- ```javascript
42
+
43
+ **Mutate `data.value` directly** โ€” `changedData` and `isDirty` update automatically:
44
+
45
+ ```js
38
46
  data.value.name = 'John'
39
- console.log(isDirty.value) // true
40
- console.log(changedData.value) // {name: 'John'}
47
+ isDirty.value // true
48
+ changedData.value // { name: 'John' }
41
49
 
50
+ // Revert to original value โ†’ field disappears from changedData
42
51
  data.value.name = 'Jack'
43
- console.log(isDirty.value) // false
44
- console.log(changedData.value) // undefined
52
+ isDirty.value // false
53
+ changedData.value // undefined
45
54
  ```
46
- Rollback initial value:
47
- ```javascript
55
+
56
+ **`reset()`** โ€” revert all changes back to the last loaded baseline:
57
+
58
+ ```js
48
59
  data.value.name = 'John'
49
60
  reset()
50
- console.log(data.value) // { name: 'Jack', isActive: false }
51
- console.log(isDirty.value) // false
52
- console.log(changedData.value) // undefined
61
+ data.value // { name: 'Jack', isActive: false }
53
62
  ```
54
- All changes should be replaced by new loaded data.
55
- The data will be considered not dirty
56
- ```javascript
57
- data.value.name = 'John'
58
- data.value.isActive = true
59
- loadData({
60
- name: 'Joe',
61
- isActive: false
62
- })
63
- console.log(isDirty.value) // false
64
- console.log(data.value) // { name: 'Joe', isActive: false }
63
+
64
+ **`loadData(newData)`** โ€” replace data without marking anything dirty (use after a successful save):
65
+
66
+ ```js
67
+ loadData({name: 'Joe', isActive: true})
68
+ isDirty.value // false โ† Joe is now the new baseline
65
69
  ```
66
70
 
67
- Can accept primitive values or arrays
68
- ```javascript
71
+ Works with primitives and arrays too:
72
+
73
+ ```js
69
74
  useTrackedInstance(false)
70
- useTrackedInstance([1,2,3])
75
+ useTrackedInstance([1, 2, 3])
71
76
  ```
72
77
 
73
- ### Real-world example
74
- [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
- ```vue
76
- <script setup>
77
- import {useTrackedInstance} from 'tracked-instance'
78
-
79
- const {data, changedData, isDirty, reset, loadData} = useTrackedInstance({
80
- title: '',
81
- year: null,
82
- isPublished: false,
83
- details: {
84
- // should be updated by loadData
85
- }
86
- })
87
-
88
- // update initial data without make form dirty
89
- loadData({
90
- id: 1,
91
- title: 'The Dark Knight',
92
- year: 2008,
93
- isPublished: true,
94
- details: {
95
- director: {
96
- name: 'Christopher Nolan' // form see changes in nested values
97
- }
98
- }
99
- })
100
-
101
- const saveChanges = () => {
102
- loadData(data.value)
103
- }
104
- </script>
105
-
106
- <template>
107
- <div>isDirty: {{ isDirty }}</div>
108
- <hr />
109
- <button @click="reset">โ™ป๏ธ Reset</button>
110
-
111
- <form @submit.prevent="saveChanges">
112
- <input
113
- v-model="data.title"
114
- type="text"
115
- />
116
- <input
117
- v-model.number="data.year"
118
- type="number"
119
- />
120
- <input
121
- v-model="data.isPublished"
122
- type="checkbox"
123
- />
124
-
125
- <input
126
- v-model="data.details.director.name"
127
- type="text"
128
- />
129
-
130
- <button
131
- type="submit"
132
- :disabled="!isDirty"
133
- >
134
- ๐Ÿ’พ Save changes
135
- </button>
136
- </form>
137
-
138
- <h2>Changed data:</h2>
139
- <pre>{{ changedData }}</pre>
140
- </template>
78
+ ### Custom equality with `equals`
141
79
 
80
+ By default values are compared with `===`. Override this for edge cases โ€” for example when a UI component writes `null`
81
+ but the backend sends `""`:
82
+
83
+ ```js
84
+ const {data, isDirty} = useTrackedInstance(
85
+ {comment: null},
86
+ {equals: (a, b) => (a ?? '') === (b ?? '')}
87
+ )
88
+
89
+ data.value.comment = '' // treated as equal to null
90
+ isDirty.value // false
91
+
92
+ data.value.comment = 'hi'
93
+ isDirty.value // true
142
94
  ```
143
95
 
144
- ## Collection
96
+ ---
97
+
98
+ ## useCollection &nbsp;ยท&nbsp; [โ–ถ Try on playground](https://play.vuejs.org/#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)
99
+
100
+ Track an array of items โ€” add, remove, modify, and reset the whole list.
101
+
102
+ ```js
103
+ import {useCollection} from 'tracked-instance'
145
104
 
146
- ```javascript
147
- const {isDirty, add, items, remove, reset, loadData} = useCollection()
105
+ const {items, isDirty, add, remove, loadData, reset} = useCollection()
148
106
 
149
107
  loadData([{name: 'Jack'}, {name: 'John'}, {name: 'Joe'}])
150
108
  ```
151
- Should be dirty on make some changes, remove or add item
152
- ```javascript
109
+
110
+ Each item in `items` is a `CollectionItem` with its own `TrackedInstance`:
111
+
112
+ ```js
153
113
  items.value[0].instance.data.value.name = 'Stepan'
154
- console.log(isDirty.value) // true
155
- ```
156
- Add new item:
157
- ```javascript
158
- const addedItem = add({name: 'Taras'})
159
- console.log(addedItem) // {instance: TrackedInstance<{name: 'Taras'}>, isRemoved: false, isNew: true, meta: {}}}
114
+ isDirty.value // true
160
115
  ```
161
- Add new item in specific position:
162
- ```javascript
163
- add({name: 'Taras'}, 0)
116
+
117
+ **`add(item, index?)`** โ€” add a new item (marked `isNew: true`):
118
+
119
+ ```js
120
+ const newItem = add({name: 'Taras'})
121
+ // newItem.isNew.value === true
122
+ // newItem.isRemoved.value === false
123
+
124
+ add({name: 'Taras'}, 0) // insert at position 0
164
125
  ```
165
126
 
166
- Item should be softly removed and can be reverted by reset()
167
- ```javascript
168
- remove(0)
169
- remove(0, true) // hard remove
127
+ **`remove(index, isHardRemove?)`** โ€” soft-delete by default, hard-delete with `true`:
128
+
129
+ ```js
130
+ remove(0) // soft remove: isRemoved = true, item stays in array
131
+ remove(0, true) // hard remove: spliced out immediately
170
132
  ```
171
133
 
172
- Reset all changes including changing data on each item
173
- ```javascript
134
+ Soft-removed items can be restored with `reset()` or by setting `isRemoved.value = false` manually.
135
+
136
+ **`reset()`** โ€” removes new items, restores soft-removed ones, reverts all changes:
137
+
138
+ ```js
174
139
  reset()
175
140
  ```
176
141
 
177
- Item meta. Additional custom fields which can watch on item instance.
178
- If set then should be applied to each item which was added by add() or loadData()
179
- ```javascript
142
+ ### Item meta
143
+
144
+ Attach computed or reactive metadata to each item via a factory function:
145
+
146
+ ```js
180
147
  const {add, items} = useCollection(instance => ({
181
148
  isValidName: computed(() => instance.data.value.name.length > 0)
182
149
  }))
183
150
 
184
151
  add({name: ''})
152
+ items.value[0].meta.isValidName.value // false
153
+ ```
185
154
 
186
- console.log(items.value[0].meta.isValidName.value) // false
155
+ The same `options` (including `equals`) are forwarded to every `TrackedInstance` in the collection:
156
+
157
+ ```js
158
+ const {items} = useCollection(
159
+ () => undefined,
160
+ {equals: (a, b) => (a ?? '') === (b ?? '')}
161
+ )
162
+ ```
163
+
164
+ ---
165
+
166
+ ## API Reference
167
+
168
+ ### useTrackedInstance(initialData?, options?)
169
+
170
+ ```typescript
171
+ useTrackedInstance<Data>(initialData ? : Data, options ? : TrackedInstanceOptions)
172
+ :
173
+ TrackedInstance<Data>
187
174
  ```
188
175
 
189
- ### Real-world example
190
- [Try on playground](https://play.vuejs.org/#eNp9VcFy0zAQ/ZWtL3ZnUvsAp5BmKG0P9FCYFk6Yg2sriRpZ9khymk7G38DADMNwob/BDDN8DD9AP4GVZDuym3BJLO1q973dp9XGOynLcFURb+xNZCpoqUASVZXTmNO8LISCjSCzGmaiyMFHR39rqCQ5LRgjqaIFb12USNIlyY4olyrhqfaPeVrgCjZUnlGh7keQZNkIqCK5HIEgebEi+h8Tj4AVSXaWqKSGY+glCA51pNYcfNjwJCdj8C8wn1+PoFsXC95fE7/+aA5bGJzcvZdEXKIZcyC7wPcds0xW5HSR8DmRaA4O4XgKm5hDhyzQC7Dww1XCKmI3AMIZZYqIQJv0sQP9EVJ5ZShm1hlTNd55UgbG1+Swvk3VwgwTOf74U8d8EtkWYXNwgf4lSxTBFcDkplKqwG0deZxRmdwwkh3H3kFT9Niztpcpo+kSDQ5PazNxAB4fvvyGazRC2lh1+MjGd3NtQ5nWxd70z/dff39+giu97J9YCIgMavzO6GragBrDZgPNN9T1JNI2x80iOsky3TQtBzG2WxPKy0q1lVwd5UVGGCJxetsSBlD3JUGbImsEaTc1GBNnSAWVGTTKcWLVhy8GsvF9zffHVwNOA+vzdYlUrE3WdgwBzwqB2Uz38SbwjKwP8c+KCiO30CeMojOdmUbuEJPjOixKvzD71BVqrttS7SmXW7KdZbOXOLBEENTjw7fPqAO92StM73gfaY/kJblrCbpeXb4+HaM/nA+Or5MNwNWlg+IpMJPYinKAA9XZ9SRitO1o1L+Ekem1/my6ZBtqROvowDTVKsCMirbx7fwIqJ0IT0fHtt0dxj09dQEPu7VLSajpWcKkVlRbLhy9NzhcB3Xq6DdsezXwRp59HY5wuIW3suD4sJjpiVmNAdWNBbahUGSD10IbY2+hVCnHUVTxcjkP0yKPnvrpCMiwxoxK4uSe0fkgH54rKSPiTamfj37ehLHi7sLsKVGRUbufLki63LF/K9cW2lutNbFCAJ1NJWKux582n19fmmvTGfH+VayhtceIsixYpTFat1cVzxC242fQvjblo3z+Tp6vFeGyJaWBmmoY/9jDR/r0P9S3cJ+Fz7sq1v8AiAKmmg==)
191
- ```vue
192
- <script setup>
193
- import {ref} from 'vue'
194
- import {useCollection} from 'tracked-instance'
195
-
196
- const {isDirty, add, items, remove, reset, loadData} = useCollection()
197
-
198
- loadData([{name: 'Jack'}, {name: 'John'}, {name: 'Joe'}])
199
-
200
- const newUserName = ref('')
201
-
202
- const saveChanges = () => {
203
- loadData(
204
- items.value
205
- .filter(item => !item.isRemoved.value)
206
- .map((item) => item.instance.data.value)
207
- )
208
- }
209
- </script>
210
-
211
- <template>
212
- <button
213
- :disabled="!isDirty"
214
- @click="saveChanges"
215
- >
216
- ๐Ÿ’พ Save changes
217
- </button>
218
- <button @click="reset">โ™ป๏ธ Reset</button>
219
- <hr />
220
-
221
- <div>isDirty: {{ isDirty }}</div>
222
-
223
- <div>
224
- Add new user:
225
- <input
226
- v-model="newUserName"
227
- type="text"
228
- />
229
- <button @click="add({name: newUserName}); newUserName = ''">โž• Add user</button>
230
- </div>
231
-
232
- <ul>
233
- <template v-for="(item, index) in items">
234
- <li v-if="!item.isRemoved.value">
235
- <input
236
- v-model="item.instance.data.value.name"
237
- type="text"
238
- />
239
- <button @click="remove(index)">๐Ÿ—‘ Remove</button>
240
- <button
241
- v-if="!item.isNew.value"
242
- @click="item.instance.reset()"
243
- >
244
- โ™ป๏ธ Reset
245
- </button>
246
- isNew: {{ item.isNew.value }}
247
- </li>
248
- </template>
249
- </ul>
250
-
251
- Removed items:
252
- <ul>
253
- <li v-for="item in items.filter((i) => i.isRemoved.value)">
254
- {{ item.instance.data.value.name }}
255
- <button @click="item.isRemoved.value = false">โ™ป๏ธ Rollback</button>
256
- </li>
257
- </ul>
258
- </template>
176
+ | Option | Type | Description |
177
+ |----------|---------------------------------------|------------------------------------------------------------|
178
+ | `equals` | `(a: unknown, b: unknown) => boolean` | Custom equality for primitive leaf values. Replaces `===`. |
179
+
180
+ | Return | Type | Description |
181
+ |---------------------|--------------------------|-------------------------------------------------------------|
182
+ | `data` | `Ref<Data>` | Reactive reference to current data. Mutate directly. |
183
+ | `changedData` | `Ref<DeepPartial<Data>>` | Only modified fields. `undefined` when nothing has changed. |
184
+ | `isDirty` | `Ref<boolean>` | `true` when any field differs from the original. |
185
+ | `loadData(newData)` | `void` | Replace data and clear dirty state (new baseline). |
186
+ | `reset()` | `void` | Revert all changes back to the last `loadData()` baseline. |
187
+
188
+ ### useCollection(createItemMeta?, options?)
189
+
190
+ ```typescript
191
+ useCollection<Item, Meta>(
192
+ createItemMeta ? : (instance: TrackedInstance<Item>) => Meta,
193
+ options ? : TrackedInstanceOptions,
194
+ )
195
+ :
196
+ Collection<Item, Meta>
259
197
  ```
260
198
 
261
- # 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
268
-
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
199
+ | Return | Type | Description |
200
+ |--------------------------------|-------------------------|------------------------------------------------------------------|
201
+ | `items` | `Ref<CollectionItem[]>` | Reactive array of collection items. |
202
+ | `isDirty` | `ComputedRef<boolean>` | `true` if any item is dirty, new, or soft-removed. |
203
+ | `add(item, index?)` | `CollectionItem` | Add a new item. Appended to end by default. |
204
+ | `remove(index, isHardRemove?)` | `void` | Soft-remove by default. Pass `true` to splice from array. |
205
+ | `loadData(items)` | `void` | Replace all items and clear dirty state. |
206
+ | `reset()` | `void` | Remove new items, restore soft-removed, reset all instance data. |
207
+
208
+ ### CollectionItem
276
209
 
277
210
  ```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
211
+ interface CollectionItem<Item, Meta = undefined> {
212
+ instance: TrackedInstance<Item> // tracked instance for this item
213
+ isNew: Ref<boolean> // true for items added via add()
214
+ isRemoved: Ref<boolean> // true after soft remove
215
+ meta: Meta // custom metadata from createItemMeta()
216
+ remove(isHardRemove?: boolean): void // shortcut to remove self
284
217
  }
285
218
  ```
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.1",
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
  ],
@@ -28,7 +34,7 @@
28
34
  },
29
35
  "repository": {
30
36
  "type": "git",
31
- "url": "git+https://github.com/rudnik275/tracked-instance"
37
+ "url": "git+https://github.com/rudnik275/tracked-instance.git"
32
38
  },
33
39
  "author": "Dmytro Rudnyk",
34
40
  "license": "MIT",