diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 440a37dd3f..83ef4074b1 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index a26d7057c7..c455cf7ab3 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -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 = (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('workflow-canvas-maximize', false) + const [storedAppSidebarMode] = useLocalStorage('app-detail-collapse-or-expand', 'expand', { raw: true }) const [eventHideHeader, setEventHideHeader] = useState(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 = (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) diff --git a/web/app/components/header/header-wrapper.tsx b/web/app/components/header/header-wrapper.tsx index e22f666c5b..f07f2fb2c0 100644 --- a/web/app/components/header/header-wrapper.tsx +++ b/web/app/components/header/header-wrapper.tsx @@ -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('workflow-canvas-maximize', false) const [eventHideHeader, setEventHideHeader] = useState(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 ( diff --git a/web/app/components/header/maintenance-notice.tsx b/web/app/components/header/maintenance-notice.tsx index 6d0adfbeba..559402aa12 100644 --- a/web/app/components/header/maintenance-notice.tsx +++ b/web/app/components/header/maintenance-notice.tsx @@ -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('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) } diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 5fe099787b..a0eed15fe6 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -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 = 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} > + + + {children} diff --git a/web/app/components/workflow/persistence/__tests__/local-storage-bridge.spec.tsx b/web/app/components/workflow/persistence/__tests__/local-storage-bridge.spec.tsx new file mode 100644 index 0000000000..3f44aeea53 --- /dev/null +++ b/web/app/components/workflow/persistence/__tests__/local-storage-bridge.spec.tsx @@ -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( + + + , + ) + + 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( + + + , + ) + + store.getState().setControlMode(ControlMode.Comment) + + await waitFor(() => { + expect(localStorage.getItem('workflow-operation-mode')).toBe(ControlMode.Comment) + }) + }) +}) diff --git a/web/app/components/workflow/persistence/local-storage-bridge.tsx b/web/app/components/workflow/persistence/local-storage-bridge.tsx new file mode 100644 index 0000000000..07eac23396 --- /dev/null +++ b/web/app/components/workflow/persistence/local-storage-bridge.tsx @@ -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(WORKFLOW_NODE_PANEL_WIDTH_KEY, undefined, numberStorageOptions) + const [storedPreviewPanelWidth] = useLocalStorage(WORKFLOW_PREVIEW_PANEL_WIDTH_KEY, undefined, numberStorageOptions) + const [storedVariableInspectPanelHeight] = useLocalStorage(WORKFLOW_VARIABLE_INSPECT_PANEL_HEIGHT_KEY, undefined, numberStorageOptions) + const [storedControlMode] = useLocalStorage(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(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 +} diff --git a/web/app/components/workflow/persistence/local-storage-options.ts b/web/app/components/workflow/persistence/local-storage-options.ts new file mode 100644 index 0000000000..af953f8287 --- /dev/null +++ b/web/app/components/workflow/persistence/local-storage-options.ts @@ -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) +} diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index abecc4f327..6e1240fae0 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -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', () => { diff --git a/web/app/components/workflow/store/workflow/__tests__/layout-slice.spec.ts b/web/app/components/workflow/store/workflow/__tests__/layout-slice.spec.ts index ea119a6077..0491f798fb 100644 --- a/web/app/components/workflow/store/workflow/__tests__/layout-slice.spec.ts +++ b/web/app/components/workflow/store/workflow/__tests__/layout-slice.spec.ts @@ -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) }) diff --git a/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts b/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts index 0ac780c807..821499ea89 100644 --- a/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts +++ b/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts @@ -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', () => { diff --git a/web/app/components/workflow/store/workflow/__tests__/workflow-slice.spec.ts b/web/app/components/workflow/store/workflow/__tests__/workflow-slice.spec.ts index 1149ea3e44..a9f33a34bd 100644 --- a/web/app/components/workflow/store/workflow/__tests__/workflow-slice.spec.ts +++ b/web/app/components/workflow/store/workflow/__tests__/workflow-slice.spec.ts @@ -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') }) }) diff --git a/web/app/components/workflow/store/workflow/layout-slice.ts b/web/app/components/workflow/store/workflow/layout-slice.ts index fe5a3d1483..fe7604aae3 100644 --- a/web/app/components/workflow/store/workflow/layout-slice.ts +++ b/web/app/components/workflow/store/workflow/layout-slice.ts @@ -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 = 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 = 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 })), }) diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index 9bace47b44..5f4f459397 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -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 = 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, diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts index bbcd178da1..33c60deb16 100644 --- a/web/app/components/workflow/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-slice.ts @@ -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 + extraContentAndFormData?: Record } 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 = 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, diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 7ed43d0a28..4e5aedc68c 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -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(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 { diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index 3d9049bed1..740bcd99eb 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -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(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]) } diff --git a/web/hooks/use-local-storage/__tests__/index.spec.tsx b/web/hooks/use-local-storage/__tests__/index.spec.tsx new file mode 100644 index 0000000000..1819f46f26 --- /dev/null +++ b/web/hooks/use-local-storage/__tests__/index.spec.tsx @@ -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('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
{value}
+ } + + expect(renderToString()).toContain('circle') + }) + + it('should throw a recoverable no-SSR error during server rendering without server value', () => { + const Component = () => { + const [value] = useLocalStorage('shape') + return
{value}
+ } + + expect(() => renderToString()).toThrow('[foxact/use-local-storage] cannot be used on the server without a serverValue') + }) +}) diff --git a/web/hooks/use-local-storage/index.ts b/web/hooks/use-local-storage/index.ts new file mode 100644 index 0000000000..da98e5feb4 --- /dev/null +++ b/web/hooks/use-local-storage/index.ts @@ -0,0 +1,260 @@ +import { useCallback, useEffect, useLayoutEffect as useLayoutEffectFromReact, useMemo, useSyncExternalStore } from 'react' +import { noop } from '../noop' +import 'client-only' + +type NotUndefined = T extends undefined ? never : T +type StateHookTuple = readonly [T, React.Dispatch>] + +type Serializer = (value: T) => string +type Deserializer = (value: string) => T + +export type UseLocalStorageRawOption = { + raw: true +} + +export type UseLocalStorageParserOption = { + raw?: false + serializer: Serializer + deserializer: Deserializer +} + +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(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(value: T): string { + return value as string +} + +function rawDeserializer(value: string): T { + return value as T +} + +function isStorageSetter( + value: React.SetStateAction, +): value is (previousState: T | null) => T | null { + return typeof value === 'function' +} + +const dispatchStorageEvent = typeof window === 'undefined' + ? noop + : (key: string) => { + window.dispatchEvent(new CustomEvent(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( + options: UseLocalStorageRawOption | UseLocalStorageParserOption, +) { + return options.raw + ? { + serializer: rawSerializer, + deserializer: rawDeserializer, + } + : { + serializer: options.serializer, + deserializer: options.deserializer, + } +} + +const defaultStorageOptions = { + raw: false, + serializer: JSON.stringify, + deserializer: JSON.parse, +} satisfies UseLocalStorageParserOption + +/** @see https://foxact.skk.moe/use-local-storage */ +export const useSetLocalStorage = ( + key: string, + options: UseLocalStorageRawOption | UseLocalStorageParserOption = defaultStorageOptions as UseLocalStorageParserOption, +) => { + const { serializer, deserializer } = getStorageOptions(options) + + return useCallback((value: React.SetStateAction) => { + 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( + key: string, + serverValue: NotUndefined, + options?: UseLocalStorageRawOption | UseLocalStorageParserOption, +): T +function useLocalStorageValue( + key: string, + serverValue?: undefined, + options?: UseLocalStorageRawOption | UseLocalStorageParserOption, +): T | null +function useLocalStorageValue( + key: string, + serverValue?: NotUndefined, + options: UseLocalStorageRawOption | UseLocalStorageParserOption = defaultStorageOptions as UseLocalStorageParserOption, +): 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( + key: string, + serverValue: NotUndefined, + options?: UseLocalStorageRawOption | UseLocalStorageParserOption, +): StateHookTuple +function useLocalStorage( + key: string, + serverValue?: undefined, + options?: UseLocalStorageRawOption | UseLocalStorageParserOption, +): StateHookTuple +/** @see https://foxact.skk.moe/use-local-storage */ +function useLocalStorage( + key: string, + serverValue?: NotUndefined, + options: UseLocalStorageRawOption | UseLocalStorageParserOption = defaultStorageOptions as UseLocalStorageParserOption, +): StateHookTuple | StateHookTuple { + const value = useLocalStorageValue(key, serverValue!, options) + const setState = useSetLocalStorage(key, options) + + return [value, setState] as const +} + +export { useLocalStorage } diff --git a/web/utils/local-storage.ts b/web/utils/local-storage.ts deleted file mode 100644 index 961a9bdd0a..0000000000 --- a/web/utils/local-storage.ts +++ /dev/null @@ -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(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(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' -}