refactor(web): add SSR-safe localStorage utility and ESLint rules

Introduce centralized storage utilities to address SSR issues with direct
localStorage access in zustand slices and components. This adds ESLint
rules to prevent future regressions while preserving existing usages
via bulk suppressions.

- Add config/storage-keys.ts for centralized storage key definitions
- Add utils/storage.ts with SSR-safe get/set/remove operations
- Add workflow/store/persist-config.ts for zustand storage adapter
- Add no-restricted-globals and no-restricted-properties ESLint rules
- Migrate workflow slices and related components to use new utilities
This commit is contained in:
yyh 2026-01-18 16:01:04 +08:00
parent 7b66bbc35a
commit e819b804ba
No known key found for this signature in database
15 changed files with 450 additions and 19 deletions

View File

@ -18,6 +18,7 @@ import { useStore } from '@/app/components/app/store'
import { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vender/pipeline'
import Loading from '@/app/components/base/loading'
import ExtraInfo from '@/app/components/datasets/extra-info'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import DatasetDetailContext from '@/context/dataset-detail'
import { useEventEmitterContextContext } from '@/context/event-emitter'
@ -25,6 +26,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
export type IAppDetailLayoutProps = {
children: React.ReactNode
@ -40,7 +42,7 @@ 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 workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const workflowCanvasMaximize = storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false) ?? false
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()

View File

@ -5,9 +5,11 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import Divider from '../base/divider'
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
import AppInfo from './app-info'
@ -53,7 +55,7 @@ const AppDetailNav = ({
const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const workflowCanvasMaximize = storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false) ?? false
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
@ -64,7 +66,7 @@ const AppDetailNav = ({
useEffect(() => {
if (appSidebarExpand) {
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
storage.set(STORAGE_KEYS.APP.DETAIL_COLLAPSE, appSidebarExpand)
setAppSidebarExpand(appSidebarExpand)
}
}, [appSidebarExpand, setAppSidebarExpand])

View File

@ -2,8 +2,10 @@
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import s from './index.module.css'
type HeaderWrapperProps = {
@ -18,7 +20,7 @@ const HeaderWrapper = ({
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const workflowCanvasMaximize = storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false) ?? false
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()

View File

@ -5,7 +5,9 @@ import {
useCallback,
} from 'react'
import { useReactFlow, useStoreApi } from 'reactflow'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { storage } from '@/utils/storage'
import {
CUSTOM_NODE,
NODE_LAYOUT_HORIZONTAL_PADDING,
@ -342,7 +344,7 @@ export const useWorkflowCanvasMaximize = () => {
return
setMaximizeCanvas(!maximizeCanvas)
localStorage.setItem('workflow-canvas-maximize', String(!maximizeCanvas))
storage.set(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, !maximizeCanvas)
eventEmitter?.emit({
type: 'workflow-canvas-maximize',
payload: !maximizeCanvas,

View File

@ -59,12 +59,14 @@ import {
hasRetryNode,
isSupportCustomRunForm,
} from '@/app/components/workflow/utils'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useModalContext } from '@/context/modal-context'
import { useAllBuiltInTools } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { FlowType } from '@/types/common'
import { canFindTool } from '@/utils'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { useResizePanel } from '../../hooks/use-resize-panel'
import BeforeRunForm from '../before-run-form'
import PanelWrap from '../before-run-form/panel-wrap'
@ -137,7 +139,7 @@ const BasePanel: FC<BasePanelProps> = ({
const newValue = Math.max(400, Math.min(width, maxNodePanelWidth))
if (source === 'user')
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
storage.set(STORAGE_KEYS.WORKFLOW.NODE_PANEL_WIDTH, newValue)
setNodePanelWidth(newValue)
}, [maxNodePanelWidth, setNodePanelWidth])

View File

@ -18,7 +18,9 @@ import Tooltip from '@/app/components/base/tooltip'
import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { useStore } from '@/app/components/workflow/store'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import {
useWorkflowInteractions,
} from '../../hooks'
@ -56,7 +58,7 @@ const DebugAndPreview = () => {
const setPanelWidth = useStore(s => s.setPreviewPanelWidth)
const handleResize = useCallback((width: number, source: 'user' | 'system' = 'user') => {
if (source === 'user')
localStorage.setItem('debug-and-preview-panel-width', `${width}`)
storage.set(STORAGE_KEYS.WORKFLOW.PREVIEW_PANEL_WIDTH, width)
setPanelWidth(width)
}, [setPanelWidth])
const maxPanelWidth = useMemo(() => {

View File

@ -1,11 +1,12 @@
import type { StateCreator } from 'zustand'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
export type LayoutSliceShape = {
workflowCanvasWidth?: number
workflowCanvasHeight?: number
setWorkflowCanvasWidth: (width: number) => void
setWorkflowCanvasHeight: (height: number) => void
// rightPanelWidth - otherPanelWidth = nodePanelWidth
rightPanelWidth?: number
setRightPanelWidth: (width: number) => void
nodePanelWidth: number
@ -14,11 +15,11 @@ export type LayoutSliceShape = {
setPreviewPanelWidth: (width: number) => void
otherPanelWidth: number
setOtherPanelWidth: (width: number) => void
bottomPanelWidth: number // min-width = 400px; default-width = auto || 480px;
bottomPanelWidth: number
setBottomPanelWidth: (width: number) => void
bottomPanelHeight: number
setBottomPanelHeight: (height: number) => void
variableInspectPanelHeight: number // min-height = 120px; default-height = 320px;
variableInspectPanelHeight: number
setVariableInspectPanelHeight: (height: number) => void
maximizeCanvas: boolean
setMaximizeCanvas: (maximize: boolean) => void
@ -31,9 +32,9 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
setWorkflowCanvasHeight: height => set(() => ({ workflowCanvasHeight: height })),
rightPanelWidth: undefined,
setRightPanelWidth: width => set(() => ({ rightPanelWidth: width })),
nodePanelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 400,
nodePanelWidth: storage.getNumber(STORAGE_KEYS.WORKFLOW.NODE_PANEL_WIDTH, 400)!,
setNodePanelWidth: width => set(() => ({ nodePanelWidth: width })),
previewPanelWidth: localStorage.getItem('debug-and-preview-panel-width') ? Number.parseFloat(localStorage.getItem('debug-and-preview-panel-width')!) : 400,
previewPanelWidth: storage.getNumber(STORAGE_KEYS.WORKFLOW.PREVIEW_PANEL_WIDTH, 400)!,
setPreviewPanelWidth: width => set(() => ({ previewPanelWidth: width })),
otherPanelWidth: 400,
setOtherPanelWidth: width => set(() => ({ otherPanelWidth: width })),
@ -41,8 +42,8 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
setBottomPanelWidth: width => set(() => ({ bottomPanelWidth: width })),
bottomPanelHeight: 324,
setBottomPanelHeight: height => set(() => ({ bottomPanelHeight: height })),
variableInspectPanelHeight: localStorage.getItem('workflow-variable-inpsect-panel-height') ? Number.parseFloat(localStorage.getItem('workflow-variable-inpsect-panel-height')!) : 320,
variableInspectPanelHeight: storage.getNumber(STORAGE_KEYS.WORKFLOW.VARIABLE_INSPECT_PANEL_HEIGHT, 320)!,
setVariableInspectPanelHeight: height => set(() => ({ variableInspectPanelHeight: height })),
maximizeCanvas: localStorage.getItem('workflow-canvas-maximize') === 'true',
maximizeCanvas: storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false)!,
setMaximizeCanvas: maximize => set(() => ({ maximizeCanvas: maximize })),
})

View File

@ -1,4 +1,6 @@
import type { StateCreator } from 'zustand'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
export type PanelSliceShape = {
panelWidth: number
@ -27,7 +29,7 @@ export type PanelSliceShape = {
}
export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
panelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 420,
panelWidth: storage.getNumber(STORAGE_KEYS.WORKFLOW.NODE_PANEL_WIDTH, 420) ?? 420,
showFeaturesPanel: false,
setShowFeaturesPanel: showFeaturesPanel => set(() => ({ showFeaturesPanel })),
showWorkflowVersionHistoryPanel: false,

View File

@ -0,0 +1,8 @@
import type { StateStorage } from 'zustand/middleware'
import { storage } from '@/utils/storage'
export const createZustandStorage = (): StateStorage => ({
getItem: (name: string) => storage.get<string>(name),
setItem: storage.set,
removeItem: storage.remove,
})

View File

@ -5,6 +5,8 @@ import type {
WorkflowRunningData,
} from '@/app/components/workflow/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
type PreviewRunningData = WorkflowRunningData & {
resultTabActive?: boolean
@ -63,10 +65,10 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
setSelection: selection => set(() => ({ selection })),
bundleNodeSize: null,
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
controlMode: localStorage.getItem('workflow-operation-mode') === 'pointer' ? 'pointer' : 'hand',
controlMode: storage.get<'pointer' | 'hand'>(STORAGE_KEYS.WORKFLOW.OPERATION_MODE) === 'pointer' ? 'pointer' : 'hand',
setControlMode: (controlMode) => {
set(() => ({ controlMode }))
localStorage.setItem('workflow-operation-mode', controlMode)
storage.set(STORAGE_KEYS.WORKFLOW.OPERATION_MODE, controlMode)
},
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
setMousePosition: mousePosition => set(() => ({ mousePosition })),

View File

@ -1,5 +1,6 @@
import type { ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { InputVarType } from '@/app/components/workflow/types'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { PromptRole } from '@/models/debug'
import { PipelineInputVarType } from '@/models/pipeline'
import { AgentStrategy } from '@/types/app'
@ -179,7 +180,7 @@ export const CSRF_COOKIE_NAME = () => {
return isSecure ? '__Host-csrf_token' : 'csrf_token'
}
export const CSRF_HEADER_NAME = 'X-CSRF-Token'
export const ACCESS_TOKEN_LOCAL_STORAGE_NAME = 'access_token'
export const ACCESS_TOKEN_LOCAL_STORAGE_NAME = STORAGE_KEYS.AUTH.ACCESS_TOKEN
export const PASSPORT_LOCAL_STORAGE_NAME = (appCode: string) => `passport-${appCode}`
export const PASSPORT_HEADER_NAME = 'X-App-Passport'
@ -229,7 +230,7 @@ export const VAR_ITEM_TEMPLATE_IN_PIPELINE = {
export const appDefaultIconBackground = '#D5F5F6'
export const NEED_REFRESH_APP_LIST_KEY = 'needRefreshAppList'
export const NEED_REFRESH_APP_LIST_KEY = STORAGE_KEYS.APP.NEED_REFRESH_LIST
export const DATASET_DEFAULT = {
top_k: 4,

View File

@ -0,0 +1,35 @@
export const STORAGE_KEYS = {
WORKFLOW: {
NODE_PANEL_WIDTH: 'workflow-node-panel-width',
PREVIEW_PANEL_WIDTH: 'debug-and-preview-panel-width',
VARIABLE_INSPECT_PANEL_HEIGHT: 'workflow-variable-inspect-panel-height',
CANVAS_MAXIMIZE: 'workflow-canvas-maximize',
OPERATION_MODE: 'workflow-operation-mode',
},
APP: {
SIDEBAR_COLLAPSE: 'webappSidebarCollapse',
NEED_REFRESH_LIST: 'needRefreshAppList',
DETAIL_COLLAPSE: 'app-detail-collapse-or-expand',
},
CONVERSATION: {
ID_INFO: 'conversationIdInfo',
},
AUTH: {
ACCESS_TOKEN: 'access_token',
REFRESH_LOCK: 'is_other_tab_refreshing',
LAST_REFRESH_TIME: 'last_refresh_time',
},
EDUCATION: {
VERIFYING: 'educationVerifying',
REVERIFY_PREV_EXPIRE_AT: 'education-reverify-prev-expire-at',
REVERIFY_HAS_NOTICED: 'education-reverify-has-noticed',
EXPIRED_HAS_NOTICED: 'education-expired-has-noticed',
},
CONFIG: {
AUTO_GEN_MODEL: 'auto-gen-model',
DEBUG_MODELS: 'app-debug-with-single-or-multiple-models',
SETUP_STATUS: 'setup_status',
},
} as const
export type StorageKeys = typeof STORAGE_KEYS

View File

@ -56,6 +56,9 @@
"no-console": {
"count": 16
},
"no-restricted-properties": {
"count": 5
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
@ -74,6 +77,9 @@
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx": {
"no-restricted-globals": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
@ -81,6 +87,11 @@
"count": 1
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 1
@ -103,6 +114,9 @@
}
},
"app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -117,12 +131,25 @@
"count": 1
}
},
"app/(shareLayout)/webapp-reset-password/page.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/account/(commonLayout)/account-page/email-change-modal.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
}
@ -132,6 +159,11 @@
"count": 1
}
},
"app/account/(commonLayout)/avatar.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/account/(commonLayout)/delete-account/components/feed-back.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 1
@ -142,17 +174,33 @@
"count": 1
}
},
"app/account/(commonLayout)/delete-account/index.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/account/oauth/authorize/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/account/oauth/authorize/page.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/app-initializer.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/components/app-sidebar/app-info.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -353,6 +401,9 @@
}
},
"app/components/app/configuration/config/automatic/get-automatic-res.tsx": {
"no-restricted-globals": {
"count": 6
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
@ -381,6 +432,9 @@
}
},
"app/components/app/configuration/config/code-generator/get-code-generator-res.tsx": {
"no-restricted-globals": {
"count": 6
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
@ -485,6 +539,9 @@
}
},
"app/components/app/configuration/debug/hooks.tsx": {
"no-restricted-globals": {
"count": 2
},
"react-hooks/refs": {
"count": 7
},
@ -541,12 +598,23 @@
"count": 1
}
},
"app/components/app/create-app-dialog/app-list/index.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/components/app/create-app-modal/index.spec.tsx": {
"no-restricted-properties": {
"count": 1
},
"ts/no-explicit-any": {
"count": 7
}
},
"app/components/app/create-app-modal/index.tsx": {
"no-restricted-globals": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
@ -555,6 +623,9 @@
}
},
"app/components/app/create-from-dsl-modal/index.tsx": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
}
@ -620,11 +691,17 @@
}
},
"app/components/app/switch-app-modal/index.spec.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/app/switch-app-modal/index.tsx": {
"no-restricted-globals": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
@ -663,6 +740,9 @@
}
},
"app/components/apps/app-card.tsx": {
"no-restricted-globals": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
@ -674,11 +754,17 @@
}
},
"app/components/apps/list.spec.tsx": {
"no-restricted-globals": {
"count": 3
},
"ts/no-explicit-any": {
"count": 9
}
},
"app/components/apps/list.tsx": {
"no-restricted-globals": {
"count": 2
},
"unused-imports/no-unused-vars": {
"count": 1
}
@ -806,7 +892,15 @@
"count": 2
}
},
"app/components/base/chat/chat-with-history/hooks.spec.tsx": {
"no-restricted-globals": {
"count": 4
}
},
"app/components/base/chat/chat-with-history/hooks.tsx": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
@ -907,6 +1001,11 @@
"count": 7
}
},
"app/components/base/chat/embedded-chatbot/hooks.spec.tsx": {
"no-restricted-globals": {
"count": 3
}
},
"app/components/base/chat/embedded-chatbot/hooks.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 6
@ -1671,7 +1770,15 @@
"count": 4
}
},
"app/components/billing/plan/index.spec.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/components/billing/plan/index.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -1701,6 +1808,11 @@
"count": 1
}
},
"app/components/browser-initializer.tsx": {
"no-restricted-properties": {
"count": 2
}
},
"app/components/custom/custom-web-app-brand/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 7
@ -2112,6 +2224,9 @@
}
},
"app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
@ -2136,6 +2251,11 @@
"count": 1
}
},
"app/components/datasets/metadata/metadata-document/info-group.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/components/datasets/settings/form/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@ -2244,6 +2364,11 @@
"count": 9
}
},
"app/components/header/account-dropdown/index.tsx": {
"no-restricted-globals": {
"count": 4
}
},
"app/components/header/account-setting/data-source-page-new/card.tsx": {
"react-hooks/immutability": {
"count": 1
@ -2459,6 +2584,11 @@
"count": 1
}
},
"app/components/header/maintenance-notice.tsx": {
"no-restricted-globals": {
"count": 2
}
},
"app/components/header/nav/nav-selector/index.tsx": {
"react-hooks/use-memo": {
"count": 1
@ -2999,6 +3129,11 @@
"count": 2
}
},
"app/components/signin/countdown.tsx": {
"no-restricted-globals": {
"count": 4
}
},
"app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3198,6 +3333,9 @@
}
},
"app/components/workflow/block-selector/featured-tools.tsx": {
"no-restricted-properties": {
"count": 3
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
@ -3206,6 +3344,9 @@
}
},
"app/components/workflow/block-selector/featured-triggers.tsx": {
"no-restricted-properties": {
"count": 3
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
@ -3224,6 +3365,9 @@
}
},
"app/components/workflow/block-selector/rag-tool-recommendations/index.tsx": {
"no-restricted-properties": {
"count": 3
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
@ -3540,6 +3684,11 @@
"count": 2
}
},
"app/components/workflow/nodes/_base/components/workflow-panel/index.spec.tsx": {
"no-restricted-globals": {
"count": 18
}
},
"app/components/workflow/nodes/_base/components/workflow-panel/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
@ -3938,6 +4087,9 @@
}
},
"app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": {
"no-restricted-globals": {
"count": 6
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
}
@ -4359,6 +4511,11 @@
"count": 7
}
},
"app/components/workflow/panel/debug-and-preview/index.spec.tsx": {
"no-restricted-globals": {
"count": 15
}
},
"app/components/workflow/panel/env-panel/variable-modal.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
@ -4627,7 +4784,15 @@
"count": 1
}
},
"app/education-apply/education-apply-page.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/education-apply/hooks.ts": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 5
}
@ -4637,6 +4802,11 @@
"count": 1
}
},
"app/education-apply/user-info.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/education-apply/verify-state-modal.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -4653,15 +4823,33 @@
}
},
"app/install/installForm.spec.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 7
}
},
"app/install/installForm.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/reset-password/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/reset-password/page.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/signin/components/mail-and-code-auth.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/signin/components/mail-and-password-auth.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4682,6 +4870,11 @@
"count": 1
}
},
"app/signin/utils/post-login-redirect.ts": {
"no-restricted-globals": {
"count": 3
}
},
"app/signup/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4698,21 +4891,36 @@
}
},
"context/hooks/use-trigger-events-limit-modal.ts": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
}
},
"context/modal-context.test.tsx": {
"no-restricted-globals": {
"count": 4
},
"no-restricted-properties": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"context/modal-context.tsx": {
"no-restricted-globals": {
"count": 2
},
"ts/no-explicit-any": {
"count": 5
}
},
"context/provider-context.tsx": {
"no-restricted-globals": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@ -4730,6 +4938,11 @@
"count": 1
}
},
"hooks/use-import-dsl.ts": {
"no-restricted-globals": {
"count": 2
}
},
"hooks/use-metadata.ts": {
"ts/no-explicit-any": {
"count": 1
@ -4914,6 +5127,11 @@
"count": 2
}
},
"service/refresh-token.ts": {
"no-restricted-properties": {
"count": 7
}
},
"service/share.ts": {
"ts/no-explicit-any": {
"count": 4
@ -4978,6 +5196,11 @@
"count": 2
}
},
"service/webapp-auth.ts": {
"no-restricted-globals": {
"count": 6
}
},
"service/workflow-payload.ts": {
"ts/no-explicit-any": {
"count": 10
@ -5089,6 +5312,16 @@
"count": 4
}
},
"utils/setup-status.spec.ts": {
"no-restricted-globals": {
"count": 11
}
},
"utils/setup-status.ts": {
"no-restricted-globals": {
"count": 3
}
},
"utils/tool-call.spec.ts": {
"ts/no-explicit-any": {
"count": 1

View File

@ -42,6 +42,40 @@ export default antfu(
{
rules: {
'node/prefer-global/process': 'off',
'no-restricted-globals': [
'error',
{
name: 'localStorage',
message: 'Use @/utils/storage instead. Direct localStorage access causes SSR issues.',
},
{
name: 'sessionStorage',
message: 'Use @/utils/storage instead. Direct sessionStorage access causes SSR issues.',
},
],
'no-restricted-properties': [
'error',
{
object: 'window',
property: 'localStorage',
message: 'Use @/utils/storage instead.',
},
{
object: 'window',
property: 'sessionStorage',
message: 'Use @/utils/storage instead.',
},
{
object: 'globalThis',
property: 'localStorage',
message: 'Use @/utils/storage instead.',
},
{
object: 'globalThis',
property: 'sessionStorage',
message: 'Use @/utils/storage instead.',
},
],
},
},
{

103
web/utils/storage.ts Normal file
View File

@ -0,0 +1,103 @@
/* eslint-disable no-restricted-globals */
import { isClient } from './client'
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
let _isAvailable: boolean | null = null
function isLocalStorageAvailable(): boolean {
if (_isAvailable !== null)
return _isAvailable
if (!isClient) {
_isAvailable = false
return false
}
try {
const testKey = '__storage_test__'
localStorage.setItem(testKey, 'test')
localStorage.removeItem(testKey)
_isAvailable = true
return true
}
catch {
_isAvailable = false
return false
}
}
function get<T extends JsonValue>(key: string, defaultValue?: T): T | null {
if (!isLocalStorageAvailable())
return defaultValue ?? null
try {
const item = localStorage.getItem(key)
if (item === null)
return defaultValue ?? null
try {
return JSON.parse(item) as T
}
catch {
return item as T
}
}
catch {
return defaultValue ?? null
}
}
function set<T extends JsonValue>(key: string, value: T): void {
if (!isLocalStorageAvailable())
return
try {
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
localStorage.setItem(key, stringValue)
}
catch {
// Silent fail - localStorage may be full or disabled
}
}
function remove(key: string): void {
if (!isLocalStorageAvailable())
return
try {
localStorage.removeItem(key)
}
catch {
// Silent fail
}
}
function getNumber(key: string, defaultValue?: number): number | null {
const value = get<string | number>(key)
if (value === null)
return defaultValue ?? null
const parsed = typeof value === 'number' ? value : Number.parseFloat(value as string)
return Number.isNaN(parsed) ? (defaultValue ?? null) : parsed
}
function getBoolean(key: string, defaultValue?: boolean): boolean | null {
const value = get<string | boolean>(key)
if (value === null)
return defaultValue ?? null
if (typeof value === 'boolean')
return value
return value === 'true'
}
export const storage = {
get,
set,
remove,
getNumber,
getBoolean,
isAvailable: isLocalStorageAvailable,
}