refactor: use foxact package for copied hooks (#37308)

This commit is contained in:
Stephen Zhou 2026-06-11 09:05:08 +08:00 committed by GitHub
parent 08f1bf20ab
commit 5ed663e7fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 84 additions and 550 deletions

39
pnpm-lock.yaml generated
View File

@ -264,9 +264,6 @@ catalogs:
cli-table3:
specifier: 0.6.5
version: 0.6.5
client-only:
specifier: 0.0.1
version: 0.0.1
clsx:
specifier: 2.1.1
version: 2.1.1
@ -348,6 +345,9 @@ catalogs:
fast-deep-equal:
specifier: 3.1.3
version: 3.1.3
foxact:
specifier: 0.3.4
version: 0.3.4
fuse.js:
specifier: 7.4.2
version: 7.4.2
@ -1089,9 +1089,6 @@ importers:
class-variance-authority:
specifier: 'catalog:'
version: 0.7.1
client-only:
specifier: 'catalog:'
version: 0.0.1
cmdk:
specifier: 'catalog:'
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@ -1134,6 +1131,9 @@ importers:
fast-deep-equal:
specifier: 'catalog:'
version: 3.1.3
foxact:
specifier: 'catalog:'
version: 0.3.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
fuse.js:
specifier: 'catalog:'
version: 7.4.2
@ -6358,6 +6358,9 @@ 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==}
eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'}
@ -6463,6 +6466,17 @@ packages:
engines: {node: '>=18.3.0'}
hasBin: true
foxact@0.3.4:
resolution: {integrity: sha512-6ENWStzEp/VczVgwBdn8scZUGccko2kLGho8Qg1VyUTG6tpSW1vz2xX/dRtAUjPEFbu618DKsjj/YL7iMU8mlA==}
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==}
@ -14740,6 +14754,8 @@ snapshots:
esutils@2.0.3: {}
event-target-bus@1.0.0: {}
eventsource-parser@3.1.0: {}
expand-template@2.0.3:
@ -14843,6 +14859,15 @@ snapshots:
dependencies:
fd-package-json: 2.0.0
foxact@0.3.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
client-only: 0.0.1
event-target-bus: 1.0.0
server-only: 0.0.1
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
fs-constants@1.0.0:
optional: true
@ -18169,7 +18194,6 @@ time:
chokidar@5.0.0: '2025-11-25T23:28:06.854Z'
class-variance-authority@0.7.1: '2024-11-26T08:20:34.604Z'
cli-table3@0.6.5: '2024-05-12T16:36:50.079Z'
client-only@0.0.1: '2022-09-03T01:07:11.981Z'
clsx@2.1.1: '2024-04-23T05:26:04.645Z'
cmdk@1.1.1: '2025-03-14T19:21:16.194Z'
code-inspector-plugin@1.5.2: '2026-06-07T02:32:04.545Z'
@ -18197,6 +18221,7 @@ time:
eslint@10.4.1: '2026-05-29T20:32:32.193Z'
eventsource-parser@3.1.0: '2026-05-27T20:55:51.466Z'
fast-deep-equal@3.1.3: '2020-06-08T07:27:28.474Z'
foxact@0.3.4: '2026-05-15T12:55:52.584Z'
fuse.js@7.4.2: '2026-06-05T22:22:52.388Z'
happy-dom@20.10.2: '2026-06-06T15:03:11.325Z'
hast-util-to-jsx-runtime@2.3.6: '2025-03-05T11:30:29.166Z'

View File

@ -131,7 +131,6 @@ catalog:
chokidar: 5.0.0
class-variance-authority: 0.7.1
cli-table3: 0.6.5
client-only: 0.0.1
clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.5.2
@ -159,6 +158,7 @@ catalog:
eslint-plugin-storybook: 10.4.2
eventsource-parser: 3.1.0
fast-deep-equal: 3.1.3
foxact: 0.3.4
fuse.js: 7.4.2
happy-dom: 20.10.2
hast-util-to-jsx-runtime: 2.3.6

View File

