refactor(web): migrate local storage access to react hook (#36888)

This commit is contained in:
yyh 2026-06-01 15:57:54 +08:00 committed by GitHub
parent becccbf288
commit 21711bebeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 584 additions and 208 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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', () => {

View File

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

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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')
})
})

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

View File

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