diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 6eff0f6b6d..5e20cc2d12 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -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 diff --git a/web/features/deployments/components/deploy-drawer/form.tsx b/web/features/deployments/components/deploy-drawer/form.tsx index 3198311448..04040c2231 100644 --- a/web/features/deployments/components/deploy-drawer/form.tsx +++ b/web/features/deployments/components/deploy-drawer/form.tsx @@ -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( @@ -297,12 +297,6 @@ export function DeployForm({ {releaseLabel(displayedRelease)} · {releaseCommit(displayedRelease)} - {displayedRelease.description && ( - <> - · - {displayedRelease.description} - - )} {displayedRelease.createdAt} diff --git a/web/features/deployments/detail/access-tab/api-keys.tsx b/web/features/deployments/detail/access-tab/api-keys.tsx index 6af6e623e5..5a21a184d3 100644 --- a/web/features/deployments/detail/access-tab/api-keys.tsx +++ b/web/features/deployments/detail/access-tab/api-keys.tsx @@ -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) diff --git a/web/features/deployments/detail/access-tab/permissions.tsx b/web/features/deployments/detail/access-tab/permissions.tsx index 8e3be4619f..1c32e0b41c 100644 --- a/web/features/deployments/detail/access-tab/permissions.tsx +++ b/web/features/deployments/detail/access-tab/permissions.tsx @@ -95,15 +95,19 @@ type SelectableAccessSubject = AccessSubjectDisplay & { subjectType: string } +type AccessPolicyOptionWithSubjects = NonNullable[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 ?? []), diff --git a/web/features/deployments/detail/deploy-tab/deployment-panel.tsx b/web/features/deployments/detail/deploy-tab/deployment-panel.tsx index 73739af2bd..f7c3a01e35 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-panel.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-panel.tsx @@ -102,19 +102,19 @@ export function DeploymentPanel({ row }: {
{modelCredentials.map(c => ( ))} {pluginCredentials.map(c => ( ))} {envVars.map(v => ( ))} diff --git a/web/features/deployments/detail/deployment-sidebar.tsx b/web/features/deployments/detail/deployment-sidebar.tsx index 4f4c5b626f..b6bde9a0a8 100644 --- a/web/features/deployments/detail/deployment-sidebar.tsx +++ b/web/features/deployments/detail/deployment-sidebar.tsx @@ -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> & { title?: string titleId?: string @@ -76,6 +79,24 @@ function isShortcutFromInputArea(target: EventTarget | null) { || target.isContentEditable } +function useDeploymentSidebarMode(isMobile: boolean) { + const [persistedMode, setPersistedMode] = useLocalStorage( + 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({ )}
diff --git a/web/features/deployments/detail/versions-tab/__tests__/release-deployments.spec.ts b/web/features/deployments/detail/versions-tab/__tests__/release-deployments.spec.ts index 118c387243..3e6536466e 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/release-deployments.spec.ts +++ b/web/features/deployments/detail/versions-tab/__tests__/release-deployments.spec.ts @@ -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 diff --git a/web/features/deployments/detail/versions-tab/release-deployments.ts b/web/features/deployments/detail/versions-tab/release-deployments.ts index 7a2cad24d4..2816cb546c 100644 --- a/web/features/deployments/detail/versions-tab/release-deployments.ts +++ b/web/features/deployments/detail/versions-tab/release-deployments.ts @@ -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 [] diff --git a/web/features/deployments/types.ts b/web/features/deployments/types.ts index 31df2ad8ac..94caef9470 100644 --- a/web/features/deployments/types.ts +++ b/web/features/deployments/types.ts @@ -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 & { - bindings?: RuntimeBindingDisplay[] -} - -export type EnvironmentDeploymentRow = Omit & { - currentRelease?: ConsoleReleaseSummary - detail?: RuntimeInstanceDetail - environment?: ConsoleEnvironmentSummary -} - -type DeploymentEnvironmentOption = EnterpriseContract.DeploymentEnvironmentOption & { - description?: string - runtime?: string - tags?: string[] -} - -export type ListDeploymentEnvironmentOptionsReply = Omit & { - 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 & { - commitId?: string - createdBy?: ConsoleUser - deployedTo?: DeployedToSummary[] - description?: string - displayId?: string - release?: ConsoleReleaseSummary - shortCommitId?: string - status?: string -} - -export type AccessPermission = Omit & { - currentRelease?: ConsoleReleaseSummary - environment?: ConsoleEnvironmentSummary -} - -export type WebAppAccessRow = Omit & { - environment?: ConsoleEnvironmentSummary -} - -export type DeveloperAPIKeySummary = Omit & { - createdAt?: Timestamp - environment?: ConsoleEnvironmentSummary - environmentId?: string - environmentName?: string - maskedPrefix?: string - token?: string -} - -export type AccessSubjectDisplay = Omit & { - memberCount?: number | string - subjectId?: string -} - -type AccessPolicyOption = EnterpriseContract.AccessModeOption & { - groups?: AccessSubjectDisplay[] - members?: AccessSubjectDisplay[] -} - -export type AccessPolicyDetail = Omit & { - 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 diff --git a/web/features/deployments/utils.ts b/web/features/deployments/utils.ts index 5f5d722c47..cba9137c49 100644 --- a/web/features/deployments/utils.ts +++ b/web/features/deployments/utils.ts @@ -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) { diff --git a/web/hooks/create-storage-hook/index.ts b/web/hooks/create-storage-hook/index.ts new file mode 100644 index 0000000000..aac7cf23f3 --- /dev/null +++ b/web/hooks/create-storage-hook/index.ts @@ -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 extends undefined ? never : T +export type StateHookTuple = readonly [T, Dispatch>] +export type StateHookTupleNullable = readonly [T | null, Dispatch>] +export type Serializer = (value: T) => string +export type Deserializer = (value: string) => T +export type CustomStorageEvent = CustomEvent +export type UseStorageRawOption = { + raw: true +} +export type UseStorageParserOption = { + raw?: false + serializer: Serializer + deserializer: Deserializer +} + +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 +const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffectFromReact + +function isFunction(value: SetStateAction): value is (prevState: T | null) => T | null { + return typeof value === 'function' +} + +function identity(value: T) { + return value +} + +function stringIdentity(value: string) { + return value as T +} + +function getOption( + option: UseStorageRawOption | UseStorageParserOption = defaultStorageOption as UseStorageParserOption, +) { + return { + serializer: option.raw ? identity : option.serializer, + deserializer: option.raw ? stringIdentity : 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( + key: string, + option?: UseStorageRawOption | UseStorageParserOption, + ) { + const { serializer, deserializer } = getOption(option) + + return useCallback((value: SetStateAction) => { + 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( + key: string, + serverValue: NotUndefined, + option?: UseStorageRawOption | UseStorageParserOption, + ): T + function useStorageValue( + key: string, + serverValue?: undefined, + option?: UseStorageRawOption | UseStorageParserOption, + ): T | null + function useStorageValue( + key: string, + serverValue?: NotUndefined, + option?: UseStorageRawOption | UseStorageParserOption, + ) { + 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( + key: string, + serverValue: NotUndefined, + option?: UseStorageRawOption | UseStorageParserOption, + ): StateHookTuple + function useStorage( + key: string, + serverValue?: undefined, + option?: UseStorageRawOption | UseStorageParserOption, + ): StateHookTupleNullable + function useStorage( + key: string, + serverValue?: NotUndefined, + option?: UseStorageRawOption | UseStorageParserOption, + ): StateHookTuple | StateHookTupleNullable { + const value = useStorageValue(key, serverValue as NotUndefined, option) + const setValue = useSetStorage(key, option) + + return [value, setValue] as const + } + + return { + useStorage, + useSetStorage, + useStorageValue, + } +} diff --git a/web/hooks/use-local-storage/index.ts b/web/hooks/use-local-storage/index.ts new file mode 100644 index 0000000000..8e3f828251 --- /dev/null +++ b/web/hooks/use-local-storage/index.ts @@ -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 = UseStorageParserOption +export type UseLocalStorageSerializer = Serializer +export type UseLocalStorageDeserializer = Deserializer + +const { + useStorage: useLocalStorage, + useSetStorage: useSetLocalStorage, + useStorageValue: useLocalStorageValue, +} = createStorage('localStorage') + +/** @see https://foxact.skk.moe/use-local-storage */ +export { + useLocalStorage, + useLocalStorageValue, + useSetLocalStorage, +}