From b206e691ca68262dfd9417d66022f83298dd900d Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:33:11 +0800 Subject: [PATCH] fix(web): keep shared ref updates out of render (#40427) --- oxlint-suppressions.json | 3 -- .../debug/__tests__/hooks.spec.tsx | 40 +++++++++++++++++++ .../app/configuration/debug/hooks.tsx | 37 ++++++----------- .../app/configuration/debug/storage.ts | 25 ++++++++++++ .../inputs-form/__tests__/content.spec.tsx | 2 +- web/app/components/base/chat/chat/hooks.ts | 4 +- web/app/components/base/features/context.tsx | 8 ++-- .../components/base/file-uploader/store.tsx | 8 ++-- .../base/markdown-blocks/code-block.tsx | 5 +-- .../base/markdown-blocks/use-elapsed-timer.ts | 7 +++- .../plugins/agent-output-block/component.tsx | 5 ++- .../hitl-input-block-replacement-block.tsx | 19 ++++++++- .../datasets/common/image-uploader/store.tsx | 8 ++-- .../local-file/hooks/use-local-file-upload.ts | 8 +--- .../data-source/store/provider.tsx | 6 +-- .../embedding/hooks/use-embedding-status.ts | 5 ++- .../use-activate-credential.ts | 9 +++-- 17 files changed, 133 insertions(+), 66 deletions(-) create mode 100644 web/app/components/app/configuration/debug/storage.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 308d9a534d3..f32ad9c478b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -468,9 +468,6 @@ } }, "web/app/components/app/configuration/debug/hooks.tsx": { - "no-restricted-globals": { - "count": 2 - }, "typescript/no-explicit-any": { "count": 3 } diff --git a/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx b/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx index 3d71af1cafd..f0456737ae7 100644 --- a/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx @@ -70,6 +70,46 @@ describe('configuration debug hooks', () => { }) }) + it('should restore persisted multiple-model debug settings', () => { + localStorage.setItem( + 'app-debug-with-single-or-multiple-models', + JSON.stringify({ + 'app-1': { + multiple: true, + configs: [ + { + id: 'model-1', + model: 'gpt-4o', + provider: 'langgenius/openai/openai', + parameters: { temperature: 0.7 }, + }, + ], + }, + }), + ) + + const { result } = renderHook(() => useDebugWithSingleOrMultipleModel('app-1')) + + expect(result.current.debugWithMultipleModel).toBe(true) + expect(result.current.multipleModelConfigs).toEqual([ + { + id: 'model-1', + model: 'gpt-4o', + provider: 'langgenius/openai/openai', + parameters: { temperature: 0.7 }, + }, + ]) + }) + + it.each(['{', 'null'])('should fall back to default settings for stored value %s', (value) => { + localStorage.setItem('app-debug-with-single-or-multiple-models', value) + + const { result } = renderHook(() => useDebugWithSingleOrMultipleModel('app-1')) + + expect(result.current.debugWithMultipleModel).toBe(false) + expect(result.current.multipleModelConfigs).toEqual([]) + }) + it('should derive chat config data from the debug context', () => { mockUseDebugConfigurationContext.mockReturnValue({ isAdvancedMode: false, diff --git a/web/app/components/app/configuration/debug/hooks.tsx b/web/app/components/app/configuration/debug/hooks.tsx index 860c548f06f..41a6e53ae9d 100644 --- a/web/app/components/app/configuration/debug/hooks.tsx +++ b/web/app/components/app/configuration/debug/hooks.tsx @@ -1,37 +1,26 @@ -import type { DebugWithSingleOrMultipleModelConfigs, ModelAndParameter } from './types' +import type { ModelAndParameter } from './types' import type { ChatConfig, ChatItem } from '@/app/components/base/chat/types' import { cloneDeep } from 'es-toolkit/object' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useState } from 'react' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import { useDebugConfigurationContext } from '@/context/debug-configuration' import { useEventEmitterContextContext } from '@/context/event-emitter' import { AgentStrategy } from '@/types/app' import { promptVariablesToUserInputsForm } from '@/utils/model-config' +import { useDebugModelConfigsStorage } from './storage' import { ORCHESTRATE_CHANGED } from './types' export const useDebugWithSingleOrMultipleModel = (appId: string) => { - const localeDebugWithSingleOrMultipleModelConfigs = localStorage.getItem( - 'app-debug-with-single-or-multiple-models', - ) - - const debugWithSingleOrMultipleModelConfigs = useRef({}) - - if (localeDebugWithSingleOrMultipleModelConfigs) { - try { - debugWithSingleOrMultipleModelConfigs.current = - JSON.parse(localeDebugWithSingleOrMultipleModelConfigs) || {} - } catch (e) { - console.error(e) - } - } + const [debugWithSingleOrMultipleModelConfigs, setDebugWithSingleOrMultipleModelConfigs] = + useDebugModelConfigsStorage() const [debugWithMultipleModel, setDebugWithMultipleModel] = useState( - debugWithSingleOrMultipleModelConfigs.current[appId]?.multiple || false, + debugWithSingleOrMultipleModelConfigs[appId]?.multiple || false, ) const [multipleModelConfigs, setMultipleModelConfigs] = useState( - debugWithSingleOrMultipleModelConfigs.current[appId]?.configs || [], + debugWithSingleOrMultipleModelConfigs[appId]?.configs || [], ) const handleMultipleModelConfigsChange = useCallback( @@ -40,15 +29,15 @@ export const useDebugWithSingleOrMultipleModel = (appId: string) => { multiple, configs: modelConfigs, } - debugWithSingleOrMultipleModelConfigs.current[appId] = value - localStorage.setItem( - 'app-debug-with-single-or-multiple-models', - JSON.stringify(debugWithSingleOrMultipleModelConfigs.current), - ) + const nextConfigs = { + ...debugWithSingleOrMultipleModelConfigs, + [appId]: value, + } + setDebugWithSingleOrMultipleModelConfigs(nextConfigs) setDebugWithMultipleModel(value.multiple) setMultipleModelConfigs(value.configs) }, - [appId], + [appId, debugWithSingleOrMultipleModelConfigs, setDebugWithSingleOrMultipleModelConfigs], ) return { diff --git a/web/app/components/app/configuration/debug/storage.ts b/web/app/components/app/configuration/debug/storage.ts new file mode 100644 index 00000000000..e6dcb81e0e7 --- /dev/null +++ b/web/app/components/app/configuration/debug/storage.ts @@ -0,0 +1,25 @@ +import type { DebugWithSingleOrMultipleModelConfigs } from './types' +import { createLocalStorageState } from 'foxact/create-local-storage-state' + +const deserializeDebugModelConfigs = (value: string): DebugWithSingleOrMultipleModelConfigs => { + try { + const parsed: unknown = JSON.parse(value) + return parsed && typeof parsed === 'object' + ? (parsed as DebugWithSingleOrMultipleModelConfigs) + : {} + } catch { + return {} + } +} + +const [useDebugModelConfigsStorage] = + createLocalStorageState( + 'app-debug-with-single-or-multiple-models', + {}, + { + serializer: (value) => JSON.stringify(value), + deserializer: deserializeDebugModelConfigs, + }, + ) + +export { useDebugModelConfigsStorage } diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx index be9a85ec841..5d13bc08b0c 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx @@ -155,7 +155,6 @@ const MockContextProvider = ({ const [newInputs, setNewInputs] = React.useState(value.newConversationInputs) const newInputsRef = React.useRef(newInputs) - newInputsRef.current = newInputs const contextValue: ChatWithHistoryContextValue = { ...value, @@ -167,6 +166,7 @@ const MockContextProvider = ({ value.setCurrentConversationInputs(v) }, handleNewConversationInputsChange: (v: Record) => { + newInputsRef.current = v setNewInputs(v) value.handleNewConversationInputsChange(v) }, diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 6809b58bbf0..36748b2b0e1 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -462,7 +462,9 @@ export const useChat = ( }, [bindWorkflowEventsReadyWaiters, markWorkflowEventsPending, resolveWorkflowEventsReadyWaiters], ) - startWorkflowEventsSubscriptionRef.current = startWorkflowEventsSubscription + useEffect(() => { + startWorkflowEventsSubscriptionRef.current = startWorkflowEventsSubscription + }, [startWorkflowEventsSubscription]) const ensureWorkflowEventsSubscription = useCallback( (workflowRunId: string, options: IOtherOptions) => { diff --git a/web/app/components/base/features/context.tsx b/web/app/components/base/features/context.tsx index 2454c015d3b..e9d9e2598b9 100644 --- a/web/app/components/base/features/context.tsx +++ b/web/app/components/base/features/context.tsx @@ -1,5 +1,5 @@ import type { FeaturesState, FeaturesStore } from './store' -import { createContext, useRef } from 'react' +import { createContext, useState } from 'react' import { createFeaturesStore } from './store' export const FeaturesContext = createContext(null) @@ -8,9 +8,7 @@ type FeaturesProviderProps = { children: React.ReactNode } & Partial export const FeaturesProvider = ({ children, ...props }: FeaturesProviderProps) => { - const storeRef = useRef(undefined) + const [store] = useState(() => createFeaturesStore(props)) - if (!storeRef.current) storeRef.current = createFeaturesStore(props) - - return {children} + return {children} } diff --git a/web/app/components/base/file-uploader/store.tsx b/web/app/components/base/file-uploader/store.tsx index 0fd7ee38e68..51355abd770 100644 --- a/web/app/components/base/file-uploader/store.tsx +++ b/web/app/components/base/file-uploader/store.tsx @@ -1,5 +1,5 @@ import type { FileEntity } from './types' -import { createContext, use, useRef } from 'react' +import { createContext, use, useState } from 'react' import { create, useStore as useZustandStore } from 'zustand' type Shape = { @@ -40,9 +40,7 @@ type FileProviderProps = { onChange?: (files: FileEntity[]) => void } export const FileContextProvider = ({ children, value, onChange }: FileProviderProps) => { - const storeRef = useRef(undefined) + const [store] = useState(() => createFileStore(value, onChange)) - if (!storeRef.current) storeRef.current = createFileStore(value, onChange) - - return {children} + return {children} } diff --git a/web/app/components/base/markdown-blocks/code-block.tsx b/web/app/components/base/markdown-blocks/code-block.tsx index 0ec68ce96a0..17e6773fb3e 100644 --- a/web/app/components/base/markdown-blocks/code-block.tsx +++ b/web/app/components/base/markdown-blocks/code-block.tsx @@ -296,6 +296,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any const parsed = JSON.parse(trimmedContent) if (typeof parsed === 'object' && parsed !== null) { setFinalChartOption(parsed) + finishedEventCountRef.current = 0 setChartState('success') processedRef.current = true return @@ -338,6 +339,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any } if (isValidOption) { + finishedEventCountRef.current = 0 setChartState('success') processedRef.current = true } @@ -424,9 +426,6 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any // Success state: show the chart if (chartState === 'success' && finalChartOption) { - // Reset finished event counter - finishedEventCountRef.current = 0 - return (
{ const [elapsedTime, setElapsedTime] = useState(0) // Latch completion so a transient flip back to "not complete" never restarts the timer. const completedRef = useRef(complete) - if (complete) completedRef.current = true - const isComplete = completedRef.current + const isComplete = complete || completedRef.current const timerRef = useRef(null) + useEffect(() => { + if (complete) completedRef.current = true + }, [complete]) + useEffect(() => { if (isComplete) return diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx index ba4e34ca0e0..5e5deb8b0ed 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx @@ -78,6 +78,10 @@ const AgentOutputBlockComponent = ({ const latestDraftNameRef = useRef(name) const skipNameFocusRef = useRef(false) + useEffect(() => { + latestDraftNameRef.current = name + }, [name]) + useEffect(() => { if (!isEditing) return if (skipNameFocusRef.current) { @@ -96,7 +100,6 @@ const AgentOutputBlockComponent = ({ if (name !== lastNodeName) { setLastNodeName(name) setDraftName(name) - latestDraftNameRef.current = name } const commitOutput = ( diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx index c199320cf72..c48e477513d 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx @@ -55,7 +55,22 @@ const HITLInputReplacementBlock = ({ ragVariables, readonly, }) - latestConfigRef.current = { + + useEffect(() => { + latestConfigRef.current = { + nodeId, + formInputs, + onFormInputsChange, + onFormInputItemRename, + onFormInputItemRemove, + workflowNodesMap, + getVarType, + environmentVariables, + conversationVariables, + ragVariables, + readonly, + } + }, [ nodeId, formInputs, onFormInputsChange, @@ -67,7 +82,7 @@ const HITLInputReplacementBlock = ({ conversationVariables, ragVariables, readonly, - } + ]) useEffect(() => { if (!editor.hasNodes([HITLInputNode])) diff --git a/web/app/components/datasets/common/image-uploader/store.tsx b/web/app/components/datasets/common/image-uploader/store.tsx index b4de86d4ebc..5839324415e 100644 --- a/web/app/components/datasets/common/image-uploader/store.tsx +++ b/web/app/components/datasets/common/image-uploader/store.tsx @@ -1,5 +1,5 @@ import type { FileEntity } from './types' -import { createContext, use, useRef } from 'react' +import { createContext, use, useState } from 'react' import { create, useStore } from 'zustand' type Shape = { @@ -40,9 +40,7 @@ type FileProviderProps = { onChange?: (files: FileEntity[]) => void } export const FileContextProvider = ({ children, value, onChange }: FileProviderProps) => { - const storeRef = useRef(undefined) + const [store] = useState(() => createFileStore(value, onChange)) - if (!storeRef.current) storeRef.current = createFileStore(value, onChange) - - return {children} + return {children} } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts index 239850c4ed0..e31dfe3ed60 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts @@ -1,6 +1,6 @@ import type { CustomFile as File, FileItem } from '@/models/datasets' import { produce } from 'immer' -import { useCallback, useRef } from 'react' +import { useCallback } from 'react' import { useFileUpload } from '@/app/components/datasets/create/file-uploader/hooks/use-file-upload' import { useDataSourceStore, useDataSourceStoreWithSelector } from '../../store' @@ -20,16 +20,11 @@ export const useLocalFileUpload = ({ }: UseLocalFileUploadOptions) => { const localFileList = useDataSourceStoreWithSelector((state) => state.localFileList) const dataSourceStore = useDataSourceStore() - const fileListRef = useRef([]) - - // Sync fileListRef with localFileList for internal tracking - fileListRef.current = localFileList const prepareFileList = useCallback( (files: FileItem[]) => { const { setLocalFileList } = dataSourceStore.getState() setLocalFileList(files) - fileListRef.current = files }, [dataSourceStore], ) @@ -56,7 +51,6 @@ export const useLocalFileUpload = ({ (files: FileItem[]) => { const { setLocalFileList } = dataSourceStore.getState() setLocalFileList(files) - fileListRef.current = files }, [dataSourceStore], ) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx index 31d62e74660..71c4eab8a96 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx @@ -12,12 +12,12 @@ type DataSourceProviderProps = { } const DataSourceProvider = ({ children }: DataSourceProviderProps) => { - const storeRef = useRef(null) + const storeRef = useRef(null) - if (!storeRef.current) storeRef.current = createDataSourceStore() + if (storeRef.current === null) storeRef.current = createDataSourceStore() return ( - {children} + {children} ) } diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts index edcac54f2ab..52c44a18653 100644 --- a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts +++ b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts @@ -39,7 +39,10 @@ export const useEmbeddingStatus = ({ const queryClient = useQueryClient() const isPolling = useRef(false) const onCompleteRef = useRef(onComplete) - onCompleteRef.current = onComplete + + useEffect(() => { + onCompleteRef.current = onComplete + }, [onComplete]) const queryKey = useMemo( () => [NAME_SPACE, 'indexing-status', datasetId, documentId] as const, diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts index 2c959336168..1c7d2a13770 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/use-activate-credential.ts @@ -1,6 +1,6 @@ import type { Credential, ModelProvider } from '../../declarations' import { toast } from '@langgenius/dify-ui/toast' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useActiveProviderCredential } from '@/service/use-models' import { useUpdateModelList, useUpdateModelProviders } from '../../hooks' @@ -14,9 +14,12 @@ export function useActivateCredential(provider: ModelProvider) { const currentId = provider.custom_configuration.current_credential_id const selectedCredentialId = optimisticId ?? currentId const selectedIdRef = useRef(selectedCredentialId) - selectedIdRef.current = selectedCredentialId const supportedModelTypesRef = useRef(provider.supported_model_types) - supportedModelTypesRef.current = provider.supported_model_types + + useEffect(() => { + selectedIdRef.current = selectedCredentialId + supportedModelTypesRef.current = provider.supported_model_types + }, [selectedCredentialId, provider.supported_model_types]) const activate = useCallback( (credential: Credential) => { if (credential.credential_id === selectedIdRef.current) return