@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.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/cjs/index.cjs +30 -0
- package/dist/cjs/index.js +27 -0
- package/dist/cjs/index.js.map +6 -0
- package/dist/cjs/index.native.js +33 -0
- package/dist/cjs/index.native.js.map +1 -0
- package/dist/cjs/useClickOutside.cjs +43 -0
- package/dist/cjs/useClickOutside.js +37 -0
- package/dist/cjs/useClickOutside.js.map +6 -0
- package/dist/cjs/useClickOutside.native.js +51 -0
- package/dist/cjs/useClickOutside.native.js.map +1 -0
- package/dist/cjs/useDebouncePrepend.cjs +46 -0
- package/dist/cjs/useDebouncePrepend.js +40 -0
- package/dist/cjs/useDebouncePrepend.js.map +6 -0
- package/dist/cjs/useDebouncePrepend.native.js +54 -0
- package/dist/cjs/useDebouncePrepend.native.js.map +1 -0
- package/dist/cjs/useDeepMemoizedObject.cjs +148 -0
- package/dist/cjs/useDeepMemoizedObject.js +122 -0
- package/dist/cjs/useDeepMemoizedObject.js.map +6 -0
- package/dist/cjs/useDeepMemoizedObject.native.js +192 -0
- package/dist/cjs/useDeepMemoizedObject.native.js.map +1 -0
- package/dist/cjs/useDeepMemoizedObject.test.cjs +251 -0
- package/dist/cjs/useDeepMemoizedObject.test.js +187 -0
- package/dist/cjs/useDeepMemoizedObject.test.js.map +6 -0
- package/dist/cjs/useDeepMemoizedObject.test.native.js +261 -0
- package/dist/cjs/useDeepMemoizedObject.test.native.js.map +1 -0
- package/dist/cjs/useDeferredBoolean.cjs +34 -0
- package/dist/cjs/useDeferredBoolean.js +29 -0
- package/dist/cjs/useDeferredBoolean.js.map +6 -0
- package/dist/cjs/useDeferredBoolean.native.js +37 -0
- package/dist/cjs/useDeferredBoolean.native.js.map +1 -0
- package/dist/cjs/useEffectOnceGlobally.cjs +33 -0
- package/dist/cjs/useEffectOnceGlobally.js +28 -0
- package/dist/cjs/useEffectOnceGlobally.js.map +6 -0
- package/dist/cjs/useEffectOnceGlobally.native.js +38 -0
- package/dist/cjs/useEffectOnceGlobally.native.js.map +1 -0
- package/dist/cjs/useIsMounted.cjs +32 -0
- package/dist/cjs/useIsMounted.js +27 -0
- package/dist/cjs/useIsMounted.js.map +6 -0
- package/dist/cjs/useIsMounted.native.js +35 -0
- package/dist/cjs/useIsMounted.native.js.map +1 -0
- package/dist/cjs/useLastValue.cjs +29 -0
- package/dist/cjs/useLastValue.js +24 -0
- package/dist/cjs/useLastValue.js.map +6 -0
- package/dist/cjs/useLastValue.native.js +32 -0
- package/dist/cjs/useLastValue.native.js.map +1 -0
- package/dist/cjs/useLastValueIf.cjs +31 -0
- package/dist/cjs/useLastValueIf.js +25 -0
- package/dist/cjs/useLastValueIf.js.map +6 -0
- package/dist/cjs/useLastValueIf.native.js +35 -0
- package/dist/cjs/useLastValueIf.native.js.map +1 -0
- package/dist/cjs/useMemoStable.cjs +32 -0
- package/dist/cjs/useMemoStable.js +26 -0
- package/dist/cjs/useMemoStable.js.map +6 -0
- package/dist/cjs/useMemoStable.native.js +36 -0
- package/dist/cjs/useMemoStable.native.js.map +1 -0
- package/dist/cjs/useMemoizedObjectList.cjs +48 -0
- package/dist/cjs/useMemoizedObjectList.js +36 -0
- package/dist/cjs/useMemoizedObjectList.js.map +6 -0
- package/dist/cjs/useMemoizedObjectList.native.js +65 -0
- package/dist/cjs/useMemoizedObjectList.native.js.map +1 -0
- package/dist/cjs/useThrottle.cjs +39 -0
- package/dist/cjs/useThrottle.js +30 -0
- package/dist/cjs/useThrottle.js.map +6 -0
- package/dist/cjs/useThrottle.native.js +45 -0
- package/dist/cjs/useThrottle.native.js.map +1 -0
- package/dist/cjs/useWarnIfDepsChange.cjs +54 -0
- package/dist/cjs/useWarnIfDepsChange.js +46 -0
- package/dist/cjs/useWarnIfDepsChange.js.map +6 -0
- package/dist/cjs/useWarnIfDepsChange.native.js +58 -0
- package/dist/cjs/useWarnIfDepsChange.native.js.map +1 -0
- package/dist/cjs/useWarnIfMemoChangesOften.cjs +34 -0
- package/dist/cjs/useWarnIfMemoChangesOften.js +29 -0
- package/dist/cjs/useWarnIfMemoChangesOften.js.map +6 -0
- package/dist/cjs/useWarnIfMemoChangesOften.native.js +42 -0
- package/dist/cjs/useWarnIfMemoChangesOften.native.js.map +1 -0
- package/dist/esm/index.js +14 -0
- package/dist/esm/index.js.map +6 -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.js +22 -0
- package/dist/esm/useClickOutside.js.map +6 -0
- package/dist/esm/useClickOutside.mjs +20 -0
- package/dist/esm/useClickOutside.mjs.map +1 -0
- package/dist/esm/useClickOutside.native.js +25 -0
- package/dist/esm/useClickOutside.native.js.map +1 -0
- package/dist/esm/useDebouncePrepend.js +25 -0
- package/dist/esm/useDebouncePrepend.js.map +6 -0
- package/dist/esm/useDebouncePrepend.mjs +23 -0
- package/dist/esm/useDebouncePrepend.mjs.map +1 -0
- package/dist/esm/useDebouncePrepend.native.js +28 -0
- package/dist/esm/useDebouncePrepend.native.js.map +1 -0
- package/dist/esm/useDeepMemoizedObject.js +106 -0
- package/dist/esm/useDeepMemoizedObject.js.map +6 -0
- package/dist/esm/useDeepMemoizedObject.mjs +123 -0
- package/dist/esm/useDeepMemoizedObject.mjs.map +1 -0
- package/dist/esm/useDeepMemoizedObject.native.js +164 -0
- package/dist/esm/useDeepMemoizedObject.native.js.map +1 -0
- package/dist/esm/useDeepMemoizedObject.test.js +188 -0
- package/dist/esm/useDeepMemoizedObject.test.js.map +6 -0
- package/dist/esm/useDeepMemoizedObject.test.mjs +252 -0
- package/dist/esm/useDeepMemoizedObject.test.mjs.map +1 -0
- package/dist/esm/useDeepMemoizedObject.test.native.js +259 -0
- package/dist/esm/useDeepMemoizedObject.test.native.js.map +1 -0
- package/dist/esm/useDeferredBoolean.js +13 -0
- package/dist/esm/useDeferredBoolean.js.map +6 -0
- package/dist/esm/useDeferredBoolean.mjs +11 -0
- package/dist/esm/useDeferredBoolean.mjs.map +1 -0
- package/dist/esm/useDeferredBoolean.native.js +11 -0
- package/dist/esm/useDeferredBoolean.native.js.map +1 -0
- package/dist/esm/useEffectOnceGlobally.js +12 -0
- package/dist/esm/useEffectOnceGlobally.js.map +6 -0
- package/dist/esm/useEffectOnceGlobally.mjs +10 -0
- package/dist/esm/useEffectOnceGlobally.mjs.map +1 -0
- package/dist/esm/useEffectOnceGlobally.native.js +12 -0
- package/dist/esm/useEffectOnceGlobally.native.js.map +1 -0
- package/dist/esm/useIsMounted.js +11 -0
- package/dist/esm/useIsMounted.js.map +6 -0
- package/dist/esm/useIsMounted.mjs +9 -0
- package/dist/esm/useIsMounted.mjs.map +1 -0
- package/dist/esm/useIsMounted.native.js +9 -0
- package/dist/esm/useIsMounted.native.js.map +1 -0
- package/dist/esm/useLastValue.js +8 -0
- package/dist/esm/useLastValue.js.map +6 -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.js +9 -0
- package/dist/esm/useLastValueIf.js.map +6 -0
- package/dist/esm/useLastValueIf.mjs +8 -0
- package/dist/esm/useLastValueIf.mjs.map +1 -0
- package/dist/esm/useLastValueIf.native.js +9 -0
- package/dist/esm/useLastValueIf.native.js.map +1 -0
- package/dist/esm/useMemoStable.js +11 -0
- package/dist/esm/useMemoStable.js.map +6 -0
- package/dist/esm/useMemoStable.mjs +9 -0
- package/dist/esm/useMemoStable.mjs.map +1 -0
- package/dist/esm/useMemoStable.native.js +10 -0
- package/dist/esm/useMemoStable.native.js.map +1 -0
- package/dist/esm/useMemoizedObjectList.js +21 -0
- package/dist/esm/useMemoizedObjectList.js.map +6 -0
- package/dist/esm/useMemoizedObjectList.mjs +25 -0
- package/dist/esm/useMemoizedObjectList.mjs.map +1 -0
- package/dist/esm/useMemoizedObjectList.native.js +39 -0
- package/dist/esm/useMemoizedObjectList.native.js.map +1 -0
- package/dist/esm/useThrottle.js +15 -0
- package/dist/esm/useThrottle.js.map +6 -0
- package/dist/esm/useThrottle.mjs +16 -0
- package/dist/esm/useThrottle.mjs.map +1 -0
- package/dist/esm/useThrottle.native.js +19 -0
- package/dist/esm/useThrottle.native.js.map +1 -0
- package/dist/esm/useWarnIfDepsChange.js +31 -0
- package/dist/esm/useWarnIfDepsChange.js.map +6 -0
- package/dist/esm/useWarnIfDepsChange.mjs +31 -0
- package/dist/esm/useWarnIfDepsChange.mjs.map +1 -0
- package/dist/esm/useWarnIfDepsChange.native.js +32 -0
- package/dist/esm/useWarnIfDepsChange.native.js.map +1 -0
- package/dist/esm/useWarnIfMemoChangesOften.js +14 -0
- package/dist/esm/useWarnIfMemoChangesOften.js.map +6 -0
- package/dist/esm/useWarnIfMemoChangesOften.mjs +11 -0
- package/dist/esm/useWarnIfMemoChangesOften.mjs.map +1 -0
- package/dist/esm/useWarnIfMemoChangesOften.native.js +16 -0
- package/dist/esm/useWarnIfMemoChangesOften.native.js.map +1 -0
- package/package.json +54 -0
- package/src/index.ts +13 -0
- package/src/useClickOutside.ts +34 -0
- package/src/useDebouncePrepend.ts +63 -0
- package/src/useDeepMemoizedObject.test.ts +343 -0
- package/src/useDeepMemoizedObject.ts +231 -0
- package/src/useDeferredBoolean.tsx +15 -0
- package/src/useEffectOnceGlobally.ts +41 -0
- package/src/useIsMounted.ts +11 -0
- package/src/useLastValue.ts +5 -0
- package/src/useLastValueIf.ts +15 -0
- package/src/useMemoStable.ts +24 -0
- package/src/useMemoizedObjectList.ts +74 -0
- package/src/useThrottle.ts +35 -0
- package/src/useWarnIfDepsChange.ts +61 -0
- 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
|
+
}
|