@o/hooks 0.16.19-canary.1790165522484
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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/cjs/index.cjs +32 -0
- package/dist/cjs/index.native.js +35 -0
- package/dist/cjs/index.native.js.map +1 -0
- package/dist/cjs/useClickOutside.cjs +52 -0
- package/dist/cjs/useClickOutside.native.js +63 -0
- package/dist/cjs/useClickOutside.native.js.map +1 -0
- package/dist/cjs/useDebouncePrepend.cjs +66 -0
- package/dist/cjs/useDebouncePrepend.native.js +70 -0
- package/dist/cjs/useDebouncePrepend.native.js.map +1 -0
- package/dist/cjs/useDeepMemoizedObject.cjs +207 -0
- package/dist/cjs/useDeepMemoizedObject.native.js +258 -0
- package/dist/cjs/useDeepMemoizedObject.native.js.map +1 -0
- package/dist/cjs/useDeepMemoizedObject.test.cjs +321 -0
- package/dist/cjs/useDeepMemoizedObject.test.native.js +324 -0
- package/dist/cjs/useDeepMemoizedObject.test.native.js.map +1 -0
- package/dist/cjs/useDeferredBoolean.cjs +39 -0
- package/dist/cjs/useDeferredBoolean.native.js +42 -0
- package/dist/cjs/useDeferredBoolean.native.js.map +1 -0
- package/dist/cjs/useEffectOnceGlobally.cjs +45 -0
- package/dist/cjs/useEffectOnceGlobally.native.js +50 -0
- package/dist/cjs/useEffectOnceGlobally.native.js.map +1 -0
- package/dist/cjs/useEvent.cjs +33 -0
- package/dist/cjs/useEvent.native.js +41 -0
- package/dist/cjs/useEvent.native.js.map +1 -0
- package/dist/cjs/useIsMounted.cjs +35 -0
- package/dist/cjs/useIsMounted.native.js +38 -0
- package/dist/cjs/useIsMounted.native.js.map +1 -0
- package/dist/cjs/useLastValue.cjs +31 -0
- package/dist/cjs/useLastValue.native.js +34 -0
- package/dist/cjs/useLastValue.native.js.map +1 -0
- package/dist/cjs/useLastValueIf.cjs +38 -0
- package/dist/cjs/useLastValueIf.native.js +42 -0
- package/dist/cjs/useLastValueIf.native.js.map +1 -0
- package/dist/cjs/useMemoStable.cjs +35 -0
- package/dist/cjs/useMemoStable.native.js +39 -0
- package/dist/cjs/useMemoStable.native.js.map +1 -0
- package/dist/cjs/useMemoizedObjectList.cjs +64 -0
- package/dist/cjs/useMemoizedObjectList.native.js +86 -0
- package/dist/cjs/useMemoizedObjectList.native.js.map +1 -0
- package/dist/cjs/useThrottle.cjs +52 -0
- package/dist/cjs/useThrottle.native.js +59 -0
- package/dist/cjs/useThrottle.native.js.map +1 -0
- package/dist/cjs/useWarnIfDepsChange.cjs +65 -0
- package/dist/cjs/useWarnIfDepsChange.native.js +68 -0
- package/dist/cjs/useWarnIfDepsChange.native.js.map +1 -0
- package/dist/cjs/useWarnIfMemoChangesOften.cjs +43 -0
- package/dist/cjs/useWarnIfMemoChangesOften.native.js +48 -0
- package/dist/cjs/useWarnIfMemoChangesOften.native.js.map +1 -0
- package/dist/esm/index.js +14 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/index.mjs +14 -0
- package/dist/esm/index.mjs.map +1 -0
- package/dist/esm/index.native.js +14 -0
- package/dist/esm/index.native.js.map +1 -0
- package/dist/esm/useClickOutside.mjs +27 -0
- package/dist/esm/useClickOutside.mjs.map +1 -0
- package/dist/esm/useClickOutside.native.js +35 -0
- package/dist/esm/useClickOutside.native.js.map +1 -0
- package/dist/esm/useDebouncePrepend.mjs +41 -0
- package/dist/esm/useDebouncePrepend.mjs.map +1 -0
- package/dist/esm/useDebouncePrepend.native.js +42 -0
- package/dist/esm/useDebouncePrepend.native.js.map +1 -0
- package/dist/esm/useDeepMemoizedObject.mjs +180 -0
- package/dist/esm/useDeepMemoizedObject.mjs.map +1 -0
- package/dist/esm/useDeepMemoizedObject.native.js +228 -0
- package/dist/esm/useDeepMemoizedObject.native.js.map +1 -0
- package/dist/esm/useDeepMemoizedObject.test.mjs +322 -0
- package/dist/esm/useDeepMemoizedObject.test.mjs.map +1 -0
- package/dist/esm/useDeepMemoizedObject.test.native.js +322 -0
- package/dist/esm/useDeepMemoizedObject.test.native.js.map +1 -0
- package/dist/esm/useDeferredBoolean.mjs +14 -0
- package/dist/esm/useDeferredBoolean.mjs.map +1 -0
- package/dist/esm/useDeferredBoolean.native.js +14 -0
- package/dist/esm/useDeferredBoolean.native.js.map +1 -0
- package/dist/esm/useEffectOnceGlobally.mjs +20 -0
- package/dist/esm/useEffectOnceGlobally.mjs.map +1 -0
- package/dist/esm/useEffectOnceGlobally.native.js +22 -0
- package/dist/esm/useEffectOnceGlobally.native.js.map +1 -0
- package/dist/esm/useEvent.mjs +8 -0
- package/dist/esm/useEvent.mjs.map +1 -0
- package/dist/esm/useEvent.native.js +13 -0
- package/dist/esm/useEvent.native.js.map +1 -0
- package/dist/esm/useIsMounted.mjs +10 -0
- package/dist/esm/useIsMounted.mjs.map +1 -0
- package/dist/esm/useIsMounted.native.js +10 -0
- package/dist/esm/useIsMounted.native.js.map +1 -0
- package/dist/esm/useLastValue.mjs +6 -0
- package/dist/esm/useLastValue.mjs.map +1 -0
- package/dist/esm/useLastValue.native.js +6 -0
- package/dist/esm/useLastValue.native.js.map +1 -0
- package/dist/esm/useLastValueIf.mjs +13 -0
- package/dist/esm/useLastValueIf.mjs.map +1 -0
- package/dist/esm/useLastValueIf.native.js +14 -0
- package/dist/esm/useLastValueIf.native.js.map +1 -0
- package/dist/esm/useMemoStable.mjs +10 -0
- package/dist/esm/useMemoStable.mjs.map +1 -0
- package/dist/esm/useMemoStable.native.js +11 -0
- package/dist/esm/useMemoStable.native.js.map +1 -0
- package/dist/esm/useMemoizedObjectList.mjs +39 -0
- package/dist/esm/useMemoizedObjectList.mjs.map +1 -0
- package/dist/esm/useMemoizedObjectList.native.js +58 -0
- package/dist/esm/useMemoizedObjectList.native.js.map +1 -0
- package/dist/esm/useThrottle.mjs +27 -0
- package/dist/esm/useThrottle.mjs.map +1 -0
- package/dist/esm/useThrottle.native.js +31 -0
- package/dist/esm/useThrottle.native.js.map +1 -0
- package/dist/esm/useWarnIfDepsChange.mjs +40 -0
- package/dist/esm/useWarnIfDepsChange.mjs.map +1 -0
- package/dist/esm/useWarnIfDepsChange.native.js +40 -0
- package/dist/esm/useWarnIfDepsChange.native.js.map +1 -0
- package/dist/esm/useWarnIfMemoChangesOften.mjs +18 -0
- package/dist/esm/useWarnIfMemoChangesOften.mjs.map +1 -0
- package/dist/esm/useWarnIfMemoChangesOften.native.js +20 -0
- package/dist/esm/useWarnIfMemoChangesOften.native.js.map +1 -0
- package/package.json +58 -0
- package/src/index.ts +13 -0
- package/src/useClickOutside.ts +34 -0
- package/src/useDebouncePrepend.ts +63 -0
- package/src/useDeepMemoizedObject.test.ts +344 -0
- package/src/useDeepMemoizedObject.ts +231 -0
- package/src/useDeferredBoolean.tsx +15 -0
- package/src/useEffectOnceGlobally.ts +41 -0
- package/src/useEvent.ts +7 -0
- package/src/useIsMounted.ts +11 -0
- package/src/useLastValue.ts +5 -0
- package/src/useLastValueIf.ts +15 -0
- package/src/useMemoStable.ts +25 -0
- package/src/useMemoizedObjectList.ts +74 -0
- package/src/useThrottle.ts +36 -0
- package/src/useWarnIfDepsChange.ts +61 -0
- package/src/useWarnIfMemoChangesOften.ts +24 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { deepMemoize, type Options } from './useDeepMemoizedObject'
|
|
4
|
+
|
|
5
|
+
function testMemoization<T>(
|
|
6
|
+
initialValue: T,
|
|
7
|
+
options?: Options
|
|
8
|
+
): { current: T; update: (value: T) => void } {
|
|
9
|
+
let previousValue = initialValue
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
get current() {
|
|
13
|
+
return previousValue
|
|
14
|
+
},
|
|
15
|
+
update(value: T) {
|
|
16
|
+
previousValue = deepMemoize(
|
|
17
|
+
value,
|
|
18
|
+
previousValue,
|
|
19
|
+
[],
|
|
20
|
+
options?.immutableToNestedChanges
|
|
21
|
+
)
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('useDeepMemoizedObject', () => {
|
|
27
|
+
it('should preserve identity for unchanged primitives', () => {
|
|
28
|
+
const hook = testMemoization(42)
|
|
29
|
+
const firstResult = hook.current
|
|
30
|
+
hook.update(42)
|
|
31
|
+
expect(hook.current).toBe(firstResult)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should preserve identity for unchanged objects', () => {
|
|
35
|
+
const obj = { a: 1, b: 2 }
|
|
36
|
+
const hook = testMemoization(obj)
|
|
37
|
+
const firstResult = hook.current
|
|
38
|
+
hook.update({ a: 1, b: 2 })
|
|
39
|
+
expect(hook.current).toBe(firstResult)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should preserve identity for unchanged arrays', () => {
|
|
43
|
+
const arr = [1, 2, 3]
|
|
44
|
+
const hook = testMemoization(arr)
|
|
45
|
+
const firstResult = hook.current
|
|
46
|
+
hook.update([1, 2, 3])
|
|
47
|
+
expect(hook.current).toBe(firstResult)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should handle nested object changes correctly', () => {
|
|
51
|
+
const initial = {
|
|
52
|
+
server: {
|
|
53
|
+
id: 1,
|
|
54
|
+
channels: [
|
|
55
|
+
{ id: 1, name: 'general' },
|
|
56
|
+
{ id: 2, name: 'random' },
|
|
57
|
+
{ id: 3, name: 'tech' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hook = testMemoization(initial)
|
|
63
|
+
const firstResult = hook.current
|
|
64
|
+
|
|
65
|
+
const updated = {
|
|
66
|
+
server: {
|
|
67
|
+
id: 1,
|
|
68
|
+
channels: [
|
|
69
|
+
{ id: 1, name: 'general-updated' },
|
|
70
|
+
{ id: 2, name: 'random' },
|
|
71
|
+
{ id: 3, name: 'tech' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
hook.update(updated)
|
|
77
|
+
const secondResult = hook.current
|
|
78
|
+
|
|
79
|
+
expect(secondResult).not.toBe(firstResult)
|
|
80
|
+
expect(secondResult.server).not.toBe(firstResult.server)
|
|
81
|
+
expect(secondResult.server.channels).not.toBe(firstResult.server.channels)
|
|
82
|
+
expect(secondResult.server.channels[0]).not.toBe(firstResult.server.channels[0])
|
|
83
|
+
expect(secondResult.server.channels[1]).toBe(firstResult.server.channels[1])
|
|
84
|
+
expect(secondResult.server.channels[2]).toBe(firstResult.server.channels[2])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should handle deep nested changes in arrays', () => {
|
|
88
|
+
const initial = {
|
|
89
|
+
server: {
|
|
90
|
+
channels: [
|
|
91
|
+
{
|
|
92
|
+
id: 1,
|
|
93
|
+
messages: [
|
|
94
|
+
{ id: 1, text: 'hi' },
|
|
95
|
+
{ id: 2, text: 'hello' },
|
|
96
|
+
{
|
|
97
|
+
id: 3,
|
|
98
|
+
text: 'world',
|
|
99
|
+
reactions: [
|
|
100
|
+
{ emoji: '👍', count: 1 },
|
|
101
|
+
{ emoji: '❤️', count: 2 },
|
|
102
|
+
{ emoji: '😂', count: 3, updatedAt: '2024-01-01' },
|
|
103
|
+
{ emoji: '🎉', count: 4 },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const hook = testMemoization(initial)
|
|
113
|
+
const firstResult = hook.current
|
|
114
|
+
|
|
115
|
+
const updated = JSON.parse(JSON.stringify(initial))
|
|
116
|
+
updated.server.channels[0].messages[2].reactions[2].updatedAt = '2024-01-02'
|
|
117
|
+
|
|
118
|
+
hook.update(updated)
|
|
119
|
+
const secondResult = hook.current
|
|
120
|
+
|
|
121
|
+
expect(secondResult).not.toBe(firstResult)
|
|
122
|
+
expect(secondResult.server).not.toBe(firstResult.server)
|
|
123
|
+
expect(secondResult.server.channels).not.toBe(firstResult.server.channels)
|
|
124
|
+
expect(secondResult.server.channels[0]).not.toBe(firstResult.server.channels[0])
|
|
125
|
+
expect(secondResult.server.channels[0]!.messages).not.toBe(
|
|
126
|
+
firstResult.server.channels[0]!.messages
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
expect(secondResult.server.channels[0]!.messages[0]).toBe(
|
|
130
|
+
firstResult.server.channels[0]!.messages[0]
|
|
131
|
+
)
|
|
132
|
+
expect(secondResult.server.channels[0]!.messages[1]).toBe(
|
|
133
|
+
firstResult.server.channels[0]!.messages[1]
|
|
134
|
+
)
|
|
135
|
+
expect(secondResult.server.channels[0]!.messages[2]).not.toBe(
|
|
136
|
+
firstResult.server.channels[0]!.messages[2]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
expect(secondResult.server.channels[0]!.messages[2]!.reactions).not.toBe(
|
|
140
|
+
firstResult.server.channels[0]!.messages[2]!.reactions
|
|
141
|
+
)
|
|
142
|
+
expect(secondResult.server.channels[0]!.messages[2]!.reactions![0]).toBe(
|
|
143
|
+
firstResult.server.channels[0]!.messages[2]!.reactions![0]
|
|
144
|
+
)
|
|
145
|
+
expect(secondResult.server.channels[0]!.messages[2]!.reactions![1]).toBe(
|
|
146
|
+
firstResult.server.channels[0]!.messages[2]!.reactions![1]
|
|
147
|
+
)
|
|
148
|
+
expect(secondResult.server.channels[0]!.messages[2]!.reactions![2]).not.toBe(
|
|
149
|
+
firstResult.server.channels[0]!.messages[2]!.reactions![2]
|
|
150
|
+
)
|
|
151
|
+
expect(secondResult.server.channels[0]!.messages[2]!.reactions![3]).toBe(
|
|
152
|
+
firstResult.server.channels[0]!.messages[2]!.reactions![3]
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('should respect immutableToNestedChanges option', () => {
|
|
157
|
+
const initial = {
|
|
158
|
+
server: {
|
|
159
|
+
id: 1,
|
|
160
|
+
channels: [
|
|
161
|
+
{
|
|
162
|
+
id: 1,
|
|
163
|
+
messages: [
|
|
164
|
+
{ id: 1, text: 'hi' },
|
|
165
|
+
{ id: 2, text: 'hello' },
|
|
166
|
+
{ id: 3, text: 'world' },
|
|
167
|
+
{ id: 4, text: 'foo' },
|
|
168
|
+
{ id: 5, text: 'bar' },
|
|
169
|
+
{
|
|
170
|
+
id: 6,
|
|
171
|
+
text: 'baz',
|
|
172
|
+
reactions: [
|
|
173
|
+
{ emoji: '👍', count: 1 },
|
|
174
|
+
{ emoji: '❤️', count: 2 },
|
|
175
|
+
{ emoji: '😂', count: 3, updatedAt: '2024-01-01' },
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const hook = testMemoization(initial, {
|
|
185
|
+
immutableToNestedChanges: {
|
|
186
|
+
server: true,
|
|
187
|
+
'server.channels.0': true,
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const firstResult = hook.current
|
|
192
|
+
// Save original references before mutation
|
|
193
|
+
const originalChannels = firstResult.server.channels
|
|
194
|
+
const originalChannel0 = firstResult.server.channels[0]
|
|
195
|
+
const originalMessages = firstResult.server.channels[0]!.messages
|
|
196
|
+
const originalMessage5 = firstResult.server.channels[0]!.messages[5]
|
|
197
|
+
const originalReactions = firstResult.server.channels[0]!.messages[5]!.reactions
|
|
198
|
+
|
|
199
|
+
const updated = JSON.parse(JSON.stringify(initial))
|
|
200
|
+
updated.server.channels[0].messages[5].reactions[2].updatedAt = '2024-01-02'
|
|
201
|
+
|
|
202
|
+
hook.update(updated)
|
|
203
|
+
const secondResult = hook.current
|
|
204
|
+
|
|
205
|
+
expect(secondResult).not.toBe(firstResult)
|
|
206
|
+
// server object should be the same reference
|
|
207
|
+
expect(secondResult.server).toBe(firstResult.server)
|
|
208
|
+
// channels array should be different (new array)
|
|
209
|
+
expect(secondResult.server.channels).not.toBe(originalChannels)
|
|
210
|
+
// channels[0] should be the same (marked as immutable)
|
|
211
|
+
expect(secondResult.server.channels[0]).toBe(originalChannel0)
|
|
212
|
+
// But its messages array should be different
|
|
213
|
+
expect(secondResult.server.channels[0]!.messages).not.toBe(originalMessages)
|
|
214
|
+
expect(secondResult.server.channels[0]!.messages[5]).not.toBe(originalMessage5)
|
|
215
|
+
expect(secondResult.server.channels[0]!.messages[5]!.reactions).not.toBe(
|
|
216
|
+
originalReactions
|
|
217
|
+
)
|
|
218
|
+
// Verify the actual change was applied
|
|
219
|
+
expect(secondResult.server.channels[0]!.messages[5]!.reactions![2]!.updatedAt).toBe(
|
|
220
|
+
'2024-01-02'
|
|
221
|
+
)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('should handle adding new properties to objects', () => {
|
|
225
|
+
const initial = { a: 1, b: 2 }
|
|
226
|
+
const hook = testMemoization(initial)
|
|
227
|
+
const firstResult = hook.current
|
|
228
|
+
const updated = { a: 1, b: 2, c: 3 }
|
|
229
|
+
|
|
230
|
+
hook.update(updated)
|
|
231
|
+
expect(hook.current).not.toBe(firstResult)
|
|
232
|
+
expect(hook.current).toEqual(updated)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should handle array length changes', () => {
|
|
236
|
+
const initial = [1, 2, 3]
|
|
237
|
+
const hook = testMemoization(initial)
|
|
238
|
+
const firstResult = hook.current
|
|
239
|
+
hook.update([1, 2, 3, 4])
|
|
240
|
+
expect(hook.current).not.toBe(firstResult)
|
|
241
|
+
expect(hook.current).toEqual([1, 2, 3, 4])
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('should handle null and undefined values', () => {
|
|
245
|
+
const nullHook = testMemoization(null as any)
|
|
246
|
+
expect(nullHook.current).toBe(null)
|
|
247
|
+
|
|
248
|
+
nullHook.update(undefined as any)
|
|
249
|
+
expect(nullHook.current).toBe(undefined)
|
|
250
|
+
|
|
251
|
+
nullHook.update({ a: null, b: undefined })
|
|
252
|
+
const objResult = nullHook.current as any
|
|
253
|
+
expect(objResult.a).toBe(null)
|
|
254
|
+
expect(objResult.b).toBe(undefined)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('should preserve array items when only one changes', () => {
|
|
258
|
+
const initial = [
|
|
259
|
+
{ id: 1, name: 'Alice' },
|
|
260
|
+
{ id: 2, name: 'Bob' },
|
|
261
|
+
{ id: 3, name: 'Charlie' },
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
const hook = testMemoization(initial)
|
|
265
|
+
const firstResult = hook.current
|
|
266
|
+
|
|
267
|
+
const updated = [
|
|
268
|
+
{ id: 1, name: 'Alice' },
|
|
269
|
+
{ id: 2, name: 'Bobby' },
|
|
270
|
+
{ id: 3, name: 'Charlie' },
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
hook.update(updated)
|
|
274
|
+
const secondResult = hook.current
|
|
275
|
+
|
|
276
|
+
expect(secondResult).not.toBe(firstResult)
|
|
277
|
+
expect(secondResult[0]).toBe(firstResult[0])
|
|
278
|
+
expect(secondResult[1]).not.toBe(firstResult[1])
|
|
279
|
+
expect(secondResult[2]).toBe(firstResult[2])
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should handle complex immutableToNestedChanges patterns', () => {
|
|
283
|
+
const initial = {
|
|
284
|
+
app: {
|
|
285
|
+
settings: {
|
|
286
|
+
theme: 'dark',
|
|
287
|
+
language: 'en',
|
|
288
|
+
nested: {
|
|
289
|
+
deep: {
|
|
290
|
+
value: 1,
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const hook = testMemoization(initial, {
|
|
298
|
+
immutableToNestedChanges: {
|
|
299
|
+
'app.settings': true,
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const firstResult = hook.current
|
|
304
|
+
const originalSettings = firstResult.app.settings
|
|
305
|
+
const originalNested = firstResult.app.settings.nested
|
|
306
|
+
const originalDeep = firstResult.app.settings.nested.deep
|
|
307
|
+
|
|
308
|
+
const updated = JSON.parse(JSON.stringify(initial))
|
|
309
|
+
updated.app.settings.nested.deep.value = 2
|
|
310
|
+
|
|
311
|
+
hook.update(updated)
|
|
312
|
+
const secondResult = hook.current
|
|
313
|
+
|
|
314
|
+
expect(secondResult).not.toBe(firstResult)
|
|
315
|
+
expect(secondResult.app).not.toBe(firstResult.app)
|
|
316
|
+
// settings should be the same (marked as immutable)
|
|
317
|
+
expect(secondResult.app.settings).toBe(originalSettings)
|
|
318
|
+
// These should also be the same since settings is immutable and contains them
|
|
319
|
+
expect(secondResult.app.settings.nested).toBe(originalNested)
|
|
320
|
+
expect(secondResult.app.settings.nested.deep).toBe(originalDeep)
|
|
321
|
+
// But the value should have been mutated
|
|
322
|
+
expect(secondResult.app.settings.nested.deep.value).toBe(2)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('should handle mixed arrays with objects and primitives', () => {
|
|
326
|
+
const initial = [1, 'string', { id: 1, name: 'object' }, [1, 2, 3], null, undefined]
|
|
327
|
+
|
|
328
|
+
const hook = testMemoization(initial)
|
|
329
|
+
const firstResult = hook.current
|
|
330
|
+
|
|
331
|
+
const updated = [1, 'string', { id: 1, name: 'updated' }, [1, 2, 3], null, undefined]
|
|
332
|
+
|
|
333
|
+
hook.update(updated)
|
|
334
|
+
const secondResult = hook.current
|
|
335
|
+
|
|
336
|
+
expect(secondResult).not.toBe(firstResult)
|
|
337
|
+
expect(secondResult[0]).toBe(1)
|
|
338
|
+
expect(secondResult[1]).toBe('string')
|
|
339
|
+
expect(secondResult[2]).not.toBe(firstResult[2])
|
|
340
|
+
expect(secondResult[3]).toBe(firstResult[3])
|
|
341
|
+
expect(secondResult[4]).toBe(null)
|
|
342
|
+
expect(secondResult[5]).toBe(undefined)
|
|
343
|
+
})
|
|
344
|
+
})
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
export type ImmutableToNestedChanges = Record<string, boolean>
|
|
4
|
+
|
|
5
|
+
export interface Options {
|
|
6
|
+
immutableToNestedChanges?: ImmutableToNestedChanges
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function shouldBeImmutable(
|
|
10
|
+
path: string[],
|
|
11
|
+
immutableToNestedChanges?: ImmutableToNestedChanges
|
|
12
|
+
): boolean {
|
|
13
|
+
if (!immutableToNestedChanges) return false
|
|
14
|
+
|
|
15
|
+
const currentPath = path.join('.')
|
|
16
|
+
for (const pattern in immutableToNestedChanges) {
|
|
17
|
+
if (immutableToNestedChanges[pattern] && currentPath === pattern) {
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface MemoResult<T> {
|
|
25
|
+
value: T
|
|
26
|
+
hasChanges: boolean
|
|
27
|
+
hasImmutableMutation: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function deepMutateInPlace<T>(target: T, source: T): void {
|
|
31
|
+
if (
|
|
32
|
+
typeof target !== 'object' ||
|
|
33
|
+
target === null ||
|
|
34
|
+
typeof source !== 'object' ||
|
|
35
|
+
source === null
|
|
36
|
+
) {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Array.isArray(target) && Array.isArray(source)) {
|
|
41
|
+
// For arrays, replace contents
|
|
42
|
+
;(target as any[]).length = 0
|
|
43
|
+
;(target as any[]).push(...(source as any[]))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// For objects, recursively mutate
|
|
48
|
+
for (const key in source as any) {
|
|
49
|
+
const targetValue = (target as any)[key]
|
|
50
|
+
const sourceValue = (source as any)[key]
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
typeof sourceValue === 'object' &&
|
|
54
|
+
sourceValue !== null &&
|
|
55
|
+
typeof targetValue === 'object' &&
|
|
56
|
+
targetValue !== null
|
|
57
|
+
) {
|
|
58
|
+
// Recursively mutate nested objects/arrays
|
|
59
|
+
deepMutateInPlace(targetValue, sourceValue)
|
|
60
|
+
} else {
|
|
61
|
+
// Replace primitive values or null/undefined
|
|
62
|
+
;(target as any)[key] = sourceValue
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function deepMemoizeWithTracking<T>(
|
|
68
|
+
current: T,
|
|
69
|
+
previous: T,
|
|
70
|
+
path: string[],
|
|
71
|
+
immutableToNestedChanges?: ImmutableToNestedChanges,
|
|
72
|
+
parentIsImmutable: boolean = false
|
|
73
|
+
): MemoResult<T> {
|
|
74
|
+
if (current === previous) {
|
|
75
|
+
return { value: previous, hasChanges: false, hasImmutableMutation: false }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (typeof current !== 'object' || current === null) {
|
|
79
|
+
return { value: current, hasChanges: true, hasImmutableMutation: false }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof previous !== 'object' || previous === null) {
|
|
83
|
+
return { value: current, hasChanges: true, hasImmutableMutation: false }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isCurrentImmutable = shouldBeImmutable(path, immutableToNestedChanges)
|
|
87
|
+
const shouldMutateInPlace = isCurrentImmutable || parentIsImmutable
|
|
88
|
+
|
|
89
|
+
if (Array.isArray(current)) {
|
|
90
|
+
if (!Array.isArray(previous)) {
|
|
91
|
+
return { value: current, hasChanges: true, hasImmutableMutation: false }
|
|
92
|
+
}
|
|
93
|
+
if (current.length !== previous.length) {
|
|
94
|
+
return { value: current, hasChanges: true, hasImmutableMutation: false }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let hasChanges = false
|
|
98
|
+
let hasImmutableMutation = false
|
|
99
|
+
const memoizedArray: any[] = []
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < current.length; i++) {
|
|
102
|
+
const itemPath = [...path, String(i)]
|
|
103
|
+
// Pass down whether the parent is immutable BUT arrays are special
|
|
104
|
+
// Items in arrays can be immutable themselves, but arrays should never be mutated in place
|
|
105
|
+
const result = deepMemoizeWithTracking(
|
|
106
|
+
current[i],
|
|
107
|
+
previous[i],
|
|
108
|
+
itemPath,
|
|
109
|
+
immutableToNestedChanges,
|
|
110
|
+
false
|
|
111
|
+
)
|
|
112
|
+
memoizedArray[i] = result.value
|
|
113
|
+
if (result.hasChanges) {
|
|
114
|
+
hasChanges = true
|
|
115
|
+
}
|
|
116
|
+
if (result.hasImmutableMutation) {
|
|
117
|
+
hasImmutableMutation = true
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Arrays are NEVER immutable themselves, always create new array if contents changed OR if there were immutable mutations
|
|
122
|
+
if (hasChanges || hasImmutableMutation) {
|
|
123
|
+
return { value: memoizedArray as T, hasChanges: true, hasImmutableMutation: false }
|
|
124
|
+
}
|
|
125
|
+
return { value: previous, hasChanges: false, hasImmutableMutation: false }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const currentKeys = Object.keys(current)
|
|
129
|
+
const previousKeys = Object.keys(previous)
|
|
130
|
+
|
|
131
|
+
if (currentKeys.length !== previousKeys.length) {
|
|
132
|
+
return { value: current, hasChanges: true, hasImmutableMutation: false }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const keysMatch = currentKeys.every((key) => key in previous)
|
|
136
|
+
if (!keysMatch) {
|
|
137
|
+
return { value: current, hasChanges: true, hasImmutableMutation: false }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let hasChanges = false
|
|
141
|
+
let hasImmutableMutation = false
|
|
142
|
+
const memoizedObject: any = {}
|
|
143
|
+
|
|
144
|
+
for (const key of currentKeys) {
|
|
145
|
+
const propPath = [...path, key]
|
|
146
|
+
const currentValue = (current as any)[key]
|
|
147
|
+
const previousValue = (previous as any)[key]
|
|
148
|
+
|
|
149
|
+
// Pass down whether this object is immutable
|
|
150
|
+
const result = deepMemoizeWithTracking(
|
|
151
|
+
currentValue,
|
|
152
|
+
previousValue,
|
|
153
|
+
propPath,
|
|
154
|
+
immutableToNestedChanges,
|
|
155
|
+
shouldMutateInPlace
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
memoizedObject[key] = result.value
|
|
159
|
+
if (result.hasChanges) {
|
|
160
|
+
hasChanges = true
|
|
161
|
+
}
|
|
162
|
+
if (result.hasImmutableMutation) {
|
|
163
|
+
hasImmutableMutation = true
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (shouldMutateInPlace && (hasChanges || hasImmutableMutation)) {
|
|
168
|
+
// This object is immutable - keep the same reference but update properties
|
|
169
|
+
// Deep mutate the previous object in place
|
|
170
|
+
if (parentIsImmutable) {
|
|
171
|
+
// If parent is already immutable, we're nested and need to mutate everything in place
|
|
172
|
+
deepMutateInPlace(previous, current)
|
|
173
|
+
} else {
|
|
174
|
+
// Just mutate the direct properties
|
|
175
|
+
for (const key of currentKeys) {
|
|
176
|
+
;(previous as any)[key] = memoizedObject[key]
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Return that this was an immutable mutation
|
|
180
|
+
return { value: previous, hasChanges: false, hasImmutableMutation: true }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (hasChanges || hasImmutableMutation) {
|
|
184
|
+
return { value: memoizedObject, hasChanges: true, hasImmutableMutation: false }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { value: previous, hasChanges: false, hasImmutableMutation: false }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function deepMemoize<T>(
|
|
191
|
+
current: T,
|
|
192
|
+
previous: T,
|
|
193
|
+
path: string[] = [],
|
|
194
|
+
immutableToNestedChanges?: ImmutableToNestedChanges
|
|
195
|
+
): T {
|
|
196
|
+
const result = deepMemoizeWithTracking(
|
|
197
|
+
current,
|
|
198
|
+
previous,
|
|
199
|
+
path,
|
|
200
|
+
immutableToNestedChanges,
|
|
201
|
+
false
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
// If root has immutable mutation but no changes, we need to force a new reference
|
|
205
|
+
if (path.length === 0 && result.hasImmutableMutation && !result.hasChanges) {
|
|
206
|
+
// The root itself was immutable and mutated, or contains immutable mutations
|
|
207
|
+
// We need to ensure the root reference changes
|
|
208
|
+
if (Array.isArray(result.value)) {
|
|
209
|
+
return [...result.value] as T
|
|
210
|
+
} else if (typeof result.value === 'object' && result.value !== null) {
|
|
211
|
+
return { ...result.value }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return result.value
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function useDeepMemoizedObject<T>(value: T, options?: Options): T {
|
|
219
|
+
const previousValueRef = useRef<T>(value)
|
|
220
|
+
|
|
221
|
+
const memoizedValue = deepMemoize(
|
|
222
|
+
value,
|
|
223
|
+
previousValueRef.current,
|
|
224
|
+
[],
|
|
225
|
+
options?.immutableToNestedChanges
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
previousValueRef.current = memoizedValue
|
|
229
|
+
|
|
230
|
+
return memoizedValue
|
|
231
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useState, startTransition, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
export const useDeferredBoolean = (inVal: boolean): boolean => {
|
|
4
|
+
const [val, setVal] = useState(inVal)
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (val !== inVal) {
|
|
8
|
+
startTransition(() => {
|
|
9
|
+
setVal(inVal)
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
}, [inVal, val])
|
|
13
|
+
|
|
14
|
+
return val
|
|
15
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
const executedKeys = new Set<string>()
|
|
4
|
+
|
|
5
|
+
type WithId = { id: string }
|
|
6
|
+
|
|
7
|
+
type KeyType<T> = string | string[] | WithId | WithId[] | readonly WithId[] | undefined
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hook that ensures an effect only runs once globally across all component instances
|
|
11
|
+
* Uses a key (or array of keys) to determine if the effect has already been executed
|
|
12
|
+
*/
|
|
13
|
+
export function useEffectOnceGlobally<T extends KeyType<T>>(
|
|
14
|
+
key: T,
|
|
15
|
+
callback: T extends undefined ? () => void : (value: NonNullable<T>) => void
|
|
16
|
+
) {
|
|
17
|
+
const keyString = !key
|
|
18
|
+
? undefined
|
|
19
|
+
: typeof key === 'string'
|
|
20
|
+
? key
|
|
21
|
+
: Array.isArray(key)
|
|
22
|
+
? typeof key[0] === 'string'
|
|
23
|
+
? (key as string[]).sort().join('')
|
|
24
|
+
: (key as readonly WithId[])
|
|
25
|
+
.map((item) => item.id)
|
|
26
|
+
.sort()
|
|
27
|
+
.join('')
|
|
28
|
+
: (key as WithId).id
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!keyString || executedKeys.has(keyString)) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
executedKeys.add(keyString)
|
|
35
|
+
if (key !== undefined) {
|
|
36
|
+
;(callback as (value: NonNullable<T>) => void)(key as NonNullable<T>)
|
|
37
|
+
} else {
|
|
38
|
+
;(callback as () => void)()
|
|
39
|
+
}
|
|
40
|
+
}, [keyString, callback, key])
|
|
41
|
+
}
|
package/src/useEvent.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
export function useLastValueIf<T>(value: T, keepLast = true): T | undefined {
|
|
4
|
+
// sorted [newest, older]
|
|
5
|
+
const lastTwoValuesRef = useRef<(T | undefined)[]>([])
|
|
6
|
+
|
|
7
|
+
const [latest] = lastTwoValuesRef.current
|
|
8
|
+
if (keepLast) {
|
|
9
|
+
if (latest !== value) {
|
|
10
|
+
lastTwoValuesRef.current = [value, latest]
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return lastTwoValuesRef.current[1]
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useWarnIfDepsChange } from './useWarnIfDepsChange'
|
|
4
|
+
|
|
5
|
+
// create a useMemo that can log when it changes too often
|
|
6
|
+
// useful for development
|
|
7
|
+
|
|
8
|
+
export interface UseMemoStableOptions {
|
|
9
|
+
maxChanges?: number
|
|
10
|
+
ignoreIndexBefore?: number
|
|
11
|
+
name: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useMemoStable<Value, T extends readonly unknown[]>(
|
|
15
|
+
getValue: () => Value,
|
|
16
|
+
deps: T,
|
|
17
|
+
options: UseMemoStableOptions = {
|
|
18
|
+
name: `(untitled)`,
|
|
19
|
+
}
|
|
20
|
+
): Value {
|
|
21
|
+
useWarnIfDepsChange(deps, options)
|
|
22
|
+
|
|
23
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
24
|
+
return useMemo(getValue, deps)
|
|
25
|
+
}
|