From e819b804ba9a38d667adb8b443d06f9b05f0d891 Mon Sep 17 00:00:00 2001 From: yyh Date: Sun, 18 Jan 2026 16:01:04 +0800 Subject: [PATCH] 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 --- .../[datasetId]/layout-main.tsx | 4 +- web/app/components/app-sidebar/index.tsx | 6 +- web/app/components/header/header-wrapper.tsx | 4 +- .../hooks/use-workflow-interactions.ts | 4 +- .../_base/components/workflow-panel/index.tsx | 4 +- .../panel/debug-and-preview/index.tsx | 4 +- .../workflow/store/workflow/layout-slice.ts | 15 +- .../workflow/store/workflow/panel-slice.ts | 4 +- .../workflow/store/workflow/persist-config.ts | 8 + .../workflow/store/workflow/workflow-slice.ts | 6 +- web/config/index.ts | 5 +- web/config/storage-keys.ts | 35 +++ web/eslint-suppressions.json | 233 ++++++++++++++++++ web/eslint.config.mjs | 34 +++ web/utils/storage.ts | 103 ++++++++ 15 files changed, 450 insertions(+), 19 deletions(-) create mode 100644 web/app/components/workflow/store/workflow/persist-config.ts create mode 100644 web/config/storage-keys.ts create mode 100644 web/utils/storage.ts diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 1c5434924f..f2a10573f2 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -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 = (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() diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index afc6bd0f13..48a0cabe78 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -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]) diff --git a/web/app/components/header/header-wrapper.tsx b/web/app/components/header/header-wrapper.tsx index 1b81c1152c..96616392cf 100644 --- a/web/app/components/header/header-wrapper.tsx +++ b/web/app/components/header/header-wrapper.tsx @@ -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() diff --git a/web/app/components/workflow/hooks/use-workflow-interactions.ts b/web/app/components/workflow/hooks/use-workflow-interactions.ts index 7a58581a99..05e4824cc9 100644 --- a/web/app/components/workflow/hooks/use-workflow-interactions.ts +++ b/web/app/components/workflow/hooks/use-workflow-interactions.ts @@ -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, diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index c834f29ab3..5c9b26abe2 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -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 = ({ 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]) diff --git a/web/app/components/workflow/panel/debug-and-preview/index.tsx b/web/app/components/workflow/panel/debug-and-preview/index.tsx index bc5116bc65..8a0f443fee 100644 --- a/web/app/components/workflow/panel/debug-and-preview/index.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/index.tsx @@ -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(() => { diff --git a/web/app/components/workflow/store/workflow/layout-slice.ts b/web/app/components/workflow/store/workflow/layout-slice.ts index 91c80ba84a..3380c6cf27 100644 --- a/web/app/components/workflow/store/workflow/layout-slice.ts +++ b/web/app/components/workflow/store/workflow/layout-slice.ts @@ -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 = 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 = 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 })), }) diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index 4848beeac5..83db26e490 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -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 = 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, diff --git a/web/app/components/workflow/store/workflow/persist-config.ts b/web/app/components/workflow/store/workflow/persist-config.ts new file mode 100644 index 0000000000..b560f2b832 --- /dev/null +++ b/web/app/components/workflow/store/workflow/persist-config.ts @@ -0,0 +1,8 @@ +import type { StateStorage } from 'zustand/middleware' +import { storage } from '@/utils/storage' + +export const createZustandStorage = (): StateStorage => ({ + getItem: (name: string) => storage.get(name), + setItem: storage.set, + removeItem: storage.remove, +}) diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts index df24058975..62b6310af3 100644 --- a/web/app/components/workflow/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-slice.ts @@ -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 = 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 })), diff --git a/web/config/index.ts b/web/config/index.ts index 08ce14b264..1483f684be 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -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, diff --git a/web/config/storage-keys.ts b/web/config/storage-keys.ts new file mode 100644 index 0000000000..c203cb4057 --- /dev/null +++ b/web/config/storage-keys.ts @@ -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 diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index b043a2d951..019090b508 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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 diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 05c7502612..70da1d166f 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -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.', + }, + ], }, }, { diff --git a/web/utils/storage.ts b/web/utils/storage.ts new file mode 100644 index 0000000000..aef1ce69aa --- /dev/null +++ b/web/utils/storage.ts @@ -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(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(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(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(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, +}