fix(web): keep shared ref updates out of render (#40427)

This commit is contained in:
yyh
2026-08-10 11:33:11 +00:00
committed by GitHub
parent 3f354c2b06
commit b206e691ca
17 changed files with 133 additions and 66 deletions
-3
View File
@@ -468,9 +468,6 @@
}
},
"web/app/components/app/configuration/debug/hooks.tsx": {
"no-restricted-globals": {
"count": 2
},
"typescript/no-explicit-any": {
"count": 3
}
@@ -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,
@@ -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<DebugWithSingleOrMultipleModelConfigs>({})
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 {
@@ -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<DebugWithSingleOrMultipleModelConfigs>(
'app-debug-with-single-or-multiple-models',
{},
{
serializer: (value) => JSON.stringify(value),
deserializer: deserializeDebugModelConfigs,
},
)
export { useDebugModelConfigsStorage }
@@ -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<string, unknown>) => {
newInputsRef.current = v
setNewInputs(v)
value.handleNewConversationInputsChange(v)
},
+3 -1
View File
@@ -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) => {
+3 -5
View File
@@ -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<FeaturesStore | null>(null)
@@ -8,9 +8,7 @@ type FeaturesProviderProps = {
children: React.ReactNode
} & Partial<FeaturesState>
export const FeaturesProvider = ({ children, ...props }: FeaturesProviderProps) => {
const storeRef = useRef<FeaturesStore | undefined>(undefined)
const [store] = useState(() => createFeaturesStore(props))
if (!storeRef.current) storeRef.current = createFeaturesStore(props)
return <FeaturesContext.Provider value={storeRef.current}>{children}</FeaturesContext.Provider>
return <FeaturesContext.Provider value={store}>{children}</FeaturesContext.Provider>
}
@@ -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<FileStore | undefined>(undefined)
const [store] = useState(() => createFileStore(value, onChange))
if (!storeRef.current) storeRef.current = createFileStore(value, onChange)
return <FileContext.Provider value={storeRef.current}>{children}</FileContext.Provider>
return <FileContext.Provider value={store}>{children}</FileContext.Provider>
}
@@ -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 (
<div
style={{
@@ -11,10 +11,13 @@ export const useElapsedTimer = (complete: boolean) => {
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<NodeJS.Timeout | null>(null)
useEffect(() => {
if (complete) completedRef.current = true
}, [complete])
useEffect(() => {
if (isComplete) return
@@ -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 = (
@@ -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]))
@@ -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<FileStore | undefined>(undefined)
const [store] = useState(() => createFileStore(value, onChange))
if (!storeRef.current) storeRef.current = createFileStore(value, onChange)
return <FileContext.Provider value={storeRef.current}>{children}</FileContext.Provider>
return <FileContext.Provider value={store}>{children}</FileContext.Provider>
}
@@ -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<FileItem[]>([])
// 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],
)
@@ -12,12 +12,12 @@ type DataSourceProviderProps = {
}
const DataSourceProvider = ({ children }: DataSourceProviderProps) => {
const storeRef = useRef<DataSourceStoreApi>(null)
const storeRef = useRef<DataSourceStoreApi | null>(null)
if (!storeRef.current) storeRef.current = createDataSourceStore()
if (storeRef.current === null) storeRef.current = createDataSourceStore()
return (
<DataSourceContext.Provider value={storeRef.current!}>{children}</DataSourceContext.Provider>
<DataSourceContext.Provider value={storeRef.current}>{children}</DataSourceContext.Provider>
)
}
@@ -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,
@@ -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