This commit is contained in:
Stephen Zhou 2026-05-08 15:24:15 +08:00
parent 4e62b048bd
commit 7cad11c856
No known key found for this signature in database
12 changed files with 355 additions and 155 deletions

View File

@ -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

View File

@ -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>

View File

@ -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)

View File

@ -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 ?? []),

View File

@ -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}
/>
))}

View File

@ -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>

View File

@ -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

View File

@ -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 []

View File

@ -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

View File

@ -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) {

View 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,
}
}

View 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,
}