mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:23:44 +08:00
refactor(web): migrate local storage access to react hook (#36888)
This commit is contained in:
parent
becccbf288
commit
21711bebeb
@ -142,11 +142,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(humanInputLayout)/form/[token]/form.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -2651,11 +2646,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/header/header-wrapper.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/card/index.tsx": {
|
||||
"ts/no-non-null-asserted-optional-chain": {
|
||||
"count": 1
|
||||
@ -3503,11 +3493,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -4770,11 +4755,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/store/workflow/workflow-slice.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 16
|
||||
@ -4895,11 +4875,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/hooks.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/search-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
import type { FC } from 'react'
|
||||
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiEqualizer2Fill,
|
||||
@ -23,9 +24,9 @@ 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'
|
||||
import { getLocalStorageItem, useLocalStorageBoolean } from '@/utils/local-storage'
|
||||
|
||||
type IAppDetailLayoutProps = {
|
||||
children: React.ReactNode
|
||||
@ -55,14 +56,15 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const pathname = usePathname()
|
||||
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
|
||||
const [storedHideHeader] = useLocalStorage<boolean>('workflow-canvas-maximize', false)
|
||||
const [storedAppSidebarMode] = useLocalStorage<string>('app-detail-collapse-or-expand', 'expand', { raw: true })
|
||||
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
|
||||
const hideHeader = eventHideHeader ?? storedHideHeader
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === 'workflow-canvas-maximize')
|
||||
setEventHideHeader(v.payload)
|
||||
eventEmitter?.useSubscription((value: EventEmitterValue) => {
|
||||
if (typeof value === 'object' && value.type === 'workflow-canvas-maximize' && typeof value.payload === 'boolean')
|
||||
setEventHideHeader(value.payload)
|
||||
})
|
||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
|
||||
@ -127,10 +129,9 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
|
||||
|
||||
useEffect(() => {
|
||||
const localeMode = getLocalStorageItem('app-detail-collapse-or-expand', 'expand') || 'expand'
|
||||
const mode = isMobile ? 'collapse' : 'expand'
|
||||
setAppSidebarExpand(isMobile ? mode : localeMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
setAppSidebarExpand(isMobile ? mode : storedAppSidebarMode)
|
||||
}, [isMobile, setAppSidebarExpand, storedAppSidebarMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirect)
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
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 { useLocalStorageBoolean } from '@/utils/local-storage'
|
||||
import s from './index.module.css'
|
||||
|
||||
type HeaderWrapperProps = {
|
||||
@ -19,14 +20,14 @@ const HeaderWrapper = ({
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
|
||||
const [storedHideHeader] = useLocalStorage<boolean>('workflow-canvas-maximize', false)
|
||||
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
|
||||
const hideHeader = eventHideHeader ?? storedHideHeader
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === 'workflow-canvas-maximize')
|
||||
setEventHideHeader(v.payload)
|
||||
eventEmitter?.useSubscription((value: EventEmitterValue) => {
|
||||
if (typeof value === 'object' && value.type === 'workflow-canvas-maximize' && typeof value.payload === 'boolean')
|
||||
setEventHideHeader(value.payload)
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@ -2,14 +2,15 @@ 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'
|
||||
import { setLocalStorageItem, useLocalStorageItem } from '@/utils/local-storage'
|
||||
|
||||
const MaintenanceNotice = () => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLanguage()
|
||||
|
||||
const hiddenNotice = useLocalStorageItem('hide-maintenance-notice') === '1'
|
||||
const [hiddenNoticeValue, setHiddenNoticeValue] = useLocalStorage<string>('hide-maintenance-notice', '0', { raw: true })
|
||||
const hiddenNotice = hiddenNoticeValue === '1'
|
||||
const [closedInSession, setClosedInSession] = useState(false)
|
||||
const showNotice = !hiddenNotice && !closedInSession
|
||||
const handleJumpNotice = () => {
|
||||
@ -17,7 +18,7 @@ const MaintenanceNotice = () => {
|
||||
}
|
||||
|
||||
const handleCloseNotice = () => {
|
||||
setLocalStorageItem('hide-maintenance-notice', '1')
|
||||
setHiddenNoticeValue('1')
|
||||
setClosedInSession(true)
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
EnvironmentVariable,
|
||||
Node,
|
||||
} from './types'
|
||||
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import {
|
||||
AlertDialog,
|
||||
@ -33,6 +34,7 @@ import { setAutoFreeze } from 'immer'
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@ -105,6 +107,7 @@ import CustomNoteNode from './note-node'
|
||||
import { CUSTOM_NOTE_NODE } from './note-node/constants'
|
||||
import Operator from './operator'
|
||||
import Control from './operator/control'
|
||||
import { WorkflowLocalStorageBridge } from './persistence/local-storage-bridge'
|
||||
import { useWorkflowHotkeys } from './shortcuts/use-workflow-hotkeys'
|
||||
import CustomSimpleNode from './simple-node'
|
||||
import { CUSTOM_SIMPLE_NODE } from './simple-node/constants'
|
||||
@ -355,20 +358,21 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
handleCommentIconClick(target)
|
||||
}, [activeComment, handleCommentIconClick, visibleComments])
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === WORKFLOW_DATA_UPDATE) {
|
||||
setNodes(v.payload.nodes)
|
||||
store.getState().setNodes(v.payload.nodes)
|
||||
setEdges(v.payload.edges)
|
||||
eventEmitter?.useSubscription((v: EventEmitterValue) => {
|
||||
if (typeof v === 'object' && v.type === WORKFLOW_DATA_UPDATE) {
|
||||
const payload = v.payload as WorkflowDataUpdatePayload
|
||||
setNodes(payload.nodes)
|
||||
store.getState().setNodes(payload.nodes)
|
||||
setEdges(payload.edges)
|
||||
workflowStore.setState({ contextMenuTarget: undefined })
|
||||
|
||||
if (v.payload.viewport)
|
||||
reactflow.setViewport(v.payload.viewport)
|
||||
if (payload.viewport)
|
||||
reactflow.setViewport(payload.viewport)
|
||||
|
||||
if (v.payload.hash)
|
||||
setSyncWorkflowDraftHash(v.payload.hash)
|
||||
if (payload.hash)
|
||||
setSyncWorkflowDraftHash(payload.hash)
|
||||
|
||||
onWorkflowDataUpdate?.(v.payload)
|
||||
onWorkflowDataUpdate?.(payload)
|
||||
|
||||
setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
|
||||
}
|
||||
@ -851,6 +855,9 @@ const WorkflowWithDefaultContext = ({
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<WorkflowLocalStorageBridge />
|
||||
</Suspense>
|
||||
<DatasetsDetailProvider nodes={nodes}>
|
||||
{children}
|
||||
</DatasetsDetailProvider>
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
|
||||
import { ControlMode } from '../../types'
|
||||
import { WorkflowLocalStorageBridge } from '../local-storage-bridge'
|
||||
|
||||
describe('WorkflowLocalStorageBridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('hydrates workflow preferences from localStorage', async () => {
|
||||
localStorage.setItem('workflow-node-panel-width', '460')
|
||||
localStorage.setItem('debug-and-preview-panel-width', '520')
|
||||
localStorage.setItem('workflow-variable-inpsect-panel-height', '240')
|
||||
localStorage.setItem('workflow-operation-mode', ControlMode.Hand)
|
||||
|
||||
const store = createWorkflowStore({})
|
||||
|
||||
render(
|
||||
<WorkflowContext value={store}>
|
||||
<WorkflowLocalStorageBridge />
|
||||
</WorkflowContext>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().nodePanelWidth).toBe(460)
|
||||
expect(store.getState().panelWidth).toBe(460)
|
||||
expect(store.getState().previewPanelWidth).toBe(520)
|
||||
expect(store.getState().variableInspectPanelHeight).toBe(240)
|
||||
expect(store.getState().controlMode).toBe(ControlMode.Hand)
|
||||
})
|
||||
})
|
||||
|
||||
it('persists control mode updates from the workflow store', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
|
||||
render(
|
||||
<WorkflowContext value={store}>
|
||||
<WorkflowLocalStorageBridge />
|
||||
</WorkflowContext>,
|
||||
)
|
||||
|
||||
store.getState().setControlMode(ControlMode.Comment)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('workflow-operation-mode')).toBe(ControlMode.Comment)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,69 @@
|
||||
import { useEffect, useLayoutEffect as useLayoutEffectFromReact } from 'react'
|
||||
import { useLocalStorage, useSetLocalStorage } from '@/hooks/use-local-storage'
|
||||
import { useStore, useWorkflowStore } from '../store'
|
||||
import {
|
||||
isControlMode,
|
||||
isFiniteNumber,
|
||||
numberStorageOptions,
|
||||
rawStorageOptions,
|
||||
WORKFLOW_NODE_PANEL_WIDTH_KEY,
|
||||
WORKFLOW_OPERATION_MODE_KEY,
|
||||
WORKFLOW_PREVIEW_PANEL_WIDTH_KEY,
|
||||
WORKFLOW_VARIABLE_INSPECT_PANEL_HEIGHT_KEY,
|
||||
} from './local-storage-options'
|
||||
|
||||
const useIsoLayoutEffect = typeof document !== 'undefined'
|
||||
? useLayoutEffectFromReact
|
||||
: useEffect
|
||||
|
||||
export const WorkflowLocalStorageBridge = () => {
|
||||
const [storedNodePanelWidth] = useLocalStorage<number>(WORKFLOW_NODE_PANEL_WIDTH_KEY, undefined, numberStorageOptions)
|
||||
const [storedPreviewPanelWidth] = useLocalStorage<number>(WORKFLOW_PREVIEW_PANEL_WIDTH_KEY, undefined, numberStorageOptions)
|
||||
const [storedVariableInspectPanelHeight] = useLocalStorage<number>(WORKFLOW_VARIABLE_INSPECT_PANEL_HEIGHT_KEY, undefined, numberStorageOptions)
|
||||
const [storedControlMode] = useLocalStorage<string>(WORKFLOW_OPERATION_MODE_KEY, undefined, rawStorageOptions)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const setNodePanelWidth = useStore(state => state.setNodePanelWidth)
|
||||
const setPanelWidth = useStore(state => state.setPanelWidth)
|
||||
const setPreviewPanelWidth = useStore(state => state.setPreviewPanelWidth)
|
||||
const setVariableInspectPanelHeight = useStore(state => state.setVariableInspectPanelHeight)
|
||||
const setControlMode = useStore(state => state.setControlMode)
|
||||
|
||||
const setControlModeStorage = useSetLocalStorage<string>(WORKFLOW_OPERATION_MODE_KEY, rawStorageOptions)
|
||||
|
||||
useIsoLayoutEffect(() => {
|
||||
if (!isFiniteNumber(storedNodePanelWidth))
|
||||
return
|
||||
|
||||
setNodePanelWidth(storedNodePanelWidth)
|
||||
setPanelWidth(storedNodePanelWidth)
|
||||
}, [setNodePanelWidth, setPanelWidth, storedNodePanelWidth])
|
||||
|
||||
useIsoLayoutEffect(() => {
|
||||
if (isFiniteNumber(storedPreviewPanelWidth))
|
||||
setPreviewPanelWidth(storedPreviewPanelWidth)
|
||||
}, [setPreviewPanelWidth, storedPreviewPanelWidth])
|
||||
|
||||
useIsoLayoutEffect(() => {
|
||||
if (isFiniteNumber(storedVariableInspectPanelHeight))
|
||||
setVariableInspectPanelHeight(storedVariableInspectPanelHeight)
|
||||
}, [setVariableInspectPanelHeight, storedVariableInspectPanelHeight])
|
||||
|
||||
useIsoLayoutEffect(() => {
|
||||
if (isControlMode(storedControlMode))
|
||||
setControlMode(storedControlMode)
|
||||
}, [setControlMode, storedControlMode])
|
||||
|
||||
useEffect(() => {
|
||||
let previousControlMode = workflowStore.getState().controlMode
|
||||
|
||||
return workflowStore.subscribe((state) => {
|
||||
if (state.controlMode !== previousControlMode) {
|
||||
previousControlMode = state.controlMode
|
||||
setControlModeStorage(state.controlMode)
|
||||
}
|
||||
})
|
||||
}, [setControlModeStorage, workflowStore])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { ControlMode } from '../types'
|
||||
|
||||
export const WORKFLOW_NODE_PANEL_WIDTH_KEY = 'workflow-node-panel-width'
|
||||
export const WORKFLOW_PREVIEW_PANEL_WIDTH_KEY = 'debug-and-preview-panel-width'
|
||||
export const WORKFLOW_VARIABLE_INSPECT_PANEL_HEIGHT_KEY = 'workflow-variable-inpsect-panel-height'
|
||||
export const WORKFLOW_OPERATION_MODE_KEY = 'workflow-operation-mode'
|
||||
|
||||
export const rawStorageOptions = { raw: true } as const
|
||||
export const numberStorageOptions = {
|
||||
serializer: String,
|
||||
deserializer: Number,
|
||||
} as const
|
||||
|
||||
export const isControlMode = (value: string | null): value is ControlMode => {
|
||||
return value === ControlMode.Pointer || value === ControlMode.Hand || value === ControlMode.Comment
|
||||
}
|
||||
|
||||
export const isFiniteNumber = (value: number | null): value is number => {
|
||||
return value !== null && Number.isFinite(value)
|
||||
}
|
||||
@ -64,11 +64,10 @@ describe('createWorkflowStore', () => {
|
||||
testSetter(setter, stateKey, value)
|
||||
})
|
||||
|
||||
it('should persist controlMode to localStorage', () => {
|
||||
it('should update controlMode in the workflow store', () => {
|
||||
const store = createStore()
|
||||
store.getState().setControlMode('pointer')
|
||||
expect(store.getState().controlMode).toBe('pointer')
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('workflow-operation-mode', 'pointer')
|
||||
})
|
||||
|
||||
it('should update clipboard nodes and edges with setClipboardData', () => {
|
||||
@ -176,9 +175,9 @@ describe('createWorkflowStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('localStorage Initialization', () => {
|
||||
it('should read controlMode from localStorage', () => {
|
||||
localStorage.setItem('workflow-operation-mode', 'pointer')
|
||||
describe('Static defaults and legacy maximize initialization', () => {
|
||||
it('should keep controlMode default in the store when localStorage has a value', () => {
|
||||
localStorage.setItem('workflow-operation-mode', 'hand')
|
||||
const store = createStore()
|
||||
expect(store.getState().controlMode).toBe('pointer')
|
||||
})
|
||||
@ -188,10 +187,10 @@ describe('createWorkflowStore', () => {
|
||||
expect(store.getState().controlMode).toBe('pointer')
|
||||
})
|
||||
|
||||
it('should read panelWidth from localStorage', () => {
|
||||
it('should keep panelWidth default in the store when localStorage has a value', () => {
|
||||
localStorage.setItem('workflow-node-panel-width', '500')
|
||||
const store = createStore()
|
||||
expect(store.getState().panelWidth).toBe(500)
|
||||
expect(store.getState().panelWidth).toBe(420)
|
||||
})
|
||||
|
||||
it('should default panelWidth to 420 when localStorage is empty', () => {
|
||||
@ -199,22 +198,22 @@ describe('createWorkflowStore', () => {
|
||||
expect(store.getState().panelWidth).toBe(420)
|
||||
})
|
||||
|
||||
it('should read nodePanelWidth from localStorage', () => {
|
||||
it('should keep nodePanelWidth default in the store when localStorage has a value', () => {
|
||||
localStorage.setItem('workflow-node-panel-width', '350')
|
||||
const store = createStore()
|
||||
expect(store.getState().nodePanelWidth).toBe(350)
|
||||
expect(store.getState().nodePanelWidth).toBe(400)
|
||||
})
|
||||
|
||||
it('should read previewPanelWidth from localStorage', () => {
|
||||
it('should keep previewPanelWidth default in the store when localStorage has a value', () => {
|
||||
localStorage.setItem('debug-and-preview-panel-width', '450')
|
||||
const store = createStore()
|
||||
expect(store.getState().previewPanelWidth).toBe(450)
|
||||
expect(store.getState().previewPanelWidth).toBe(400)
|
||||
})
|
||||
|
||||
it('should read variableInspectPanelHeight from localStorage', () => {
|
||||
it('should keep variableInspectPanelHeight default in the store when localStorage has a value', () => {
|
||||
localStorage.setItem('workflow-variable-inpsect-panel-height', '200')
|
||||
const store = createStore()
|
||||
expect(store.getState().variableInspectPanelHeight).toBe(200)
|
||||
expect(store.getState().variableInspectPanelHeight).toBe(320)
|
||||
})
|
||||
|
||||
it('should read maximizeCanvas from localStorage', () => {
|
||||
|
||||
@ -6,7 +6,7 @@ describe('createLayoutSlice', () => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('reads persisted panel sizes and maximize state from localStorage', () => {
|
||||
it('uses static panel defaults and keeps the legacy maximize bootstrap', () => {
|
||||
localStorage.setItem('workflow-node-panel-width', '460')
|
||||
localStorage.setItem('debug-and-preview-panel-width', '520')
|
||||
localStorage.setItem('workflow-variable-inpsect-panel-height', '240')
|
||||
@ -14,9 +14,9 @@ describe('createLayoutSlice', () => {
|
||||
|
||||
const store = createStore(createLayoutSlice)
|
||||
|
||||
expect(store.getState().nodePanelWidth).toBe(460)
|
||||
expect(store.getState().previewPanelWidth).toBe(520)
|
||||
expect(store.getState().variableInspectPanelHeight).toBe(240)
|
||||
expect(store.getState().nodePanelWidth).toBe(400)
|
||||
expect(store.getState().previewPanelWidth).toBe(400)
|
||||
expect(store.getState().variableInspectPanelHeight).toBe(320)
|
||||
expect(store.getState().maximizeCanvas).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@ -6,12 +6,12 @@ describe('createPanelSlice', () => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('uses the persisted panel width when present', () => {
|
||||
it('uses the static panel width default', () => {
|
||||
localStorage.setItem('workflow-node-panel-width', '480')
|
||||
|
||||
const store = createStore(createPanelSlice)
|
||||
|
||||
expect(store.getState().panelWidth).toBe(480)
|
||||
expect(store.getState().panelWidth).toBe(420)
|
||||
})
|
||||
|
||||
it('updates panel visibility and context menus through the slice setters', () => {
|
||||
|
||||
@ -8,7 +8,7 @@ describe('createWorkflowSlice', () => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults to pointer mode and restores persisted control mode', () => {
|
||||
it('defaults to pointer mode', () => {
|
||||
const defaultStore = createStore(createWorkflowSlice)
|
||||
|
||||
expect(defaultStore.getState().controlMode).toBe('pointer')
|
||||
@ -16,10 +16,10 @@ describe('createWorkflowSlice', () => {
|
||||
localStorage.setItem('workflow-operation-mode', 'hand')
|
||||
const persistedStore = createStore(createWorkflowSlice)
|
||||
|
||||
expect(persistedStore.getState().controlMode).toBe('hand')
|
||||
expect(persistedStore.getState().controlMode).toBe('pointer')
|
||||
})
|
||||
|
||||
it('persists control mode updates and stores run state payloads', () => {
|
||||
it('updates control mode and stores run state payloads', () => {
|
||||
const store = createStore(createWorkflowSlice)
|
||||
const workflowRunningData: WorkflowRunningData & { resultText: string } = {
|
||||
result: {
|
||||
@ -35,7 +35,6 @@ describe('createWorkflowSlice', () => {
|
||||
store.getState().setWorkflowRunningData(workflowRunningData)
|
||||
|
||||
expect(store.getState().controlMode).toBe('pointer')
|
||||
expect(localStorage.getItem('workflow-operation-mode')).toBe('pointer')
|
||||
expect(store.getState().workflowRunningData?.resultText).toBe('streaming')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,16 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import { getLocalStorageBoolean, getLocalStorageNumber } from '@/utils/local-storage'
|
||||
|
||||
const getStoredMaximizeCanvas = () => {
|
||||
if (typeof window === 'undefined')
|
||||
return false
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem('workflow-canvas-maximize') === 'true'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export type LayoutSliceShape = {
|
||||
workflowCanvasWidth?: number
|
||||
@ -35,10 +46,10 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
|
||||
rightPanelWidth: undefined,
|
||||
setRightPanelWidth: width => set(state =>
|
||||
state.rightPanelWidth === width ? state : ({ rightPanelWidth: width })),
|
||||
nodePanelWidth: getLocalStorageNumber('workflow-node-panel-width', 400),
|
||||
nodePanelWidth: 400,
|
||||
setNodePanelWidth: width => set(state =>
|
||||
state.nodePanelWidth === width ? state : ({ nodePanelWidth: width })),
|
||||
previewPanelWidth: getLocalStorageNumber('debug-and-preview-panel-width', 400),
|
||||
previewPanelWidth: 400,
|
||||
setPreviewPanelWidth: width => set(state =>
|
||||
state.previewPanelWidth === width ? state : ({ previewPanelWidth: width })),
|
||||
otherPanelWidth: 400,
|
||||
@ -50,10 +61,10 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
|
||||
bottomPanelHeight: 324,
|
||||
setBottomPanelHeight: height => set(state =>
|
||||
state.bottomPanelHeight === height ? state : ({ bottomPanelHeight: height })),
|
||||
variableInspectPanelHeight: getLocalStorageNumber('workflow-variable-inpsect-panel-height', 320),
|
||||
variableInspectPanelHeight: 320,
|
||||
setVariableInspectPanelHeight: height => set(state =>
|
||||
state.variableInspectPanelHeight === height ? state : ({ variableInspectPanelHeight: height })),
|
||||
maximizeCanvas: getLocalStorageBoolean('workflow-canvas-maximize'),
|
||||
maximizeCanvas: getStoredMaximizeCanvas(),
|
||||
setMaximizeCanvas: maximize => set(state =>
|
||||
state.maximizeCanvas === maximize ? state : ({ maximizeCanvas: maximize })),
|
||||
})
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import { getLocalStorageNumber } from '@/utils/local-storage'
|
||||
|
||||
export type WorkflowContextMenuTarget
|
||||
= | { type: 'panel' }
|
||||
@ -9,6 +8,7 @@ export type WorkflowContextMenuTarget
|
||||
|
||||
export type PanelSliceShape = {
|
||||
panelWidth: number
|
||||
setPanelWidth: (width: number) => void
|
||||
showFeaturesPanel: boolean
|
||||
setShowFeaturesPanel: (showFeaturesPanel: boolean) => void
|
||||
showWorkflowVersionHistoryPanel: boolean
|
||||
@ -34,7 +34,9 @@ export type PanelSliceShape = {
|
||||
}
|
||||
|
||||
export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
|
||||
panelWidth: getLocalStorageNumber('workflow-node-panel-width', 420),
|
||||
panelWidth: 420,
|
||||
setPanelWidth: width => set(state =>
|
||||
state.panelWidth === width ? state : ({ panelWidth: width })),
|
||||
showFeaturesPanel: false,
|
||||
setShowFeaturesPanel: showFeaturesPanel => set(() => ({ showFeaturesPanel })),
|
||||
showWorkflowVersionHistoryPanel: false,
|
||||
|
||||
@ -6,13 +6,12 @@ import type {
|
||||
WorkflowRunningData,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/utils/local-storage'
|
||||
|
||||
type PreviewRunningData = WorkflowRunningData & {
|
||||
resultTabActive?: boolean
|
||||
resultText?: string
|
||||
// human input form schema or data cached when node is in 'Paused' status
|
||||
extraContentAndFormData?: Record<string, any>
|
||||
extraContentAndFormData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type MousePosition = {
|
||||
@ -22,14 +21,6 @@ type MousePosition = {
|
||||
elementY: number
|
||||
}
|
||||
|
||||
const getStoredControlMode = () => {
|
||||
const storedControlMode = getLocalStorageItem('workflow-operation-mode')
|
||||
if (storedControlMode === 'pointer' || storedControlMode === 'hand' || storedControlMode === 'comment')
|
||||
return storedControlMode
|
||||
|
||||
return 'pointer'
|
||||
}
|
||||
|
||||
export type WorkflowSliceShape = {
|
||||
workflowRunningData?: PreviewRunningData
|
||||
setWorkflowRunningData: (workflowData: PreviewRunningData) => void
|
||||
@ -101,11 +92,8 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
|
||||
setSelection: selection => set(() => ({ selection })),
|
||||
bundleNodeSize: null,
|
||||
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
|
||||
controlMode: getStoredControlMode(),
|
||||
setControlMode: (controlMode) => {
|
||||
set(() => ({ controlMode }))
|
||||
setLocalStorageItem('workflow-operation-mode', controlMode)
|
||||
},
|
||||
controlMode: 'pointer',
|
||||
setControlMode: controlMode => set(() => ({ controlMode })),
|
||||
pendingComment: null,
|
||||
setPendingComment: pendingComment => set(() => ({ pendingComment })),
|
||||
isCommentPlacing: false,
|
||||
|
||||
@ -19,6 +19,7 @@ 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,
|
||||
@ -30,7 +31,6 @@ import {
|
||||
useEducationAdd,
|
||||
useInvalidateEducationStatus,
|
||||
} from '@/service/use-education'
|
||||
import { removeLocalStorageItem } from '@/utils/local-storage'
|
||||
import DifyLogo from '../components/base/logo/dify-logo'
|
||||
import AppliedEducationContent from './applied-education-content'
|
||||
import RoleSelector from './role-selector'
|
||||
@ -63,6 +63,7 @@ const EducationApplyAgeContent = () => {
|
||||
const router = useRouter()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
const queryClient = useQueryClient()
|
||||
const setEducationVerifying = useSetLocalStorage<string>(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, { raw: true })
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
@ -84,7 +85,7 @@ const EducationApplyAgeContent = () => {
|
||||
if (res.message === 'success') {
|
||||
onPlanInfoChanged()
|
||||
updateEducationStatus()
|
||||
removeLocalStorageItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
setEducationVerifying(null)
|
||||
setHasSubmittedEducation(true)
|
||||
}
|
||||
else {
|
||||
|
||||
@ -12,9 +12,9 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useLocalStorage } from '@/hooks/use-local-storage'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education'
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/utils/local-storage'
|
||||
import {
|
||||
EDUCATION_RE_VERIFY_ACTION,
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
@ -95,6 +95,7 @@ const useEducationReverifyNotice = ({
|
||||
defaultValue: false,
|
||||
})
|
||||
|
||||
/* eslint-disable react/set-state-in-effect -- this persists education notice acknowledgement after provider metadata changes. */
|
||||
useEffect(() => {
|
||||
if (isLoading || !timezone)
|
||||
return
|
||||
@ -123,6 +124,7 @@ const useEducationReverifyNotice = ({
|
||||
}
|
||||
}
|
||||
}, [allowRefreshEducationVerify, timezone])
|
||||
/* eslint-enable react/set-state-in-effect */
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
@ -134,7 +136,7 @@ const useEducationReverifyNotice = ({
|
||||
export const useEducationInit = () => {
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal)
|
||||
const educationVerifying = getLocalStorageItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
const [educationVerifying, setEducationVerifying] = useLocalStorage<string>(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'no', { raw: true })
|
||||
const searchParams = useSearchParams()
|
||||
const educationVerifyAction = searchParams.get('action')
|
||||
|
||||
@ -157,9 +159,9 @@ export const useEducationInit = () => {
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
|
||||
|
||||
if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
|
||||
setLocalStorageItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
|
||||
setEducationVerifying('yes')
|
||||
}
|
||||
if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION)
|
||||
handleVerify()
|
||||
}, [setShowAccountSettingModal, educationVerifying, educationVerifyAction])
|
||||
}, [setShowAccountSettingModal, setEducationVerifying, educationVerifying, educationVerifyAction])
|
||||
}
|
||||
|
||||
95
web/hooks/use-local-storage/__tests__/index.spec.tsx
Normal file
95
web/hooks/use-local-storage/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
260
web/hooks/use-local-storage/index.ts
Normal file
260
web/hooks/use-local-storage/index.ts
Normal file
@ -0,0 +1,260 @@
|
||||
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 }
|
||||
@ -1,106 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { isClient } from './client'
|
||||
|
||||
const LOCAL_STORAGE_CHANGE_EVENT = 'dify-local-storage-change'
|
||||
|
||||
type LocalStorageChangeDetail = {
|
||||
key: string
|
||||
}
|
||||
|
||||
export const getLocalStorageItem = (key: string, fallback: string | null = null) => {
|
||||
if (!isClient)
|
||||
return fallback
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(key) ?? fallback
|
||||
}
|
||||
catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const setLocalStorageItem = (key: string, value: string) => {
|
||||
if (!isClient)
|
||||
return
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(key, value)
|
||||
window.dispatchEvent(new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
|
||||
detail: { key },
|
||||
}))
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* @public */
|
||||
export const removeLocalStorageItem = (key: string) => {
|
||||
if (!isClient)
|
||||
return
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(key)
|
||||
window.dispatchEvent(new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
|
||||
detail: { key },
|
||||
}))
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export const getLocalStorageBoolean = (key: string, fallback = false) => {
|
||||
const value = getLocalStorageItem(key)
|
||||
if (value === null)
|
||||
return fallback
|
||||
|
||||
return value === 'true'
|
||||
}
|
||||
|
||||
export const getLocalStorageNumber = (key: string, fallback: number) => {
|
||||
const value = getLocalStorageItem(key)
|
||||
if (!value)
|
||||
return fallback
|
||||
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isNaN(parsed) ? fallback : parsed
|
||||
}
|
||||
|
||||
const subscribeLocalStorage = (key: string, onStoreChange: () => void) => {
|
||||
if (!isClient)
|
||||
return () => {}
|
||||
|
||||
const handleChange = (event: Event) => {
|
||||
if (event instanceof StorageEvent && event.key !== key)
|
||||
return
|
||||
if (event instanceof CustomEvent && event.detail?.key !== key)
|
||||
return
|
||||
|
||||
onStoreChange()
|
||||
}
|
||||
|
||||
window.addEventListener('storage', handleChange)
|
||||
window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleChange)
|
||||
window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleChange)
|
||||
}
|
||||
}
|
||||
|
||||
export const useLocalStorageItem = (key: string, fallback: string | null = null) => {
|
||||
return useSyncExternalStore(
|
||||
onStoreChange => subscribeLocalStorage(key, onStoreChange),
|
||||
() => getLocalStorageItem(key, fallback),
|
||||
() => fallback,
|
||||
)
|
||||
}
|
||||
|
||||
export const useLocalStorageBoolean = (key: string, fallback = false) => {
|
||||
const value = useLocalStorageItem(key)
|
||||
if (value === null)
|
||||
return fallback
|
||||
|
||||
return value === 'true'
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user