refactor(web): remove mutateCurrentWorkspace from AppContext, use service-layer invalidation hook

The previous implementation stored query invalidation logic (mutateCurrentWorkspace) and
isFetching state (isValidatingCurrentWorkspace) in React Context, causing QuotaPanel to
flash a loading spinner on every settings tab switch due to a useEffect calling
invalidateQueries on mount. This violated separation of concerns and React best practices.

- Remove mutateCurrentWorkspace and isValidatingCurrentWorkspace from AppContext
- Add useInvalidateCurrentWorkspace hook in service layer (consistent with project pattern)
- Remove redundant useEffect + invalidateQueries in ModelProviderPage
- QuotaPanel now derives loading from !currentWorkspace.id instead of external prop
- Update custom-web-app-brand to use the new service-layer hook
This commit is contained in:
yyh
2026-02-09 18:32:15 +08:00
parent 898e09264b
commit 72b96ba972
8 changed files with 41 additions and 70 deletions

View File

@@ -96,10 +96,8 @@ const buildAppContext = (overrides: Partial<AppContextValue> = {}): AppContextVa
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceDatasetOperator: false,
mutateUserProfile: vi.fn(),
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo,
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
const useSelector: AppContextValue['useSelector'] = selector => selector({ ...base, useSelector })
return {

View File

@@ -7,6 +7,7 @@ import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { updateCurrentWorkspace } from '@/service/common'
import { useInvalidateCurrentWorkspace } from '@/service/use-common'
import CustomWebAppBrand from './index'
vi.mock('@/app/components/base/toast', () => ({
@@ -15,6 +16,9 @@ vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/service/common', () => ({
updateCurrentWorkspace: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useInvalidateCurrentWorkspace: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
@@ -37,6 +41,8 @@ const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockImageUpload = vi.mocked(imageUpload)
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
const mockInvalidateCurrentWorkspace = vi.fn()
vi.mocked(useInvalidateCurrentWorkspace).mockReturnValue(mockInvalidateCurrentWorkspace)
const defaultPlanUsage = {
buildApps: 0,
@@ -62,7 +68,6 @@ describe('CustomWebAppBrand', () => {
remove_webapp_brand: false,
},
},
mutateCurrentWorkspace: vi.fn(),
isCurrentWorkspaceManager: true,
} as any)
mockUseProviderContext.mockReturnValue({
@@ -92,7 +97,6 @@ describe('CustomWebAppBrand', () => {
remove_webapp_brand: false,
},
},
mutateCurrentWorkspace: vi.fn(),
isCurrentWorkspaceManager: false,
} as any)
@@ -101,8 +105,7 @@ describe('CustomWebAppBrand', () => {
expect(fileInput).toBeDisabled()
})
it('toggles remove brand switch and calls the backend + mutate', async () => {
const mutateMock = vi.fn()
it('toggles remove brand switch and calls the backend + invalidate', async () => {
mockUseAppContext.mockReturnValue({
currentWorkspace: {
custom_config: {
@@ -110,7 +113,6 @@ describe('CustomWebAppBrand', () => {
remove_webapp_brand: false,
},
},
mutateCurrentWorkspace: mutateMock,
isCurrentWorkspaceManager: true,
} as any)
@@ -122,7 +124,7 @@ describe('CustomWebAppBrand', () => {
url: '/workspaces/custom-config',
body: { remove_webapp_brand: true },
}))
await waitFor(() => expect(mutateMock).toHaveBeenCalled())
await waitFor(() => expect(mockInvalidateCurrentWorkspace).toHaveBeenCalled())
})
it('shows cancel/apply buttons after successful upload and cancels properly', async () => {

View File

@@ -24,6 +24,7 @@ import { useProviderContext } from '@/context/provider-context'
import {
updateCurrentWorkspace,
} from '@/service/common'
import { useInvalidateCurrentWorkspace } from '@/service/use-common'
import { cn } from '@/utils/classnames'
const ALLOW_FILE_EXTENSIONS = ['svg', 'png']
@@ -34,9 +35,9 @@ const CustomWebAppBrand = () => {
const { plan, enableBilling } = useProviderContext()
const {
currentWorkspace,
mutateCurrentWorkspace,
isCurrentWorkspaceManager,
} = useAppContext()
const invalidateCurrentWorkspace = useInvalidateCurrentWorkspace()
const [fileId, setFileId] = useState('')
const [imgKey, setImgKey] = useState(() => Date.now())
const [uploadProgress, setUploadProgress] = useState(0)
@@ -83,7 +84,7 @@ const CustomWebAppBrand = () => {
replace_webapp_logo: fileId,
},
})
mutateCurrentWorkspace()
invalidateCurrentWorkspace()
setFileId('')
setImgKey(Date.now())
}
@@ -96,7 +97,7 @@ const CustomWebAppBrand = () => {
replace_webapp_logo: '',
},
})
mutateCurrentWorkspace()
invalidateCurrentWorkspace()
}
const handleSwitch = async (checked: boolean) => {
@@ -106,7 +107,7 @@ const CustomWebAppBrand = () => {
remove_webapp_brand: checked,
},
})
mutateCurrentWorkspace()
invalidateCurrentWorkspace()
}
const handleCancel = () => {
@@ -116,7 +117,7 @@ const CustomWebAppBrand = () => {
return (
<div className="py-4">
<div className="system-md-medium mb-2 flex items-center justify-between rounded-xl bg-background-section-burn p-4 text-text-primary">
<div className="mb-2 flex items-center justify-between rounded-xl bg-background-section-burn p-4 text-text-primary system-md-medium">
{t('webapp.removeBrand', { ns: 'custom' })}
<Switch
size="l"
@@ -127,8 +128,8 @@ const CustomWebAppBrand = () => {
</div>
<div className={cn('flex h-14 items-center justify-between rounded-xl bg-background-section-burn px-4', webappBrandRemoved && 'opacity-30')}>
<div>
<div className="system-md-medium text-text-primary">{t('webapp.changeLogo', { ns: 'custom' })}</div>
<div className="system-xs-regular text-text-tertiary">{t('webapp.changeLogoTip', { ns: 'custom' })}</div>
<div className="text-text-primary system-md-medium">{t('webapp.changeLogo', { ns: 'custom' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('webapp.changeLogoTip', { ns: 'custom' })}</div>
</div>
<div className="flex items-center">
{(!uploadDisabled && webappLogo && !webappBrandRemoved) && (
@@ -204,7 +205,7 @@ const CustomWebAppBrand = () => {
<div className="mt-2 text-xs text-[#D92D20]">{t('uploadedFail', { ns: 'custom' })}</div>
)}
<div className="mb-2 mt-5 flex items-center gap-2">
<div className="system-xs-medium-uppercase shrink-0 text-text-tertiary">{t('overview.appInfo.preview', { ns: 'appOverview' })}</div>
<div className="shrink-0 text-text-tertiary system-xs-medium-uppercase">{t('overview.appInfo.preview', { ns: 'appOverview' })}</div>
<Divider bgStyle="gradient" className="grow" />
</div>
<div className="relative mb-2 flex items-center gap-3">
@@ -215,7 +216,7 @@ const CustomWebAppBrand = () => {
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
<BubbleTextMod className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
<div className="system-md-semibold grow text-text-secondary">Chatflow App</div>
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
<div className="p-1.5">
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
</div>
@@ -246,7 +247,7 @@ const CustomWebAppBrand = () => {
<div className="flex items-center gap-1.5">
{!webappBrandRemoved && (
<>
<div className="system-2xs-medium-uppercase text-text-tertiary">POWERED BY</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
@@ -262,12 +263,12 @@ const CustomWebAppBrand = () => {
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
<div className="body-md-regular mb-1 text-text-primary">Hello! How can I assist you today?</div>
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
<Button size="small">
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
</Button>
</div>
<div className="body-lg-regular flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm">Talk to Dify</div>
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
</div>
</div>
</div>
@@ -278,14 +279,14 @@ const CustomWebAppBrand = () => {
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
<RiExchange2Fill className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
<div className="system-md-semibold grow text-text-secondary">Workflow App</div>
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
<div className="p-1.5">
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="flex items-center gap-4">
<div className="system-md-semibold-uppercase flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary">RUN ONCE</div>
<div className="system-md-semibold-uppercase flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary">RUN BATCH</div>
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
</div>
</div>
<div className="grow bg-components-panel-bg">
@@ -293,7 +294,7 @@ const CustomWebAppBrand = () => {
<div className="mb-1 py-2">
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal "></div>
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<Button size="small">
@@ -308,7 +309,7 @@ const CustomWebAppBrand = () => {
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
{!webappBrandRemoved && (
<>
<div className="system-2xs-medium-uppercase text-text-tertiary">POWERED BY</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />

View File

@@ -6,10 +6,9 @@ import {
RiBrainLine,
} from '@remixicon/react'
import { useDebounce } from 'ahooks'
import { useEffect, useMemo } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@@ -34,7 +33,6 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
const ModelProviderPage = ({ searchText }: Props) => {
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const { t } = useTranslation()
const { mutateCurrentWorkspace, isValidatingCurrentWorkspace } = useAppContext()
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
@@ -92,14 +90,10 @@ const ModelProviderPage = ({ searchText }: Props) => {
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
useEffect(() => {
mutateCurrentWorkspace()
}, [mutateCurrentWorkspace])
return (
<div className="relative -mt-2 pt-1">
<div className={cn('mb-2 flex items-center')}>
<div className="system-md-semibold grow text-text-primary">{t('modelProvider.models', { ns: 'common' })}</div>
<div className="grow text-text-primary system-md-semibold">{t('modelProvider.models', { ns: 'common' })}</div>
<div className={cn(
'relative flex shrink-0 items-center justify-end gap-2 rounded-lg border border-transparent p-px',
defaultModelNotConfigured && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
@@ -107,7 +101,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
>
{defaultModelNotConfigured && <div className="absolute bottom-0 left-0 right-0 top-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
{defaultModelNotConfigured && (
<div className="system-xs-medium flex items-center gap-1 text-text-primary">
<div className="flex items-center gap-1 text-text-primary system-xs-medium">
<RiAlertFill className="h-4 w-4 text-text-warning-secondary" />
<span className="max-w-[460px] truncate" title={t('modelProvider.notConfigured', { ns: 'common' })}>{t('modelProvider.notConfigured', { ns: 'common' })}</span>
</div>
@@ -123,14 +117,14 @@ const ModelProviderPage = ({ searchText }: Props) => {
/>
</div>
</div>
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} isLoading={isValidatingCurrentWorkspace} />}
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
{!filteredConfiguredProviders?.length && (
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur">
<RiBrainLine className="h-5 w-5 text-text-primary" />
</div>
<div className="system-sm-medium mt-2 text-text-secondary">{t('modelProvider.emptyProviderTitle', { ns: 'common' })}</div>
<div className="system-xs-regular mt-1 text-text-tertiary">{t('modelProvider.emptyProviderTip', { ns: 'common' })}</div>
<div className="mt-2 text-text-secondary system-sm-medium">{t('modelProvider.emptyProviderTitle', { ns: 'common' })}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{t('modelProvider.emptyProviderTip', { ns: 'common' })}</div>
</div>
)}
{!!filteredConfiguredProviders?.length && (
@@ -145,7 +139,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
)}
{!!filteredNotConfiguredProviders?.length && (
<>
<div className="system-md-semibold mb-2 flex items-center pt-2 text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
<div className="mb-2 flex items-center pt-2 text-text-primary system-md-semibold">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
<div className="relative">
{filteredNotConfiguredProviders?.map(provider => (
<ProviderAddedCard

View File

@@ -48,11 +48,9 @@ const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
type QuotaPanelProps = {
providers: ModelProvider[]
isLoading?: boolean
}
const QuotaPanel: FC<QuotaPanelProps> = ({
providers,
isLoading = false,
}) => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
@@ -98,7 +96,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}
}, [providers, isShowInstallModal, hideInstallFromMarketplace])
if (isLoading) {
if (!currentWorkspace.id) {
return (
<div className="my-2 flex min-h-[72px] items-center justify-center rounded-xl border-[0.5px] border-components-panel-border bg-third-party-model-bg-default shadow-xs">
<Loading />
@@ -108,13 +106,13 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
return (
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
<div className="mb-2 flex h-4 items-center text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trial_models.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
<span className="system-md-semibold-uppercase mr-0.5 text-text-secondary">{formatNumber(credits)}</span>
<span className="mr-0.5 text-text-secondary system-md-semibold-uppercase">{formatNumber(credits)}</span>
<span>{t('modelProvider.credits', { ns: 'common' })}</span>
{currentWorkspace.next_credit_reset_date
? (

View File

@@ -25,11 +25,9 @@ export type AppContextValue = {
isCurrentWorkspaceOwner: boolean
isCurrentWorkspaceEditor: boolean
isCurrentWorkspaceDatasetOperator: boolean
mutateCurrentWorkspace: VoidFunction
langGeniusVersionInfo: LangGeniusVersionResponse
useSelector: typeof useSelector
isLoadingCurrentWorkspace: boolean
isValidatingCurrentWorkspace: boolean
}
const userProfilePlaceholder = {
@@ -72,11 +70,9 @@ const AppContext = createContext<AppContextValue>({
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceDatasetOperator: false,
mutateUserProfile: noop,
mutateCurrentWorkspace: noop,
langGeniusVersionInfo: initialLangGeniusVersionInfo,
useSelector,
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
})
export function useSelector<T>(selector: (value: AppContextValue) => T): T {
@@ -91,7 +87,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
const queryClient = useQueryClient()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: userProfileResp } = useUserProfile()
const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace, isFetching: isValidatingCurrentWorkspace } = useCurrentWorkspace()
const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace } = useCurrentWorkspace()
const langGeniusVersionQuery = useLangGeniusVersion(
userProfileResp?.meta.currentVersion,
!systemFeatures.branding.enabled,
@@ -123,10 +119,6 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
queryClient.invalidateQueries({ queryKey: ['common', 'user-profile'] })
}, [queryClient])
const mutateCurrentWorkspace = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['common', 'current-workspace'] })
}, [queryClient])
// #region Zendesk conversation fields
useEffect(() => {
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
@@ -198,9 +190,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
isCurrentWorkspaceOwner,
isCurrentWorkspaceEditor,
isCurrentWorkspaceDatasetOperator,
mutateCurrentWorkspace,
isLoadingCurrentWorkspace,
isValidatingCurrentWorkspace,
}}
>
<div className="flex h-full flex-col overflow-y-auto">

View File

@@ -3081,12 +3081,6 @@
}
},
"app/components/custom/custom-web-app-brand/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 12
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@@ -4531,11 +4525,6 @@
"count": 3
}
},
"app/components/header/account-setting/model-provider-page/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 5
}
},
"app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
@@ -4739,11 +4728,6 @@
"count": 3
}
},
"app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/header/account-setting/model-provider-page/provider-icon/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1

View File

@@ -318,6 +318,10 @@ export const useInvalidDataSourceIntegrates = () => {
return useInvalid(commonQueryKeys.dataSourceIntegrates)
}
export const useInvalidateCurrentWorkspace = () => {
return useInvalid(commonQueryKeys.currentWorkspace)
}
export const usePluginProviders = () => {
return useQuery<PluginProvider[] | null>({
queryKey: commonQueryKeys.pluginProviders,