mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
tweaks
This commit is contained in:
parent
4e62b048bd
commit
7cad11c856
@ -36,7 +36,8 @@ Follow existing project patterns first. Use these rules to resolve unclear compo
|
||||
|
||||
## Effects
|
||||
|
||||
- Do not use `useEffect` directly in components. If an effect is genuinely unavoidable, encapsulate it in a purpose-built hook so the component consumes a declarative API instead of managing the effect inline.
|
||||
- Treat `useEffect` as a last resort. Before adding or keeping one, first try to delete it by deriving values during render, moving event-driven work into handlers, or replacing persistence, subscription, media-query, timer, and DOM sync cases with existing equivalent hooks/APIs.
|
||||
- Do not use `useEffect` directly in components. If an effect remains genuinely unavoidable after checking for a declarative substitute, encapsulate it in a purpose-built hook so the component consumes a declarative API instead of managing the effect inline.
|
||||
|
||||
## Performance
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { DeploymentBindingOptionSlot, DeploymentRuntimeBinding } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ConsoleReleaseSummary, EnvironmentOption } from '@/features/deployments/types'
|
||||
import type { EnvironmentOption, ReleaseHistoryRow } from '@/features/deployments/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
@ -29,7 +29,7 @@ export type DeployFormSubmit = {
|
||||
type DeployFormProps = {
|
||||
appInstanceId: string
|
||||
environments: EnvironmentOption[]
|
||||
releases: ConsoleReleaseSummary[]
|
||||
releases: ReleaseHistoryRow[]
|
||||
defaultReleaseId?: string
|
||||
lockedEnvId?: string
|
||||
presetReleaseId?: string
|
||||
@ -219,7 +219,7 @@ export function DeployForm({
|
||||
}: DeployFormProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const presetRelease = presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined
|
||||
const displayedRelease = presetRelease ?? (presetReleaseId ? { id: presetReleaseId } : undefined)
|
||||
const displayedRelease: ReleaseHistoryRow | undefined = presetRelease ?? (presetReleaseId ? { id: presetReleaseId } : undefined)
|
||||
const isPromote = Boolean(presetReleaseId)
|
||||
|
||||
const [selectedEnvId, setSelectedEnvId] = useState<string>(
|
||||
@ -297,12 +297,6 @@ export function DeployForm({
|
||||
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(displayedRelease)}</span>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
|
||||
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(displayedRelease)}</span>
|
||||
{displayedRelease.description && (
|
||||
<>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
|
||||
<span className="truncate system-xs-regular text-text-secondary">{displayedRelease.description}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 system-xs-regular text-text-quaternary">{displayedRelease.createdAt}</span>
|
||||
</div>
|
||||
|
||||
@ -20,8 +20,8 @@ export function ApiKeyRow({ apiKey, onCopy, onRevoke }: {
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const displayValue = apiKey.maskedKey || apiKey.maskedPrefix || apiKey.id || '—'
|
||||
const environmentLabel = apiKey.environment?.name || apiKey.environmentName || apiKey.environmentId || apiKey.environment?.id
|
||||
const displayValue = apiKey.maskedKey || apiKey.id || '—'
|
||||
const environmentLabel = environmentName(apiKey.environment)
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!apiKey.id)
|
||||
|
||||
@ -95,15 +95,19 @@ type SelectableAccessSubject = AccessSubjectDisplay & {
|
||||
subjectType: string
|
||||
}
|
||||
|
||||
type AccessPolicyOptionWithSubjects = NonNullable<AccessPolicyDetail['options']>[number] & {
|
||||
groups?: AccessSubjectDisplay[]
|
||||
members?: AccessSubjectDisplay[]
|
||||
}
|
||||
|
||||
function normalizeSubject(subject: AccessSubjectDisplay): SelectableAccessSubject | undefined {
|
||||
const id = subject.id ?? subject.subjectId
|
||||
const id = subject.id
|
||||
if (!id || !subject.subjectType)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
...subject,
|
||||
id,
|
||||
subjectId: subject.subjectId ?? id,
|
||||
subjectType: subject.subjectType,
|
||||
}
|
||||
}
|
||||
@ -125,8 +129,10 @@ function selectedSubjectsFromPolicy(policy?: AccessPolicyDetail) {
|
||||
.map(normalizeSubject)
|
||||
.filter((subject): subject is SelectableAccessSubject => Boolean(subject))
|
||||
}
|
||||
const selectedOption = policy?.options?.find(option => option.selected)
|
||||
const selectedOption = (
|
||||
policy?.options?.find(option => option.selected)
|
||||
?? policy?.options?.find(option => option.mode === policy?.accessMode)
|
||||
) as AccessPolicyOptionWithSubjects | undefined
|
||||
return [
|
||||
...(selectedOption?.groups ?? []),
|
||||
...(selectedOption?.members ?? []),
|
||||
|
||||
@ -102,19 +102,19 @@ export function DeploymentPanel({ row }: {
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{modelCredentials.map(c => (
|
||||
<RuntimeBindingItem
|
||||
key={`${c.kind}-${c.slot}-${c.label}-${c.displayName}-${c.displayValue}-${c.maskedValue}`}
|
||||
key={`${c.kind}-${c.label}-${c.displayValue}`}
|
||||
binding={c}
|
||||
/>
|
||||
))}
|
||||
{pluginCredentials.map(c => (
|
||||
<RuntimeBindingItem
|
||||
key={`${c.kind}-${c.slot}-${c.label}-${c.displayName}-${c.displayValue}-${c.maskedValue}`}
|
||||
key={`${c.kind}-${c.label}-${c.displayValue}`}
|
||||
binding={c}
|
||||
/>
|
||||
))}
|
||||
{envVars.map(v => (
|
||||
<RuntimeBindingItem
|
||||
key={`${v.kind}-${v.slot}-${v.label}-${v.displayName}-${v.displayValue}`}
|
||||
key={`${v.kind}-${v.label}-${v.displayValue}`}
|
||||
binding={v}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -6,16 +6,15 @@ import type { InstanceDetailTabKey } from './tabs'
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useHover, useKeyPress } from 'ahooks'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useLocalStorage } from '@/hooks/use-local-storage'
|
||||
|
||||
type TabDef = {
|
||||
key: InstanceDetailTabKey
|
||||
@ -23,6 +22,10 @@ type TabDef = {
|
||||
selectedIcon: NavIcon
|
||||
}
|
||||
|
||||
type DeploymentSidebarMode = 'expand' | 'collapse'
|
||||
|
||||
const DEPLOYMENT_SIDEBAR_MODE_KEY = 'deployment-sidebar-collapse-or-expand'
|
||||
|
||||
type TailwindNavIconProps = PropsWithoutRef<ComponentProps<'svg'>> & {
|
||||
title?: string
|
||||
titleId?: string
|
||||
@ -76,6 +79,24 @@ function isShortcutFromInputArea(target: EventTarget | null) {
|
||||
|| target.isContentEditable
|
||||
}
|
||||
|
||||
function useDeploymentSidebarMode(isMobile: boolean) {
|
||||
const [persistedMode, setPersistedMode] = useLocalStorage<DeploymentSidebarMode>(
|
||||
DEPLOYMENT_SIDEBAR_MODE_KEY,
|
||||
'expand',
|
||||
{ raw: true },
|
||||
)
|
||||
const sidebarMode = isMobile ? 'collapse' : persistedMode
|
||||
|
||||
function toggleSidebarMode() {
|
||||
setPersistedMode(sidebarMode === 'expand' ? 'collapse' : 'expand')
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarMode,
|
||||
toggleSidebarMode,
|
||||
}
|
||||
}
|
||||
|
||||
type DeploymentSidebarProps = {
|
||||
instanceId: string
|
||||
instanceName: string
|
||||
@ -96,33 +117,15 @@ export function DeploymentSidebar({
|
||||
const isHoveringSidebar = useHover(sidebarRef)
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
setAppSidebarExpand: state.setAppSidebarExpand,
|
||||
})))
|
||||
const sidebarMode = appSidebarExpand || 'expand'
|
||||
const { sidebarMode, toggleSidebarMode } = useDeploymentSidebarMode(isMobile)
|
||||
const expand = sidebarMode === 'expand'
|
||||
|
||||
function handleToggle() {
|
||||
setAppSidebarExpand(sidebarMode === 'expand' ? 'collapse' : 'expand')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const persistedMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
|
||||
setAppSidebarExpand(isMobile ? 'collapse' : persistedMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
|
||||
useEffect(() => {
|
||||
if (appSidebarExpand)
|
||||
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
|
||||
}, [appSidebarExpand])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
|
||||
if (isShortcutFromInputArea(e.target))
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
toggleSidebarMode()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
return (
|
||||
@ -193,7 +196,7 @@ export function DeploymentSidebar({
|
||||
<ToggleButton
|
||||
className="absolute top-[-3.5px] -right-3 z-20"
|
||||
expand={expand}
|
||||
handleToggle={handleToggle}
|
||||
handleToggle={toggleSidebarMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -11,7 +11,6 @@ describe('getReleaseDeployments', () => {
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
environmentName: 'Production',
|
||||
instanceStatus: 'failed',
|
||||
},
|
||||
],
|
||||
} satisfies ReleaseHistoryRow
|
||||
@ -45,14 +44,11 @@ describe('getReleaseDeployments', () => {
|
||||
it('should merge history deployments with runtime deployments for different environments', () => {
|
||||
// Arrange
|
||||
const releaseRow = {
|
||||
release: {
|
||||
id: 'release-1',
|
||||
},
|
||||
id: 'release-1',
|
||||
deployedTo: [
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
environmentName: 'Production',
|
||||
instanceStatus: 'ready',
|
||||
},
|
||||
],
|
||||
} satisfies ReleaseHistoryRow
|
||||
@ -95,7 +91,6 @@ describe('getReleaseDeployments', () => {
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
environmentName: 'Production',
|
||||
instanceStatus: 'ready',
|
||||
},
|
||||
],
|
||||
} satisfies ReleaseHistoryRow
|
||||
|
||||
@ -30,7 +30,7 @@ function fromDeployedTo(item: DeployedToSummary): ReleaseDeployment | undefined
|
||||
return {
|
||||
environmentId: item.environmentId,
|
||||
environmentName: item.environmentName || item.environmentId,
|
||||
state: releaseDeploymentState(item.instanceStatus),
|
||||
state: 'active',
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ function dedupeReleaseDeployments(items: ReleaseDeployment[]) {
|
||||
}
|
||||
|
||||
export function getReleaseDeployments(row: ReleaseHistoryRow, deploymentRows: EnvironmentDeploymentRow[]) {
|
||||
const releaseId = (row.release ?? row).id
|
||||
const releaseId = row.id
|
||||
if (!releaseId)
|
||||
return []
|
||||
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import type * as EnterpriseContract from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
type Timestamp = string
|
||||
|
||||
export type EnvironmentMode = 'shared' | 'isolated'
|
||||
export type EnvironmentHealth = 'ready' | 'degraded'
|
||||
|
||||
@ -9,104 +7,21 @@ export type DeployStatus = 'ready' | 'deploying' | 'deploy_failed'
|
||||
|
||||
export type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
|
||||
|
||||
export type ConsoleEnvironmentSummary = EnterpriseContract.ConsoleEnvironment & {
|
||||
backend?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export type ConsoleReleaseSummary = EnterpriseContract.ConsoleRelease & {
|
||||
commitId?: string
|
||||
description?: string
|
||||
displayId?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type ConsoleUser = EnterpriseContract.ConsoleUser & {
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export type RuntimeBindingDisplay = EnterpriseContract.ReleaseRuntimeBinding & {
|
||||
displayName?: string
|
||||
maskedValue?: string
|
||||
slot?: string
|
||||
}
|
||||
|
||||
type RuntimeInstanceDetail = Omit<EnterpriseContract.RuntimeInstanceDetail, 'bindings'> & {
|
||||
bindings?: RuntimeBindingDisplay[]
|
||||
}
|
||||
|
||||
export type EnvironmentDeploymentRow = Omit<EnterpriseContract.RuntimeInstanceRow, 'currentRelease' | 'detail' | 'environment'> & {
|
||||
currentRelease?: ConsoleReleaseSummary
|
||||
detail?: RuntimeInstanceDetail
|
||||
environment?: ConsoleEnvironmentSummary
|
||||
}
|
||||
|
||||
type DeploymentEnvironmentOption = EnterpriseContract.DeploymentEnvironmentOption & {
|
||||
description?: string
|
||||
runtime?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export type ListDeploymentEnvironmentOptionsReply = Omit<EnterpriseContract.ListDeploymentEnvironmentOptionsReply, 'environments'> & {
|
||||
environments?: DeploymentEnvironmentOption[]
|
||||
}
|
||||
|
||||
export type EnvironmentOption = DeploymentEnvironmentOption & {
|
||||
export type ConsoleEnvironmentSummary = EnterpriseContract.ConsoleEnvironment
|
||||
export type ConsoleReleaseSummary = EnterpriseContract.ConsoleRelease
|
||||
export type RuntimeBindingDisplay = EnterpriseContract.ReleaseRuntimeBinding
|
||||
export type EnvironmentDeploymentRow = EnterpriseContract.RuntimeInstanceRow
|
||||
export type ListDeploymentEnvironmentOptionsReply = EnterpriseContract.ListDeploymentEnvironmentOptionsReply
|
||||
export type EnvironmentOption = EnterpriseContract.DeploymentEnvironmentOption & {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type DeployedToSummary = EnterpriseContract.DeployedEnvironment & {
|
||||
instanceStatus?: string
|
||||
}
|
||||
|
||||
export type ReleaseHistoryRow = Omit<EnterpriseContract.ReleaseRow, 'createdBy' | 'deployedTo'> & {
|
||||
commitId?: string
|
||||
createdBy?: ConsoleUser
|
||||
deployedTo?: DeployedToSummary[]
|
||||
description?: string
|
||||
displayId?: string
|
||||
release?: ConsoleReleaseSummary
|
||||
shortCommitId?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type AccessPermission = Omit<EnterpriseContract.EnvironmentAccessRow, 'currentRelease' | 'environment'> & {
|
||||
currentRelease?: ConsoleReleaseSummary
|
||||
environment?: ConsoleEnvironmentSummary
|
||||
}
|
||||
|
||||
export type WebAppAccessRow = Omit<EnterpriseContract.WebAppAccessRow, 'environment'> & {
|
||||
environment?: ConsoleEnvironmentSummary
|
||||
}
|
||||
|
||||
export type DeveloperAPIKeySummary = Omit<EnterpriseContract.DeveloperApiKeyRow, 'environment'> & {
|
||||
createdAt?: Timestamp
|
||||
environment?: ConsoleEnvironmentSummary
|
||||
environmentId?: string
|
||||
environmentName?: string
|
||||
maskedPrefix?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
export type AccessSubjectDisplay = Omit<EnterpriseContract.AccessSubjectDisplay, 'memberCount'> & {
|
||||
memberCount?: number | string
|
||||
subjectId?: string
|
||||
}
|
||||
|
||||
type AccessPolicyOption = EnterpriseContract.AccessModeOption & {
|
||||
groups?: AccessSubjectDisplay[]
|
||||
members?: AccessSubjectDisplay[]
|
||||
}
|
||||
|
||||
export type AccessPolicyDetail = Omit<EnterpriseContract.AccessPolicyDetail, 'options' | 'subjects'> & {
|
||||
enabled?: boolean
|
||||
id?: string
|
||||
options?: AccessPolicyOption[]
|
||||
subjects?: AccessSubjectDisplay[]
|
||||
version?: number
|
||||
}
|
||||
|
||||
export type DeployedToSummary = EnterpriseContract.DeployedEnvironment
|
||||
export type ReleaseHistoryRow = EnterpriseContract.ReleaseRow
|
||||
export type AccessPermission = EnterpriseContract.EnvironmentAccessRow
|
||||
export type WebAppAccessRow = EnterpriseContract.WebAppAccessRow
|
||||
export type DeveloperAPIKeySummary = EnterpriseContract.DeveloperApiKeyRow
|
||||
export type AccessSubjectDisplay = EnterpriseContract.AccessSubjectDisplay
|
||||
export type AccessPolicyDetail = EnterpriseContract.AccessPolicyDetail
|
||||
export type AccessSubject = EnterpriseContract.AccessSubject
|
||||
|
||||
export type GetAppInstanceSettingsReply = EnterpriseContract.GetAppInstanceSettingsReply
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
EnvironmentDeploymentRow,
|
||||
EnvironmentOption,
|
||||
ListDeploymentEnvironmentOptionsReply,
|
||||
ReleaseHistoryRow,
|
||||
RuntimeBindingDisplay,
|
||||
} from './types'
|
||||
import { PUBLIC_API_PREFIX } from '@/config'
|
||||
@ -30,8 +31,18 @@ export function environmentMode(environment?: ConsoleEnvironmentSummary | Enviro
|
||||
return type.includes('isolated') ? 'isolated' : 'shared'
|
||||
}
|
||||
|
||||
export function environmentBackend(environment?: ConsoleEnvironmentSummary) {
|
||||
const runtime = (environment?.backend || environment?.runtime)?.toLowerCase() ?? ''
|
||||
function environmentRuntimeName(environment?: ConsoleEnvironmentSummary | EnvironmentOption) {
|
||||
if (!environment)
|
||||
return ''
|
||||
if ('backend' in environment && environment.backend)
|
||||
return environment.backend
|
||||
if ('runtime' in environment && environment.runtime)
|
||||
return environment.runtime
|
||||
return ''
|
||||
}
|
||||
|
||||
export function environmentBackend(environment?: ConsoleEnvironmentSummary | EnvironmentOption) {
|
||||
const runtime = environmentRuntimeName(environment).toLowerCase()
|
||||
return runtime.includes('host') ? 'host' : 'k8s'
|
||||
}
|
||||
|
||||
@ -40,16 +51,16 @@ export function environmentHealth(environment?: ConsoleEnvironmentSummary | Envi
|
||||
return status.includes('ready') ? 'ready' : 'degraded'
|
||||
}
|
||||
|
||||
export function releaseLabel(release?: ConsoleReleaseSummary) {
|
||||
return release?.name || release?.displayId || release?.id || '—'
|
||||
export function releaseLabel(release?: ConsoleReleaseSummary | ReleaseHistoryRow) {
|
||||
return release?.name || release?.id || '—'
|
||||
}
|
||||
|
||||
export function releaseCommit(release?: ConsoleReleaseSummary) {
|
||||
return release?.shortCommitId || release?.commitId || '—'
|
||||
export function releaseCommit(release?: ConsoleReleaseSummary | ReleaseHistoryRow) {
|
||||
return release && 'shortCommitId' in release ? release.shortCommitId || '—' : '—'
|
||||
}
|
||||
|
||||
export function runtimeBindingSummary(binding?: RuntimeBindingDisplay) {
|
||||
return binding?.label || binding?.slot || binding?.displayName || binding?.displayValue || binding?.maskedValue || binding?.kind || '—'
|
||||
return binding?.label || binding?.displayValue || binding?.kind || '—'
|
||||
}
|
||||
|
||||
export function isRuntimeEnvVarBinding(binding?: RuntimeBindingDisplay) {
|
||||
|
||||
249
web/hooks/create-storage-hook/index.ts
Normal file
249
web/hooks/create-storage-hook/index.ts
Normal file
@ -0,0 +1,249 @@
|
||||
/* eslint-disable react/component-hook-factories -- Mirrors foxact's storage hook factory shape. */
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect as useLayoutEffectFromReact, useMemo, useSyncExternalStore } from 'react'
|
||||
import { noop } from '../noop'
|
||||
import 'client-only'
|
||||
|
||||
/*
|
||||
* Adapted from foxact/create-storage-hook.
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2023 Sukka
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
export type StorageType = 'localStorage' | 'sessionStorage'
|
||||
export type NotUndefined<T> = T extends undefined ? never : T
|
||||
export type StateHookTuple<T> = readonly [T, Dispatch<SetStateAction<T | null>>]
|
||||
export type StateHookTupleNullable<T> = readonly [T | null, Dispatch<SetStateAction<T | null>>]
|
||||
export type Serializer<T> = (value: T) => string
|
||||
export type Deserializer<T> = (value: string) => T
|
||||
export type CustomStorageEvent = CustomEvent<string>
|
||||
export type UseStorageRawOption = {
|
||||
raw: true
|
||||
}
|
||||
export type UseStorageParserOption<T> = {
|
||||
raw?: false
|
||||
serializer: Serializer<T>
|
||||
deserializer: Deserializer<T>
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line ts/consistent-type-definitions -- WindowEventMap uses interface merging.
|
||||
interface WindowEventMap {
|
||||
'foxact-use-local-storage': CustomStorageEvent
|
||||
'foxact-use-session-storage': CustomStorageEvent
|
||||
}
|
||||
}
|
||||
|
||||
const defaultStorageOption = {
|
||||
raw: false,
|
||||
serializer: JSON.stringify,
|
||||
deserializer: JSON.parse,
|
||||
} satisfies UseStorageParserOption<unknown>
|
||||
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffectFromReact
|
||||
|
||||
function isFunction<T>(value: SetStateAction<T | null>): value is (prevState: T | null) => T | null {
|
||||
return typeof value === 'function'
|
||||
}
|
||||
|
||||
function identity<T>(value: T) {
|
||||
return value
|
||||
}
|
||||
|
||||
function stringIdentity<T>(value: string) {
|
||||
return value as T
|
||||
}
|
||||
|
||||
function getOption<T>(
|
||||
option: UseStorageRawOption | UseStorageParserOption<T> = defaultStorageOption as UseStorageParserOption<T>,
|
||||
) {
|
||||
return {
|
||||
serializer: option.raw ? identity<T> : option.serializer,
|
||||
deserializer: option.raw ? stringIdentity<T> : option.deserializer,
|
||||
}
|
||||
}
|
||||
|
||||
export function createStorage(type: StorageType) {
|
||||
const storageEventKey = type === 'localStorage' ? 'foxact-use-local-storage' : 'foxact-use-session-storage'
|
||||
const hookName = type === 'localStorage' ? 'useLocalStorage' : 'useSessionStorage'
|
||||
|
||||
function getServerSnapshotWithoutServerValue(): never {
|
||||
throw new Error(`[${hookName}] cannot be used on the server without a serverValue`)
|
||||
}
|
||||
|
||||
function dispatchStorageEvent(key: string) {
|
||||
window.dispatchEvent(new CustomEvent(storageEventKey, { detail: key }))
|
||||
}
|
||||
|
||||
function setStorageItem(key: string, value: string) {
|
||||
try {
|
||||
window[type].setItem(key, value)
|
||||
}
|
||||
catch {
|
||||
console.warn(`[${hookName}] Failed to set value to ${type}, it might be blocked`)
|
||||
}
|
||||
finally {
|
||||
dispatchStorageEvent(key)
|
||||
}
|
||||
}
|
||||
|
||||
function removeStorageItem(key: string) {
|
||||
try {
|
||||
window[type].removeItem(key)
|
||||
}
|
||||
catch {
|
||||
console.warn(`[${hookName}] Failed to remove value from ${type}, it might be blocked`)
|
||||
}
|
||||
finally {
|
||||
dispatchStorageEvent(key)
|
||||
}
|
||||
}
|
||||
|
||||
function getStorageItem(key: string) {
|
||||
if (typeof window === 'undefined')
|
||||
return null
|
||||
|
||||
try {
|
||||
return window[type].getItem(key)
|
||||
}
|
||||
catch {
|
||||
console.warn(`[${hookName}] Failed to get value from ${type}, it might be blocked`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function useSetStorage<T>(
|
||||
key: string,
|
||||
option?: UseStorageRawOption | UseStorageParserOption<T>,
|
||||
) {
|
||||
const { serializer, deserializer } = getOption(option)
|
||||
|
||||
return useCallback((value: SetStateAction<T | null>) => {
|
||||
try {
|
||||
let nextState: T | null
|
||||
if (isFunction(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) as string)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
}, [deserializer, key, serializer])
|
||||
}
|
||||
|
||||
function useStorageValue<T>(
|
||||
key: string,
|
||||
serverValue: NotUndefined<T>,
|
||||
option?: UseStorageRawOption | UseStorageParserOption<T>,
|
||||
): T
|
||||
function useStorageValue<T = string>(
|
||||
key: string,
|
||||
serverValue?: undefined,
|
||||
option?: UseStorageRawOption | UseStorageParserOption<T>,
|
||||
): T | null
|
||||
function useStorageValue<T>(
|
||||
key: string,
|
||||
serverValue?: NotUndefined<T>,
|
||||
option?: UseStorageRawOption | UseStorageParserOption<T>,
|
||||
) {
|
||||
const subscribeToKey = useCallback((callback: () => void) => {
|
||||
if (typeof window === 'undefined')
|
||||
return noop
|
||||
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (!('key' in event) || event.key === key)
|
||||
callback()
|
||||
}
|
||||
const handleCustomStorageEvent = (event: CustomStorageEvent) => {
|
||||
if (event.detail === key)
|
||||
callback()
|
||||
}
|
||||
|
||||
window.addEventListener('storage', handleStorageEvent)
|
||||
window.addEventListener(storageEventKey, handleCustomStorageEvent)
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageEvent)
|
||||
window.removeEventListener(storageEventKey, handleCustomStorageEvent)
|
||||
}
|
||||
}, [key])
|
||||
|
||||
const { serializer, deserializer } = getOption(option)
|
||||
const getClientSnapshot = () => getStorageItem(key)
|
||||
const getServerSnapshot = serverValue === undefined
|
||||
? getServerSnapshotWithoutServerValue
|
||||
: () => serializer(serverValue) as string
|
||||
|
||||
const store = useSyncExternalStore(
|
||||
subscribeToKey,
|
||||
getClientSnapshot,
|
||||
getServerSnapshot,
|
||||
)
|
||||
const deserialized = useMemo(() => (store === null ? null : deserializer(store)), [deserializer, store])
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (getStorageItem(key) === null && serverValue !== undefined)
|
||||
setStorageItem(key, serializer(serverValue) as string)
|
||||
}, [key, serializer, serverValue])
|
||||
|
||||
return deserialized === null
|
||||
? serverValue === undefined
|
||||
? null
|
||||
: serverValue
|
||||
: deserialized
|
||||
}
|
||||
|
||||
function useStorage<T>(
|
||||
key: string,
|
||||
serverValue: NotUndefined<T>,
|
||||
option?: UseStorageRawOption | UseStorageParserOption<T>,
|
||||
): StateHookTuple<T>
|
||||
function useStorage<T = string>(
|
||||
key: string,
|
||||
serverValue?: undefined,
|
||||
option?: UseStorageRawOption | UseStorageParserOption<T>,
|
||||
): StateHookTupleNullable<T>
|
||||
function useStorage<T>(
|
||||
key: string,
|
||||
serverValue?: NotUndefined<T>,
|
||||
option?: UseStorageRawOption | UseStorageParserOption<T>,
|
||||
): StateHookTuple<T> | StateHookTupleNullable<T> {
|
||||
const value = useStorageValue<T>(key, serverValue as NotUndefined<T>, option)
|
||||
const setValue = useSetStorage<T>(key, option)
|
||||
|
||||
return [value, setValue] as const
|
||||
}
|
||||
|
||||
return {
|
||||
useStorage,
|
||||
useSetStorage,
|
||||
useStorageValue,
|
||||
}
|
||||
}
|
||||
26
web/hooks/use-local-storage/index.ts
Normal file
26
web/hooks/use-local-storage/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type {
|
||||
Deserializer,
|
||||
Serializer,
|
||||
UseStorageParserOption,
|
||||
UseStorageRawOption,
|
||||
} from '../create-storage-hook'
|
||||
import { createStorage } from '../create-storage-hook'
|
||||
import 'client-only'
|
||||
|
||||
export type UseLocalStorageRawOption = UseStorageRawOption
|
||||
export type UseLocalStorageParserOption<T> = UseStorageParserOption<T>
|
||||
export type UseLocalStorageSerializer<T> = Serializer<T>
|
||||
export type UseLocalStorageDeserializer<T> = Deserializer<T>
|
||||
|
||||
const {
|
||||
useStorage: useLocalStorage,
|
||||
useSetStorage: useSetLocalStorage,
|
||||
useStorageValue: useLocalStorageValue,
|
||||
} = createStorage('localStorage')
|
||||
|
||||
/** @see https://foxact.skk.moe/use-local-storage */
|
||||
export {
|
||||
useLocalStorage,
|
||||
useLocalStorageValue,
|
||||
useSetLocalStorage,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user