From 9308287fea9c834407af91918c3d921f7eca6502 Mon Sep 17 00:00:00 2001 From: BrianWang1990 Date: Thu, 9 Apr 2026 10:49:40 +0800 Subject: [PATCH] fix: copy button not working on API Server and API Key pages (#34515) Co-authored-by: Brian Wang Co-authored-by: test Co-authored-by: BrianWang1990 <512dabing99@163.com> Co-authored-by: Stephen Zhou Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> --- pnpm-lock.yaml | 42 ++--------- pnpm-workspace.yaml | 2 +- .../copy-feedback/__tests__/index.spec.tsx | 2 +- .../components/base/copy-feedback/index.tsx | 2 +- .../base/copy-icon/__tests__/index.spec.tsx | 2 +- web/app/components/base/copy-icon/index.tsx | 2 +- .../input-with-copy/__tests__/index.spec.tsx | 2 +- .../components/base/input-with-copy/index.tsx | 2 +- web/hooks/noop.ts | 7 ++ web/hooks/use-clipboard.ts | 72 +++++++++++++++++++ ...what-you-are-doing-or-you-will-be-fired.ts | 44 ++++++++++++ web/hooks/use-typescript-happy-callback.ts | 10 +++ web/package.json | 2 +- web/vitest.setup.ts | 5 +- 14 files changed, 150 insertions(+), 46 deletions(-) create mode 100644 web/hooks/noop.ts create mode 100644 web/hooks/use-clipboard.ts create mode 100644 web/hooks/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired.ts create mode 100644 web/hooks/use-typescript-happy-callback.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e8c0970a6..ee3794d88d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ catalogs: class-variance-authority: specifier: 0.7.1 version: 0.7.1 + client-only: + specifier: 0.0.1 + version: 0.0.1 clsx: specifier: 2.1.1 version: 2.1.1 @@ -324,9 +327,6 @@ catalogs: fast-deep-equal: specifier: 3.1.3 version: 3.1.3 - foxact: - specifier: 0.3.0 - version: 0.3.0 happy-dom: specifier: 20.8.9 version: 20.8.9 @@ -736,6 +736,9 @@ importers: class-variance-authority: specifier: 'catalog:' version: 0.7.1 + client-only: + specifier: 'catalog:' + version: 0.0.1 clsx: specifier: 'catalog:' version: 2.1.1 @@ -781,9 +784,6 @@ importers: fast-deep-equal: specifier: 'catalog:' version: 3.1.3 - foxact: - specifier: 'catalog:' - version: 0.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) hast-util-to-jsx-runtime: specifier: 'catalog:' version: 2.3.6 @@ -5871,9 +5871,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-target-bus@1.0.0: - resolution: {integrity: sha512-uPcWKbj/BJU3Tbw9XqhHqET4/LBOhvv3/SJWr7NksxA6TC5YqBpaZgawE9R+WpYFCBFSAE4Vun+xQS6w4ABdlA==} - events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -5986,17 +5983,6 @@ packages: engines: {node: '>=18.3.0'} hasBin: true - foxact@0.3.0: - resolution: {integrity: sha512-CSlMlC0KlKQQEO83iLeQCLuT1V0OqnMWj7mjLstIDV8baMe1w4F7z3cz3/T+6Z8W12jqkQj07rwlw4Gi39knGg==} - peerDependencies: - react: '*' - react-dom: '*' - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -7710,9 +7696,6 @@ packages: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} - server-only@0.0.1: - resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -13552,8 +13535,6 @@ snapshots: esutils@2.0.3: {} - event-target-bus@1.0.0: {} - events@3.3.0: {} expand-template@2.0.3: @@ -13661,15 +13642,6 @@ snapshots: dependencies: fd-package-json: 2.0.0 - foxact@0.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - client-only: 0.0.1 - event-target-bus: 1.0.0 - server-only: 0.0.1 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - fs-constants@1.0.0: optional: true @@ -15905,8 +15877,6 @@ snapshots: seroval@1.5.1: {} - server-only@0.0.1: {} - sharp@0.34.5: dependencies: '@img/colour': 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6fe023066a..b7918fff1b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -129,6 +129,7 @@ catalog: ahooks: 3.9.7 autoprefixer: 10.4.27 class-variance-authority: 0.7.1 + client-only: 0.0.1 clsx: 2.1.1 cmdk: 1.1.1 code-inspector-plugin: 1.5.1 @@ -154,7 +155,6 @@ catalog: eslint-plugin-sonarjs: 4.0.2 eslint-plugin-storybook: 10.3.5 fast-deep-equal: 3.1.3 - foxact: 0.3.0 happy-dom: 20.8.9 hast-util-to-jsx-runtime: 2.3.6 hono: 4.12.12 diff --git a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx index 322a9970af..8cc22693b6 100644 --- a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ const mockCopy = vi.fn() const mockReset = vi.fn() let mockCopied = false -vi.mock('foxact/use-clipboard', () => ({ +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy: mockCopy, reset: mockReset, diff --git a/web/app/components/base/copy-feedback/index.tsx b/web/app/components/base/copy-feedback/index.tsx index 80b35eb3a8..5210066670 100644 --- a/web/app/components/base/copy-feedback/index.tsx +++ b/web/app/components/base/copy-feedback/index.tsx @@ -3,11 +3,11 @@ import { RiClipboardFill, RiClipboardLine, } from '@remixicon/react' -import { useClipboard } from 'foxact/use-clipboard' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Tooltip from '@/app/components/base/tooltip' +import { useClipboard } from '@/hooks/use-clipboard' import copyStyle from './style.module.css' type Props = { diff --git a/web/app/components/base/copy-icon/__tests__/index.spec.tsx b/web/app/components/base/copy-icon/__tests__/index.spec.tsx index 3db76ef606..1ce9e6dbf5 100644 --- a/web/app/components/base/copy-icon/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-icon/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ const copy = vi.fn() const reset = vi.fn() let copied = false -vi.mock('foxact/use-clipboard', () => ({ +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy, reset, diff --git a/web/app/components/base/copy-icon/index.tsx b/web/app/components/base/copy-icon/index.tsx index 78c0fcb8c3..15332592d0 100644 --- a/web/app/components/base/copy-icon/index.tsx +++ b/web/app/components/base/copy-icon/index.tsx @@ -1,7 +1,7 @@ 'use client' -import { useClipboard } from 'foxact/use-clipboard' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useClipboard } from '@/hooks/use-clipboard' import Tooltip from '../tooltip' type Props = { diff --git a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx index 201c419444..33ebec5cbc 100644 --- a/web/app/components/base/input-with-copy/__tests__/index.spec.tsx +++ b/web/app/components/base/input-with-copy/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ const mockCopy = vi.fn() let mockCopied = false const mockReset = vi.fn() -vi.mock('foxact/use-clipboard', () => ({ +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy: mockCopy, copied: mockCopied, diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx index e85a7bd6f4..33db47baaa 100644 --- a/web/app/components/base/input-with-copy/index.tsx +++ b/web/app/components/base/input-with-copy/index.tsx @@ -1,8 +1,8 @@ 'use client' import type { InputProps } from '../input' -import { useClipboard } from 'foxact/use-clipboard' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useClipboard } from '@/hooks/use-clipboard' import { cn } from '@/utils/classnames' import ActionButton from '../action-button' import Tooltip from '../tooltip' diff --git a/web/hooks/noop.ts b/web/hooks/noop.ts new file mode 100644 index 0000000000..9cf6f968dc --- /dev/null +++ b/web/hooks/noop.ts @@ -0,0 +1,7 @@ +type Noop = { + // eslint-disable-next-line ts/no-explicit-any + (...args: any[]): any +} + +/** @see https://foxact.skk.moe/noop */ +export const noop: Noop = () => { /* noop */ } diff --git a/web/hooks/use-clipboard.ts b/web/hooks/use-clipboard.ts new file mode 100644 index 0000000000..6d24c04027 --- /dev/null +++ b/web/hooks/use-clipboard.ts @@ -0,0 +1,72 @@ +import { useRef, useState } from 'react' +import { writeTextToClipboard } from '@/utils/clipboard' +import { noop } from './noop' +import { useStableHandler } from './use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired' +import { useCallback } from './use-typescript-happy-callback' +import 'client-only' + +type UseClipboardOption = { + timeout?: number + usePromptAsFallback?: boolean + promptFallbackText?: string + onCopyError?: (error: Error) => void +} + +/** @see https://foxact.skk.moe/use-clipboard */ +export function useClipboard({ + timeout = 1000, + usePromptAsFallback = false, + promptFallbackText = 'Failed to copy to clipboard automatically, please manually copy the text below.', + onCopyError, +}: UseClipboardOption = {}) { + const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) + const copyTimeoutRef = useRef(null) + + const stablizedOnCopyError = useStableHandler<[e: Error], void>(onCopyError || noop) + + const handleCopyResult = useCallback((isCopied: boolean) => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current) + } + if (isCopied) { + copyTimeoutRef.current = window.setTimeout(() => setCopied(false), timeout) + } + setCopied(isCopied) + }, [timeout]) + + const handleCopyError = useCallback((e: Error) => { + setError(e) + stablizedOnCopyError(e) + }, [stablizedOnCopyError]) + + const copy = useCallback(async (valueToCopy: string) => { + try { + await writeTextToClipboard(valueToCopy) + } + catch (e) { + if (usePromptAsFallback) { + try { + // eslint-disable-next-line no-alert -- prompt as fallback in case of copy error + window.prompt(promptFallbackText, valueToCopy) + } + catch (e2) { + handleCopyError(e2 as Error) + } + } + else { + handleCopyError(e as Error) + } + } + }, [handleCopyResult, promptFallbackText, handleCopyError, usePromptAsFallback]) + + const reset = useCallback(() => { + setCopied(false) + setError(null) + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current) + } + }, []) + + return { copy, reset, error, copied } +} diff --git a/web/hooks/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired.ts b/web/hooks/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired.ts new file mode 100644 index 0000000000..227f4fd1fb --- /dev/null +++ b/web/hooks/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired.ts @@ -0,0 +1,44 @@ +import * as reactExports from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' + +// useIsomorphicInsertionEffect +const useInsertionEffect + = typeof window === 'undefined' + // useInsertionEffect is only available in React 18+ + + ? useEffect + : reactExports.useInsertionEffect || useLayoutEffect + +/** + * @see https://foxact.skk.moe/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired + * Similar to useCallback, with a few subtle differences: + * - The returned function is a stable reference, and will always be the same between renders + * - No dependency lists required + * - Properties or state accessed within the callback will always be "current" + */ +// eslint-disable-next-line ts/no-explicit-any +export function useStableHandler( + callback: (...args: Args) => Result, +): typeof callback { + // Keep track of the latest callback: + // eslint-disable-next-line ts/no-explicit-any + const latestRef = useRef(shouldNotBeInvokedBeforeMount as any) + useInsertionEffect(() => { + latestRef.current = callback + }, [callback]) + + return useCallback((...args) => { + const fn = latestRef.current + return fn(...args) + }, []) +} + +/** + * Render methods should be pure, especially when concurrency is used, + * so we will throw this error if the callback is called while rendering. + */ +function shouldNotBeInvokedBeforeMount() { + throw new Error( + 'foxact: the stablized handler cannot be invoked before the component has mounted.', + ) +} diff --git a/web/hooks/use-typescript-happy-callback.ts b/web/hooks/use-typescript-happy-callback.ts new file mode 100644 index 0000000000..db3ba372c0 --- /dev/null +++ b/web/hooks/use-typescript-happy-callback.ts @@ -0,0 +1,10 @@ +import { useCallback as useCallbackFromReact } from 'react' + +/** @see https://foxact.skk.moe/use-typescript-happy-callback */ +const useTypeScriptHappyCallback: ( + fn: (...args: Args) => R, + deps: React.DependencyList, +) => (...args: Args) => R = useCallbackFromReact + +/** @see https://foxact.skk.moe/use-typescript-happy-callback */ +export const useCallback = useTypeScriptHappyCallback diff --git a/web/package.json b/web/package.json index d2a9e88f4a..8bc31dce31 100644 --- a/web/package.json +++ b/web/package.json @@ -85,6 +85,7 @@ "abcjs": "catalog:", "ahooks": "catalog:", "class-variance-authority": "catalog:", + "client-only": "catalog:", "clsx": "catalog:", "cmdk": "catalog:", "copy-to-clipboard": "catalog:", @@ -100,7 +101,6 @@ "emoji-mart": "catalog:", "es-toolkit": "catalog:", "fast-deep-equal": "catalog:", - "foxact": "catalog:", "hast-util-to-jsx-runtime": "catalog:", "html-entities": "catalog:", "html-to-image": "catalog:", diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index b17a59bab6..b945f675f7 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -83,11 +83,12 @@ afterEach(async () => { }) }) -// mock foxact/use-clipboard - not available in test environment -vi.mock('foxact/use-clipboard', () => ({ +// mock custom clipboard hook - wraps writeTextToClipboard with fallback +vi.mock('@/hooks/use-clipboard', () => ({ useClipboard: () => ({ copy: vi.fn(), copied: false, + reset: vi.fn(), }), }))