app-tutor-ai-consumer 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/staging-staging.yml +148 -0
- package/.github/workflows/staging.yml +1 -2
- package/CHANGELOG.md +19 -0
- package/config/rspack/rspack.config.js +5 -1
- package/config/vitest/__mocks__/icons.tsx +3 -0
- package/config/vitest/__mocks__/intersection-observer.ts +10 -0
- package/config/vitest/__mocks__/sparkie.tsx +9 -9
- package/config/vitest/__mocks__/use-init-sparkie.tsx +14 -0
- package/config/vitest/vitest.config.mts +13 -8
- package/environments/.env.test +2 -0
- package/eslint.config.mjs +27 -0
- package/package.json +8 -4
- package/public/index.html +3 -4
- package/src/@types/index.d.ts +5 -2
- package/src/config/styles/global.css +2 -2
- package/src/config/tanstack/query-client.ts +3 -2
- package/src/config/tests/utils.tsx +3 -2
- package/src/config/tests/wrappers.tsx +4 -1
- package/src/development-bootstrap.tsx +15 -15
- package/src/index.tsx +37 -5
- package/src/lib/components/icons/ai-color.svg +17 -0
- package/src/lib/components/icons/arrow-down.svg +5 -0
- package/src/lib/components/icons/chevron-down.svg +4 -0
- package/src/lib/components/icons/icon-names.d.ts +1 -1
- package/src/lib/components/markdownrenderer/markdownrenderer.tsx +7 -9
- package/src/lib/hooks/index.ts +3 -0
- package/src/lib/hooks/use-intersection-observer-reverse-scroll/index.ts +2 -0
- package/src/lib/hooks/use-intersection-observer-reverse-scroll/use-intersection-observer-reverse-scroll.tsx +147 -0
- package/src/lib/hooks/use-ref-client-height/index.ts +2 -0
- package/src/lib/hooks/use-ref-client-height/use-ref-client-height.tsx +38 -0
- package/src/lib/hooks/use-scroll-to-ref/index.ts +2 -0
- package/src/lib/hooks/use-scroll-to-ref/use-scroll-to-ref.tsx +14 -0
- package/src/lib/hooks/use-throttle/index.ts +3 -0
- package/src/lib/hooks/use-throttle/types.ts +13 -0
- package/src/lib/hooks/use-throttle/use-throttle.spec.tsx +296 -0
- package/src/lib/hooks/use-throttle/use-throttle.tsx +91 -0
- package/src/lib/utils/is-text-empty.ts +3 -0
- package/src/main/main.spec.tsx +7 -6
- package/src/modules/cursor/__tests__/icursor-update.builder.ts +42 -0
- package/src/modules/cursor/hooks/index.ts +1 -0
- package/src/modules/cursor/hooks/use-update-cursor/index.ts +2 -0
- package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.spec.tsx +23 -0
- package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.ts +11 -0
- package/src/modules/cursor/index.ts +2 -0
- package/src/modules/cursor/service.ts +15 -0
- package/src/modules/cursor/types.ts +9 -0
- package/src/modules/global-providers/index.ts +1 -0
- package/src/modules/messages/__tests__/parsed-message.builder.ts +164 -0
- package/src/modules/messages/components/chat-input/chat-input.spec.tsx +72 -0
- package/src/modules/messages/components/chat-input/chat-input.tsx +52 -6
- package/src/modules/messages/components/index.ts +1 -0
- package/src/modules/messages/components/message-item/message-item.spec.tsx +2 -2
- package/src/modules/messages/components/message-item/message-item.tsx +14 -1
- package/src/modules/messages/components/message-item-end-of-scroll/index.ts +2 -0
- package/src/modules/messages/components/message-item-end-of-scroll/message-item-end-of-scroll.tsx +14 -0
- package/src/modules/messages/components/message-item-error/index.ts +2 -0
- package/src/modules/messages/components/message-item-error/message-item-error.tsx +25 -0
- package/src/modules/messages/components/message-item-loading/index.ts +2 -0
- package/src/modules/messages/components/message-item-loading/message-item-loading.tsx +16 -0
- package/src/modules/messages/components/message-skeleton/index.ts +1 -0
- package/src/modules/messages/components/message-skeleton/message-skeleton.tsx +23 -0
- package/src/modules/messages/components/messages-list/index.ts +1 -1
- package/src/modules/messages/components/messages-list/messages-list.tsx +82 -39
- package/src/modules/messages/constants.ts +1 -0
- package/src/modules/messages/hooks/index.ts +5 -0
- package/src/modules/messages/hooks/use-all-messages/index.ts +2 -0
- package/src/modules/messages/hooks/use-all-messages/use-all-messages.tsx +30 -0
- package/src/modules/messages/hooks/use-infinite-get-messages/index.ts +2 -0
- package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx +65 -0
- package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.tsx +81 -0
- package/src/modules/messages/hooks/use-manage-scroll/index.ts +2 -0
- package/src/modules/messages/hooks/use-manage-scroll/use-manage-scroll.tsx +70 -0
- package/src/modules/messages/hooks/use-send-text-message/index.ts +2 -0
- package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.spec.tsx +86 -0
- package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +60 -0
- package/src/modules/messages/hooks/use-skeleton-ref/index.ts +2 -0
- package/src/modules/messages/hooks/use-skeleton-ref/use-skeleton-ref.tsx +34 -0
- package/src/modules/messages/hooks/use-subscribe-message-received-event/index.ts +2 -0
- package/src/modules/messages/hooks/use-subscribe-message-received-event/use-subscribe-message-received-event.tsx +80 -0
- package/src/modules/messages/service.ts +8 -7
- package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.spec.tsx +58 -0
- package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.ts +30 -0
- package/src/modules/messages/utils/has-to-update-cursor/index.ts +2 -0
- package/src/modules/sparkie/__tests__/sparkie.mock.ts +33 -0
- package/src/modules/sparkie/service.ts +182 -35
- package/src/modules/sparkie/types.ts +10 -2
- package/src/modules/widget/__tests__/widget-settings-props.builder.ts +29 -1
- package/src/modules/widget/components/ai-avatar/ai-avatar.tsx +11 -53
- package/src/modules/widget/components/chat-page/chat-page.spec.tsx +28 -0
- package/src/modules/widget/components/chat-page/chat-page.tsx +23 -4
- package/src/modules/widget/components/container/container.tsx +5 -19
- package/src/modules/widget/components/index.ts +1 -0
- package/src/modules/widget/components/scroll-to-bottom-button/index.ts +2 -0
- package/src/modules/widget/components/scroll-to-bottom-button/scroll-to-bottom-button.tsx +32 -0
- package/src/modules/widget/events.ts +4 -0
- package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +8 -6
- package/src/modules/widget/store/index.ts +3 -0
- package/src/modules/widget/store/widget-container-intrinsic-height.atom.ts +13 -0
- package/src/modules/widget/store/widget-loading.atom.ts +11 -0
- package/src/modules/widget/store/widget-scrolling.atom.ts +11 -0
- package/src/modules/widget/store/widget-tabs.atom.ts +2 -1
- package/src/types.ts +4 -1
|
@@ -28,9 +28,7 @@ const mdComponents: Partial<Components> = {
|
|
|
28
28
|
return <MdCodeBlock {...props} />
|
|
29
29
|
},
|
|
30
30
|
pre({ children }) {
|
|
31
|
-
return
|
|
32
|
-
<span className='my-2 inline-block w-full overflow-hidden rounded-lg border'>{children}</span>
|
|
33
|
-
)
|
|
31
|
+
return <span className='my-2 inline-block w-full overflow-hidden rounded-lg'>{children}</span>
|
|
34
32
|
},
|
|
35
33
|
a({ href, children, ...props }) {
|
|
36
34
|
const url = URLutils.getURLwithProtocol(href)
|
|
@@ -40,7 +38,7 @@ const mdComponents: Partial<Components> = {
|
|
|
40
38
|
href={url}
|
|
41
39
|
target={url?.startsWith('http') ? '_blank' : '_self'}
|
|
42
40
|
rel={url?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
43
|
-
className='text-blue-600 underline hover:text-blue-800'
|
|
41
|
+
className='inline-block break-all text-blue-600 underline hover:text-blue-800'
|
|
44
42
|
{...props}>
|
|
45
43
|
{children}
|
|
46
44
|
</a>
|
|
@@ -49,7 +47,7 @@ const mdComponents: Partial<Components> = {
|
|
|
49
47
|
table({ children, ...props }) {
|
|
50
48
|
return (
|
|
51
49
|
<div className='overflow-x-auto'>
|
|
52
|
-
<table className='min-w-full border-collapse border border-neutral-
|
|
50
|
+
<table className='min-w-full border-collapse border border-neutral-800' {...props}>
|
|
53
51
|
{children}
|
|
54
52
|
</table>
|
|
55
53
|
</div>
|
|
@@ -57,14 +55,14 @@ const mdComponents: Partial<Components> = {
|
|
|
57
55
|
},
|
|
58
56
|
th({ children, ...props }) {
|
|
59
57
|
return (
|
|
60
|
-
<th className='border border-neutral-
|
|
58
|
+
<th className='border border-neutral-800 px-4 py-2 text-left font-semibold' {...props}>
|
|
61
59
|
{children}
|
|
62
60
|
</th>
|
|
63
61
|
)
|
|
64
62
|
},
|
|
65
63
|
td({ children, ...props }) {
|
|
66
64
|
return (
|
|
67
|
-
<td className='border border-neutral-
|
|
65
|
+
<td className='border border-neutral-800 px-4 py-2' {...props}>
|
|
68
66
|
{children}
|
|
69
67
|
</td>
|
|
70
68
|
)
|
|
@@ -72,14 +70,14 @@ const mdComponents: Partial<Components> = {
|
|
|
72
70
|
blockquote({ children, ...props }) {
|
|
73
71
|
return (
|
|
74
72
|
<blockquote
|
|
75
|
-
className='my-2 border-l-4 border-
|
|
73
|
+
className='my-2 border-l-4 border-primary-500 pl-4 italic text-neutral-100'
|
|
76
74
|
{...props}>
|
|
77
75
|
{children}
|
|
78
76
|
</blockquote>
|
|
79
77
|
)
|
|
80
78
|
},
|
|
81
79
|
p({ children }) {
|
|
82
|
-
return <span className='my-
|
|
80
|
+
return <span className='my-3 inline-block'>{children}</span>
|
|
83
81
|
}
|
|
84
82
|
}
|
|
85
83
|
|
package/src/lib/hooks/index.ts
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useThrottle } from '../use-throttle'
|
|
4
|
+
|
|
5
|
+
export type UseIntersectionObserverReverseScrollProps = {
|
|
6
|
+
hasMore: boolean
|
|
7
|
+
asyncCallback: () => Promise<void>
|
|
8
|
+
onScroll?: () => void
|
|
9
|
+
isLoading?: boolean
|
|
10
|
+
threshold?: number
|
|
11
|
+
rootMargin?: string
|
|
12
|
+
enabled?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function useIntersectionObserverReverseScroll({
|
|
16
|
+
hasMore,
|
|
17
|
+
asyncCallback,
|
|
18
|
+
onScroll,
|
|
19
|
+
enabled = true,
|
|
20
|
+
isLoading = false,
|
|
21
|
+
rootMargin = '100px 0px',
|
|
22
|
+
threshold = 0.5
|
|
23
|
+
}: UseIntersectionObserverReverseScrollProps) {
|
|
24
|
+
const [heightBeforeRender, setHeightBeforeRender] = useState(0)
|
|
25
|
+
const [shouldRestorePosition, setShouldRestorePosition] = useState(false)
|
|
26
|
+
const [hasUserScrolled, setHasUserScrolled] = useState(false)
|
|
27
|
+
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
|
28
|
+
|
|
29
|
+
const scrollerRef = useRef<HTMLDivElement>(null)
|
|
30
|
+
const intersectionTargetRef = useRef<HTMLDivElement>(null)
|
|
31
|
+
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
32
|
+
|
|
33
|
+
const { throttledCallback: throttledHandleScroll } = useThrottle({
|
|
34
|
+
callback: () => {
|
|
35
|
+
onScroll?.()
|
|
36
|
+
},
|
|
37
|
+
delay: 50
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const handleScroll = useCallback(() => {
|
|
41
|
+
const { current: scroller } = scrollerRef
|
|
42
|
+
|
|
43
|
+
if (!scroller) return
|
|
44
|
+
|
|
45
|
+
if (!hasUserScrolled) setHasUserScrolled(true)
|
|
46
|
+
|
|
47
|
+
if (!isInitialLoad) setIsInitialLoad(true)
|
|
48
|
+
|
|
49
|
+
throttledHandleScroll()
|
|
50
|
+
}, [hasUserScrolled, isInitialLoad, throttledHandleScroll])
|
|
51
|
+
|
|
52
|
+
const handleIntersection = useCallback(
|
|
53
|
+
async (entries: IntersectionObserverEntry[]) => {
|
|
54
|
+
const [entry] = entries
|
|
55
|
+
const { current: scroller } = scrollerRef
|
|
56
|
+
|
|
57
|
+
if (!enabled || !scroller || !isInitialLoad || !hasUserScrolled || isLoading || !hasMore)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if (entry?.isIntersecting) {
|
|
61
|
+
setHeightBeforeRender(scroller.scrollHeight)
|
|
62
|
+
setShouldRestorePosition(true)
|
|
63
|
+
await asyncCallback()
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
[enabled, hasMore, hasUserScrolled, isInitialLoad, isLoading, asyncCallback]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const scrollToBottom = useCallback((smooth = false) => {
|
|
70
|
+
const { current: scroller } = scrollerRef
|
|
71
|
+
|
|
72
|
+
if (!scroller) return
|
|
73
|
+
|
|
74
|
+
scroller.scrollTo({
|
|
75
|
+
top: scroller.scrollHeight,
|
|
76
|
+
behavior: smooth ? 'smooth' : 'auto'
|
|
77
|
+
})
|
|
78
|
+
}, [])
|
|
79
|
+
|
|
80
|
+
const resetScrollState = useCallback(() => {
|
|
81
|
+
setHasUserScrolled(false)
|
|
82
|
+
setIsInitialLoad(true)
|
|
83
|
+
setShouldRestorePosition(false)
|
|
84
|
+
setHeightBeforeRender(0)
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const { current: scroller } = scrollerRef
|
|
89
|
+
const { current: intersectionTarget } = intersectionTargetRef
|
|
90
|
+
|
|
91
|
+
if (!intersectionTarget || !scroller) return
|
|
92
|
+
|
|
93
|
+
observerRef.current = new IntersectionObserver((entries) => void handleIntersection(entries), {
|
|
94
|
+
root: scroller,
|
|
95
|
+
rootMargin,
|
|
96
|
+
threshold
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
observerRef.current.observe(intersectionTarget)
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
observerRef.current?.disconnect()
|
|
103
|
+
}
|
|
104
|
+
}, [handleIntersection, rootMargin, threshold])
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const { current: scroller } = scrollerRef
|
|
108
|
+
const controller = new AbortController()
|
|
109
|
+
|
|
110
|
+
if (!scroller) return
|
|
111
|
+
|
|
112
|
+
scroller.addEventListener('scroll', handleScroll, { passive: true, signal: controller.signal })
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
controller.abort()
|
|
116
|
+
}
|
|
117
|
+
}, [handleScroll])
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const { current: scroller } = scrollerRef
|
|
121
|
+
|
|
122
|
+
if (!shouldRestorePosition || isLoading || !scroller) return
|
|
123
|
+
|
|
124
|
+
const frameId = requestAnimationFrame(() => {
|
|
125
|
+
const newScrollTop = scroller.scrollHeight - heightBeforeRender
|
|
126
|
+
|
|
127
|
+
scroller.scrollTop = newScrollTop
|
|
128
|
+
setShouldRestorePosition(false)
|
|
129
|
+
setHeightBeforeRender(0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return () => {
|
|
133
|
+
cancelAnimationFrame(frameId)
|
|
134
|
+
}
|
|
135
|
+
}, [heightBeforeRender, isLoading, shouldRestorePosition])
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
scrollerRef,
|
|
139
|
+
intersectionTargetRef,
|
|
140
|
+
scrollToBottom,
|
|
141
|
+
resetScrollState,
|
|
142
|
+
hasUserScrolled,
|
|
143
|
+
isInitialLoad
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default useIntersectionObserverReverseScroll
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useThrottle } from '@/src/lib/hooks'
|
|
4
|
+
|
|
5
|
+
function useRefClientHeight<T extends HTMLElement>(
|
|
6
|
+
refElement: RefObject<T | null>,
|
|
7
|
+
defaultHeight = '100vh'
|
|
8
|
+
) {
|
|
9
|
+
const [clientHeight, setClientHeight] = useState(defaultHeight)
|
|
10
|
+
|
|
11
|
+
const { throttledCallback: resizeHandler } = useThrottle({
|
|
12
|
+
callback: () => {
|
|
13
|
+
if (!refElement?.current?.clientHeight) return
|
|
14
|
+
|
|
15
|
+
setClientHeight(`${refElement?.current.clientHeight}px`)
|
|
16
|
+
},
|
|
17
|
+
delay: 650
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
useEffect(resizeHandler, [resizeHandler])
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!refElement?.current?.clientHeight) return
|
|
24
|
+
|
|
25
|
+
const abortController = new AbortController()
|
|
26
|
+
const signal = abortController.signal
|
|
27
|
+
|
|
28
|
+
window.addEventListener('resize', resizeHandler, { signal })
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
abortController.abort()
|
|
32
|
+
}
|
|
33
|
+
}, [refElement, resizeHandler])
|
|
34
|
+
|
|
35
|
+
return clientHeight
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default useRefClientHeight
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
function useScrollToRef<R extends HTMLElement>() {
|
|
4
|
+
const ref = useRef<R>(null)
|
|
5
|
+
|
|
6
|
+
const scrollToRef = useCallback(() => {
|
|
7
|
+
const { current: element } = ref
|
|
8
|
+
element?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
|
9
|
+
}, [])
|
|
10
|
+
|
|
11
|
+
return { scrollTargetRef: ref, scrollToRef }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default useScrollToRef
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type UseThrottleProps<T extends (...args: unknown[]) => unknown> = {
|
|
2
|
+
callback: T
|
|
3
|
+
delay: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ThrottleControls {
|
|
7
|
+
cancel: () => void
|
|
8
|
+
pending: () => boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ThrottledFunction<T extends (...args: unknown[]) => unknown> = {
|
|
12
|
+
throttledCallback: T
|
|
13
|
+
} & ThrottleControls
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { Mock } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { act, renderHook } from '@/src/config/tests'
|
|
4
|
+
|
|
5
|
+
import type { UseThrottleProps } from './types'
|
|
6
|
+
import useThrottle from './use-throttle'
|
|
7
|
+
|
|
8
|
+
describe('useThrottle', () => {
|
|
9
|
+
const mockCallback = vi.fn()
|
|
10
|
+
const mockDelay = 1000
|
|
11
|
+
|
|
12
|
+
const createHook = (
|
|
13
|
+
{ callback, delay }: UseThrottleProps<ReturnType<typeof vi.fn>> = {
|
|
14
|
+
callback: mockCallback,
|
|
15
|
+
delay: mockDelay
|
|
16
|
+
}
|
|
17
|
+
) =>
|
|
18
|
+
renderHook(() =>
|
|
19
|
+
useThrottle({
|
|
20
|
+
callback,
|
|
21
|
+
delay
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.useFakeTimers()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.runOnlyPendingTimers()
|
|
31
|
+
vi.useRealTimers()
|
|
32
|
+
vi.clearAllMocks()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should execute callback immediately on first call', () => {
|
|
36
|
+
const { result } = createHook()
|
|
37
|
+
|
|
38
|
+
result.current.throttledCallback('test')
|
|
39
|
+
|
|
40
|
+
expect(mockCallback).toHaveBeenCalledTimes(1)
|
|
41
|
+
expect(mockCallback).toHaveBeenCalledWith('test')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should throttle subsequent calls within delay period', () => {
|
|
45
|
+
const { result } = createHook()
|
|
46
|
+
|
|
47
|
+
result.current.throttledCallback('call1')
|
|
48
|
+
result.current.throttledCallback('call2')
|
|
49
|
+
result.current.throttledCallback('call3')
|
|
50
|
+
|
|
51
|
+
expect(mockCallback).toHaveBeenCalledTimes(1)
|
|
52
|
+
expect(mockCallback).toHaveBeenCalledWith('call1')
|
|
53
|
+
|
|
54
|
+
act(() => {
|
|
55
|
+
vi.advanceTimersByTime(1000)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(mockCallback).toHaveBeenCalledTimes(2)
|
|
59
|
+
expect(mockCallback).toHaveBeenLastCalledWith('call3')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should allow execution after delay period has passed', () => {
|
|
63
|
+
const { result } = createHook()
|
|
64
|
+
|
|
65
|
+
result.current.throttledCallback('call1')
|
|
66
|
+
|
|
67
|
+
act(() => {
|
|
68
|
+
vi.advanceTimersByTime(1000)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
result.current.throttledCallback('call2')
|
|
72
|
+
|
|
73
|
+
expect(mockCallback).toHaveBeenCalledTimes(2)
|
|
74
|
+
expect(mockCallback).toHaveBeenNthCalledWith(1, 'call1')
|
|
75
|
+
expect(mockCallback).toHaveBeenNthCalledWith(2, 'call2')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should handle multiple arguments correctly', () => {
|
|
79
|
+
const multiArgCallback: Mock<(a: string, b: number, c: boolean) => void> = vi.fn()
|
|
80
|
+
|
|
81
|
+
const { result } = createHook({ callback: multiArgCallback, delay: mockDelay })
|
|
82
|
+
|
|
83
|
+
result.current.throttledCallback('hello', 42, true)
|
|
84
|
+
|
|
85
|
+
expect(multiArgCallback).toHaveBeenCalledWith('hello', 42, true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('cancel()', () => {
|
|
89
|
+
it('should cancel pending execution', () => {
|
|
90
|
+
const { result } = createHook()
|
|
91
|
+
|
|
92
|
+
act(() => {
|
|
93
|
+
result.current.throttledCallback('call1')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
act(() => {
|
|
97
|
+
result.current.throttledCallback('call2')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(mockCallback).toHaveBeenCalledTimes(1)
|
|
101
|
+
expect(result.current.pending()).toBe(true)
|
|
102
|
+
|
|
103
|
+
act(() => {
|
|
104
|
+
result.current.cancel()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(result.current.pending()).toBe(false)
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
vi.advanceTimersByTime(1000)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(mockCallback).toHaveBeenCalledTimes(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should be safe to call cancel when no execution is pending', () => {
|
|
117
|
+
const { result } = createHook()
|
|
118
|
+
|
|
119
|
+
expect(() => {
|
|
120
|
+
act(() => {
|
|
121
|
+
result.current.cancel()
|
|
122
|
+
})
|
|
123
|
+
}).not.toThrow()
|
|
124
|
+
|
|
125
|
+
expect(result.current.pending()).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('pending()', () => {
|
|
130
|
+
it('should return false when no execution is pending', () => {
|
|
131
|
+
const { result } = createHook()
|
|
132
|
+
|
|
133
|
+
expect(result.current.pending()).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('should return true when execution is pending', () => {
|
|
137
|
+
const { result } = createHook()
|
|
138
|
+
|
|
139
|
+
result.current.throttledCallback('call1')
|
|
140
|
+
|
|
141
|
+
expect(result.current.pending()).toBe(false)
|
|
142
|
+
|
|
143
|
+
result.current.throttledCallback('call2')
|
|
144
|
+
|
|
145
|
+
expect(result.current.pending()).toBe(true)
|
|
146
|
+
|
|
147
|
+
act(() => {
|
|
148
|
+
vi.advanceTimersByTime(1000)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
expect(result.current.pending()).toBe(false)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should use the latest callback reference', () => {
|
|
156
|
+
let callbackVersion = 1
|
|
157
|
+
const initialCallback = vi.fn(() => `version-${callbackVersion}`)
|
|
158
|
+
|
|
159
|
+
const { result, rerender } = renderHook(
|
|
160
|
+
({ callback }) => useThrottle({ callback, delay: 1000 }),
|
|
161
|
+
{ initialProps: { callback: initialCallback } }
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
result.current.throttledCallback()
|
|
165
|
+
|
|
166
|
+
expect(initialCallback).toHaveBeenCalledTimes(1)
|
|
167
|
+
|
|
168
|
+
callbackVersion = 2
|
|
169
|
+
const updatedCallback = vi.fn(() => `version-${callbackVersion}`)
|
|
170
|
+
|
|
171
|
+
rerender({ callback: updatedCallback })
|
|
172
|
+
|
|
173
|
+
result.current.throttledCallback()
|
|
174
|
+
|
|
175
|
+
act(() => {
|
|
176
|
+
vi.advanceTimersByTime(1000)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
expect(updatedCallback).toHaveBeenCalledTimes(1)
|
|
180
|
+
expect(initialCallback).toHaveBeenCalledTimes(1)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('should handle callback changes during pending execution', () => {
|
|
184
|
+
const callback1 = vi.fn()
|
|
185
|
+
const callback2 = vi.fn()
|
|
186
|
+
|
|
187
|
+
const { result, rerender } = renderHook(
|
|
188
|
+
({ callback }) => useThrottle({ callback, delay: 1000 }),
|
|
189
|
+
{ initialProps: { callback: callback1 } }
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
result.current.throttledCallback('immediate')
|
|
193
|
+
result.current.throttledCallback('throttled')
|
|
194
|
+
|
|
195
|
+
expect(callback1).toHaveBeenCalledTimes(1)
|
|
196
|
+
expect(result.current.pending()).toBe(true)
|
|
197
|
+
|
|
198
|
+
rerender({ callback: callback2 })
|
|
199
|
+
|
|
200
|
+
act(() => {
|
|
201
|
+
vi.advanceTimersByTime(1000)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(callback1).toHaveBeenCalledTimes(1)
|
|
205
|
+
expect(callback2).toHaveBeenCalledTimes(1)
|
|
206
|
+
expect(callback2).toHaveBeenCalledWith('throttled')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should handle zero delay', () => {
|
|
210
|
+
const { result } = createHook({ callback: mockCallback, delay: 0 })
|
|
211
|
+
|
|
212
|
+
result.current.throttledCallback('call1')
|
|
213
|
+
result.current.throttledCallback('call2')
|
|
214
|
+
result.current.throttledCallback('call3')
|
|
215
|
+
|
|
216
|
+
expect(mockCallback).toHaveBeenCalledTimes(3)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should handle rapid successive calls correctly', () => {
|
|
220
|
+
const { result } = createHook()
|
|
221
|
+
|
|
222
|
+
for (let i = 0; i < 100; i++) {
|
|
223
|
+
result.current.throttledCallback(`call-${i}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
expect(mockCallback).toHaveBeenCalledTimes(1)
|
|
227
|
+
expect(mockCallback).toHaveBeenCalledWith('call-0')
|
|
228
|
+
|
|
229
|
+
act(() => {
|
|
230
|
+
vi.advanceTimersByTime(1000)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
expect(mockCallback).toHaveBeenCalledTimes(2)
|
|
234
|
+
expect(mockCallback).toHaveBeenLastCalledWith('call-99')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('should cleanup timeout on unmount', () => {
|
|
238
|
+
const { result, unmount } = createHook()
|
|
239
|
+
|
|
240
|
+
result.current.throttledCallback('call1')
|
|
241
|
+
result.current.throttledCallback('call2')
|
|
242
|
+
|
|
243
|
+
expect(result.current.pending()).toBe(true)
|
|
244
|
+
|
|
245
|
+
unmount()
|
|
246
|
+
|
|
247
|
+
act(() => {
|
|
248
|
+
vi.advanceTimersByTime(1000)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
expect(mockCallback).toHaveBeenCalledTimes(1)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should not cause memory leaks with multiple calls', () => {
|
|
255
|
+
const { result } = createHook({
|
|
256
|
+
callback: mockCallback,
|
|
257
|
+
delay: 100
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < 1000; i++) {
|
|
261
|
+
result.current.throttledCallback(`call-${i}`)
|
|
262
|
+
|
|
263
|
+
if (i % 10 === 0) {
|
|
264
|
+
result.current.cancel()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
expect(() => {
|
|
269
|
+
act(() => {
|
|
270
|
+
vi.advanceTimersByTime(1000)
|
|
271
|
+
})
|
|
272
|
+
}).not.toThrow()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('should maintain stable reference when dependencies do not change', () => {
|
|
276
|
+
const { result, rerender } = createHook()
|
|
277
|
+
|
|
278
|
+
const firstReference = result.current
|
|
279
|
+
|
|
280
|
+
rerender()
|
|
281
|
+
|
|
282
|
+
expect(result.current).toBe(firstReference)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('should maintain stable control methods', () => {
|
|
286
|
+
const { result, rerender } = createHook()
|
|
287
|
+
|
|
288
|
+
const initialCancel = result.current.cancel
|
|
289
|
+
const initialPending = result.current.pending
|
|
290
|
+
|
|
291
|
+
rerender()
|
|
292
|
+
|
|
293
|
+
expect(result.current.cancel).toBe(initialCancel)
|
|
294
|
+
expect(result.current.pending).toBe(initialPending)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ThrottledFunction, UseThrottleProps } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a throttled version of the provided function that only executes at most once per specified delay.
|
|
7
|
+
*
|
|
8
|
+
* @template T - The function type to throttle
|
|
9
|
+
* @param params - Configuration object
|
|
10
|
+
* @param params.callback - The function to throttle
|
|
11
|
+
* @param params.delay - Minimum time between executions in milliseconds
|
|
12
|
+
* @returns Throttled function with control methods (cancel, pending)
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const throttledSearch = useThrottle({
|
|
17
|
+
* callback: (query: string) => searchAPI(query),
|
|
18
|
+
* delay: 300
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* // Use it
|
|
22
|
+
* throttledSearch('hello')
|
|
23
|
+
*
|
|
24
|
+
* // Check if pending
|
|
25
|
+
* if (throttledSearch.pending()) {
|
|
26
|
+
* console.log('Search is pending...')
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* // Cancel if needed
|
|
30
|
+
* throttledSearch.cancel()
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
function useThrottle<T extends (...args: any[]) => any>({
|
|
34
|
+
callback,
|
|
35
|
+
delay
|
|
36
|
+
}: UseThrottleProps<T>): ThrottledFunction<T> {
|
|
37
|
+
const lastExecTime = useRef<number>(0)
|
|
38
|
+
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
39
|
+
const callbackRef = useRef(callback)
|
|
40
|
+
|
|
41
|
+
const cancel = useCallback(() => {
|
|
42
|
+
if (timeoutRef.current) {
|
|
43
|
+
clearTimeout(timeoutRef.current)
|
|
44
|
+
timeoutRef.current = undefined
|
|
45
|
+
}
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
const pending = useCallback(() => {
|
|
49
|
+
return timeoutRef.current !== undefined
|
|
50
|
+
}, [])
|
|
51
|
+
|
|
52
|
+
const throttledCallback = useCallback(
|
|
53
|
+
(...args: Parameters<T>) => {
|
|
54
|
+
const now = Date.now()
|
|
55
|
+
const timeSinceLastExec = now - lastExecTime.current
|
|
56
|
+
|
|
57
|
+
cancel()
|
|
58
|
+
|
|
59
|
+
if (timeSinceLastExec >= delay) {
|
|
60
|
+
lastExecTime.current = now
|
|
61
|
+
callbackRef.current(...args)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
timeoutRef.current = setTimeout(() => {
|
|
66
|
+
lastExecTime.current = Date.now()
|
|
67
|
+
callbackRef.current(...args)
|
|
68
|
+
timeoutRef.current = undefined
|
|
69
|
+
}, delay - timeSinceLastExec)
|
|
70
|
+
},
|
|
71
|
+
[cancel, delay]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
callbackRef.current = callback
|
|
76
|
+
}, [callback])
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
return () => cancel()
|
|
80
|
+
}, [cancel])
|
|
81
|
+
|
|
82
|
+
return useMemo(() => {
|
|
83
|
+
return {
|
|
84
|
+
throttledCallback,
|
|
85
|
+
cancel,
|
|
86
|
+
pending
|
|
87
|
+
} as ThrottledFunction<T>
|
|
88
|
+
}, [cancel, pending, throttledCallback])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export default useThrottle
|