@take-out/hooks 0.0.28

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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/dist/cjs/index.cjs +30 -0
  4. package/dist/cjs/index.js +27 -0
  5. package/dist/cjs/index.js.map +6 -0
  6. package/dist/cjs/index.native.js +33 -0
  7. package/dist/cjs/index.native.js.map +1 -0
  8. package/dist/cjs/useClickOutside.cjs +43 -0
  9. package/dist/cjs/useClickOutside.js +37 -0
  10. package/dist/cjs/useClickOutside.js.map +6 -0
  11. package/dist/cjs/useClickOutside.native.js +51 -0
  12. package/dist/cjs/useClickOutside.native.js.map +1 -0
  13. package/dist/cjs/useDebouncePrepend.cjs +46 -0
  14. package/dist/cjs/useDebouncePrepend.js +40 -0
  15. package/dist/cjs/useDebouncePrepend.js.map +6 -0
  16. package/dist/cjs/useDebouncePrepend.native.js +54 -0
  17. package/dist/cjs/useDebouncePrepend.native.js.map +1 -0
  18. package/dist/cjs/useDeepMemoizedObject.cjs +148 -0
  19. package/dist/cjs/useDeepMemoizedObject.js +122 -0
  20. package/dist/cjs/useDeepMemoizedObject.js.map +6 -0
  21. package/dist/cjs/useDeepMemoizedObject.native.js +192 -0
  22. package/dist/cjs/useDeepMemoizedObject.native.js.map +1 -0
  23. package/dist/cjs/useDeepMemoizedObject.test.cjs +251 -0
  24. package/dist/cjs/useDeepMemoizedObject.test.js +187 -0
  25. package/dist/cjs/useDeepMemoizedObject.test.js.map +6 -0
  26. package/dist/cjs/useDeepMemoizedObject.test.native.js +261 -0
  27. package/dist/cjs/useDeepMemoizedObject.test.native.js.map +1 -0
  28. package/dist/cjs/useDeferredBoolean.cjs +34 -0
  29. package/dist/cjs/useDeferredBoolean.js +29 -0
  30. package/dist/cjs/useDeferredBoolean.js.map +6 -0
  31. package/dist/cjs/useDeferredBoolean.native.js +37 -0
  32. package/dist/cjs/useDeferredBoolean.native.js.map +1 -0
  33. package/dist/cjs/useEffectOnceGlobally.cjs +33 -0
  34. package/dist/cjs/useEffectOnceGlobally.js +28 -0
  35. package/dist/cjs/useEffectOnceGlobally.js.map +6 -0
  36. package/dist/cjs/useEffectOnceGlobally.native.js +38 -0
  37. package/dist/cjs/useEffectOnceGlobally.native.js.map +1 -0
  38. package/dist/cjs/useIsMounted.cjs +32 -0
  39. package/dist/cjs/useIsMounted.js +27 -0
  40. package/dist/cjs/useIsMounted.js.map +6 -0
  41. package/dist/cjs/useIsMounted.native.js +35 -0
  42. package/dist/cjs/useIsMounted.native.js.map +1 -0
  43. package/dist/cjs/useLastValue.cjs +29 -0
  44. package/dist/cjs/useLastValue.js +24 -0
  45. package/dist/cjs/useLastValue.js.map +6 -0
  46. package/dist/cjs/useLastValue.native.js +32 -0
  47. package/dist/cjs/useLastValue.native.js.map +1 -0
  48. package/dist/cjs/useLastValueIf.cjs +31 -0
  49. package/dist/cjs/useLastValueIf.js +25 -0
  50. package/dist/cjs/useLastValueIf.js.map +6 -0
  51. package/dist/cjs/useLastValueIf.native.js +35 -0
  52. package/dist/cjs/useLastValueIf.native.js.map +1 -0
  53. package/dist/cjs/useMemoStable.cjs +32 -0
  54. package/dist/cjs/useMemoStable.js +26 -0
  55. package/dist/cjs/useMemoStable.js.map +6 -0
  56. package/dist/cjs/useMemoStable.native.js +36 -0
  57. package/dist/cjs/useMemoStable.native.js.map +1 -0
  58. package/dist/cjs/useMemoizedObjectList.cjs +48 -0
  59. package/dist/cjs/useMemoizedObjectList.js +36 -0
  60. package/dist/cjs/useMemoizedObjectList.js.map +6 -0
  61. package/dist/cjs/useMemoizedObjectList.native.js +65 -0
  62. package/dist/cjs/useMemoizedObjectList.native.js.map +1 -0
  63. package/dist/cjs/useThrottle.cjs +39 -0
  64. package/dist/cjs/useThrottle.js +30 -0
  65. package/dist/cjs/useThrottle.js.map +6 -0
  66. package/dist/cjs/useThrottle.native.js +45 -0
  67. package/dist/cjs/useThrottle.native.js.map +1 -0
  68. package/dist/cjs/useWarnIfDepsChange.cjs +54 -0
  69. package/dist/cjs/useWarnIfDepsChange.js +46 -0
  70. package/dist/cjs/useWarnIfDepsChange.js.map +6 -0
  71. package/dist/cjs/useWarnIfDepsChange.native.js +58 -0
  72. package/dist/cjs/useWarnIfDepsChange.native.js.map +1 -0
  73. package/dist/cjs/useWarnIfMemoChangesOften.cjs +34 -0
  74. package/dist/cjs/useWarnIfMemoChangesOften.js +29 -0
  75. package/dist/cjs/useWarnIfMemoChangesOften.js.map +6 -0
  76. package/dist/cjs/useWarnIfMemoChangesOften.native.js +42 -0
  77. package/dist/cjs/useWarnIfMemoChangesOften.native.js.map +1 -0
  78. package/dist/esm/index.js +14 -0
  79. package/dist/esm/index.js.map +6 -0
  80. package/dist/esm/index.mjs +14 -0
  81. package/dist/esm/index.mjs.map +1 -0
  82. package/dist/esm/index.native.js +14 -0
  83. package/dist/esm/index.native.js.map +1 -0
  84. package/dist/esm/useClickOutside.js +22 -0
  85. package/dist/esm/useClickOutside.js.map +6 -0
  86. package/dist/esm/useClickOutside.mjs +20 -0
  87. package/dist/esm/useClickOutside.mjs.map +1 -0
  88. package/dist/esm/useClickOutside.native.js +25 -0
  89. package/dist/esm/useClickOutside.native.js.map +1 -0
  90. package/dist/esm/useDebouncePrepend.js +25 -0
  91. package/dist/esm/useDebouncePrepend.js.map +6 -0
  92. package/dist/esm/useDebouncePrepend.mjs +23 -0
  93. package/dist/esm/useDebouncePrepend.mjs.map +1 -0
  94. package/dist/esm/useDebouncePrepend.native.js +28 -0
  95. package/dist/esm/useDebouncePrepend.native.js.map +1 -0
  96. package/dist/esm/useDeepMemoizedObject.js +106 -0
  97. package/dist/esm/useDeepMemoizedObject.js.map +6 -0
  98. package/dist/esm/useDeepMemoizedObject.mjs +123 -0
  99. package/dist/esm/useDeepMemoizedObject.mjs.map +1 -0
  100. package/dist/esm/useDeepMemoizedObject.native.js +164 -0
  101. package/dist/esm/useDeepMemoizedObject.native.js.map +1 -0
  102. package/dist/esm/useDeepMemoizedObject.test.js +188 -0
  103. package/dist/esm/useDeepMemoizedObject.test.js.map +6 -0
  104. package/dist/esm/useDeepMemoizedObject.test.mjs +252 -0
  105. package/dist/esm/useDeepMemoizedObject.test.mjs.map +1 -0
  106. package/dist/esm/useDeepMemoizedObject.test.native.js +259 -0
  107. package/dist/esm/useDeepMemoizedObject.test.native.js.map +1 -0
  108. package/dist/esm/useDeferredBoolean.js +13 -0
  109. package/dist/esm/useDeferredBoolean.js.map +6 -0
  110. package/dist/esm/useDeferredBoolean.mjs +11 -0
  111. package/dist/esm/useDeferredBoolean.mjs.map +1 -0
  112. package/dist/esm/useDeferredBoolean.native.js +11 -0
  113. package/dist/esm/useDeferredBoolean.native.js.map +1 -0
  114. package/dist/esm/useEffectOnceGlobally.js +12 -0
  115. package/dist/esm/useEffectOnceGlobally.js.map +6 -0
  116. package/dist/esm/useEffectOnceGlobally.mjs +10 -0
  117. package/dist/esm/useEffectOnceGlobally.mjs.map +1 -0
  118. package/dist/esm/useEffectOnceGlobally.native.js +12 -0
  119. package/dist/esm/useEffectOnceGlobally.native.js.map +1 -0
  120. package/dist/esm/useIsMounted.js +11 -0
  121. package/dist/esm/useIsMounted.js.map +6 -0
  122. package/dist/esm/useIsMounted.mjs +9 -0
  123. package/dist/esm/useIsMounted.mjs.map +1 -0
  124. package/dist/esm/useIsMounted.native.js +9 -0
  125. package/dist/esm/useIsMounted.native.js.map +1 -0
  126. package/dist/esm/useLastValue.js +8 -0
  127. package/dist/esm/useLastValue.js.map +6 -0
  128. package/dist/esm/useLastValue.mjs +6 -0
  129. package/dist/esm/useLastValue.mjs.map +1 -0
  130. package/dist/esm/useLastValue.native.js +6 -0
  131. package/dist/esm/useLastValue.native.js.map +1 -0
  132. package/dist/esm/useLastValueIf.js +9 -0
  133. package/dist/esm/useLastValueIf.js.map +6 -0
  134. package/dist/esm/useLastValueIf.mjs +8 -0
  135. package/dist/esm/useLastValueIf.mjs.map +1 -0
  136. package/dist/esm/useLastValueIf.native.js +9 -0
  137. package/dist/esm/useLastValueIf.native.js.map +1 -0
  138. package/dist/esm/useMemoStable.js +11 -0
  139. package/dist/esm/useMemoStable.js.map +6 -0
  140. package/dist/esm/useMemoStable.mjs +9 -0
  141. package/dist/esm/useMemoStable.mjs.map +1 -0
  142. package/dist/esm/useMemoStable.native.js +10 -0
  143. package/dist/esm/useMemoStable.native.js.map +1 -0
  144. package/dist/esm/useMemoizedObjectList.js +21 -0
  145. package/dist/esm/useMemoizedObjectList.js.map +6 -0
  146. package/dist/esm/useMemoizedObjectList.mjs +25 -0
  147. package/dist/esm/useMemoizedObjectList.mjs.map +1 -0
  148. package/dist/esm/useMemoizedObjectList.native.js +39 -0
  149. package/dist/esm/useMemoizedObjectList.native.js.map +1 -0
  150. package/dist/esm/useThrottle.js +15 -0
  151. package/dist/esm/useThrottle.js.map +6 -0
  152. package/dist/esm/useThrottle.mjs +16 -0
  153. package/dist/esm/useThrottle.mjs.map +1 -0
  154. package/dist/esm/useThrottle.native.js +19 -0
  155. package/dist/esm/useThrottle.native.js.map +1 -0
  156. package/dist/esm/useWarnIfDepsChange.js +31 -0
  157. package/dist/esm/useWarnIfDepsChange.js.map +6 -0
  158. package/dist/esm/useWarnIfDepsChange.mjs +31 -0
  159. package/dist/esm/useWarnIfDepsChange.mjs.map +1 -0
  160. package/dist/esm/useWarnIfDepsChange.native.js +32 -0
  161. package/dist/esm/useWarnIfDepsChange.native.js.map +1 -0
  162. package/dist/esm/useWarnIfMemoChangesOften.js +14 -0
  163. package/dist/esm/useWarnIfMemoChangesOften.js.map +6 -0
  164. package/dist/esm/useWarnIfMemoChangesOften.mjs +11 -0
  165. package/dist/esm/useWarnIfMemoChangesOften.mjs.map +1 -0
  166. package/dist/esm/useWarnIfMemoChangesOften.native.js +16 -0
  167. package/dist/esm/useWarnIfMemoChangesOften.native.js.map +1 -0
  168. package/package.json +54 -0
  169. package/src/index.ts +13 -0
  170. package/src/useClickOutside.ts +34 -0
  171. package/src/useDebouncePrepend.ts +63 -0
  172. package/src/useDeepMemoizedObject.test.ts +343 -0
  173. package/src/useDeepMemoizedObject.ts +231 -0
  174. package/src/useDeferredBoolean.tsx +15 -0
  175. package/src/useEffectOnceGlobally.ts +41 -0
  176. package/src/useIsMounted.ts +11 -0
  177. package/src/useLastValue.ts +5 -0
  178. package/src/useLastValueIf.ts +15 -0
  179. package/src/useMemoStable.ts +24 -0
  180. package/src/useMemoizedObjectList.ts +74 -0
  181. package/src/useThrottle.ts +35 -0
  182. package/src/useWarnIfDepsChange.ts +61 -0
  183. package/src/useWarnIfMemoChangesOften.ts +24 -0
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from './useClickOutside'
2
+ export * from './useDebouncePrepend'
3
+ export * from './useDeepMemoizedObject'
4
+ export * from './useDeferredBoolean'
5
+ export * from './useEffectOnceGlobally'
6
+ export * from './useIsMounted'
7
+ export * from './useLastValue'
8
+ export * from './useLastValueIf'
9
+ export * from './useMemoizedObjectList'
10
+ export * from './useMemoStable'
11
+ export * from './useThrottle'
12
+ export * from './useWarnIfDepsChange'
13
+ export * from './useWarnIfMemoChangesOften'
@@ -0,0 +1,34 @@
1
+ import { useEffect } from 'react'
2
+ import { isWeb, type TamaguiElement } from 'tamagui'
3
+
4
+ type UseClickOutsideProps = {
5
+ ref: React.RefObject<TamaguiElement | null>
6
+ active: boolean
7
+ onClickOutside?: () => void
8
+ }
9
+
10
+ export const useClickOutside = ({
11
+ ref,
12
+ active,
13
+ onClickOutside,
14
+ }: UseClickOutsideProps) => {
15
+ useEffect(() => {
16
+ if (!isWeb) return
17
+ if (!active) return
18
+ if (!onClickOutside) return
19
+
20
+ const handleClickOutside = (e: MouseEvent) => {
21
+ const node = ref.current as HTMLElement
22
+ if (!node) return
23
+ if (!(e.target instanceof HTMLElement)) return
24
+ if (!node.contains(e.target)) {
25
+ onClickOutside()
26
+ }
27
+ }
28
+
29
+ document.addEventListener('click', handleClickOutside)
30
+ return () => {
31
+ document.removeEventListener('click', handleClickOutside)
32
+ }
33
+ }, [ref, active, onClickOutside])
34
+ }
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import { debounce } from 'tamagui'
3
+
4
+ export function useDebouncePrepend<T extends readonly { id: any }[]>(
5
+ list: T,
6
+ delay: number
7
+ ): T {
8
+ const [current, setCurrent] = useState(list)
9
+ const [previous, setPrevious] = useState(list)
10
+ const [pendingUpdate, setPendingUpdate] = useState<T | null>(null)
11
+
12
+ const debouncedUpdate = useMemo(() => {
13
+ return debounce((newList: T) => {
14
+ setCurrent(newList)
15
+ setPendingUpdate(null)
16
+ }, delay)
17
+ }, [delay])
18
+
19
+ const updateState = useCallback(
20
+ (newList: T) => {
21
+ setCurrent((prevCurrent) => {
22
+ // If there's a pending update, use the most recent list
23
+ const currentList = pendingUpdate || prevCurrent
24
+
25
+ // Check if we're prepending by comparing with the actual previous state
26
+ const isPrepending =
27
+ newList.length > previous.length && newList[0]?.id !== previous[0]?.id
28
+
29
+ if (isPrepending) {
30
+ // Cancel any existing debounced update
31
+ debouncedUpdate.cancel()
32
+ setPendingUpdate(newList)
33
+ debouncedUpdate(newList)
34
+ return currentList // Keep current state until debounced update fires
35
+ }
36
+
37
+ // Immediate update for non-prepending changes
38
+ debouncedUpdate.cancel()
39
+ setPendingUpdate(null)
40
+ return newList
41
+ })
42
+ },
43
+ [previous, pendingUpdate, debouncedUpdate]
44
+ )
45
+
46
+ useEffect(() => {
47
+ if (list === previous) {
48
+ return
49
+ }
50
+
51
+ // Update previous state
52
+ setPrevious(list)
53
+
54
+ // Update current state with concurrent safety
55
+ updateState(list)
56
+
57
+ return () => {
58
+ debouncedUpdate.cancel()
59
+ }
60
+ }, [list, previous, updateState, debouncedUpdate])
61
+
62
+ return current
63
+ }
@@ -0,0 +1,343 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { deepMemoize, type Options } from './useDeepMemoizedObject'
3
+
4
+ function testMemoization<T>(
5
+ initialValue: T,
6
+ options?: Options
7
+ ): { current: T; update: (value: T) => void } {
8
+ let previousValue = initialValue
9
+
10
+ return {
11
+ get current() {
12
+ return previousValue
13
+ },
14
+ update(value: T) {
15
+ previousValue = deepMemoize(
16
+ value,
17
+ previousValue,
18
+ [],
19
+ options?.immutableToNestedChanges
20
+ )
21
+ },
22
+ }
23
+ }
24
+
25
+ describe('useDeepMemoizedObject', () => {
26
+ it('should preserve identity for unchanged primitives', () => {
27
+ const hook = testMemoization(42)
28
+ const firstResult = hook.current
29
+ hook.update(42)
30
+ expect(hook.current).toBe(firstResult)
31
+ })
32
+
33
+ it('should preserve identity for unchanged objects', () => {
34
+ const obj = { a: 1, b: 2 }
35
+ const hook = testMemoization(obj)
36
+ const firstResult = hook.current
37
+ hook.update({ a: 1, b: 2 })
38
+ expect(hook.current).toBe(firstResult)
39
+ })
40
+
41
+ it('should preserve identity for unchanged arrays', () => {
42
+ const arr = [1, 2, 3]
43
+ const hook = testMemoization(arr)
44
+ const firstResult = hook.current
45
+ hook.update([1, 2, 3])
46
+ expect(hook.current).toBe(firstResult)
47
+ })
48
+
49
+ it('should handle nested object changes correctly', () => {
50
+ const initial = {
51
+ server: {
52
+ id: 1,
53
+ channels: [
54
+ { id: 1, name: 'general' },
55
+ { id: 2, name: 'random' },
56
+ { id: 3, name: 'tech' },
57
+ ],
58
+ },
59
+ }
60
+
61
+ const hook = testMemoization(initial)
62
+ const firstResult = hook.current
63
+
64
+ const updated = {
65
+ server: {
66
+ id: 1,
67
+ channels: [
68
+ { id: 1, name: 'general-updated' },
69
+ { id: 2, name: 'random' },
70
+ { id: 3, name: 'tech' },
71
+ ],
72
+ },
73
+ }
74
+
75
+ hook.update(updated)
76
+ const secondResult = hook.current
77
+
78
+ expect(secondResult).not.toBe(firstResult)
79
+ expect(secondResult.server).not.toBe(firstResult.server)
80
+ expect(secondResult.server.channels).not.toBe(firstResult.server.channels)
81
+ expect(secondResult.server.channels[0]).not.toBe(firstResult.server.channels[0])
82
+ expect(secondResult.server.channels[1]).toBe(firstResult.server.channels[1])
83
+ expect(secondResult.server.channels[2]).toBe(firstResult.server.channels[2])
84
+ })
85
+
86
+ it('should handle deep nested changes in arrays', () => {
87
+ const initial = {
88
+ server: {
89
+ channels: [
90
+ {
91
+ id: 1,
92
+ messages: [
93
+ { id: 1, text: 'hi' },
94
+ { id: 2, text: 'hello' },
95
+ {
96
+ id: 3,
97
+ text: 'world',
98
+ reactions: [
99
+ { emoji: '👍', count: 1 },
100
+ { emoji: '❤️', count: 2 },
101
+ { emoji: '😂', count: 3, updatedAt: '2024-01-01' },
102
+ { emoji: '🎉', count: 4 },
103
+ ],
104
+ },
105
+ ],
106
+ },
107
+ ],
108
+ },
109
+ }
110
+
111
+ const hook = testMemoization(initial)
112
+ const firstResult = hook.current
113
+
114
+ const updated = JSON.parse(JSON.stringify(initial))
115
+ updated.server.channels[0].messages[2].reactions[2].updatedAt = '2024-01-02'
116
+
117
+ hook.update(updated)
118
+ const secondResult = hook.current
119
+
120
+ expect(secondResult).not.toBe(firstResult)
121
+ expect(secondResult.server).not.toBe(firstResult.server)
122
+ expect(secondResult.server.channels).not.toBe(firstResult.server.channels)
123
+ expect(secondResult.server.channels[0]).not.toBe(firstResult.server.channels[0])
124
+ expect(secondResult.server.channels[0]!.messages).not.toBe(
125
+ firstResult.server.channels[0]!.messages
126
+ )
127
+
128
+ expect(secondResult.server.channels[0]!.messages[0]).toBe(
129
+ firstResult.server.channels[0]!.messages[0]
130
+ )
131
+ expect(secondResult.server.channels[0]!.messages[1]).toBe(
132
+ firstResult.server.channels[0]!.messages[1]
133
+ )
134
+ expect(secondResult.server.channels[0]!.messages[2]).not.toBe(
135
+ firstResult.server.channels[0]!.messages[2]
136
+ )
137
+
138
+ expect(secondResult.server.channels[0]!.messages[2]!.reactions).not.toBe(
139
+ firstResult.server.channels[0]!.messages[2]!.reactions
140
+ )
141
+ expect(secondResult.server.channels[0]!.messages[2]!.reactions![0]).toBe(
142
+ firstResult.server.channels[0]!.messages[2]!.reactions![0]
143
+ )
144
+ expect(secondResult.server.channels[0]!.messages[2]!.reactions![1]).toBe(
145
+ firstResult.server.channels[0]!.messages[2]!.reactions![1]
146
+ )
147
+ expect(secondResult.server.channels[0]!.messages[2]!.reactions![2]).not.toBe(
148
+ firstResult.server.channels[0]!.messages[2]!.reactions![2]
149
+ )
150
+ expect(secondResult.server.channels[0]!.messages[2]!.reactions![3]).toBe(
151
+ firstResult.server.channels[0]!.messages[2]!.reactions![3]
152
+ )
153
+ })
154
+
155
+ it('should respect immutableToNestedChanges option', () => {
156
+ const initial = {
157
+ server: {
158
+ id: 1,
159
+ channels: [
160
+ {
161
+ id: 1,
162
+ messages: [
163
+ { id: 1, text: 'hi' },
164
+ { id: 2, text: 'hello' },
165
+ { id: 3, text: 'world' },
166
+ { id: 4, text: 'foo' },
167
+ { id: 5, text: 'bar' },
168
+ {
169
+ id: 6,
170
+ text: 'baz',
171
+ reactions: [
172
+ { emoji: '👍', count: 1 },
173
+ { emoji: '❤️', count: 2 },
174
+ { emoji: '😂', count: 3, updatedAt: '2024-01-01' },
175
+ ],
176
+ },
177
+ ],
178
+ },
179
+ ],
180
+ },
181
+ }
182
+
183
+ const hook = testMemoization(initial, {
184
+ immutableToNestedChanges: {
185
+ server: true,
186
+ 'server.channels.0': true,
187
+ },
188
+ })
189
+
190
+ const firstResult = hook.current
191
+ // Save original references before mutation
192
+ const originalChannels = firstResult.server.channels
193
+ const originalChannel0 = firstResult.server.channels[0]
194
+ const originalMessages = firstResult.server.channels[0]!.messages
195
+ const originalMessage5 = firstResult.server.channels[0]!.messages[5]
196
+ const originalReactions = firstResult.server.channels[0]!.messages[5]!.reactions
197
+
198
+ const updated = JSON.parse(JSON.stringify(initial))
199
+ updated.server.channels[0].messages[5].reactions[2].updatedAt = '2024-01-02'
200
+
201
+ hook.update(updated)
202
+ const secondResult = hook.current
203
+
204
+ expect(secondResult).not.toBe(firstResult)
205
+ // server object should be the same reference
206
+ expect(secondResult.server).toBe(firstResult.server)
207
+ // channels array should be different (new array)
208
+ expect(secondResult.server.channels).not.toBe(originalChannels)
209
+ // channels[0] should be the same (marked as immutable)
210
+ expect(secondResult.server.channels[0]).toBe(originalChannel0)
211
+ // But its messages array should be different
212
+ expect(secondResult.server.channels[0]!.messages).not.toBe(originalMessages)
213
+ expect(secondResult.server.channels[0]!.messages[5]).not.toBe(originalMessage5)
214
+ expect(secondResult.server.channels[0]!.messages[5]!.reactions).not.toBe(
215
+ originalReactions
216
+ )
217
+ // Verify the actual change was applied
218
+ expect(secondResult.server.channels[0]!.messages[5]!.reactions![2]!.updatedAt).toBe(
219
+ '2024-01-02'
220
+ )
221
+ })
222
+
223
+ it('should handle adding new properties to objects', () => {
224
+ const initial = { a: 1, b: 2 }
225
+ const hook = testMemoization(initial)
226
+ const firstResult = hook.current
227
+ const updated = { a: 1, b: 2, c: 3 }
228
+
229
+ hook.update(updated)
230
+ expect(hook.current).not.toBe(firstResult)
231
+ expect(hook.current).toEqual(updated)
232
+ })
233
+
234
+ it('should handle array length changes', () => {
235
+ const initial = [1, 2, 3]
236
+ const hook = testMemoization(initial)
237
+ const firstResult = hook.current
238
+ hook.update([1, 2, 3, 4])
239
+ expect(hook.current).not.toBe(firstResult)
240
+ expect(hook.current).toEqual([1, 2, 3, 4])
241
+ })
242
+
243
+ it('should handle null and undefined values', () => {
244
+ const nullHook = testMemoization(null as any)
245
+ expect(nullHook.current).toBe(null)
246
+
247
+ nullHook.update(undefined as any)
248
+ expect(nullHook.current).toBe(undefined)
249
+
250
+ nullHook.update({ a: null, b: undefined })
251
+ const objResult = nullHook.current as any
252
+ expect(objResult.a).toBe(null)
253
+ expect(objResult.b).toBe(undefined)
254
+ })
255
+
256
+ it('should preserve array items when only one changes', () => {
257
+ const initial = [
258
+ { id: 1, name: 'Alice' },
259
+ { id: 2, name: 'Bob' },
260
+ { id: 3, name: 'Charlie' },
261
+ ]
262
+
263
+ const hook = testMemoization(initial)
264
+ const firstResult = hook.current
265
+
266
+ const updated = [
267
+ { id: 1, name: 'Alice' },
268
+ { id: 2, name: 'Bobby' },
269
+ { id: 3, name: 'Charlie' },
270
+ ]
271
+
272
+ hook.update(updated)
273
+ const secondResult = hook.current
274
+
275
+ expect(secondResult).not.toBe(firstResult)
276
+ expect(secondResult[0]).toBe(firstResult[0])
277
+ expect(secondResult[1]).not.toBe(firstResult[1])
278
+ expect(secondResult[2]).toBe(firstResult[2])
279
+ })
280
+
281
+ it('should handle complex immutableToNestedChanges patterns', () => {
282
+ const initial = {
283
+ app: {
284
+ settings: {
285
+ theme: 'dark',
286
+ language: 'en',
287
+ nested: {
288
+ deep: {
289
+ value: 1,
290
+ },
291
+ },
292
+ },
293
+ },
294
+ }
295
+
296
+ const hook = testMemoization(initial, {
297
+ immutableToNestedChanges: {
298
+ 'app.settings': true,
299
+ },
300
+ })
301
+
302
+ const firstResult = hook.current
303
+ const originalSettings = firstResult.app.settings
304
+ const originalNested = firstResult.app.settings.nested
305
+ const originalDeep = firstResult.app.settings.nested.deep
306
+
307
+ const updated = JSON.parse(JSON.stringify(initial))
308
+ updated.app.settings.nested.deep.value = 2
309
+
310
+ hook.update(updated)
311
+ const secondResult = hook.current
312
+
313
+ expect(secondResult).not.toBe(firstResult)
314
+ expect(secondResult.app).not.toBe(firstResult.app)
315
+ // settings should be the same (marked as immutable)
316
+ expect(secondResult.app.settings).toBe(originalSettings)
317
+ // These should also be the same since settings is immutable and contains them
318
+ expect(secondResult.app.settings.nested).toBe(originalNested)
319
+ expect(secondResult.app.settings.nested.deep).toBe(originalDeep)
320
+ // But the value should have been mutated
321
+ expect(secondResult.app.settings.nested.deep.value).toBe(2)
322
+ })
323
+
324
+ it('should handle mixed arrays with objects and primitives', () => {
325
+ const initial = [1, 'string', { id: 1, name: 'object' }, [1, 2, 3], null, undefined]
326
+
327
+ const hook = testMemoization(initial)
328
+ const firstResult = hook.current
329
+
330
+ const updated = [1, 'string', { id: 1, name: 'updated' }, [1, 2, 3], null, undefined]
331
+
332
+ hook.update(updated)
333
+ const secondResult = hook.current
334
+
335
+ expect(secondResult).not.toBe(firstResult)
336
+ expect(secondResult[0]).toBe(1)
337
+ expect(secondResult[1]).toBe('string')
338
+ expect(secondResult[2]).not.toBe(firstResult[2])
339
+ expect(secondResult[3]).toBe(firstResult[3])
340
+ expect(secondResult[4]).toBe(null)
341
+ expect(secondResult[5]).toBe(undefined)
342
+ })
343
+ })
@@ -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
+ }