fix(ui-primitives): dispose copy feedback on unmount

This commit is contained in:
zhc committed 2026-10-21 22:03:53 +08:00
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
+2
View File
@@ -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)
})