refactor(web): standardize lazy ref initialization (#42562)

This commit is contained in:
yyh
2026-09-20 05:15:48 +00:00
committed by GitHub
parent f16702de80
commit 6f9cb382c4
25 changed files with 399 additions and 106 deletions
@@ -3,8 +3,9 @@
import { DeploymentOperationStatus } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { useQueryClient } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useEffect, useRef } from 'react'
import { useEffect } from 'react'
import { shouldPollEnvironmentDeployment } from '@/app/components/app/deploy/utils/environment-deployment'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { consoleQuery } from '@/service/console'
import {
appPublisherOpenAtom,
@@ -17,8 +18,8 @@ import {
export function useRefreshAppEnvironmentsAfterPublisherDeploymentPolling(appId?: string) {
const queryClient = useQueryClient()
const operationsNeedingEnvironmentRefreshRef = useRef(new Set<string>())
const refreshedOperationKeysRef = useRef(new Set<string>())
const operationsNeedingEnvironmentRefreshRef = useRefWithInit(() => new Set<string>())
const refreshedOperationKeysRef = useRefWithInit(() => new Set<string>())
const open = useAtomValue(appPublisherOpenAtom)
const polling = useAtomValue(publisherEnvironmentDeploymentPollingAtom)
const environment = useAtomValue(selectedPublisherEnvironmentAtom)
@@ -117,5 +118,15 @@ export function useRefreshAppEnvironmentsAfterPublisherDeploymentPolling(appId?:
})
void queryClient.invalidateQueries({ queryKey: appEnvironmentsQuery.queryKey })
}, [appId, deployment, environment, finishPolling, open, polling, queryClient])
}, [
appId,
deployment,
environment,
finishPolling,
open,
polling,
queryClient,
operationsNeedingEnvironmentRefreshRef,
refreshedOperationKeysRef,
])
}
@@ -2,6 +2,7 @@
import { useId, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
type Options<T> = {
items: T[]
@@ -30,7 +31,7 @@ export function useKeyboardSortable<T>({
const descriptionId = useId()
const [session, setSession] = useState<SortSession<T> | null>(null)
const [message, setMessage] = useState('')
const handlesRef = useRef(new Map<number, HTMLButtonElement>())
const handlesRef = useRefWithInit(() => new Map<number, HTMLButtonElement>())
const pendingFocusRef = useRef<number | null>(null)
const current =
session?.source === items && !disabled && session.minIndex === minIndex ? session : null
@@ -1,4 +1,5 @@
import { createContext, useRef } from 'react'
import { createContext } from 'react'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { createDataSourceStore } from './'
type DataSourceStoreApi = ReturnType<typeof createDataSourceStore>
@@ -12,9 +13,7 @@ type DataSourceProviderProps = {
}
const DataSourceProvider = ({ children }: DataSourceProviderProps) => {
const storeRef = useRef<DataSourceStoreApi | null>(null)
if (storeRef.current === null) storeRef.current = createDataSourceStore()
const storeRef = useRefWithInit(createDataSourceStore)
return (
<DataSourceContext.Provider value={storeRef.current}>{children}</DataSourceContext.Provider>
@@ -5,6 +5,7 @@ import { useTheme } from 'next-themes'
import { useCallback, useEffect, useRef } from 'react'
import { useLocale, useTranslation } from '#i18n'
import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { getPluginLinkInMarketplace } from '../utils'
import MarketplaceDetailDialogFrame from './frame'
import { useSilentMarketplaceInstall } from './use-silent-install'
@@ -45,14 +46,14 @@ function OpenMarketplaceDetailDialog({
title: string
}) {
const { install } = useSilentMarketplaceInstall()
const timeoutIdsRef = useRef(new Set<number>())
const timeoutIdsRef = useRefWithInit(() => new Set<number>())
useEffect(
() => () => {
timeoutIdsRef.current.forEach((id) => window.clearTimeout(id))
timeoutIdsRef.current.clear()
},
[],
[timeoutIdsRef],
)
const handleMessage = useCallback(
@@ -96,7 +97,7 @@ function OpenMarketplaceDetailDialog({
})
})
},
[canInstallPlugin, install, plugin],
[canInstallPlugin, install, plugin, timeoutIdsRef],
)
return (
@@ -5,6 +5,7 @@ import type { RefObject } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from '#i18n'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { MARKETPLACE_CONTAINER_ID } from '../constants'
import styles from './home-trending.module.css'
@@ -49,8 +50,8 @@ function TrendingNavigation({
const { t } = useTranslation('plugin')
const progressRef = useRef<HTMLSpanElement>(null)
const progressAnimationRef = useRef<Animation | null>(null)
const pauseReasonsRef = useRef(
new Set<AutoplayPauseReason>(pauseWhenOffscreen ? ['viewport'] : []),
const pauseReasonsRef = useRefWithInit(
() => new Set<AutoplayPauseReason>(pauseWhenOffscreen ? ['viewport'] : []),
)
const [isUserPaused, setIsUserPaused] = useState(false)
const [isReducedMotionPaused, setIsReducedMotionPaused] = useState(false)
@@ -72,7 +73,7 @@ function TrendingNavigation({
if (isPaused) progressAnimation.pause()
else progressAnimation.play()
},
[onPausedChange],
[onPausedChange, pauseReasonsRef],
)
useEffect(() => {
@@ -103,7 +104,7 @@ function TrendingNavigation({
progressAnimation.cancel()
if (progressAnimationRef.current === progressAnimation) progressAnimationRef.current = null
}
}, [onNext, selectedIndex])
}, [onNext, selectedIndex, pauseReasonsRef])
useEffect(() => {
const carouselRoot = carouselRootRef.current
@@ -10,6 +10,7 @@ import Divider from '@/app/components/base/divider'
import { Infotip } from '@/app/components/base/infotip'
import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector'
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { useAllMCPTools } from '@/service/use-tools'
type Props = Readonly<{
@@ -45,7 +46,7 @@ const MultipleToolSelector = ({
const { allowed: isMCPToolAllowed } = useMCPToolAvailability()
const { data: mcpTools } = useAllMCPTools()
const addToolButtonRef = React.useRef<HTMLButtonElement>(null)
const toolItemTriggerByKeyRef = React.useRef(new Map<string, HTMLButtonElement>())
const toolItemTriggerByKeyRef = useRefWithInit(() => new Map<string, HTMLButtonElement>())
const pendingFocusTargetRef = React.useRef<{ toolKey?: string } | null>(null)
const enabledCount = value.filter((item) => {
const isMCPTool = mcpTools?.find((tool) => tool.id === item.provider_name)
@@ -65,7 +66,7 @@ const MultipleToolSelector = ({
resolvedFocusTarget?.focus()
pendingFocusTargetRef.current = null
}, [value])
}, [value, toolItemTriggerByKeyRef])
// add tool
const [selectorOpen, setSelectorOpen] = React.useState(false)
@@ -9,10 +9,6 @@ const reactFlowState = vi.hoisted(() => ({
nodes: [] as Node[],
}))
vi.mock('es-toolkit/compat', () => ({
debounce: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}))
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
@@ -57,8 +53,13 @@ describe('useWorkflowHistory', () => {
reactFlowState.edges = edges
})
it('stores the latest workflow graph snapshot for supported events', () => {
const { result } = renderWorkflowHook(() => useWorkflowHistory(), {
afterEach(() => {
vi.useRealTimers()
})
it('coalesces saves across rerenders and stores the latest graph snapshot', () => {
vi.useFakeTimers()
const { result, rerender } = renderWorkflowHook(() => useWorkflowHistory(), {
historyStore: {
nodes,
edges,
@@ -66,8 +67,20 @@ describe('useWorkflowHistory', () => {
})
act(() => {
result.current.saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: 'node-1' })
result.current.saveStateToHistory(WorkflowHistoryEvent.NodeDelete, { nodeId: 'node-2' })
vi.advanceTimersByTime(200)
})
rerender()
reactFlowState.nodes = nodes.map((node) => ({
...node,
data: { ...node.data, title: 'Updated' },
}))
act(() => {
result.current.saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: 'node-1' })
vi.advanceTimersByTime(499)
})
expect(result.current.store.getState().workflowHistoryEvent).toBeUndefined()
act(() => vi.advanceTimersByTime(1))
expect(result.current.store.getState().workflowHistoryEvent).toBe(WorkflowHistoryEvent.NodeAdd)
expect(result.current.store.getState().workflowHistoryEventMeta).toEqual({ nodeId: 'node-1' })
@@ -76,7 +89,7 @@ describe('useWorkflowHistory', () => {
id: 'node-1',
data: expect.objectContaining({
selected: false,
title: 'Start',
title: 'Updated',
}),
}),
])
@@ -1,10 +1,11 @@
import { useCallback, useRef } from 'react'
import { useCallback } from 'react'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
export const useSerialAsyncCallback = <Args extends any[], Result = void>(
fn: (...args: Args) => Promise<Result> | Result,
shouldSkip?: () => boolean,
) => {
const queueRef = useRef<Promise<unknown>>(Promise.resolve())
const queueRef = useRefWithInit<Promise<unknown>>(() => Promise.resolve())
return useCallback(
(...args: Args) => {
@@ -16,6 +17,6 @@ export const useSerialAsyncCallback = <Args extends any[], Result = void>(
return nextPromise
},
[fn, shouldSkip],
[fn, shouldSkip, queueRef],
)
}
@@ -1,8 +1,9 @@
import type { WorkflowHistoryEventMeta } from '../store/workflow/history-slice'
import { debounce } from 'es-toolkit/compat'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { useWorkflowHistoryStore } from '../workflow-history-store'
/**
@@ -63,7 +64,7 @@ export const useWorkflowHistory = () => {
// Some events may be triggered multiple times in a short period of time.
// We debounce the history state update to avoid creating multiple history states
// with minimal changes.
const saveStateToHistoryRef = useRef(
const saveStateToHistoryRef = useRefWithInit(() =>
debounce((event: WorkflowHistoryEventT, meta?: WorkflowHistoryEventMeta) => {
workflowHistoryStore.setState({
workflowHistoryEvent: event,
@@ -105,7 +106,7 @@ export const useWorkflowHistory = () => {
break
}
},
[],
[saveStateToHistoryRef],
)
const getHistoryLabel = useCallback(
@@ -19,6 +19,7 @@ import {
} from '@/app/components/base/prompt-editor/plugins/agent-output-block/utils'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { useStore } from '@/app/components/workflow/store'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { consoleQuery } from '@/service/console'
import { FlowType } from '@/types/common'
import { useNodeDataUpdate } from '../../hooks/use-node-data-update'
@@ -119,7 +120,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
const { t } = useTranslation()
const { inputs, setInputs } = useNodeCrud<AgentV2NodeType>(id, data)
const inputsRef = useRef(inputs)
const promptOutputNamesRef = useRef(extractAgentOutputNames(inputs.agent_task || ''))
const promptOutputNamesRef = useRefWithInit(extractAgentOutputNames, inputs.agent_task || '')
const [isRosterAgentPanelOpen, setIsRosterAgentPanelOpen] = useState(false)
const [isInlineAgentPanelOpenedFromTrigger, setIsInlineAgentPanelOpenedFromTrigger] =
useState(false)
@@ -208,7 +209,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
useEffect(() => {
inputsRef.current = inputs
promptOutputNamesRef.current = extractAgentOutputNames(inputs.agent_task || '')
}, [inputs])
}, [inputs, promptOutputNamesRef])
useEffect(() => {
if (!inputs._openInlineAgentPanel || !isInlineAgentReady) return
@@ -268,7 +269,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
setLocalDeclaredOutputs(getAgentV2DeclaredOutputs(newInputs))
setInputs(newInputs)
},
[setInputs],
[setInputs, promptOutputNamesRef],
)
const handleRosterChange = useCallback(
@@ -606,7 +607,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
},
)
},
[handleNodeDataUpdateWithSyncDraft, id],
[handleNodeDataUpdateWithSyncDraft, id, promptOutputNamesRef],
)
const handleEditTaskOutput = useCallback(
@@ -1,6 +1,7 @@
import { noop } from 'es-toolkit/function'
import { createContext, use, useRef } from 'react'
import { createContext, use } from 'react'
import { useMitt } from '@/hooks/use-mitt'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { createVisualEditorStore } from './store'
type VisualEditorStore = ReturnType<typeof createVisualEditorStore>
@@ -14,9 +15,7 @@ type VisualEditorProviderProps = {
export const VisualEditorContext = createContext<VisualEditorContextType>(null)
export const VisualEditorContextProvider = ({ children }: VisualEditorProviderProps) => {
const storeRef = useRef<VisualEditorStore | null>(null)
if (!storeRef.current) storeRef.current = createVisualEditorStore()
const storeRef = useRefWithInit(createVisualEditorStore)
return (
<VisualEditorContext.Provider value={storeRef.current}>{children}</VisualEditorContext.Provider>
@@ -6,6 +6,7 @@ import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getRoot } from 'lexical'
import { createContext, memo, useEffect, useRef } from 'react'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { createNoteEditorStore } from './store'
import theme from './theme'
@@ -65,9 +66,7 @@ type NoteEditorContextProviderProps = {
}
export const NoteEditorContextProvider = memo(
({ value, children, editable = true }: NoteEditorContextProviderProps) => {
const storeRef = useRef<NoteEditorStore | undefined>(undefined)
if (!storeRef.current) storeRef.current = createNoteEditorStore()
const storeRef = useRefWithInit(createNoteEditorStore)
let initialValue = null
try {
@@ -24,6 +24,7 @@ import VariableTrigger from '@/app/components/workflow/panel/env-panel/variable-
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { toast } from '@/app/notifications'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { Resolution } from '@/types/app'
import {
fetchModelParameterRulesForModel,
@@ -447,25 +448,28 @@ const EnvPanel = () => {
const [showRemoveVarConfirm, setShowRemoveVarConfirm] = useState(false)
const [cacheForDelete, setCacheForDelete] = useState<EnvironmentVariable>()
const saveOperationQueueRef = useRef(Promise.resolve())
const saveOperationQueueRef = useRefWithInit(() => Promise.resolve())
const committedEnvListRef = useRef(envList)
const latestEnvListRef = useRef(envList)
const pendingSaveEnvIdsRef = useRef(new Map<string, number>())
const pendingSaveEnvIdsRef = useRefWithInit(() => new Map<string, number>())
useLayoutEffect(() => {
latestEnvListRef.current = envList
}, [envList])
useEffect(() => {
if (pendingSaveEnvIdsRef.current.size === 0) committedEnvListRef.current = envList
}, [envList])
}, [envList, pendingSaveEnvIdsRef])
const mergeLatestUntouchedEnvList = useCallback((envListToMerge: EnvironmentVariable[]) => {
return mergeUntouchedEnvironmentVariables(
envListToMerge,
latestEnvListRef.current,
new Set(pendingSaveEnvIdsRef.current.keys()),
)
}, [])
const mergeLatestUntouchedEnvList = useCallback(
(envListToMerge: EnvironmentVariable[]) => {
return mergeUntouchedEnvironmentVariables(
envListToMerge,
latestEnvListRef.current,
new Set(pendingSaveEnvIdsRef.current.keys()),
)
},
[pendingSaveEnvIdsRef],
)
const restoreCommittedEnvList = useCallback(() => {
committedEnvListRef.current = mergeLatestUntouchedEnvList(committedEnvListRef.current)
@@ -523,7 +527,7 @@ const EnvPanel = () => {
saveOperationQueueRef.current = queuedOperation.catch(() => undefined)
return queuedOperation
},
[mergeLatestUntouchedEnvList],
[mergeLatestUntouchedEnvList, pendingSaveEnvIdsRef, saveOperationQueueRef],
)
const syncDraftWithResult = useCallback(
@@ -23,6 +23,7 @@ import {
isAgentComposerDirtyAtom,
} from '@/features/agent-v2/agent-composer/store'
import { agentComposerToolPresentationIdentitiesAtom } from '@/features/agent-v2/agent-composer/store-modules/tools'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { consoleQuery } from '@/service/console'
import {
getAgentToolPublishIssue,
@@ -61,7 +62,7 @@ export function useAgentConfigureSync({
const latestAppliedSaveSequenceRef = useRef(0)
const nextSaveSequenceRef = useRef(0)
const pageCloseSavingDraftKeyRef = useRef<string | undefined>(undefined)
const explicitlySavingDraftKeysRef = useRef(new Set<string>())
const explicitlySavingDraftKeysRef = useRefWithInit(() => new Set<string>())
const publishInFlightRef = useRef(false)
// The layout can unmount this hook before a revoked permission reaches its props.
@@ -314,7 +315,14 @@ export function useAgentConfigureSync({
} finally {
explicitlySavingDraftKeysRef.current.delete(draftKey)
}
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store, tCommon])
}, [
debouncedSaveDraft,
getAgentSoulDraft,
saveComposer,
store,
tCommon,
explicitlySavingDraftKeysRef,
])
const saveDirtyDraftOnPageClose = useCallback(
(allowInFlightDuplicate = false) => {
@@ -346,7 +354,13 @@ export function useAgentConfigureSync({
pageCloseSavingDraftKeyRef.current = undefined
})
},
[debouncedSaveDraft, getAgentSoulDraft, saveComposerOnPageClose, store],
[
debouncedSaveDraft,
getAgentSoulDraft,
saveComposerOnPageClose,
store,
explicitlySavingDraftKeysRef,
],
)
useEffect(() => {
+3 -2
View File
@@ -12,6 +12,7 @@ import { useLocale } from '#i18n'
import { trackEvent } from '@/app/components/base/amplitude'
import { Carousel, useCarousel } from '@/app/components/base/carousel'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { BannerItem } from './banner-item'
import { IndicatorButton } from './indicator-button'
@@ -78,7 +79,7 @@ function BannerCarouselContent({
}: BannerCarouselContentProps) {
const { t } = useTranslation()
const { api, selectedIndex } = useCarousel()
const trackedBannerKeysRef = useRef(new Set<string>())
const trackedBannerKeysRef = useRefWithInit(() => new Set<string>())
const nextIndex = (selectedIndex + 1) % banners.length
const activeBanner = banners[selectedIndex]
const trackingKey = accountId && activeBanner ? `${accountId}:${activeBanner.id}` : null
@@ -107,7 +108,7 @@ function BannerCarouselContent({
event_time: Date.now(),
})
trackedBannerKeysRef.current.add(trackingKey)
}, [accountId, activeBanner, language, selectedIndex, trackingKey])
}, [accountId, activeBanner, language, selectedIndex, trackingKey, trackedBannerKeysRef])
const controls =
banners.length > 1 ? (
+4 -2
View File
@@ -15,6 +15,7 @@ import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-quer
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { useRouter } from '@/next/navigation'
import { consoleClient, consoleQuery } from '@/service/console'
import { PendingWebsiteSetup, UnavailableConnectedSourceSetup } from './add-source-placeholder'
@@ -608,8 +609,9 @@ export function AddSourcePage({
const { t } = useTranslation('dataset')
const router = useRouter()
const queryClient = useQueryClient()
const initialDraftRef = useRef<NewKnowledgeSourceDraft>(
initialSourceDraft ??
const initialDraftRef = useRefWithInit<NewKnowledgeSourceDraft>(
() =>
initialSourceDraft ??
createNewKnowledgeSourceDraft(normalizeSourceType(initialSourceType ?? null)),
)
const [sourceDraft, setSourceDraft] = useState<NewKnowledgeSourceDraft>(initialDraftRef.current)
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { taskVersionIsAfter } from './document-model'
export type AuxiliaryTaskReadGuard = ReturnType<typeof createAuxiliaryTaskReadGuard>
type RefetchDocuments = (options: { cancelRefetch: true }) => Promise<{ error: unknown }>
export function createAuxiliaryTaskReadGuard() {
@@ -45,8 +44,7 @@ export function useAuxiliaryTaskReadGuard({
documentPermissionDenied: boolean
refetchDocuments: RefetchDocuments
}) {
const guardRef = useRef<AuxiliaryTaskReadGuard | null>(null)
if (!guardRef.current) guardRef.current = createAuxiliaryTaskReadGuard()
const guardRef = useRefWithInit(createAuxiliaryTaskReadGuard)
const guard = guardRef.current
const previousDocumentPermissionDeniedRef = useRef(documentPermissionDenied)
const denialGenerationRef = useRef(0)
+9 -2
View File
@@ -7,6 +7,7 @@ import { SpinnerIcon } from '@langgenius/dify-ui/spinner'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { chunkTreeLabel, visibleDocumentChunkNodes } from './document-detail-model'
const VIRTUALIZATION_THRESHOLD = 80
@@ -46,7 +47,7 @@ export function DocumentChunkTreePanel({
const [treeHasFocus, setTreeHasFocus] = useState(false)
const treeScrollRef = useRef<HTMLDivElement>(null)
const loadMoreRequestedRef = useRef(false)
const chunkIdsBeforeLoadRef = useRef<Set<string>>(new Set())
const chunkIdsBeforeLoadRef = useRefWithInit<Set<string>>(() => new Set())
const wasFetchingNextPageRef = useRef(false)
const expandedChunkIds = useMemo(
() => new Set([...tree.byId.keys()].filter((id) => !collapsedChunkIds.has(id))),
@@ -148,7 +149,13 @@ export function DocumentChunkTreePanel({
if (shouldVirtualize) rowVirtualizerRef.current.scrollToIndex(index, { align: 'auto' })
treeScrollRef.current?.focus()
})
}, [isFetchNextPageError, isFetchingNextPage, shouldVirtualize, visibleNodes])
}, [
isFetchNextPageError,
isFetchingNextPage,
shouldVirtualize,
visibleNodes,
chunkIdsBeforeLoadRef,
])
const renderTreeItem = (item: (typeof visibleNodes)[number], style?: React.CSSProperties) => {
const { depth, node, positionInSet, setSize } = item
+153 -30
View File
@@ -22,6 +22,7 @@ import {
workspacePermissionKeysFetchingAtom,
workspacePermissionKeysLoadingAtom,
} from '@/context/permission-state'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { consoleClient, consoleQuery } from '@/service/console'
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
import { useAuxiliaryTaskReadGuard } from './auxiliary-task-read-guard'
@@ -186,21 +187,30 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
>({})
const [terminalTaskPins, setTerminalTaskPins] = useState<Record<string, TerminalTaskPin>>({})
const [taskObserverGenerations, setTaskObserverGenerations] = useState<Record<string, number>>({})
const terminalReconciliationGenerationsRef = useRef(new Map<string, number>())
const failedTaskPollGenerationsRef = useRef(new Map<string, number>())
const blockedFailedTaskPollVersionsRef = useRef(new Map<string, string>())
const failedPollAuxiliaryDenialsRef = useRef(new Map<string, AuxiliaryTaskReadDenial>())
const terminalConfirmableAuxiliaryDenialsRef = useRef(new Map<string, AuxiliaryTaskReadDenial>())
const equalRetryListGenerationsRef = useRef(new Map<string, number>())
const terminalReconciliationTimeoutsRef = useRef(new Map<string, number>())
const terminalReconciliationControllersRef = useRef(new Map<string, AbortController>())
const pendingTerminalProgressRef = useRef(new Map<string, ProcessingTaskProgressEvent>())
const terminalReconciliationGenerationsRef = useRefWithInit(() => new Map<string, number>())
const failedTaskPollGenerationsRef = useRefWithInit(() => new Map<string, number>())
const blockedFailedTaskPollVersionsRef = useRefWithInit(() => new Map<string, string>())
const failedPollAuxiliaryDenialsRef = useRefWithInit(
() => new Map<string, AuxiliaryTaskReadDenial>(),
)
const terminalConfirmableAuxiliaryDenialsRef = useRefWithInit(
() => new Map<string, AuxiliaryTaskReadDenial>(),
)
const equalRetryListGenerationsRef = useRefWithInit(() => new Map<string, number>())
const terminalReconciliationTimeoutsRef = useRefWithInit(() => new Map<string, number>())
const terminalReconciliationControllersRef = useRefWithInit(
() => new Map<string, AbortController>(),
)
const pendingTerminalProgressRef = useRefWithInit(
() => new Map<string, ProcessingTaskProgressEvent>(),
)
const taskEventCursorsRef = useRef(new Map<string, string>())
const streamActiveOverrideVersionsRef = useRef(new Map<string, string>())
const trustedActiveOverrideVersionsRef = useRef(new Map<string, TrustedActiveOverride>())
const trustedOverrideListGenerationsRef = useRef(new Map<string, number>())
const taskProgressStoreRef = useRef<ReturnType<typeof createTaskProgressStore> | null>(null)
if (!taskProgressStoreRef.current) taskProgressStoreRef.current = createTaskProgressStore()
const trustedActiveOverrideVersionsRef = useRefWithInit(
() => new Map<string, TrustedActiveOverride>(),
)
const trustedOverrideListGenerationsRef = useRefWithInit(() => new Map<string, number>())
const taskProgressStoreRef = useRefWithInit(createTaskProgressStore)
const taskProgressStore = taskProgressStoreRef.current
const [uploading, setUploading] = useState(false)
const [reindexing, setReindexing] = useState(false)
@@ -242,7 +252,11 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
if (!documentPermissionDenied) return
failedPollAuxiliaryDenialsRef.current.clear()
terminalConfirmableAuxiliaryDenialsRef.current.clear()
}, [documentPermissionDenied])
}, [
documentPermissionDenied,
failedPollAuxiliaryDenialsRef,
terminalConfirmableAuxiliaryDenialsRef,
])
const tasksQueryOptions = useMemo(
() =>
consoleQuery.knowledgeFs.getKnowledgeSpacesByIdProcessingTasks.infiniteOptions({
@@ -445,7 +459,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
trustedOverrideListGenerationsRef.current.delete(task.id)
}
}
}, [tasks])
}, [tasks, trustedActiveOverrideVersionsRef, trustedOverrideListGenerationsRef])
useEffect(() => {
const clearedActiveTaskIds: string[] = []
@@ -470,9 +484,19 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
for (const taskId of clearedActiveTaskIds) next[taskId] = (next[taskId] ?? 0) + 1
return next
})
}, [auxiliaryTaskReadGuard, baseTasks, effectiveTaskById, taskListGeneration])
const currentTaskStateRef = useRef(new Map(tasks.map((task) => [task.id, task.state])))
const currentTaskVersionRef = useRef(new Map(tasks.map((task) => [task.id, task.updatedAt])))
}, [
auxiliaryTaskReadGuard,
baseTasks,
effectiveTaskById,
taskListGeneration,
terminalConfirmableAuxiliaryDenialsRef,
])
const currentTaskStateRef = useRefWithInit(
() => new Map(tasks.map((task) => [task.id, task.state])),
)
const currentTaskVersionRef = useRefWithInit(
() => new Map(tasks.map((task) => [task.id, task.updatedAt])),
)
useLayoutEffect(() => {
const currentTaskIds = new Set(tasks.map((task) => task.id))
for (const task of tasks) {
@@ -487,7 +511,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
currentTaskStateRef.current.delete(taskId)
currentTaskVersionRef.current.delete(taskId)
}
}, [tasks])
}, [tasks, currentTaskStateRef, currentTaskVersionRef])
const taskByDocument = useMemo(() => newestTaskByDocument(tasks), [tasks])
const documentStatuses = useMemo(
@@ -813,7 +837,14 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
}
if (shouldRestoreFocus) documentPermissionAlertRef.current?.focus()
bulkActionsHadFocusRef.current = false
}, [permissionDenialMask, permissionDenied, refetchSourcesQuery, refetchTasksQuery, tasksOpen])
}, [
permissionDenialMask,
permissionDenied,
refetchSourcesQuery,
refetchTasksQuery,
tasksOpen,
blockedFailedTaskPollVersionsRef,
])
useEffect(() => {
if (
@@ -1123,7 +1154,22 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
window.clearTimeout(requestTimeout)
}
},
[auxiliaryTaskReadGuard, denyAuxiliaryTaskRead, knowledgeSpaceId, taskProgressStore],
[
auxiliaryTaskReadGuard,
denyAuxiliaryTaskRead,
knowledgeSpaceId,
taskProgressStore,
currentTaskStateRef,
currentTaskVersionRef,
terminalReconciliationTimeoutsRef,
terminalReconciliationControllersRef,
terminalReconciliationGenerationsRef,
failedPollAuxiliaryDenialsRef,
terminalConfirmableAuxiliaryDenialsRef,
trustedActiveOverrideVersionsRef,
blockedFailedTaskPollVersionsRef,
failedTaskPollGenerationsRef,
],
)
useEffect(() => {
@@ -1147,7 +1193,16 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
terminalReconciliationGenerationsRef.current.set(task.id, reconciliationGeneration)
void reconcileTerminalTask(task.id, task.updatedAt, reconciliationGeneration)
}
}, [baseTasks, permissionDenied, reconcileTerminalTask, taskListGeneration, taskOverrides])
}, [
baseTasks,
permissionDenied,
reconcileTerminalTask,
taskListGeneration,
taskOverrides,
trustedActiveOverrideVersionsRef,
trustedOverrideListGenerationsRef,
terminalReconciliationGenerationsRef,
])
useEffect(
() => () => {
@@ -1158,7 +1213,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
window.clearTimeout(timeout)
terminalReconciliationTimeoutsRef.current.clear()
},
[knowledgeSpaceId],
[knowledgeSpaceId, terminalReconciliationControllersRef, terminalReconciliationTimeoutsRef],
)
useEffect(() => {
@@ -1181,7 +1236,15 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
window.clearTimeout(timeout)
terminalReconciliationTimeoutsRef.current.clear()
equalRetryListGenerationsRef.current.clear()
}, [knowledgeSpaceId, permissionDenied, queryClient])
}, [
knowledgeSpaceId,
permissionDenied,
queryClient,
terminalReconciliationControllersRef,
terminalReconciliationGenerationsRef,
terminalReconciliationTimeoutsRef,
equalRetryListGenerationsRef,
])
useEffect(() => {
const taskIds = new Set(baseTasks.map((task) => task.id))
@@ -1234,7 +1297,22 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
setTerminalTaskPins(retainTaskState)
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Prune state for tasks removed by a refreshed cursor result.
setTaskObserverGenerations(retainTaskState)
}, [auxiliaryTaskReadGuard, baseTasks, taskProgressStore])
}, [
auxiliaryTaskReadGuard,
baseTasks,
taskProgressStore,
terminalReconciliationControllersRef,
terminalReconciliationTimeoutsRef,
terminalReconciliationGenerationsRef,
failedTaskPollGenerationsRef,
blockedFailedTaskPollVersionsRef,
failedPollAuxiliaryDenialsRef,
terminalConfirmableAuxiliaryDenialsRef,
equalRetryListGenerationsRef,
pendingTerminalProgressRef,
trustedOverrideListGenerationsRef,
trustedActiveOverrideVersionsRef,
])
useEffect(() => {
if (permissionDenied) return
@@ -1340,6 +1418,14 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
taskListGeneration,
taskProgressStore,
terminalTaskPins,
terminalReconciliationGenerationsRef,
terminalReconciliationControllersRef,
terminalReconciliationTimeoutsRef,
blockedFailedTaskPollVersionsRef,
failedPollAuxiliaryDenialsRef,
terminalConfirmableAuxiliaryDenialsRef,
failedTaskPollGenerationsRef,
equalRetryListGenerationsRef,
])
const handleUploadFiles = useCallback(
@@ -1599,7 +1685,19 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
}
return true
},
[reconcileTerminalTask, refreshDocuments, t, taskProgressStore],
[
reconcileTerminalTask,
refreshDocuments,
t,
taskProgressStore,
currentTaskStateRef,
currentTaskVersionRef,
pendingTerminalProgressRef,
trustedActiveOverrideVersionsRef,
failedTaskPollGenerationsRef,
terminalReconciliationTimeoutsRef,
terminalReconciliationGenerationsRef,
],
)
const handleTaskUpdated = useCallback(
@@ -1643,7 +1741,22 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
})
}
},
[auxiliaryTaskReadGuard, taskProgressStore],
[
auxiliaryTaskReadGuard,
taskProgressStore,
currentTaskStateRef,
currentTaskVersionRef,
failedPollAuxiliaryDenialsRef,
terminalConfirmableAuxiliaryDenialsRef,
trustedActiveOverrideVersionsRef,
trustedOverrideListGenerationsRef,
blockedFailedTaskPollVersionsRef,
terminalReconciliationControllersRef,
terminalReconciliationTimeoutsRef,
terminalReconciliationGenerationsRef,
failedTaskPollGenerationsRef,
pendingTerminalProgressRef,
],
)
useEffect(() => {
@@ -1656,7 +1769,13 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
failedPollAuxiliaryDenialsRef.current.delete(task.id)
handleTaskUpdated(task)
}
}, [baseTasks, handleTaskUpdated, permissionDenied, taskListGeneration])
}, [
baseTasks,
handleTaskUpdated,
permissionDenied,
taskListGeneration,
failedPollAuxiliaryDenialsRef,
])
useEffect(() => {
for (const task of activeTasks) {
@@ -1665,7 +1784,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
const generation = failedTaskPollGenerationsRef.current.get(task.id) ?? 0
failedTaskPollGenerationsRef.current.set(task.id, generation + 1)
}
}, [activeTasks])
}, [activeTasks, blockedFailedTaskPollVersionsRef, failedTaskPollGenerationsRef])
useEffect(() => {
if (permissionDenied || !orderedFailedTasksRef.current.length) return
@@ -1767,10 +1886,14 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
}, [
auxiliaryTaskReadGuard,
denyAuxiliaryTaskRead,
currentTaskVersionRef,
failedTaskPollSignature,
handleTaskUpdated,
knowledgeSpaceId,
permissionDenied,
blockedFailedTaskPollVersionsRef,
failedTaskPollGenerationsRef,
failedPollAuxiliaryDenialsRef,
])
const toggleDocument = useCallback(
@@ -1830,7 +1953,7 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
})
denyAuxiliaryTaskRead(taskId, taskVersion)
},
[denyAuxiliaryTaskRead],
[denyAuxiliaryTaskRead, terminalConfirmableAuxiliaryDenialsRef],
)
return (
@@ -22,6 +22,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalS
import { useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { consoleQuery } from '@/service/console'
import { taskCanRetry, taskIsActive, taskVersionIsAfter } from './document-model'
@@ -150,7 +151,7 @@ export function ProcessingTasksDrawer({
const retryTask = useMutation(
consoleQuery.knowledgeFs.postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry.mutationOptions(),
)
const pendingActionsRef = useRef(new Set<string>())
const pendingActionsRef = useRefWithInit(() => new Set<string>())
const drawerCloseButtonRef = useRef<HTMLButtonElement>(null)
const taskQueryRetryButtonRef = useRef<HTMLButtonElement>(null)
const documentQueryRetryButtonRef = useRef<HTMLButtonElement>(null)
@@ -222,8 +223,8 @@ export function ProcessingTasksDrawer({
() => new Map(tasks.map((task) => [task.id, taskLifecycle(task)])),
[tasks],
)
const taskLifecycleGenerationsRef = useRef(
new Map<string, { generation: number; lifecycle: string }>(),
const taskLifecycleGenerationsRef = useRefWithInit(
() => new Map<string, { generation: number; lifecycle: string }>(),
)
useLayoutEffect(() => {
actionResultsValidRef.current = actionResultsValid
@@ -240,7 +241,7 @@ export function ProcessingTasksDrawer({
for (const taskId of taskLifecycleGenerationsRef.current.keys()) {
if (!currentTaskIds.has(taskId)) taskLifecycleGenerationsRef.current.delete(taskId)
}
}, [actionResultsValid, tasks])
}, [actionResultsValid, tasks, taskLifecycleGenerationsRef])
useEffect(() => {
openRef.current = open
@@ -20,6 +20,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { useRouter } from '@/next/navigation'
import { consoleClient } from '@/service/console'
import { CrawlSelectionForm } from './crawl-selection-form'
@@ -328,7 +329,7 @@ export function WebsiteCrawlPreview({
const cancelFingerprintRef = useRef<string | undefined>(undefined)
const rootUrlInputRef = useRef<HTMLInputElement>(null)
const sourceNameInputRef = useRef<HTMLInputElement>(null)
const pageMapRef = useRef(new Map<string, PreviewPage>())
const pageMapRef = useRefWithInit(() => new Map<string, PreviewPage>())
const pageCursorRef = useRef<string | undefined>(undefined)
const submittedRef = useRef(false)
const discardRequestedRef = useRef(false)
@@ -350,7 +351,7 @@ export function WebsiteCrawlPreview({
pageCursorRef.current = undefined
setPages([])
setPagesLoaded(false)
}, [])
}, [pageMapRef])
const updateRun = useCallback((nextRun: SourceWorkflowRun | undefined) => {
if (nextRun && pendingCancelRunRef.current?.id === nextRun.id)
@@ -842,7 +843,7 @@ export function WebsiteCrawlPreview({
disposed = true
if (timer) clearTimeout(timer)
}
}, [knowledgeSpaceId, runId, shouldPoll, updateRun])
}, [knowledgeSpaceId, runId, shouldPoll, updateRun, pageMapRef])
const stop = async (targetRun = run) => {
if (!targetRun || isTerminal(targetRun.state)) return true
+3 -2
View File
@@ -20,6 +20,7 @@ import { useDefaultModel } from '@/app/components/header/account-setting/model-p
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { toast } from '@/app/notifications'
import { useRefWithInit } from '@/hooks/use-ref-with-init'
import { consoleQuery } from '@/service/console'
import { sendSkillAssistMessage, uploadSkillFile } from '../client'
import { SkillBuilderGridTexture } from './builder-grid-texture'
@@ -325,7 +326,7 @@ export function SkillBuilderPanel({
)
const [messages, setMessages] = useState<BuilderChatMessage[]>(initialMessages)
const messagesRef = useRef<BuilderChatMessage[]>(initialMessages)
const rawAssistantMessagesRef = useRef(new Map<string, string>())
const rawAssistantMessagesRef = useRefWithInit(() => new Map<string, string>())
const [attachments, setAttachments] = useState<SkillBuilderAttachment[]>([])
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
const attachmentInputRef = useRef<HTMLInputElement>(null)
@@ -419,7 +420,7 @@ export function SkillBuilderPanel({
messagesRef.current = initialMessages
rawAssistantMessagesRef.current.clear()
setMessages(initialMessages)
}, [initialMessages])
}, [initialMessages, rawAssistantMessagesRef])
useEffect(() => {
messagesRef.current = messages
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Material-UI SAS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+66
View File
@@ -0,0 +1,66 @@
import { renderHook } from '@testing-library/react'
import { StrictMode } from 'react'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { useRefWithInit } from './index'
describe('useRefWithInit', () => {
it('preserves the ref and initial value when the initializer and argument change', () => {
const init = vi.fn((value: number) => ({ value }))
const replacementInit = vi.fn((value: number) => ({ value }))
const { result, rerender } = renderHook(
({ initializer, value }) => useRefWithInit(initializer, value),
{ initialProps: { initializer: init, value: 1 } },
)
const ref = result.current
const initialValue = ref.current
rerender({ initializer: replacementInit, value: 2 })
expect(result.current).toBe(ref)
expect(result.current.current).toBe(initialValue)
expect(result.current.current).toEqual({ value: 1 })
expect(init).toHaveBeenCalledExactlyOnceWith(1)
expect(replacementInit).not.toHaveBeenCalled()
expectTypeOf(result.current.current).toEqualTypeOf<{ value: number }>()
})
it.each([null, undefined])('accepts %s as an initialized value', (value) => {
const init = vi.fn(() => value)
const { result, rerender } = renderHook(() => useRefWithInit(init))
rerender()
expect(result.current.current).toBe(value)
expect(init).toHaveBeenCalledTimes(1)
})
it('preserves explicit ref updates, including clearing the value', () => {
const init = vi.fn((): number | null => 1)
const { result, rerender } = renderHook(() => useRefWithInit(init))
expectTypeOf(result.current.current).toEqualTypeOf<number | null>()
result.current.current = 2
rerender()
expect(result.current.current).toBe(2)
result.current.current = null
rerender()
expect(result.current.current).toBeNull()
expect(init).toHaveBeenCalledTimes(1)
})
it('keeps committed ref identity across rerenders in Strict Mode', () => {
const { result, rerender } = renderHook(() => useRefWithInit(() => new Map<string, number>()), {
wrapper: StrictMode,
})
const ref = result.current
const map = ref.current
map.set('count', 1)
rerender()
expect(result.current).toBe(ref)
expect(result.current.current).toBe(map)
expect(result.current.current.get('count')).toBe(1)
})
})
+26
View File
@@ -0,0 +1,26 @@
/*!
* Adapted from Base UI (MIT). Copyright (c) 2019 Material-UI SAS.
* https://github.com/mui/base-ui/blob/50f90e8f8ff4cc77b624b6ad8550346e00b15445/packages/utils/src/useRefWithInit.ts
* See ./LICENSE for the full license.
*/
'use client'
import * as React from 'react'
const UNINITIALIZED = {}
/**
* Lazily initializes a ref. Later changes to initArg do not reinitialize it.
* The initializer runs during render and must be safe to retry or discard.
*/
export function useRefWithInit<T>(init: () => T): React.RefObject<T>
export function useRefWithInit<T, U>(init: (arg: U) => T, initArg: U): React.RefObject<T>
export function useRefWithInit(init: (arg?: unknown) => unknown, initArg?: unknown) {
const ref = React.useRef<unknown>(UNINITIALIZED)
if (ref.current === UNINITIALIZED) {
ref.current = init(initArg)
}
return ref
}