fix(ui-primitives): dispose copy feedback on unmount
This commit is contained in:
1 parent
4e85a34e45
commit
d1ad034443
5 files changed
+75
-5
No files matched your search
@@ -27,8 +27,8 @@
|
||||
en: 02f310acbae29955
|
||||
zh: 1e41393d5d714b2d
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
|
||||
en: 4aef65dea11de083
|
||||
zh: abc22cd7c65cdf38
|
||||
en: 750eabfcdbe18c35
|
||||
zh: c71342944b8a4fa3
|
||||
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
|
||||
en: 846ac0f563a53a64
|
||||
zh: bb89d7647bdcc1ab
|
||||
|
||||
@@ -119,6 +119,8 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and
|
||||
|
||||
### Localizing copy
|
||||
|
||||
Shared output-card copy controls show success for one second. Unmount clears the reset timer and ignores pending clipboard completions.
|
||||
|
||||
The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
|
||||
|
||||
-----
|
||||
|
||||
@@ -119,6 +119,8 @@ kind: "package-library"
|
||||
|
||||
### 本地化文案
|
||||
|
||||
共享输出卡片的复制控件显示一秒成功反馈。卸载会清除重置定时器,并忽略尚未完成的剪贴板写入结果。
|
||||
|
||||
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
|
||||
|
||||
-----
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
|
||||
/** How long the `copied` flag stays true after a successful write, in ms. */
|
||||
@@ -14,17 +14,27 @@ export interface CopyFeedback {
|
||||
|
||||
/**
|
||||
* Copy `text` to the clipboard with one-second success feedback.
|
||||
* Unmount cancels the reset timer and ignores pending clipboard completions.
|
||||
* @param text - the text to write on copy.
|
||||
* @returns the `copied` flag and the `onCopy` handler.
|
||||
*/
|
||||
export function useCopyFeedback(text: string): CopyFeedback {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const mounted = useRef(false)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => { mounted.current = false }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const timer = window.setTimeout(() => { setCopied(false) }, COPIED_FEEDBACK_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [copied])
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
if (!ok || !mounted.current) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, COPIED_FEEDBACK_MS)
|
||||
})
|
||||
}, [copied, text])
|
||||
return { copied, onCopy }
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Clipboard feedback stays within its mounted component's lifetime. */
|
||||
import { StrictMode } from 'react'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useCopyFeedback } from '../src/use-copy-feedback.ts'
|
||||
import { writeClipboard } from '../src/clipboard.ts'
|
||||
|
||||
vi.mock('../src/clipboard.ts', () => ({ writeClipboard: vi.fn() }))
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { cleanup(); vi.clearAllTimers(); vi.useRealTimers(); vi.resetAllMocks() })
|
||||
|
||||
it('expires successful feedback after one second and permits another copy', async () => {
|
||||
vi.mocked(writeClipboard).mockResolvedValue(true)
|
||||
const { result } = renderHook(() => useCopyFeedback('command'), { wrapper: StrictMode })
|
||||
await act(async () => { result.current.onCopy() })
|
||||
expect(result.current.copied).toBe(true)
|
||||
act(() => { result.current.onCopy() })
|
||||
expect(writeClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(writeClipboard).toHaveBeenCalledWith('command')
|
||||
act(() => { vi.advanceTimersByTime(999) })
|
||||
expect(result.current.copied).toBe(true)
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(result.current.copied).toBe(false)
|
||||
await act(async () => { result.current.onCopy() })
|
||||
expect(writeClipboard).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not start feedback for a refused clipboard write', async () => {
|
||||
vi.mocked(writeClipboard).mockResolvedValue(false)
|
||||
const { result } = renderHook(() => useCopyFeedback('command'))
|
||||
await act(async () => { result.current.onCopy() })
|
||||
expect(result.current.copied).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels a successful copy reset timer on unmount', async () => {
|
||||
vi.mocked(writeClipboard).mockResolvedValue(true)
|
||||
const { result, unmount } = renderHook(() => useCopyFeedback('command'))
|
||||
await act(async () => { result.current.onCopy() })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores a clipboard completion after unmount without scheduling feedback', async () => {
|
||||
const pending = Promise.withResolvers<boolean>()
|
||||
vi.mocked(writeClipboard).mockReturnValue(pending.promise)
|
||||
const { result, unmount } = renderHook(() => useCopyFeedback('command'))
|
||||
act(() => { result.current.onCopy() })
|
||||
unmount()
|
||||
await act(async () => { pending.resolve(true); await pending.promise })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(result.current.copied).toBe(false)
|
||||
})
|
||||
Reference in new issue
Block a user