@ -33,7 +33,7 @@
- Use local component state for state owned by one component.
- Use feature-level Jotai atoms for simple client state shared across components in the same feature, especially when components need a shared source of truth, derived values, or shared actions.
- Use existing feature stores for complex or high-frequency interaction state such as workflow canvas, drag, resize, and panel runtime state.
- Use `@/hooks/use-local-storage` only for low-frequency, client-only persistence such as user preferences, dismissed notices, and UI defaults. Do not use localStorage as the live source of truth for app state.
- Use `foxact/use-local-storage` only for low-frequency, client-only persistence such as user preferences, dismissed notices, and UI defaults. Do not use localStorage as the live source of truth for app state.
- For high-frequency interactions, update the feature state during interaction and persist storage only on commit or settled updates.
- Do not access `localStorage`, `window.localStorage`, or `globalThis.localStorage` directly in app code; use the storage hook boundary and preserve existing raw/custom storage formats.
- Do not add ad hoc global event listeners for shared state. Prefer atoms, existing stores, or a shared subscription hook so listeners are centralized and deduplicated.

View File

@ -77,7 +77,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
vi.mock('@/hooks/use-local-storage', () => ({
vi.mock('foxact/use-local-storage', () => ({
useSetLocalStorage: () => mockSetEducationVerifying,
}))

View File

@ -6,6 +6,7 @@ import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { toast } from '@langgenius/dify-ui/toast'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -18,7 +19,6 @@ import { collaborationManager } from '@/app/components/workflow/collaboration/co
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { isTriggerNode } from '@/app/components/workflow/types'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import {
fetchAppDetail,
updateAppSiteAccessToken,

View File

@ -11,6 +11,7 @@ import {
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
import { useLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -24,7 +25,6 @@ import DatasetDetailContext from '@/context/dataset-detail'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'

View File

@ -12,7 +12,7 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('@/hooks/use-local-storage', () => ({
vi.mock('foxact/use-local-storage', () => ({
useSetLocalStorage: () => setEducationVerifyingMock,
}))

View File

@ -3,12 +3,12 @@ import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-moda
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { toast } from '@langgenius/dify-ui/toast'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { useRouter } from '@/next/navigation'
import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'

View File

@ -19,6 +19,7 @@ import {
useBoolean,
useSessionStorageState,
} from 'ahooks'
import { useLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -27,7 +28,6 @@ import Loading from '@/app/components/base/loading'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { generateRule } from '@/service/debug'
import { useGenerateRuleTemplate } from '@/service/use-apps'
import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index'

View File

@ -6,6 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiRobot2Line } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -17,7 +18,6 @@ import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { DSLImportMode } from '@/models/app'
import { useRouter } from '@/next/navigation'
import { importDSL } from '@/service/apps'

View File

@ -10,6 +10,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useDebounceFn } from 'ahooks'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
@ -20,7 +21,6 @@ import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import useTheme from '@/hooks/use-theme'
import { useRouter } from '@/next/navigation'
import { createApp } from '@/service/apps'

View File

@ -8,6 +8,7 @@ import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { toast } from '@langgenius/dify-ui/toast'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useDebounceFn } from 'ahooks'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
@ -16,7 +17,6 @@ import { usePluginDependencies } from '@/app/components/workflow/plugin-dependen
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import {
DSLImportMode,
DSLImportStatus,

View File

@ -16,6 +16,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
@ -26,7 +27,6 @@ import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { useRouter } from '@/next/navigation'
import { deleteApp, switchApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'

View File

@ -30,6 +30,7 @@ import {
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
@ -42,7 +43,6 @@ import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AppCardTags } from '@/features/tag-management/components/app-card-tags'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { AccessMode } from '@/models/access-control'
import dynamic from '@/next/dynamic'
import { useRouter } from '@/next/navigation'

View File

@ -4,6 +4,7 @@ import type { AppListQuery } from '@/contract/console/apps'
import { cn } from '@langgenius/dify-ui/cn'
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useLocalStorage } from 'foxact/use-local-storage'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
@ -11,7 +12,6 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'

View File

@ -5,6 +5,7 @@ import type { AppData, ConversationItem } from '@/models/share'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import { useLocalStorage } from 'foxact/use-local-storage'
import { produce } from 'immer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -12,7 +13,6 @@ import { getProcessedFilesFromResponse } from '@/app/components/base/file-upload
import { InputVarType } from '@/app/components/workflow/types'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { changeLanguage } from '@/i18n-config/client'
import { AppSourceType, delConversation, pinConversation, renameConversation, unpinConversation, updateFeedback } from '@/service/share'
import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share'

View File

@ -5,13 +5,13 @@ import type { Locale } from '@/i18n-config'
import type { AppData, ConversationItem } from '@/models/share'
import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import { useLocalStorage } from 'foxact/use-local-storage'
import { produce } from 'immer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { InputVarType } from '@/app/components/workflow/types'
import { useWebAppStore } from '@/context/web-app-context'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { changeLanguage } from '@/i18n-config/client'
import { AppSourceType, updateFeedback } from '@/service/share'
import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share'

View File

@ -5,7 +5,7 @@ const mockCopy = vi.fn()
const mockReset = vi.fn()
let mockCopied = false
vi.mock('@/hooks/use-clipboard', () => ({
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copy: mockCopy,
reset: mockReset,

View File

@ -4,10 +4,10 @@ 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 { useClipboard } from '@/hooks/use-clipboard'
import copyStyle from './style.module.css'
type Props = Readonly<{

View File

@ -5,7 +5,7 @@ const copy = vi.fn()
const reset = vi.fn()
let copied = false
vi.mock('@/hooks/use-clipboard', () => ({
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copy,
reset,

View File

@ -1,8 +1,8 @@
'use client'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useClipboard } from 'foxact/use-clipboard'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useClipboard } from '@/hooks/use-clipboard'
type Props = Readonly<{
content: string

View File

@ -6,7 +6,7 @@ const mockCopy = vi.fn()
let mockCopied = false
const mockReset = vi.fn()
vi.mock('@/hooks/use-clipboard', () => ({
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copy: mockCopy,
copied: mockCopied,

View File

@ -2,9 +2,9 @@
import type { InputProps } from '../input'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useClipboard } from 'foxact/use-clipboard'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useClipboard } from '@/hooks/use-clipboard'
import ActionButton from '../action-button'
type InputWithCopyProps = {

View File

@ -36,7 +36,7 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/hooks/use-local-storage', () => ({
vi.mock('foxact/use-local-storage', () => ({
useSetLocalStorage: () => setEducationVerifyingMock,
}))

View File

@ -8,6 +8,7 @@ import {
RiGroupLine,
} from '@remixicon/react'
import { useUnmountedRef } from 'ahooks'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@ -18,7 +19,6 @@ import VerifyStateModal from '@/app/education-apply/verify-state-modal'
import { useAppContext } from '@/context/app-context'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { usePathname, useRouter } from '@/next/navigation'
import { useEducationVerify } from '@/service/use-education'
import { getDaysUntilEndOfMonth } from '@/utils/time'

View File

@ -1,11 +1,11 @@
'use client'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useEffect } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { useSearchParams } from '@/next/navigation'
export function EducationVerifyActionRecorder() {

View File

@ -5,6 +5,7 @@ import { Avatar } from '@langgenius/dify-ui/avatar'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
@ -18,7 +19,6 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { env } from '@/env'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'

View File

@ -1,10 +1,10 @@
'use client'
import type { EventEmitterValue } from '@/context/event-emitter'
import { cn } from '@langgenius/dify-ui/cn'
import { useLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useState } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { usePathname } from '@/next/navigation'
import s from './index.module.css'

View File

@ -1,8 +1,8 @@
import { useLocalStorage } from 'foxact/use-local-storage'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { X } from '@/app/components/base/icons/src/vender/line/general'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { NOTICE_I18N } from '@/i18n-config/language'
const MaintenanceNotice = () => {

View File

@ -6,6 +6,7 @@ import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { createPreviewCardHandle, PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { RiMoreLine } from '@remixicon/react'
import { useLocalStorage } from 'foxact/use-local-storage'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
@ -13,7 +14,6 @@ import Loading from '@/app/components/base/loading'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n'
import { useLocalStorage } from '@/hooks/use-local-storage'
import Link from '@/next/link'
import { formatNumber } from '@/utils/format'
import { getMarketplaceUrl } from '@/utils/var'

View File

@ -6,6 +6,7 @@ import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { createPreviewCardHandle, PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { RiMoreLine } from '@remixicon/react'
import { useLocalStorage } from 'foxact/use-local-storage'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
@ -13,7 +14,6 @@ import Loading from '@/app/components/base/loading'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n'
import { useLocalStorage } from '@/hooks/use-local-storage'
import Link from '@/next/link'
import { formatNumber } from '@/utils/format'
import { getMarketplaceUrl } from '@/utils/var'

View File

@ -3,13 +3,13 @@ import type { Dispatch, SetStateAction } from 'react'
import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select'
import type { OnSelectBlock } from '@/app/components/workflow/types'
import { RiMoreLine } from '@remixicon/react'
import { useLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows'
import Loading from '@/app/components/base/loading'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import { useLocalStorage } from '@/hooks/use-local-storage'
import Link from '@/next/link'
import { useRAGRecommendedPlugins } from '@/service/use-tools'
import { getMarketplaceUrl } from '@/utils/var'

View File

@ -13,6 +13,7 @@ import {
RiPlayLargeLine,
} from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import {
cloneElement,
@ -68,7 +69,6 @@ import {
} from '@/app/components/workflow/utils'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { useAllBuiltInTools } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { FlowType } from '@/types/common'

View File

@ -5,13 +5,13 @@ import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { RiDraggable } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useLocalStorage } from 'foxact/use-local-storage'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { useEdgesInteractions } from '../../../hooks'
import AddButton from '../../_base/components/add-button'
import Item from './class-item'

View File

@ -1,7 +1,7 @@
import type { EditorState } from 'lexical'
import type { NoteTheme } from './types'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useCallback } from 'react'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { useNodeDataUpdate, useWorkflowHistory, WorkflowHistoryEvent } from '../hooks'
import { NOTE_SHOW_AUTHOR_STORAGE_KEY } from './constants'

View File

@ -1,7 +1,7 @@
import type { NoteNodeType } from '../note-node/types'
import { useLocalStorage } from 'foxact/use-local-storage'
import { useCallback } from 'react'
import { useAppContext } from '@/context/app-context'
import { useLocalStorage } from '@/hooks/use-local-storage'
import {
CUSTOM_NOTE_NODE,
NOTE_SHOW_AUTHOR_STORAGE_KEY,

View File

@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { RiCloseLine, RiEqualizer2Line } from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import {
memo,
useCallback,
@ -19,7 +20,6 @@ import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows
import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { useStore } from '@/app/components/workflow/store'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import {
useWorkflowInteractions,
} from '../../hooks'

View File

@ -1,5 +1,5 @@
import { useLocalStorage, useSetLocalStorage } from 'foxact/use-local-storage'
import { useEffect, useLayoutEffect as useLayoutEffectFromReact } from 'react'
import { useLocalStorage, useSetLocalStorage } from '@/hooks/use-local-storage'
import { useStore, useWorkflowStore } from '../store'
import {
isControlMode,

View File

@ -1,11 +1,11 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { debounce } from 'es-toolkit/compat'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import {
useCallback,
useMemo,
} from 'react'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { useResizePanel } from '../nodes/_base/hooks/use-resize-panel'
import { useStore } from '../store'
import Panel from './panel'

View File

@ -16,6 +16,7 @@ import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean } from 'ahooks'
import { useLocalStorage } from 'foxact/use-local-storage'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -26,7 +27,6 @@ import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/com
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { useAppContext } from '@/context/app-context'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { useRouter } from '@/next/navigation'
import { generateWorkflow } from '@/service/debug'
import { fetchWorkflowDraft } from '@/service/workflow'

View File

@ -8,6 +8,7 @@ import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useEducationDiscount } from '@/app/components/billing/hooks/use-education-discount'
@ -19,7 +20,6 @@ import { useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import { WorkspaceProvider } from '@/context/workspace-context-provider'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import {
useRouter,
useSearchParams,

View File

@ -4,6 +4,7 @@ import { useDebounceFn } from 'ahooks'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { useLocalStorage } from 'foxact/use-local-storage'
import {
useCallback,
useEffect,
@ -13,7 +14,6 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { useRouter, useSearchParams } from '@/next/navigation'
import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education'
import {

View File

@ -2,12 +2,12 @@ import { Button } from '@langgenius/dify-ui/button'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { toast } from '@langgenius/dify-ui/toast'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { useRouter, useSearchParams } from '@/next/navigation'
import { sendEMailLoginCode } from '@/service/common'

View File

@ -11,6 +11,7 @@ import type { InputVar } from '@/app/components/workflow/types'
import type { ExpireNoticeModalPayloadProps } from '@/app/education-apply/expire-notice-modal'
import type { ExternalDataTool } from '@/models/common'
import type { ModerationConfig, PromptVariable } from '@/models/debug'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
@ -22,7 +23,6 @@ import {
} from '@/app/education-apply/constants'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import {
useAccountSettingModal,
usePricingModal,

View File

@ -32,7 +32,7 @@ vi.mock('@/app/components/header/account-setting', () => ({
),
}))
vi.mock('@/hooks/use-local-storage', () => ({
vi.mock('foxact/use-local-storage', () => ({
useSetLocalStorage: () => mockSetEducationVerifying,
}))

View File

@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useLocalStorage } from 'foxact/use-local-storage'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
@ -15,7 +16,6 @@ import {
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ZENDESK_FIELD_IDS } from '@/config'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { fetchCurrentPlanInfo } from '@/service/billing'
import {
useModelListByType,

View File

@ -186,14 +186,13 @@ export default antfu(
...GLOB_TESTS,
'vitest.setup.ts',
'instrumentation-client.ts',
'hooks/use-local-storage/index.ts',
],
rules: {
'no-restricted-globals': [
'error',
{
name: 'localStorage',
message: 'Do not use localStorage directly. Use @/hooks/use-local-storage instead.',
message: 'Do not use localStorage directly. Use foxact/use-local-storage instead.',
},
],
'no-restricted-properties': [
@ -201,19 +200,19 @@ export default antfu(
{
object: 'window',
property: 'localStorage',
message: 'Do not use window.localStorage directly. Use @/hooks/use-local-storage instead.',
message: 'Do not use window.localStorage directly. Use foxact/use-local-storage instead.',
},
{
object: 'globalThis',
property: 'localStorage',
message: 'Do not use globalThis.localStorage directly. Use @/hooks/use-local-storage instead.',
message: 'Do not use globalThis.localStorage directly. Use foxact/use-local-storage instead.',
},
],
'no-restricted-syntax': [
'error',
{
selector: 'ImportDeclaration[source.value="ahooks"] ImportSpecifier[imported.name="useLocalStorageState"]',
message: 'Do not use ahooks useLocalStorageState. Use @/hooks/use-local-storage instead.',
message: 'Do not use ahooks useLocalStorageState. Use foxact/use-local-storage instead.',
},
],
},

View File

@ -1,7 +0,0 @@
type Noop = {
// eslint-disable-next-line ts/no-explicit-any
(...args: any[]): any
}
/** @see https://foxact.skk.moe/noop */
export const noop: Noop = () => { /* noop */ }

View File

@ -1,74 +0,0 @@
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<Error | null>(null)
const [copied, setCopied] = useState(false)
const copyTimeoutRef = useRef<number | null>(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)
handleCopyResult(true)
}
catch (e) {
if (usePromptAsFallback) {
try {
// eslint-disable-next-line no-alert -- prompt as fallback in case of copy error
window.prompt(promptFallbackText, valueToCopy)
handleCopyResult(true)
}
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 }
}

View File

@ -4,6 +4,7 @@ import type {
} from '@/models/app'
import type { AppIconType } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { useSetLocalStorage } from 'foxact/use-local-storage'
import {
useCallback,
useRef,
@ -13,7 +14,6 @@ import { useTranslation } from 'react-i18next'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useSelector } from '@/context/app-context'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { DSLImportStatus } from '@/models/app'
import { useRouter } from '@/next/navigation'
import {

View File

@ -1,95 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { renderToString } from 'react-dom/server'
import { useLocalStorage } from '../index'
describe('useLocalStorage', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
})
it('should return server value and persist it when storage is empty', () => {
const { result } = renderHook(() => useLocalStorage('shape', 'circle'))
expect(result.current[0]).toBe('circle')
expect(window.localStorage.getItem('shape')).toBe(JSON.stringify('circle'))
})
it('should prefer stored value over server value', () => {
window.localStorage.setItem('shape', JSON.stringify('square'))
const { result } = renderHook(() => useLocalStorage('shape', 'circle'))
expect(result.current[0]).toBe('square')
expect(window.localStorage.getItem('shape')).toBe(JSON.stringify('square'))
})
it('should update storage and subscribers from setter calls', () => {
const { result } = renderHook(() => useLocalStorage('shape', 'circle'))
act(() => {
result.current[1]('triangle')
})
expect(result.current[0]).toBe('triangle')
expect(window.localStorage.getItem('shape')).toBe(JSON.stringify('triangle'))
})
it('should support updater functions and null removal', () => {
const { result } = renderHook(() => useLocalStorage<number>('count', 1))
act(() => {
result.current[1](current => (current ?? 0) + 1)
})
expect(result.current[0]).toBe(2)
expect(window.localStorage.getItem('count')).toBe(JSON.stringify(2))
act(() => {
result.current[1](null)
})
expect(result.current[0]).toBe(1)
expect(window.localStorage.getItem('count')).toBeNull()
})
it('should update from cross-tab storage events', () => {
const { result } = renderHook(() => useLocalStorage('shape', 'circle'))
window.localStorage.setItem('shape', JSON.stringify('square'))
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: 'shape' }))
})
expect(result.current[0]).toBe('square')
})
it('should support raw string values', () => {
const { result } = renderHook(() => useLocalStorage('raw-shape', 'circle', { raw: true }))
act(() => {
result.current[1]('square')
})
expect(result.current[0]).toBe('square')
expect(window.localStorage.getItem('raw-shape')).toBe('square')
})
it('should render with server value during server rendering', () => {
const Component = () => {
const [value] = useLocalStorage('shape', 'circle')
return <div>{value}</div>
}
expect(renderToString(<Component />)).toContain('circle')
})
it('should throw a recoverable no-SSR error during server rendering without server value', () => {
const Component = () => {
const [value] = useLocalStorage<string>('shape')
return <div>{value}</div>
}
expect(() => renderToString(<Component />)).toThrow('[foxact/use-local-storage] cannot be used on the server without a serverValue')
})
})

View File

@ -1,260 +0,0 @@
import { useCallback, useEffect, useLayoutEffect as useLayoutEffectFromReact, useMemo, useSyncExternalStore } from 'react'
import { noop } from '../noop'
import 'client-only'
type NotUndefined<T> = T extends undefined ? never : T
type StateHookTuple<T> = readonly [T, React.Dispatch<React.SetStateAction<T | null>>]
type Serializer<T> = (value: T) => string
type Deserializer<T> = (value: string) => T
export type UseLocalStorageRawOption = {
raw: true
}
export type UseLocalStorageParserOption<T> = {
raw?: false
serializer: Serializer<T>
deserializer: Deserializer<T>
}
const FOXACT_LOCAL_STORAGE_EVENT_KEY = 'foxact-use-local-storage'
const HOOK_NAME = 'foxact/use-local-storage'
const useLayoutEffect = typeof window === 'undefined'
? useEffect
: useLayoutEffectFromReact
type ErrorConstructorWithStackTraceLimit = ErrorConstructor & {
stackTraceLimit?: number
}
const errorConstructor = Error as ErrorConstructorWithStackTraceLimit
const stackTraceLimitProperty = Object.getOwnPropertyDescriptor(errorConstructor, 'stackTraceLimit')
const hasWritableStackTraceLimit = stackTraceLimitProperty?.writable && typeof stackTraceLimitProperty.value === 'number'
function createStacklessError<T = Error>(errorFactory: () => T): T {
const originalStackTraceLimit = errorConstructor.stackTraceLimit
if (hasWritableStackTraceLimit)
errorConstructor.stackTraceLimit = 0
const error = errorFactory()
if (hasWritableStackTraceLimit)
errorConstructor.stackTraceLimit = originalStackTraceLimit
return error
}
function noSSRError(errorMessage?: string, nextjsDigest = 'BAILOUT_TO_CLIENT_SIDE_RENDERING') {
const error = createStacklessError(() => new Error(errorMessage)) as Error & {
digest?: string
recoverableError?: string
}
error.digest = nextjsDigest
error.recoverableError = 'NO_SSR'
return error
}
function getServerSnapshotWithoutServerValue(): never {
throw noSSRError(`[${HOOK_NAME}] cannot be used on the server without a serverValue`)
}
function rawSerializer<T>(value: T): string {
return value as string
}
function rawDeserializer<T>(value: string): T {
return value as T
}
function isStorageSetter<T>(
value: React.SetStateAction<T | null>,
): value is (previousState: T | null) => T | null {
return typeof value === 'function'
}
const dispatchStorageEvent = typeof window === 'undefined'
? noop
: (key: string) => {
window.dispatchEvent(new CustomEvent<string>(FOXACT_LOCAL_STORAGE_EVENT_KEY, { detail: key }))
}
const setStorageItem = typeof window === 'undefined'
? noop
: (key: string, value: string) => {
try {
window.localStorage.setItem(key, value)
}
catch {
console.warn(`[${HOOK_NAME}] Failed to set value to localStorage, it might be blocked`)
}
finally {
dispatchStorageEvent(key)
}
}
const removeStorageItem = typeof window === 'undefined'
? noop
: (key: string) => {
try {
window.localStorage.removeItem(key)
}
catch {
console.warn(`[${HOOK_NAME}] Failed to remove value from localStorage, it might be blocked`)
}
finally {
dispatchStorageEvent(key)
}
}
function getStorageItem(key: string) {
if (typeof window === 'undefined')
return null
try {
return window.localStorage.getItem(key)
}
catch {
console.warn(`[${HOOK_NAME}] Failed to get value from localStorage, it might be blocked`)
return null
}
}
function getStorageOptions<T>(
options: UseLocalStorageRawOption | UseLocalStorageParserOption<T>,
) {
return options.raw
? {
serializer: rawSerializer<T>,
deserializer: rawDeserializer<T>,
}
: {
serializer: options.serializer,
deserializer: options.deserializer,
}
}
const defaultStorageOptions = {
raw: false,
serializer: JSON.stringify,
deserializer: JSON.parse,
} satisfies UseLocalStorageParserOption<unknown>
/** @see https://foxact.skk.moe/use-local-storage */
export const useSetLocalStorage = <T>(
key: string,
options: UseLocalStorageRawOption | UseLocalStorageParserOption<T> = defaultStorageOptions as UseLocalStorageParserOption<T>,
) => {
const { serializer, deserializer } = getStorageOptions(options)
return useCallback((value: React.SetStateAction<T | null>) => {
try {
let nextState: T | null
if (isStorageSetter(value)) {
const currentRaw = getStorageItem(key)
const currentState = currentRaw === null ? null : deserializer(currentRaw)
nextState = value(currentState)
}
else {
nextState = value
}
if (nextState === null)
removeStorageItem(key)
else
setStorageItem(key, serializer(nextState))
}
catch (error) {
console.warn(error)
}
}, [key, serializer, deserializer])
}
function useLocalStorageValue<T>(
key: string,
serverValue: NotUndefined<T>,
options?: UseLocalStorageRawOption | UseLocalStorageParserOption<T>,
): T
function useLocalStorageValue<T>(
key: string,
serverValue?: undefined,
options?: UseLocalStorageRawOption | UseLocalStorageParserOption<T>,
): T | null
function useLocalStorageValue<T>(
key: string,
serverValue?: NotUndefined<T>,
options: UseLocalStorageRawOption | UseLocalStorageParserOption<T> = defaultStorageOptions as UseLocalStorageParserOption<T>,
): T | null {
const subscribeToSpecificKeyOfLocalStorage = useCallback((callback: () => void) => {
if (typeof window === 'undefined')
return noop
const handleStorageEvent = (event: StorageEvent) => {
if (!('key' in event) || event.key === key)
callback()
}
const handleCustomStorageEvent: EventListener = (event) => {
if (event instanceof CustomEvent && event.detail === key)
callback()
}
window.addEventListener('storage', handleStorageEvent)
window.addEventListener(FOXACT_LOCAL_STORAGE_EVENT_KEY, handleCustomStorageEvent)
return () => {
window.removeEventListener('storage', handleStorageEvent)
window.removeEventListener(FOXACT_LOCAL_STORAGE_EVENT_KEY, handleCustomStorageEvent)
}
}, [key])
const { serializer, deserializer } = getStorageOptions(options)
const getClientSnapshot = () => getStorageItem(key)
const getServerSnapshot = serverValue === undefined
? getServerSnapshotWithoutServerValue
: () => serializer(serverValue)
const store = useSyncExternalStore(
subscribeToSpecificKeyOfLocalStorage,
getClientSnapshot,
getServerSnapshot,
)
const deserialized = useMemo(() => (store === null ? null : deserializer(store)), [store, deserializer])
useLayoutEffect(() => {
if (getStorageItem(key) === null && serverValue !== undefined)
setStorageItem(key, serializer(serverValue))
}, [key, serializer, serverValue])
return deserialized === null
? (serverValue === undefined ? null : serverValue)
: deserialized
}
function useLocalStorage<T>(
key: string,
serverValue: NotUndefined<T>,
options?: UseLocalStorageRawOption | UseLocalStorageParserOption<T>,
): StateHookTuple<T>
function useLocalStorage<T>(
key: string,
serverValue?: undefined,
options?: UseLocalStorageRawOption | UseLocalStorageParserOption<T>,
): StateHookTuple<T | null>
/** @see https://foxact.skk.moe/use-local-storage */
function useLocalStorage<T>(
key: string,
serverValue?: NotUndefined<T>,
options: UseLocalStorageRawOption | UseLocalStorageParserOption<T> = defaultStorageOptions as UseLocalStorageParserOption<T>,
): StateHookTuple<T> | StateHookTuple<T | null> {
const value = useLocalStorageValue<T>(key, serverValue!, options)
const setState = useSetLocalStorage<T>(key, options)
return [value, setState] as const
}
export { useLocalStorage }

View File

@ -1,44 +0,0 @@
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<Args extends any[], Result>(
callback: (...args: Args) => Result,
): typeof callback {
// Keep track of the latest callback:
// eslint-disable-next-line ts/no-explicit-any
const latestRef = useRef<typeof callback>(shouldNotBeInvokedBeforeMount as any)
useInsertionEffect(() => {
latestRef.current = callback
}, [callback])
return useCallback<typeof callback>((...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.',
)
}

View File

@ -1,10 +0,0 @@
import { useCallback as useCallbackFromReact } from 'react'
/** @see https://foxact.skk.moe/use-typescript-happy-callback */
const useTypeScriptHappyCallback: <Args extends unknown[], R>(
fn: (...args: Args) => R,
deps: React.DependencyList,
) => (...args: Args) => R = useCallbackFromReact
/** @see https://foxact.skk.moe/use-typescript-happy-callback */
export const useCallback = useTypeScriptHappyCallback

View File

@ -80,7 +80,6 @@
"abcjs": "catalog:",
"ahooks": "catalog:",
"class-variance-authority": "catalog:",
"client-only": "catalog:",
"cmdk": "catalog:",
"copy-to-clipboard": "catalog:",
"cron-parser": "catalog:",
@ -95,6 +94,7 @@
"emoji-mart": "catalog:",
"es-toolkit": "catalog:",
"fast-deep-equal": "catalog:",
"foxact": "catalog:",
"fuse.js": "catalog:",
"hast-util-to-jsx-runtime": "catalog:",
"html-entities": "catalog:",

View File

@ -76,7 +76,7 @@ afterEach(async () => {
})
// mock custom clipboard hook - wraps writeTextToClipboard with fallback
vi.mock('@/hooks/use-clipboard', () => ({
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copy: vi.fn(),
copied: false,