refactor(query): migrate imperative APIs to query semantics (#41746)

This commit is contained in:
yyh
2026-09-03 13:54:48 +00:00
committed by GitHub
parent e6a38fd793
commit 65b4091402
53 changed files with 914 additions and 404 deletions
@@ -12,7 +12,9 @@ Flag:
- A page/tab-level section component becoming the data owner without needing a shared snapshot or shared loading/error/empty UI.
- Feature code promoted to shared only because it appears once or might be reused later.
Accept repeated TanStack Query calls in siblings when each component independently consumes the data. Cache deduplication is not a reason to hoist by itself.
Accept repeated TanStack Query hooks for the same key and input in Client Component siblings under one QueryClient;
shared cache is not a reason to hoist. Separate Server Component QueryClients do not share it, so request-level
deduplication needs an identified request-local cache or verified framework or transport owner.
## Component Boundaries
@@ -22,10 +22,22 @@ Flag:
- Fake fallback IDs or placeholder inputs used to force a query to run.
- Query results copied into local state for rendering.
- Shared query behavior such as invalidation, stale defaults, or retry rules reimplemented at call sites.
- `prefetchQuery` treated as a hard gate or as returning data/errors to the caller.
- Deprecated imperative reads such as `fetchQuery`, `prefetchQuery`, `ensureQueryData`, or their infinite variants when
the current `query` or `infiniteQuery` contract applies.
Use `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))` directly unless a feature hook performs real orchestration.
For imperative access, treat the choices as independent dimensions:
- `query` or `infiniteQuery` resolves the generated query and returns its data.
- `staleTime` decides whether cached data satisfies this call: `0` treats it as stale, a finite value accepts a freshness
window, `Infinity` accepts it until invalidation, and `'static'` accepts available data even after invalidation.
- `select` projects the resolved value without replacing cached query-function data. An imperative query defaults to no
retries when `retry` is not configured; `enabled` is observer-only, so guard before a conditional call.
- `await` blocks the current flow, `return` transfers the Promise to the caller, and `void` discards the result without
handling rejection. Handle rejection before discarding a potentially rejecting Promise; use `.catch(noop)` only for
intentional silence or feedback owned elsewhere, and preserve rejection for hard gates.
## Mutations
Flag:
@@ -44,12 +56,18 @@ Flag:
- Request-time auth, setup, workspace role, or tenant decisions moved into static `next.config redirects()`.
- Dynamic role gates depending on `workspaces.current` implemented as static path redirects.
- Authorization logic depending on soft `prefetchQuery`.
- Authorization logic depending on an imperative query whose rejection is swallowed.
- Removing a client fallback before server API unavailable behavior is defined.
- Global placeholder query contracts introduced to solve a route-local Suspense issue.
- Branding-sensitive UI reading placeholder defaults without checking pending/placeholder state.
- A Server Component rendering or passing an imperative query result that the browser can independently revalidate,
leaving server and client output with different owners.
- A non-blocking Server Component query without pending-query dehydration, Next-compatible error redaction, a
`HydrationBoundary` covering the same-key client consumer, or an explicit Suspense and SSR-content decision.
Separate hard gates from soft prefetches. `fetchQuery` can be a server decision boundary; `prefetchQuery` is cache warmup.
Hard gates await `query` or `infiniteQuery` and preserve rejection; soft prefetches handle failure at the fallback owner.
Treat Server Components as prefetch-and-dehydrate owners by default, rendering returned data only under exclusive server
ownership or a freshness contract that prevents server/client drift.
## Workspace And Tenant
@@ -18,7 +18,9 @@ Read this document when a component consumes generated contracts, nullable API v
- If query input comes from atom state, keep it in `atomWithQuery`; do not unwrap the atom in a component solely to call `useQuery`.
- For missing required input, branch the whole generated input with `skipToken`. Add `enabled` only for an independent execution condition; do not put `skipToken` inside a placeholder payload or coerce IDs to empty strings.
- Return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly from TanStack Query atoms. Pass supported options into the generated call instead of spreading into a parallel object.
- Share the exact options between prefetch and render when they represent the same request. Do not extract option helpers merely to reuse input construction.
- For the same logical request, preserve key, input, operation, and result contracts. Reuse exact options only when
transport, context, and cache policy are shared; imperative and observer freshness may differ. Do not extract a helper
merely to reuse input construction.
- Avoid pass-through service hooks that only rename generated options. Keep feature hooks for actual orchestration or shared domain behavior.
## Mutations And Cache
@@ -31,12 +33,21 @@ Read this document when a component consumes generated contracts, nullable API v
## Prefetch And Hidden Surfaces
- Prefetch expensive secondary content from the trigger or menu-open event when it benefits the visible path. Do not mount hidden subscribers solely to warm the cache.
- `prefetchQuery` is cache warmup, not an authorization or availability gate. Use a hard fetch boundary when the server must decide whether rendering may proceed.
- Use `query` or `infiniteQuery` for imperative access. `staleTime` defines cache acceptance; `select` projects the return
value without replacing cached query-function data. Imperative queries default to no retries when `retry` is not
configured, and observer-only `enabled` does not prevent an imperative call.
- `await` blocks, `return` transfers the Promise, and `void` discards its value without handling rejection. Handle
rejection before discarding a potentially rejecting Promise; use `.catch(noop)` only for intentional silence or
feedback owned elsewhere. Hard server gates await the query and preserve rejection.
## SSR, Authentication, And Workspace
- Static configuration owns path-invariant routing. Request-dependent authentication, setup, role, and tenant decisions belong to SSR or runtime decision boundaries.
- Distinguish soft SSR cache warming from authoritative decisions. Prefetched or placeholder data must not grant access or represent successful availability.
- Treat Server Components as query prefetch-and-dehydrate owners by default. Do not render or pass an imperative query
result when a browser observer can revalidate the same data unless ownership and freshness explicitly prevent drift.
- Non-blocking RSC streaming requires pending-query dehydration without redacting Next.js server errors, a
`HydrationBoundary` around the same-key client consumer, and Suspense when that content must be server-rendered.
- Never reuse tenant-scoped state after switching workspaces. Discard it at the switch boundary or isolate it by workspace identity.
- Do not make product or authorization decisions from bootstrap defaults. Wait for authoritative data, or render an explicit loading or error state.
- Keep loading and Suspense behavior inside the feature that owns the request. Do not add fake global data merely to bypass that boundary.
@@ -14,7 +14,9 @@ Read this document when adding, moving, splitting, or refactoring React componen
- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence.
- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
- Repeated TanStack Query calls in siblings are acceptable when each sibling independently consumes the data; the cache already deduplicates requests.
- Repeated TanStack Query hooks for the same key and input in Client Component siblings under one QueryClient are
acceptable; shared cache is not a reason to hoist. Separate Server Component QueryClients do not share it, so
request-level deduplication needs an identified request-local cache or verified framework or transport owner.
- Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept.
- One pass-through prop layer is acceptable. Repeated forwarding means ownership should move closer to the consumer or into feature-scoped shared state.
- Do not replace prop drilling with one large view-model hook. Move each query, derived value, and handler to the concrete owner that consumes it.
@@ -180,6 +180,29 @@ describe('CommonLayoutHydrationBoundary', () => {
)
})
it.each(['workspace', 'permissions'] as const)(
'should keep the Common shell recoverable when the %s query fails',
async (target) => {
const failedQueryFn =
target === 'workspace' ? mocks.workspaceQueryFn : mocks.permissionQueryFn
failedQueryFn.mockRejectedValue(new Error(`${target} unavailable`))
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
const element = await CommonLayoutHydrationBoundary({ children: <div>Common shell</div> })
const state = (element as ReactElement<{ state: DehydratedState }>).props.state
expect(mocks.redirect).not.toHaveBeenCalled()
expect(state.queries.map((query) => query.queryKey)).not.toContainEqual(
target === 'workspace'
? ['console', 'workspaces', 'current', 'summary', 'get']
: [
['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'],
{ type: 'query' },
],
)
},
)
it('should redirect unauthorized users to the refresh route with the current path', async () => {
mocks.basePath = '/workflow'
mocks.profileQueryFn.mockRejectedValue(
@@ -2,14 +2,14 @@ import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
const mocks = vi.hoisted(() => ({
ensureSystemFeatures: vi.fn(),
getSystemFeatures: vi.fn(),
redirect: vi.fn((_href: string) => {
throw new Error('NEXT_REDIRECT')
}),
}))
vi.mock('@/features/system-features/server', () => ({
ensureSystemFeatures: () => mocks.ensureSystemFeatures(),
getSystemFeatures: () => mocks.getSystemFeatures(),
}))
vi.mock('@/next/navigation', () => ({
@@ -25,19 +25,19 @@ async function renderLayout(children: ReactNode) {
describe('NewKnowledgeLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.ensureSystemFeatures.mockResolvedValue({ knowledge_fs_enabled: true })
mocks.getSystemFeatures.mockResolvedValue({ knowledge_fs_enabled: true })
})
it('renders new knowledge routes when KnowledgeFS is enabled', async () => {
await renderLayout(<div>New knowledge content</div>)
expect(mocks.ensureSystemFeatures).toHaveBeenCalledOnce()
expect(mocks.getSystemFeatures).toHaveBeenCalledOnce()
expect(screen.getByText('New knowledge content')).toBeInTheDocument()
expect(mocks.redirect).not.toHaveBeenCalled()
})
it('redirects to datasets when KnowledgeFS is disabled', async () => {
mocks.ensureSystemFeatures.mockResolvedValue({ knowledge_fs_enabled: false })
mocks.getSystemFeatures.mockResolvedValue({ knowledge_fs_enabled: false })
const { default: Layout } = await import('../layout')
await expect(Layout({ children: <div>New knowledge content</div> })).rejects.toThrow(
@@ -49,7 +49,7 @@ describe('NewKnowledgeLayout', () => {
it('preserves System Features failures', async () => {
const error = new Error('System Features unavailable')
mocks.ensureSystemFeatures.mockRejectedValue(error)
mocks.getSystemFeatures.mockRejectedValue(error)
const { default: Layout } = await import('../layout')
await expect(Layout({ children: <div>New knowledge content</div> })).rejects.toBe(error)
@@ -1,9 +1,9 @@
import type { ReactNode } from 'react'
import { ensureSystemFeatures } from '@/features/system-features/server'
import { getSystemFeatures } from '@/features/system-features/server'
import { redirect } from '@/next/navigation'
export default async function Layout({ children }: { children: ReactNode }) {
const systemFeatures = await ensureSystemFeatures()
const systemFeatures = await getSystemFeatures()
if (!systemFeatures.knowledge_fs_enabled) redirect('/datasets')
+18 -14
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { dehydrate, HydrationBoundary, noop } from '@tanstack/react-query'
import { getQueryClient } from '@/app/get-query-client'
import { serverUserProfileQueryOptions } from '@/features/account-profile/server'
import { headers } from '@/next/headers'
@@ -63,19 +63,23 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re
const context = await getServerConsoleClientContext()
await Promise.all([
queryClient.fetchQuery(serverUserProfileQueryOptions()),
queryClient.prefetchQuery(
serverConsoleQuery.workspaces.current.summary.get.queryOptions({
context,
retry: false,
}),
),
queryClient.prefetchQuery(
serverConsoleQuery.workspaces.current.rbac.myPermissions.get.queryOptions({
context,
retry: false,
}),
),
queryClient.query(serverUserProfileQueryOptions()),
queryClient
.query(
serverConsoleQuery.workspaces.current.summary.get.queryOptions({
context,
retry: false,
}),
)
.catch(noop),
queryClient
.query(
serverConsoleQuery.workspaces.current.rbac.myPermissions.get.queryOptions({
context,
retry: false,
}),
)
.catch(noop),
])
} catch (error) {
await handleProfileError(error)
+2 -2
View File
@@ -9,7 +9,7 @@ describe('getQueryClient', () => {
const queryPromise = new Promise<string>((resolve) => {
resolveQuery = resolve
})
const prefetchPromise = queryClient.prefetchQuery({
const queryExecution = queryClient.query({
queryKey,
queryFn: () => queryPromise,
})
@@ -24,7 +24,7 @@ describe('getQueryClient', () => {
)
resolveQuery('ready')
await prefetchPromise
await queryExecution
queryClient.removeQueries({ queryKey })
})
})
+11 -35
View File
@@ -1,24 +1,12 @@
import { QueryClient } from '@tanstack/react-query'
let queryClient: QueryClient
const mocks = vi.hoisted(() => ({
getSystemFeatures: vi.fn(),
dehydrateSystemFeatures: vi.fn(() => ({ mutations: [], queries: [] })),
getOptionalSystemFeatures: vi.fn(),
headers: vi.fn(async () => new Headers()),
}))
vi.mock('@/features/system-features/server', () => ({
getSystemFeaturesQueryClient: () => queryClient,
prefetchSystemFeatures: async () => {
const queryOptions = {
queryKey: ['console', 'system-features'],
queryFn: mocks.getSystemFeatures,
retry: false,
}
if (!queryClient.getQueryState(queryOptions.queryKey))
await queryClient.prefetchQuery(queryOptions)
return queryClient.getQueryData(queryOptions.queryKey)
},
dehydrateSystemFeatures: mocks.dehydrateSystemFeatures,
getOptionalSystemFeatures: mocks.getOptionalSystemFeatures,
}))
vi.mock('@/env', async (importOriginal) => {
@@ -42,11 +30,10 @@ describe('Root layout System Features bootstrap', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.headers.mockResolvedValue(new Headers())
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
})
it('caches the resolved System Features for dehydration', async () => {
mocks.getSystemFeatures.mockResolvedValue({
it('uses optional System Features for branded metadata', async () => {
mocks.getOptionalSystemFeatures.mockResolvedValue({
branding: {
application_title: 'Acme AI',
enabled: true,
@@ -62,19 +49,10 @@ describe('Root layout System Features bootstrap', () => {
template: '%s - Acme AI',
},
})
expect(mocks.getSystemFeatures).toHaveBeenCalledTimes(1)
expect(queryClient.getQueryData(['console', 'system-features'])).toEqual({
branding: {
application_title: 'Acme AI',
enabled: true,
},
deployment_edition: 'CLOUD',
})
})
it('points the icons at the branding favicon when one is configured', async () => {
mocks.getSystemFeatures.mockResolvedValue({
mocks.getOptionalSystemFeatures.mockResolvedValue({
branding: {
application_title: 'Acme AI',
enabled: true,
@@ -93,7 +71,7 @@ describe('Root layout System Features bootstrap', () => {
})
it('falls back to the static favicon without branding', async () => {
mocks.getSystemFeatures.mockResolvedValue({
mocks.getOptionalSystemFeatures.mockResolvedValue({
branding: { enabled: false },
deployment_edition: 'CLOUD',
})
@@ -105,7 +83,7 @@ describe('Root layout System Features bootstrap', () => {
})
it('falls back to the static favicon when branding is enabled without one', async () => {
mocks.getSystemFeatures.mockResolvedValue({
mocks.getOptionalSystemFeatures.mockResolvedValue({
branding: { application_title: 'Acme AI', enabled: true, favicon: '' },
deployment_edition: 'CLOUD',
})
@@ -116,8 +94,8 @@ describe('Root layout System Features bootstrap', () => {
})
})
it('renders the client recovery path when the server prefetch fails', async () => {
mocks.getSystemFeatures.mockRejectedValue(new Error('system features unavailable'))
it('renders the client recovery path when optional System Features are unavailable', async () => {
mocks.getOptionalSystemFeatures.mockResolvedValue(undefined)
const { default: RootLayout, generateMetadata } = await import('../layout')
await expect(RootLayout({ children: <div>App</div> })).resolves.toBeDefined()
@@ -127,7 +105,5 @@ describe('Root layout System Features bootstrap', () => {
template: '%s - Dify',
},
})
expect(queryClient.getQueryData(['console', 'system-features'])).toBeUndefined()
})
})
@@ -16,8 +16,12 @@ import { AppACLPermission } from '@/utils/permission'
import { basePath } from '@/utils/var'
import { AppPublisher } from '../index'
const render = (ui: React.ReactElement) =>
const render = (
ui: React.ReactElement,
queryClient?: ReturnType<typeof createConsoleQueryClient>,
) =>
renderWithConsoleQuery(ui, {
queryClient,
systemFeatures: { webapp_auth: { enabled: true } },
})
@@ -1118,6 +1122,7 @@ describe('AppPublisher', () => {
it('should refresh the shared workflow query and store after a collaborator publishes', async () => {
const setPublishedAt = vi.fn()
const queryClient = createConsoleQueryClient()
const workflowStore = {
getState: () => ({ setPublishedAt }),
}
@@ -1129,11 +1134,16 @@ describe('AppPublisher', () => {
created_at: 1_710_000_300,
hash: 'published-hash',
})
queryClient.setQueryData(['workflow', 'publish', 'app-1'], {
created_at: 1_710_000_100,
hash: 'stale-published-hash',
})
render(
<WorkflowContext value={workflowStore as any}>
<AppPublisher publishedAt={1_710_000_100_000} />
</WorkflowContext>,
queryClient,
)
act(() => {
@@ -155,7 +155,10 @@ export function usePublishController({
if (supportsMultiEnvironment) refreshAppDeploymentData(queryClient, appId)
void queryClient
.fetchQuery(appWorkflowQueryOptions(appId))
.query({
...appWorkflowQueryOptions(appId),
staleTime: 0,
})
.then((publishedWorkflow) => {
workflowStore?.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0)
})
@@ -21,7 +21,7 @@ const { configState, mockHeadersGet, systemFeaturesQueryKey } = vi.hoisted(() =>
}))
vi.mock('@/features/system-features/server', () => ({
getCachedSystemFeatures: () => queryClient.getQueryData(systemFeaturesQueryKey),
getOptionalSystemFeatures: async () => queryClient.getQueryData(systemFeaturesQueryKey),
}))
vi.mock('@/config', () => ({
@@ -1,12 +1,12 @@
import { COOKIEYES_SITE_KEY, IS_PROD, WEB_PREFIX } from '@/config'
import { getCachedSystemFeatures } from '@/features/system-features/server'
import { getOptionalSystemFeatures } from '@/features/system-features/server'
import { headers } from '@/next/headers'
import { AnalyticsDisabled } from './analytics-disabled'
import { CloudAnalyticsLayoutBoundary } from './cloud-analytics-layout-boundary'
import { isCloudAnalyticsRequest } from './request-boundary'
export async function CloudAnalytics() {
const systemFeatures = getCachedSystemFeatures()
const systemFeatures = await getOptionalSystemFeatures()
if (!systemFeatures) return null
@@ -1,6 +1,5 @@
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { ReactNode } from 'react'
import { QueryClient } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import Zendesk from '../index'
@@ -10,10 +9,8 @@ let mockDeploymentEdition: DeploymentEdition = 'CLOUD'
let mockZendeskWidgetKey: string | undefined = 'test-key'
let mockIsProd = false
let mockNonce: string | null = 'test-nonce'
let queryClient: QueryClient
const systemFeaturesQueryKey = ['console', 'system-features']
const getSystemFeatures = vi.fn()
const mocks = vi.hoisted(() => ({
getOptionalSystemFeatures: vi.fn(),
headers: vi.fn(),
scriptProps: vi.fn(),
}))
@@ -37,15 +34,7 @@ vi.mock('@/config', () => ({
}))
vi.mock('@/features/system-features/server', () => ({
prefetchSystemFeatures: async () => {
const queryOptions = {
queryKey: systemFeaturesQueryKey,
queryFn: getSystemFeatures,
retry: false,
}
await queryClient.prefetchQuery(queryOptions)
return queryClient.getQueryData(queryOptions.queryKey)
},
getOptionalSystemFeatures: mocks.getOptionalSystemFeatures,
}))
// Mock next/headers
@@ -71,8 +60,7 @@ describe('Zendesk', () => {
mockZendeskWidgetKey = 'test-key'
mockIsProd = false
mockNonce = 'test-nonce'
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
getSystemFeatures.mockImplementation(async () => ({
mocks.getOptionalSystemFeatures.mockImplementation(async () => ({
deployment_edition: mockDeploymentEdition,
}))
mocks.headers.mockImplementation(async () => {
@@ -102,11 +90,11 @@ describe('Zendesk', () => {
const result = await renderZendesk()
expect(result).toBeNull()
expect(mocks.headers).not.toHaveBeenCalled()
expect(getSystemFeatures).not.toHaveBeenCalled()
expect(mocks.getOptionalSystemFeatures).not.toHaveBeenCalled()
})
it('should render nothing when System Features is unavailable', async () => {
getSystemFeatures.mockRejectedValue(new Error('system features unavailable'))
mocks.getOptionalSystemFeatures.mockResolvedValue(undefined)
const result = await renderZendesk()
+2 -2
View File
@@ -1,13 +1,13 @@
import { memo } from 'react'
import { IS_PROD, ZENDESK_WIDGET_KEY } from '@/config'
import { prefetchSystemFeatures } from '@/features/system-features/server'
import { getOptionalSystemFeatures } from '@/features/system-features/server'
import { headers } from '@/next/headers'
import { ZendeskScript } from './script'
const Zendesk = async () => {
if (!ZENDESK_WIDGET_KEY) return null
const systemFeatures = await prefetchSystemFeatures()
const systemFeatures = await getOptionalSystemFeatures()
if (!systemFeatures || systemFeatures.deployment_edition !== 'CLOUD') return null
const nonce = IS_PROD ? ((await headers()).get('x-nonce') ?? '') : ''
@@ -89,7 +89,7 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
// Delete flow handlers
const detectIsUsedByApp = useCallback(async () => {
try {
const { is_using: isUsedByApp } = await queryClient.fetchQuery(
const { is_using: isUsedByApp } = await queryClient.query(
consoleQuery.datasets.byDatasetId.useCheck.get.queryOptions({
input: {
params: {
@@ -240,7 +240,7 @@ export const useLazyModelProviderDetail = (providerName: string) => {
const loadProviderDetail = useCallback(async () => {
setEnabled(true)
try {
const response = await queryClient.fetchQuery(modelProviderDetailsQueryOptions())
const response = await queryClient.query(modelProviderDetailsQueryOptions())
return response.data.find((provider) => provider.provider === providerName)
} catch {
return undefined
@@ -1,6 +1,8 @@
import type { QueryClient } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import type { ModelProviderPluginSummary } from '../../index'
import type { PluginDetail } from '@/app/components/plugins/types'
import { zPostWorkspacesCurrentPluginListInstallationsIdsResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PluginSource } from '@/app/components/plugins/types'
@@ -143,6 +145,46 @@ const createSummary = (
...overrides,
})
const pluginInstallationsResponse = zPostWorkspacesCurrentPluginListInstallationsIdsResponse.parse({
plugins: [
{
checksum: 'checksum',
created_at: '2026-09-03T00:00:00Z',
declaration: {
author: 'langgenius',
category: 'model',
created_at: '2026-09-03T00:00:00Z',
description: { en_US: 'Provider plugin' },
icon: 'icon.svg',
label: { en_US: 'Provider Plugin' },
meta: {},
name: 'provider-plugin',
plugins: {},
resource: {},
version: '1.0.0',
},
endpoints_active: 0,
endpoints_setups: 0,
id: 'installation-id',
meta: {},
plugin_id: 'langgenius/provider-plugin',
plugin_unique_identifier: 'langgenius/provider-plugin@1.0.0',
runtime_type: 'local',
source: 'github',
tenant_id: 'tenant-id',
updated_at: '2026-09-03T00:00:00Z',
version: '1.0.0',
},
],
})
const seedPluginDetail = (queryClient: QueryClient) => {
const options = consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({
input: { body: { plugin_ids: ['langgenius/provider-plugin'] } },
})
queryClient.setQueryData(options.queryKey, pluginInstallationsResponse)
}
describe('ProviderCardActions', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -206,9 +248,7 @@ describe('ProviderCardActions', () => {
const rendered = render(
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
)
const fetchQuery = vi
.spyOn(rendered.queryClient, 'fetchQuery')
.mockResolvedValue({ plugins: [{}] })
seedPluginDetail(rendered.queryClient)
openActionsMenu()
fireEvent.click(screen.getByText('plugin.detailPanel.operation.info'))
@@ -216,7 +256,6 @@ describe('ProviderCardActions', () => {
await waitFor(() => {
expect(mockShowPluginInfo).toHaveBeenCalledTimes(1)
})
expect(fetchQuery).toHaveBeenCalledTimes(1)
expect(mockNormalizeInstalledPluginDetail).toHaveBeenCalledTimes(1)
})
@@ -224,7 +263,6 @@ describe('ProviderCardActions', () => {
const rendered = render(
<ProviderCardActions summary={createSummary()} providerLabel="Provider Plugin" />,
)
const fetchQuery = vi.spyOn(rendered.queryClient, 'fetchQuery')
const invalidateQueries = vi.spyOn(rendered.queryClient, 'invalidateQueries')
openActionsMenu()
@@ -234,7 +272,7 @@ describe('ProviderCardActions', () => {
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('installation-id')
})
expect(fetchQuery).not.toHaveBeenCalled()
expect(mockNormalizeInstalledPluginDetail).not.toHaveBeenCalled()
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(),
})
@@ -298,7 +336,7 @@ describe('ProviderCardActions', () => {
providerLabel="Provider Plugin"
/>,
)
vi.spyOn(rendered.queryClient, 'fetchQuery').mockResolvedValue({ plugins: [{}] })
seedPluginDetail(rendered.queryClient)
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' }))
@@ -331,7 +369,7 @@ describe('ProviderCardActions', () => {
onUpdate={onUpdate}
/>,
)
vi.spyOn(rendered.queryClient, 'fetchQuery').mockResolvedValue({ plugins: [{}] })
seedPluginDetail(rendered.queryClient)
await user.click(screen.getByRole('button', { name: current }))
let updateComplete: Promise<void> | undefined
@@ -374,7 +412,7 @@ describe('ProviderCardActions', () => {
onUpdate={onUpdate}
/>,
)
vi.spyOn(rendered.queryClient, 'fetchQuery').mockResolvedValue({ plugins: [{}] })
seedPluginDetail(rendered.queryClient)
await user.click(screen.getByRole('button', { name: '1.0.0' }))
let thrownError: unknown
@@ -296,7 +296,7 @@ describe('ModelAuthDropdown', () => {
current_credential_usable: true,
},
} as unknown as ModelProviderSummaryResponse
const rendered = render(
render(
<ModelAuthDropdown
provider={providerSummary}
state={createState({ hasCredentials: true, variant: 'api-active' })}
@@ -304,13 +304,13 @@ describe('ModelAuthDropdown', () => {
onChangePriority={onChangePriority}
/>,
)
const fetchQuery = vi
.spyOn(rendered.queryClient, 'fetchQuery')
.mockImplementation(async () => {
const response = { data: [fullProvider] }
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, response)
return response
})
const fetchMock = vi.mocked(globalThis.fetch)
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ data: [fullProvider] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
const trigger = screen.getByRole('button', { name: /config/i })
fireEvent.click(trigger)
@@ -318,7 +318,7 @@ describe('ModelAuthDropdown', () => {
await waitFor(() => {
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
expect(fetchQuery).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledTimes(1)
fireEvent.click(trigger)
await waitFor(() => {
@@ -329,7 +329,7 @@ describe('ModelAuthDropdown', () => {
await waitFor(() => {
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
expect(fetchQuery).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('should render updated provider detail from the query cache', async () => {
@@ -365,11 +365,13 @@ describe('ModelAuthDropdown', () => {
onChangePriority={onChangePriority}
/>,
)
vi.spyOn(rendered.queryClient, 'fetchQuery').mockImplementation(async () => {
const response = { data: [firstProvider] }
rendered.queryClient.setQueryData(commonQueryKeys.modelProviderDetails, response)
return response
})
const fetchMock = vi.mocked(globalThis.fetch)
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ data: [firstProvider] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
fireEvent.click(screen.getByRole('button', { name: /config/i }))
await waitFor(() => expect(screen.getByText('Key 1')).toBeInTheDocument())
@@ -120,7 +120,7 @@ function SummaryProviderCardActions({ summary, providerLabel, onUpdate }: Summar
const loadDetail = async (action: DetailAction) => {
setLoadingAction(action)
try {
const response = await queryClient.fetchQuery(
const response = await queryClient.query(
consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({
input: { body: { plugin_ids: [summary.plugin_id] } },
}),
@@ -1,4 +1,5 @@
import { zLicenseStatus } from '@dify/contracts/api/console/system-features/zod.gen'
import { noop } from '@tanstack/react-query'
import { screen } from '@testing-library/react'
import dayjs from 'dayjs'
import { consoleQuery } from '@/service/client'
@@ -13,10 +14,12 @@ const renderLicenseBadge = (license?: Parameters<typeof seedSystemFeaturesLicens
const queryClient = createConsoleQueryClient()
if (license) seedSystemFeaturesLicense(queryClient, license)
else {
void queryClient.prefetchQuery({
queryKey: consoleQuery.systemFeatures.license.get.queryOptions().queryKey,
queryFn: () => new Promise(() => {}),
})
void queryClient
.query({
queryKey: consoleQuery.systemFeatures.license.get.queryOptions().queryKey,
queryFn: () => new Promise(() => {}),
})
.catch(noop)
}
return renderWithConsoleQuery(<LicenseBadge />, { queryClient })
}
@@ -5,7 +5,13 @@ import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import {
noop,
useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
@@ -269,7 +275,7 @@ export function WorkspaceCard() {
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const isCloudEdition = deploymentEdition === 'CLOUD'
const prefetchWorkspaces = () => {
void queryClient.prefetchQuery(workspacesQueryOptions)
void queryClient.query(workspacesQueryOptions).catch(noop)
}
if (currentWorkspaceQuery.isPending || !currentWorkspace?.name) {
@@ -12,6 +12,11 @@ const render = (ui: ReactElement) => {
queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
data: [{ provider: 'model-collection-id' }],
})
void queryClient.invalidateQueries({
queryKey: commonQueryKeys.modelProviderDetails,
exact: true,
refetchType: 'none',
})
return renderWithConsoleQuery(ui, { queryClient })
}
@@ -584,6 +589,7 @@ describe('ProviderDetail', () => {
await waitFor(() => {
expect(mockSetShowModelModal).toHaveBeenCalled()
})
expect(globalThis.fetch).not.toHaveBeenCalled()
})
})
+4 -1
View File
@@ -101,7 +101,10 @@ const ProviderDetail = ({ collection, onHide, onRefreshData }: Props) => {
const summary = providers.find((item) => item.provider === collection?.id)
if (!summary) return
try {
const response = await queryClient.ensureQueryData(modelProviderDetailsQueryOptions())
const response = await queryClient.query({
...modelProviderDetailsQueryOptions(),
staleTime: 'static',
})
const provider = response.data.find((item) => item.provider === summary.provider)
if (!provider) return
setShowModelModal({
@@ -195,7 +195,7 @@ export const useWorkflowInit = () => {
const handleFetchPreloadData = useCallback(async () => {
const [nodesDefaultConfigsResult, publishedWorkflowResult] = await Promise.allSettled([
fetchNodesDefaultConfigs(`/apps/${appDetail.id}/workflows/default-workflow-block-configs`),
queryClient.fetchQuery(appWorkflowQueryOptions(appDetail.id)),
queryClient.query(appWorkflowQueryOptions(appDetail.id)),
])
if (nodesDefaultConfigsResult.status === 'fulfilled') {
@@ -1,4 +1,9 @@
import type { SnippetWorkflowResponse } from '@dify/contracts/api/console/snippets/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import { consoleQuery } from '@/service/client'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { createTestQueryClient } from '@/test/query-client'
import { NESTED_ELEMENT_Z_INDEX } from '../../../constants'
import { useInsertSnippet } from '../use-insert-snippet'
@@ -38,7 +43,7 @@ type TestEdge = {
}
}
const mockFetchQuery = vi.fn()
const mockRequest = vi.hoisted(() => vi.fn())
const mockHandleSyncWorkflowDraft = vi.fn()
const mockSaveStateToHistory = vi.fn()
const mockToastError = vi.fn()
@@ -48,10 +53,9 @@ const mockSetEdges = vi.fn()
const mockIncrementSnippetUseCount = vi.fn()
let mockEdges: unknown[] = [{ id: 'existing-edge', source: 'old', target: 'old-2' }]
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
fetchQuery: mockFetchQuery,
}),
vi.mock('@/service/base', () => ({
request: (...args: unknown[]) => mockRequest(...args),
sseGeneratorPost: vi.fn(),
}))
vi.mock('reactflow', () => ({
@@ -92,9 +96,28 @@ vi.mock('@/service/use-snippets', () => ({
}),
}))
type PublishedWorkflow = Pick<SnippetWorkflowResponse, 'graph'>
const seedPublishedWorkflow = (queryClient: QueryClient, workflow: PublishedWorkflow) => {
const queryOptions = consoleQuery.snippets.bySnippetId.workflows.publish.get.queryOptions({
input: {
params: { snippet_id: 'snippet-1' },
},
})
queryClient.setQueryData<PublishedWorkflow>(queryOptions.queryKey, workflow)
}
describe('useInsertSnippet', () => {
let queryClient: QueryClient
const renderUseInsertSnippet = () =>
renderHook(() => useInsertSnippet(), {
wrapper: createQueryClientWrapper(queryClient),
})
beforeEach(() => {
vi.clearAllMocks()
queryClient = createTestQueryClient()
mockEdges = [{ id: 'existing-edge', source: 'old', target: 'old-2' }]
mockGetNodes.mockReturnValue([
{
@@ -105,9 +128,13 @@ describe('useInsertSnippet', () => {
])
})
afterEach(() => {
queryClient.clear()
})
describe('Insert Flow', () => {
it('should append remapped snippet graph into current workflow graph', async () => {
mockFetchQuery.mockResolvedValue({
seedPublishedWorkflow(queryClient, {
graph: {
nodes: [
{
@@ -149,13 +176,12 @@ describe('useInsertSnippet', () => {
},
})
const { result } = renderHook(() => useInsertSnippet())
const { result } = renderUseInsertSnippet()
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
})
expect(mockFetchQuery).toHaveBeenCalledTimes(1)
expect(mockSetNodes).toHaveBeenCalledTimes(1)
expect(mockSetEdges).toHaveBeenCalledTimes(1)
@@ -189,7 +215,7 @@ describe('useInsertSnippet', () => {
})
it('should remap variable selectors that reference nodes inside the snippet', async () => {
mockFetchQuery.mockResolvedValue({
seedPublishedWorkflow(queryClient, {
graph: {
nodes: [
{
@@ -228,7 +254,7 @@ describe('useInsertSnippet', () => {
},
})
const { result } = renderHook(() => useInsertSnippet())
const { result } = renderUseInsertSnippet()
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
@@ -246,7 +272,7 @@ describe('useInsertSnippet', () => {
})
it('should remap structural node ids inside a loop snippet', async () => {
mockFetchQuery.mockResolvedValue({
seedPublishedWorkflow(queryClient, {
graph: {
nodes: [
{
@@ -274,7 +300,7 @@ describe('useInsertSnippet', () => {
},
})
const { result } = renderHook(() => useInsertSnippet())
const { result } = renderUseInsertSnippet()
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
@@ -331,7 +357,7 @@ describe('useInsertSnippet', () => {
},
},
]
mockFetchQuery.mockResolvedValue({
seedPublishedWorkflow(queryClient, {
graph: {
nodes: [
{
@@ -366,7 +392,7 @@ describe('useInsertSnippet', () => {
},
})
const { result } = renderHook(() => useInsertSnippet())
const { result } = renderUseInsertSnippet()
await act(async () => {
await result.current.handleInsertSnippet('snippet-1', {
@@ -450,9 +476,9 @@ describe('useInsertSnippet', () => {
)
it('should show error toast when fetching snippet workflow fails', async () => {
mockFetchQuery.mockRejectedValue(new Error('insert failed'))
mockRequest.mockRejectedValue(new Error('insert failed'))
const { result } = renderHook(() => useInsertSnippet())
const { result } = renderUseInsertSnippet()
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
@@ -358,7 +358,7 @@ export const useInsertSnippet = () => {
const handleInsertSnippet = useCallback(
async (snippetId: string, insertPayload?: SnippetInsertPayload) => {
try {
const workflow = await queryClient.fetchQuery(
const workflow = await queryClient.query(
consoleQuery.snippets.bySnippetId.workflows.publish.get.queryOptions({
input: {
params: { snippet_id: snippetId },
@@ -691,7 +691,7 @@ export const useChecklistBeforePublish = () => {
await Promise.all(
knowledgeBaseEmbeddingProviders.map(async (provider) => {
try {
const modelList = await queryClient.fetchQuery(
const modelList = await queryClient.query(
consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({
input: { params: { provider } },
}),
+6 -6
View File
@@ -2,7 +2,7 @@ import type { ThemeProviderProps } from 'next-themes'
import type { Metadata, Viewport } from '@/next'
import { ToastHost } from '@langgenius/dify-ui/toast'
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { HydrationBoundary } from '@tanstack/react-query'
import { Provider as JotaiProvider } from 'jotai/react'
import { ThemeProvider } from 'next-themes'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
@@ -10,8 +10,8 @@ import { IS_PROD } from '@/config'
import { getDatasetMap } from '@/env'
import { SystemFeaturesBootstrapBoundary } from '@/features/system-features/bootstrap-boundary'
import {
getSystemFeaturesQueryClient,
prefetchSystemFeatures,
dehydrateSystemFeatures,
getOptionalSystemFeatures,
} from '@/features/system-features/server'
import { getLocaleOnServer } from '@/i18n-config/server'
import { headers } from '@/next/headers'
@@ -33,7 +33,7 @@ export const viewport: Viewport = {
}
export async function generateMetadata(): Promise<Metadata> {
const systemFeatures = await prefetchSystemFeatures()
const systemFeatures = await getOptionalSystemFeatures()
const branding = systemFeatures?.branding
const applicationTitle = getApplicationTitle(branding)
const brandedFavicon = branding?.enabled ? branding.favicon : undefined
@@ -54,9 +54,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const [locale, requestHeaders] = await Promise.all([
getLocaleOnServer(),
headers(),
prefetchSystemFeatures(),
getOptionalSystemFeatures(),
])
const dehydratedState = dehydrate(getSystemFeaturesQueryClient())
const dehydratedState = dehydrateSystemFeatures()
const nonce = IS_PROD ? (requestHeaders.get('x-nonce') ?? undefined) : undefined
const themeProviderProps: Omit<ThemeProviderProps, 'children'> = {
attribute: 'data-theme',
@@ -1,6 +1,6 @@
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { noop, QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { userProfileQueryOptions } from '@/features/account-profile/client'
@@ -535,7 +535,7 @@ describe('CheckCode', () => {
serviceBaseMocks.get
.mockRejectedValueOnce(new Response(null, { status: 401 }))
.mockReturnValueOnce(profileResponse)
await queryClient.prefetchQuery(profileQueryOptions)
await queryClient.query(profileQueryOptions).catch(noop)
expect(queryClient.getQueryState(profileQueryKey)?.status).toBe('error')
render(
+1 -1
View File
@@ -82,7 +82,7 @@ export default function CheckCode() {
} else {
const profileQueryOptions = userProfileQueryOptions()
await queryClient.resetQueries({ queryKey: profileQueryOptions.queryKey })
await queryClient.fetchQuery(profileQueryOptions)
await queryClient.query(profileQueryOptions)
replaceLoginRedirect(resolvePostLoginRedirect(searchParams), router.replace, basePath)
}
} else {
@@ -54,7 +54,7 @@ describe('serverUserProfileQueryOptions', () => {
const { serverUserProfileQueryOptions } = await import('../server')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const data = await queryClient.fetchQuery(serverUserProfileQueryOptions())
const data = await queryClient.query(serverUserProfileQueryOptions())
expect(serverUserProfileQueryOptions().queryKey).toEqual(userProfileQueryOptions().queryKey)
expect(data).toEqual({
@@ -594,6 +594,35 @@ describe('AgentConfigurePublishBar', () => {
expect(onPublish).not.toHaveBeenCalled()
})
it('should refresh cached workflow references before publishing', async () => {
const { onPublish, queryClient } = renderPublishBar({
activeConfigSnapshot,
prompt: 'Updated system prompt',
})
const referencesQueryKey = ['agent-referencing-workflows', { params: { agent_id: 'agent-1' } }]
await waitFor(() => {
expect(workflowReferences.fetchCount).toBe(1)
})
queryClient.setQueryDefaults(['agent-referencing-workflows'], { staleTime: Infinity })
queryClient.setQueryData(referencesQueryKey, { data: [] })
workflowReferences.data = publishedReferences
await userEvent.click(
screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/,
}),
)
expect(onPublish).not.toHaveBeenCalled()
expect(
await screen.findByRole('region', {
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
}),
).toBeInTheDocument()
expect(workflowReferences.fetchCount).toBe(2)
})
it('should mark non-prompt draft changes as unpublished', () => {
renderPublishBar({
activeConfigSnapshot,
@@ -709,7 +738,7 @@ describe('AgentConfigurePublishBar', () => {
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
})
expect(impactDetails).toBeInTheDocument()
expect(workflowReferences.fetchCount).toBe(1)
expect(workflowReferences.fetchCount).toBe(2)
expect(
screen.getAllByRole('button', {
name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/,
@@ -2,7 +2,7 @@ import type { AgentConfigApiContext } from '../../config-context'
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useAtomValue } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
@@ -194,7 +194,7 @@ function renderAgentFiles({
} = {}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
})
@@ -203,20 +203,23 @@ function renderAgentFiles({
createSystemFeaturesFixture({ deployment_edition: 'COMMUNITY' }),
)
return render(
<QueryClientTestProvider queryClient={queryClient}>
<AgentConfigApiContextProvider value={apiContext}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentFiles />
<ConfigSnapshotProbe />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateViewingVersionContext>
</AgentComposerProvider>
</AgentConfigApiContextProvider>
</QueryClientTestProvider>,
)
return {
...render(
<QueryClientTestProvider queryClient={queryClient}>
<AgentConfigApiContextProvider value={apiContext}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentFiles />
<ConfigSnapshotProbe />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateViewingVersionContext>
</AgentComposerProvider>
</AgentConfigApiContextProvider>
</QueryClientTestProvider>,
),
queryClient,
}
}
describe('AgentFiles', () => {
@@ -495,42 +498,57 @@ describe('AgentFiles', () => {
})
})
it('should preview and download files through config file endpoints by name', async () => {
it('should not expose a stale image URL while the preview refreshes', async () => {
const user = userEvent.setup()
renderAgentFiles()
let resolveDownload!: (value: { url: string }) => void
const downloadResponse = new Promise<{ url: string }>((resolve) => {
resolveDownload = resolve
})
const downloadQueryFn = vi.fn(() => downloadResponse)
mocks.downloadQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['download-config-file', input],
queryFn: downloadQueryFn,
}))
const { queryClient } = renderAgentFiles()
queryClient.setQueryData(
[
'download-config-file',
{
params: { agent_id: 'agent-1', name: 'diagram.png' },
query: { draft_type: 'draft', version_id: undefined },
},
],
{ url: 'https://example.com/expired-diagram.png' },
)
await user.click(screen.getByText('diagram.png').closest('button')!)
await waitFor(() => {
expect(mocks.previewQueryOptions).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
params: {
agent_id: 'agent-1',
name: 'diagram.png',
},
}),
}),
)
await waitFor(() => expect(downloadQueryFn).toHaveBeenCalledOnce())
expect(screen.queryByRole('img', { name: 'diagram.png' })).not.toBeInTheDocument()
await act(async () => {
resolveDownload({ url: 'https://example.com/current-diagram.png' })
})
await waitFor(() => {
expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
params: {
agent_id: 'agent-1',
name: 'diagram.png',
},
}),
}),
)
})
expect(await screen.findByRole('img', { name: 'diagram.png' })).toHaveAttribute(
'src',
'https://example.com/current-diagram.png',
)
})
it('should download configured files from the row action by config name', async () => {
const user = userEvent.setup()
renderAgentFiles()
const { queryClient } = renderAgentFiles()
queryClient.setQueryData(
[
'download-config-file',
{
params: { agent_id: 'agent-1', name: 'diagram.png' },
query: { draft_type: 'draft', version_id: undefined },
},
],
{ url: 'https://example.com/stale-diagram.png' },
)
await user.click(
screen.getByRole('button', {
@@ -13,6 +13,7 @@ import {
FileTreeLabel,
} from '@langgenius/dify-ui/file-tree'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useAtomValue, useSetAtom } from 'jotai'
import { useCallback, useRef, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
@@ -145,6 +146,7 @@ function AgentFileItem({
},
},
}),
staleTime: 0,
enabled: shouldDownloadPreviewFile && !apiContext.workflow,
})
const workflowDownloadQuery = useQuery({
@@ -161,6 +163,7 @@ function AgentFileItem({
},
},
}),
staleTime: 0,
enabled: shouldDownloadPreviewFile && !!apiContext.workflow,
})
const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
@@ -179,8 +182,8 @@ function AgentFileItem({
const fileName = getAgentFilePreviewKey(targetFile)
if (apiContext.workflow) {
const result = await queryClient.fetchQuery(
consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({
const result = await queryClient.query({
...consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({
input: {
params: {
app_id: apiContext.workflow.appId,
@@ -193,13 +196,14 @@ function AgentFileItem({
},
},
}),
)
staleTime: 0,
})
downloadUrl({ url: result.url, fileName: targetFile.name })
return
}
const result = await queryClient.fetchQuery(
consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({
const result = await queryClient.query({
...consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({
input: {
params: {
agent_id: apiContext.agentId,
@@ -211,19 +215,26 @@ function AgentFileItem({
},
},
}),
)
staleTime: 0,
})
downloadUrl({ url: result.url, fileName: targetFile.name })
},
[apiContext, queryClient],
)
const downloadFileAction = useCallback(
(targetFile: AgentFileNode) => {
void downloadFile(targetFile).catch(noop)
},
[downloadFile],
)
const handleDownload = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
(event: MouseEvent<HTMLButtonElement>) => {
if (file.isMissing) return
event.stopPropagation()
await downloadFile(file)
downloadFileAction(file)
},
[downloadFile, file],
[downloadFileAction, file],
)
const handlePreviewOpenChange = useCallback(
(open: boolean) => {
@@ -268,15 +279,15 @@ function AgentFileItem({
filePreview: {
binary: previewQuery.data?.binary,
content: selectedPreviewFile.virtualContent ?? previewQuery.data?.text ?? undefined,
downloadUrl: downloadQuery.data?.url,
downloadUrl: downloadQuery.isFetching ? undefined : downloadQuery.data?.url,
fileName: selectedPreviewFile.name,
isDownloadError: downloadQuery.isError,
isDownloadLoading: shouldDownloadPreviewFile && downloadQuery.isPending,
isDownloadLoading: shouldDownloadPreviewFile && downloadQuery.isFetching,
isError: !isVirtualPreviewFile && previewQuery.isError,
isImage: isImagePreviewFile,
isLoading: !isVirtualPreviewFile && previewQuery.isPending,
},
onDownloadFile: () => downloadFile(selectedPreviewFile),
onDownloadFile: () => downloadFileAction(selectedPreviewFile),
onSelectFile: (selectedFile) => setSelectedFileId(selectedFile.id),
selectedFileId: selectedFileId ?? file.id,
sections: [],
@@ -134,7 +134,7 @@ export function AgentConfigurePublishBar({
},
enabled: publishIsAvailable && !selectedVersionSnapshot,
})
const workflowReferencesQuery = useQuery(workflowReferencesQueryOptions)
useQuery(workflowReferencesQueryOptions)
const restoreVersionMutation = useMutation(
consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions(),
)
@@ -203,12 +203,10 @@ export function AgentConfigurePublishBar({
let referencesResponse: AgentReferencingWorkflowsResponse | undefined
try {
referencesResponse =
queryClient.getQueryData<AgentReferencingWorkflowsResponse>(
workflowReferencesQueryOptions.queryKey,
) ??
workflowReferencesQuery.data ??
(await queryClient.ensureQueryData(workflowReferencesQueryOptions))
referencesResponse = await queryClient.query({
...workflowReferencesQueryOptions,
staleTime: 0,
})
} catch {
toast.error(tCommon(($) => $['api.actionFailed']))
return
@@ -323,28 +323,31 @@ function renderAgentSkills({
} = {}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<AgentConfigApiContextProvider value={apiContext}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateAddActionsProvider>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentSkills />
<ConfigSnapshotProbe />
<PromptSkillAddProbe />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateAddActionsProvider>
</AgentOrchestrateViewingVersionContext>
</AgentComposerProvider>
</AgentConfigApiContextProvider>
</QueryClientProvider>,
)
return {
...render(
<QueryClientProvider client={queryClient}>
<AgentConfigApiContextProvider value={apiContext}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateAddActionsProvider>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentSkills />
<ConfigSnapshotProbe />
<PromptSkillAddProbe />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateAddActionsProvider>
</AgentOrchestrateViewingVersionContext>
</AgentComposerProvider>
</AgentConfigApiContextProvider>
</QueryClientProvider>,
),
queryClient,
}
}
describe('AgentSkills', () => {
@@ -354,10 +357,11 @@ describe('AgentSkills', () => {
mocks.fileUploadConfig.skill_file_size_limit = 64
vi.stubGlobal('fetch', mocks.fetch)
document.cookie = 'csrf_token=csrf-token; path=/'
mocks.fetch.mockResolvedValue(
new Response('downloaded skill file', {
headers: { 'Content-Type': 'application/octet-stream' },
}),
mocks.fetch.mockImplementation(
async () =>
new Response('downloaded skill file', {
headers: { 'Content-Type': 'application/octet-stream' },
}),
)
mocks.agentSkillBindingsKey.mockImplementation((options) => {
const { input } = options as { input: { params: { agent_id: string } } }
@@ -1523,7 +1527,17 @@ describe('AgentSkills', () => {
it('should download a whole skill package from the row action', async () => {
const user = userEvent.setup()
renderAgentSkills()
const { queryClient } = renderAgentSkills()
queryClient.setQueryData(
[
'download-skill',
{
params: { agent_id: 'agent-1', name: 'Tender Analyzer' },
query: { draft_type: 'draft', version_id: undefined },
},
],
{ url: 'https://example.com/stale.skill' },
)
await user.click(
screen.getByRole('button', {
@@ -1682,7 +1696,30 @@ describe('AgentSkills', () => {
it('should download skill package members from the detail file tree', async () => {
const user = userEvent.setup()
renderAgentSkills()
const { queryClient } = renderAgentSkills()
const downloadInput = {
params: {
agent_id: 'agent-1',
name: 'Tender Analyzer',
},
query: {
path: 'references/guide.md',
draft_type: 'draft',
version_id: undefined,
},
}
const staleContentUrl = '/console/api/agent/agent-1/config/skills/stale/files/content'
const currentContentUrl =
'/console/api/agent/agent-1/config/skills/Tender%20Analyzer/files/content?path=references%2Fguide.md'
const staleBlob = new Blob(['stale skill file'])
queryClient.setQueryData(['download-skill-file', downloadInput], { url: staleContentUrl })
queryClient.setQueryData(['agent-v2', 'skill-file-content', staleContentUrl], staleBlob)
queryClient.setQueryData(['agent-v2', 'skill-file-content', currentContentUrl], staleBlob)
mocks.fetch.mockResolvedValueOnce(
new Response('current skill file', {
headers: { 'Content-Type': 'application/octet-stream' },
}),
)
await user.click(screen.getByText('Tender Analyzer').closest('button')!)
await user.click(await screen.findByText('references'))
@@ -1722,7 +1759,7 @@ describe('AgentSkills', () => {
fileName: 'guide.md',
})
const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob
await expect(blob.text()).resolves.toBe('downloaded skill file')
await expect(blob.text()).resolves.toBe('current skill file')
expect(mocks.downloadUrl).not.toHaveBeenCalled()
})
@@ -1773,15 +1810,41 @@ describe('AgentSkills', () => {
const user = userEvent.setup()
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:skill-image')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
let resolveImageRequest!: (response: Response) => void
mocks.fetch.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveImageRequest = resolve
}),
)
const view = renderAgentSkills()
const currentContentUrl =
'/console/api/agent/agent-1/config/skills/Tender%20Analyzer/files/content?path=assets%2Ficon.png'
view.queryClient.setQueryData(
['agent-v2', 'skill-file-content', currentContentUrl],
new Blob(['stale skill image']),
)
await user.click(screen.getByText('Tender Analyzer').closest('button')!)
await user.click(await screen.findByText('assets'))
await user.click(screen.getByText('icon.png').closest('button')!)
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledOnce())
expect(screen.queryByRole('img', { name: 'icon.png' })).not.toBeInTheDocument()
expect(createObjectURL).not.toHaveBeenCalled()
resolveImageRequest(
new Response('current skill image', {
headers: { 'Content-Type': 'image/png' },
}),
)
const image = await screen.findByRole('img', { name: 'icon.png' })
expect(image).toHaveAttribute('src', 'blob:skill-image')
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
await expect((createObjectURL.mock.calls[0]?.[0] as Blob).text()).resolves.toBe(
'current skill image',
)
await user.click(
screen.getByRole('button', {
@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useQueryClient } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
@@ -40,12 +41,12 @@ export function AgentSkillItem({
const handleRemove = useCallback(() => {
onRemove(skill.id)
}, [onRemove, skill.id])
const handleDownload = useCallback(async () => {
const downloadSkill = useCallback(async () => {
if (skill.isMissing) return
if (apiContext.workflow) {
const result = await queryClient.fetchQuery(
consoleQuery.apps.byAppId.agent.config.skills.byName.download.get.queryOptions({
const result = await queryClient.query({
...consoleQuery.apps.byAppId.agent.config.skills.byName.download.get.queryOptions({
input: {
params: {
app_id: apiContext.workflow.appId,
@@ -58,13 +59,14 @@ export function AgentSkillItem({
},
},
}),
)
staleTime: 0,
})
downloadUrl({ url: result.url, fileName: skill.name })
return
}
const result = await queryClient.fetchQuery(
consoleQuery.agent.byAgentId.config.skills.byName.download.get.queryOptions({
const result = await queryClient.query({
...consoleQuery.agent.byAgentId.config.skills.byName.download.get.queryOptions({
input: {
params: {
agent_id: apiContext.agentId,
@@ -76,9 +78,13 @@ export function AgentSkillItem({
},
},
}),
)
staleTime: 0,
})
downloadUrl({ url: result.url, fileName: skill.name })
}, [apiContext, queryClient, skill.isMissing, skill.name])
const handleDownload = useCallback(() => {
void downloadSkill().catch(noop)
}, [downloadSkill])
const handleOpenPreview = useCallback(() => {
if (skill.isMissing) return
@@ -41,7 +41,7 @@ const getSkillFileContentQueryOptions = (url: string) =>
queryOptions({
queryKey: skillFileContentQueryKey(url),
queryFn: () => fetchSkillFileContent(url),
staleTime: Infinity,
staleTime: 0,
})
const isSkillFolder = (file: AgentConfigSkillFileResponse) => file.type === 'directory'
@@ -281,6 +281,7 @@ export function useAgentSkillDetail({
},
},
}),
staleTime: 0,
enabled: shouldLoadImagePreview && !apiContext.workflow,
})
const workflowDownloadQuery = useQuery({
@@ -298,6 +299,7 @@ export function useAgentSkillDetail({
},
},
}),
staleTime: 0,
enabled: shouldLoadImagePreview && !!apiContext.workflow,
})
const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
@@ -334,29 +336,32 @@ export function useAgentSkillDetail({
setDownloadActionLoadingTarget(action)
try {
if (apiContext.workflow) {
const result = await queryClient.fetchQuery(
consoleQuery.apps.byAppId.agent.config.skills.byName.files.download.get.queryOptions({
input: {
params: {
app_id: apiContext.workflow.appId,
name: skill.name,
},
query: {
node_id: apiContext.workflow.nodeId,
path,
draft_type: apiContext.draftType,
version_id: apiContext.versionId,
const result = await queryClient.query({
...consoleQuery.apps.byAppId.agent.config.skills.byName.files.download.get.queryOptions(
{
input: {
params: {
app_id: apiContext.workflow.appId,
name: skill.name,
},
query: {
node_id: apiContext.workflow.nodeId,
path,
draft_type: apiContext.draftType,
version_id: apiContext.versionId,
},
},
},
}),
)
const data = await queryClient.fetchQuery(getSkillFileContentQueryOptions(result.url))
),
staleTime: 0,
})
const data = await queryClient.query(getSkillFileContentQueryOptions(result.url))
downloadBlob({ data, fileName: file.name })
return
}
const result = await queryClient.fetchQuery(
consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({
const result = await queryClient.query({
...consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({
input: {
params: {
agent_id: apiContext.agentId,
@@ -369,8 +374,9 @@ export function useAgentSkillDetail({
},
},
}),
)
const data = await queryClient.fetchQuery(getSkillFileContentQueryOptions(result.url))
staleTime: 0,
})
const data = await queryClient.query(getSkillFileContentQueryOptions(result.url))
downloadBlob({ data, fileName: file.name })
} catch {
toast.error(tCommon(($) => $['operation.downloadFailed']))
@@ -400,11 +406,11 @@ export function useAgentSkillDetail({
: (previewQuery.data?.text ?? undefined),
downloadActionLoadingTarget,
fileName: selectedFile?.name,
imageData: imageContentQuery.data,
imageData: imageContentQuery.isFetching ? undefined : imageContentQuery.data,
isDownloadError:
shouldLoadImagePreview && (downloadQuery.isError || imageContentQuery.isError),
isDownloadLoading:
shouldLoadImagePreview && (downloadQuery.isPending || imageContentQuery.isPending),
shouldLoadImagePreview && (downloadQuery.isFetching || imageContentQuery.isFetching),
isError: isSkillMdSelected
? inspectQuery.isError
: !!selectedPreviewPath && previewQuery.isError,
@@ -191,7 +191,7 @@ export function AgentPreviewChatConversation({
handleSend,
callbacks: {
onGetConversationMessages: async (conversationId) => {
return queryClient.fetchQuery({
return queryClient.query({
...consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({
input: {
params: {
@@ -546,6 +546,14 @@ type RenderOptions = {
const localeInput = { query: { language: 'en-US' } }
const homeTemplatesQueryKey = ['console', 'explore', 'apps', 'get', localeInput]
const exploreBannersQueryKey = ['console', 'explore', 'banners', 'get', localeInput]
const recommendedAppQueryKey = (appId: string) => [
'console',
'explore',
'apps',
'byAppId',
'get',
{ params: { app_id: appId } },
]
const renderHomeContent = ({
hasEditPermission = false,
@@ -1006,6 +1014,36 @@ describe('HomeContent', () => {
})
})
it('should reuse an invalidated cached template snapshot when creating an app', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
mockGetRecommendedApp.mockRejectedValue(new Error('should not fetch'))
mockHandleImportDSL.mockResolvedValue(undefined)
const { queryClient } = renderHomeContent({ hasEditPermission: true })
queryClient.setQueryData(recommendedAppQueryKey('app-1'), {
export_data: 'cached-yaml',
mode: AppModeEnum.CHAT,
})
await queryClient.invalidateQueries({
queryKey: recommendedAppQueryKey('app-1'),
exact: true,
refetchType: 'none',
})
fireEvent.click(screen.getByRole('button', { name: 'Alpha' }))
fireEvent.click(await screen.findByTestId('confirm-create'))
await waitFor(() => expect(mockHandleImportDSL).toHaveBeenCalledTimes(1))
expect(mockGetRecommendedApp).not.toHaveBeenCalled()
expect(mockHandleImportDSL).toHaveBeenCalledWith(
expect.objectContaining({ yaml_content: 'cached-yaml' }),
expect.any(Object),
)
})
it('should open create flow from learn dify item card click', async () => {
vi.useRealTimers()
const user = userEvent.setup()
@@ -343,11 +343,12 @@ export function HomeContent() {
const appId = currApp?.app_id
if (!appId) return
const appDetail = await queryClient.ensureQueryData(
consoleQuery.explore.apps.byAppId.get.queryOptions({
const appDetail = await queryClient.query({
...consoleQuery.explore.apps.byAppId.get.queryOptions({
input: { params: { app_id: appId } },
}),
)
staleTime: 'static',
})
const { export_data, mode } = appDetail
currentCreateAppModeRef.current = mode
+25 -19
View File
@@ -1,7 +1,7 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { dehydrate, HydrationBoundary, noop } from '@tanstack/react-query'
import { Suspense } from 'react'
import { getQueryClient } from '@/app/get-query-client'
import { ensureSystemFeatures } from '@/features/system-features/server'
import { getOptionalSystemFeatures } from '@/features/system-features/server'
import { getLocaleOnServer } from '@/i18n-config/server'
import { getServerConsoleClientContext, serverConsoleQuery } from '@/service/server'
import { HomeContent } from './home-content/home-content'
@@ -15,27 +15,33 @@ export async function HomePage() {
getServerConsoleClientContext(),
])
void homeQueryClient.prefetchQuery(
serverConsoleQuery.explore.apps.get.queryOptions({
context,
input: { query: { language: locale } },
}),
)
void homeQueryClient.prefetchQuery(
serverConsoleQuery.apps.recent.get.queryOptions({
context,
input: { query: { limit: 8 } },
}),
)
const enableExploreBanner = (await ensureSystemFeatures()).enable_explore_banner
if (enableExploreBanner) {
void homeQueryClient.prefetchQuery(
serverConsoleQuery.explore.banners.get.queryOptions({
void homeQueryClient
.query(
serverConsoleQuery.explore.apps.get.queryOptions({
context,
input: { query: { language: locale } },
}),
)
.catch(noop)
void homeQueryClient
.query(
serverConsoleQuery.apps.recent.get.queryOptions({
context,
input: { query: { limit: 8 } },
}),
)
.catch(noop)
const enableExploreBanner = (await getOptionalSystemFeatures())?.enable_explore_banner ?? false
if (enableExploreBanner) {
void homeQueryClient
.query(
serverConsoleQuery.explore.banners.get.queryOptions({
context,
input: { query: { language: locale } },
}),
)
.catch(noop)
}
const dehydratedState = dehydrate(homeQueryClient)
@@ -491,14 +491,18 @@ function createAgentReference(
}
}
function renderSkillDetailPage({ strict = false }: { strict?: boolean } = {}) {
const queryClient = new QueryClient({
function renderSkillDetailPage({
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
}),
strict = false,
}: {
queryClient?: QueryClient
strict?: boolean
} = {}) {
return {
...render(
<QueryClientProvider client={queryClient}>
@@ -3031,22 +3035,44 @@ describe('SkillDetailPage', () => {
})
})
it('checks references before publishing when the cached reference count is stale', async () => {
it('refreshes a fresh empty references cache before publishing', async () => {
const user = userEvent.setup()
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
})
const referencesQueryKey = [
'skill-references',
{
params: {
skill_id: 'skill-1',
},
},
]
let remoteReferences: SkillReferenceResponse[] = []
const referencesQueryFn = vi.fn(async () => ({ data: remoteReferences }))
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
queryKey: ['skill-references', options],
queryFn: async () => ({
data: [
createAgentReference({
display_name: 'Stale Count Agent',
name: 'stale-count-agent',
}),
],
}),
queryKey: ['skill-references', (options as { input: unknown }).input],
queryFn: referencesQueryFn,
}))
queryClient.setQueryData(referencesQueryKey, { data: [] })
renderSkillDetailPage()
renderSkillDetailPage({ queryClient })
await waitFor(() => {
expect(referencesQueryFn).toHaveBeenCalled()
expect(queryClient.getQueryState(referencesQueryKey)?.fetchStatus).toBe('idle')
})
remoteReferences = [
createAgentReference({
display_name: 'Fresh Cache Agent',
name: 'fresh-cache-agent',
}),
]
referencesQueryFn.mockClear()
await user.click(
await screen.findByRole('button', {
@@ -3054,6 +3080,7 @@ describe('SkillDetailPage', () => {
}),
)
expect(referencesQueryFn).toHaveBeenCalled()
expect(
await screen.findByRole('dialog', {
name: 'skill.skillManagement.detail.publishReferencesTitle',
@@ -3062,10 +3089,53 @@ describe('SkillDetailPage', () => {
expect(
screen.getByText('skill.skillManagement.detail.publishReferencesDescription_one:{"count":1}'),
).toBeInTheDocument()
expect(await screen.findByText('Stale Count Agent')).toBeInTheDocument()
expect(await screen.findByText('Fresh Cache Agent')).toBeInTheDocument()
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
})
it('does not publish when refreshing references fails', async () => {
const user = userEvent.setup()
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
})
let refreshShouldFail = false
const referencesQueryFn = vi.fn(async () => {
if (refreshShouldFail) throw new Error('references unavailable')
return { data: [] }
})
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
queryKey: ['skill-references', (options as { input: unknown }).input],
queryFn: referencesQueryFn,
}))
renderSkillDetailPage({ queryClient })
await waitFor(() => {
expect(referencesQueryFn).toHaveBeenCalled()
expect(queryClient.isFetching()).toBe(0)
})
refreshShouldFail = true
referencesQueryFn.mockClear()
await user.click(
await screen.findByRole('button', {
name: 'skill.skillManagement.detail.publishUpdate',
}),
)
await waitFor(() => expect(referencesQueryFn).toHaveBeenCalledOnce())
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
expect(
screen.queryByRole('dialog', {
name: 'skill.skillManagement.detail.publishReferencesTitle',
}),
).not.toBeInTheDocument()
})
it('cancels publishing from the referenced skill confirmation dialog', async () => {
const user = userEvent.setup()
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
+13 -6
View File
@@ -13,6 +13,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
@@ -986,16 +987,18 @@ export function FileEditor({
return
}
const references = await queryClient.fetchQuery(
consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({
const references = await queryClient.query({
...consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({
input: {
params: {
skill_id: skillId,
},
},
}),
)
staleTime: 0,
})
const fetchedReferenceCount = references.data?.length ?? 0
if (fetchedReferenceCount > 0) {
if (detail && detail.reference_count !== fetchedReferenceCount)
setSkillDetailCache(queryClient, skillId, {
@@ -1019,12 +1022,16 @@ export function FileEditor({
skillId,
])
const handlePublishAction = useCallback(() => {
void handlePublish().catch(noop)
}, [handlePublish])
useEffect(() => {
if (!pendingPublishAfterSaveRef.current || saveStatus === 'saving') return
pendingPublishAfterSaveRef.current = false
void handlePublish()
}, [handlePublish, saveStatus])
handlePublishAction()
}, [handlePublishAction, saveStatus])
const saveStateText =
saveStatus === 'saving'
@@ -1496,7 +1503,7 @@ export function FileEditor({
canPublish={canPublish}
metaLabel={publishMetaText}
onOpenVersions={onOpenVersions}
onPublish={handlePublish}
onPublish={handlePublishAction}
state={publishState}
/>
</div>
+19 -5
View File
@@ -1,10 +1,24 @@
# System Features
This feature owns the deployment-wide capability query contract and application bootstrap boundary.
This feature owns the deployment-wide capability query contract and application bootstrap boundary. It is neither a
cross-request server cache nor a general configuration registry.
- `server.ts` exposes the request-scoped query client and canonical server query options; route and layout owners perform prefetching and hydration.
- `client.ts` owns the canonical query options.
- `bootstrap-boundary.tsx` blocks application rendering until capability data is available and owns retry UI.
- The generated System Features operation owns the shared query identity and response contract.
- `server.ts` owns request-scoped imperative resolution and dehydration. Under the [TanStack Query prefetching] semantics,
`'static'` accepts any successful request-local snapshot, even after invalidation. Optional access maps an initial
no-data failure to `undefined` without another optional attempt; required access reuses a successful snapshot, starts
a new resolution after an earlier no-data failure, and preserves rejection for route gates. Neither path configures
automatic retry.
- `client.ts` owns the browser observer policy. `Infinity` keeps the hydrated bootstrap snapshot fresh without automatic
revalidation, while still allowing explicit invalidation or refetching.
- `bootstrap-boundary.tsx` blocks application rendering until the browser cache has capability data. When optional SSR
access fails, this boundary owns the client recovery request and its loading, error, and retry UI.
- `state.ts` exposes narrow derived client atoms backed by the same TanStack Query data.
Consumers read capabilities through this feature instead of issuing another request or copying deployment-edition state.
Following [TanStack Query advanced SSR], server reads are limited to metadata, route gates, integration inclusion, and
Home prefetch shape; application rendering stays client-owned through the hydrated cache. Revisit that split if the
browser snapshot becomes automatically revalidating. Consumers must not issue a second capability request or copy
deployment-edition state.
[TanStack Query advanced SSR]: https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr
[TanStack Query prefetching]: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
@@ -1,12 +1,16 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
dehydrate,
HydrationBoundary,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { serverConsoleQuery } from '@/service/server'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
import { SystemFeaturesBootstrapBoundary } from '../bootstrap-boundary'
const queryKey = ['console', 'system-features', 'get']
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
@@ -26,24 +30,39 @@ const mocks = vi.hoisted(() => ({
query: vi.fn(),
}))
vi.mock('../client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey,
queryFn: mocks.query,
retryDelay: 0,
staleTime: Infinity,
}),
vi.mock('server-only', () => ({}))
vi.mock('@/config/server', () => ({
SERVER_CONSOLE_API_PREFIX: 'http://localhost/console/api',
}))
vi.mock('../client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../client')>()
return {
...actual,
systemFeaturesQueryOptions: () => ({
...actual.systemFeaturesQueryOptions(),
queryFn: mocks.query,
retryDelay: 0,
}),
}
})
vi.mock('@/app/components/full-screen-loading', () => ({
FullScreenLoading: () => <div role="status">Loading system features</div>,
}))
vi.mock('@/utils/client', () => ({
get isClient() {
return mocks.isClient
},
}))
vi.mock('@/utils/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/client')>()
return {
...actual,
get isClient() {
return mocks.isClient
},
}
})
function createQueryClient() {
return new QueryClient()
@@ -65,11 +84,23 @@ describe('SystemFeaturesBootstrapBoundary', () => {
mocks.isClient = true
})
it('renders immediately from hydrated data without a client request', () => {
it('hydrates the server System Features query into the client boundary', () => {
const serverQueryClient = createQueryClient()
serverQueryClient.setQueryData(
serverConsoleQuery.systemFeatures.get.queryKey(),
createSystemFeaturesFixture(),
)
const queryClient = createQueryClient()
queryClient.setQueryData(queryKey, createSystemFeaturesFixture())
renderBoundary(<div>Application</div>, queryClient)
render(
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydrate(serverQueryClient)}>
<SystemFeaturesBootstrapBoundary>
<div>Application</div>
</SystemFeaturesBootstrapBoundary>
</HydrationBoundary>
</QueryClientProvider>,
)
expect(screen.getByText('Application')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
@@ -102,7 +133,7 @@ describe('SystemFeaturesBootstrapBoundary', () => {
expect(mocks.query).not.toHaveBeenCalled()
})
it('allows a manual retry after the client retry budget is exhausted', async () => {
it('allows a manual retry after a client query error', async () => {
const user = userEvent.setup()
mocks.query.mockRejectedValue(new Error('system features unavailable'))
@@ -2,17 +2,38 @@
import { QueryClient } from '@tanstack/react-query'
let queryClient: QueryClient
const mocks = vi.hoisted(() => ({
connection: vi.fn(async () => undefined),
getQueryClient: vi.fn(),
getSystemFeatures: vi.fn(),
queryKey: [['console', 'systemFeatures', 'get'], { type: 'query' }] as const,
}))
vi.mock('server-only', () => ({}))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
cache: (factory: () => unknown) => {
let initialized = false
let value: unknown
return () => {
if (!initialized) {
value = factory()
initialized = true
}
return value
}
},
}
})
vi.mock('@/app/get-query-client', () => ({
getQueryClient: () => queryClient,
getQueryClient: mocks.getQueryClient,
}))
vi.mock('@/next/server', () => ({
@@ -23,10 +44,11 @@ vi.mock('@/service/server', () => ({
serverConsoleQuery: {
systemFeatures: {
get: {
queryOptions: () => ({
queryKey: ['console', 'system-features'],
queryOptions: (options?: { staleTime?: 'static' }) => ({
queryKey: mocks.queryKey,
queryFn: mocks.getSystemFeatures,
retry: false,
...options,
}),
},
},
@@ -36,50 +58,51 @@ vi.mock('@/service/server', () => ({
describe('System Features server requests', () => {
beforeEach(() => {
vi.clearAllMocks()
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
vi.resetModules()
mocks.getQueryClient.mockImplementation(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
)
mocks.connection.mockResolvedValue(undefined)
mocks.getSystemFeatures.mockResolvedValue({ deployment_edition: 'CLOUD' })
})
it.each(['prefetch', 'ensure'] as const)(
'waits for the request boundary before the %s query starts',
async (operation) => {
let establishConnection!: () => void
mocks.connection.mockImplementation(
() =>
new Promise<undefined>((resolve) => {
establishConnection = () => resolve(undefined)
}),
)
const { ensureSystemFeatures, prefetchSystemFeatures } = await import('../server')
it('reuses a successful optional lookup for the rest of the request', async () => {
const { dehydrateSystemFeatures, getOptionalSystemFeatures } = await import('../server')
const result = operation === 'prefetch' ? prefetchSystemFeatures() : ensureSystemFeatures()
await Promise.resolve()
await expect(getOptionalSystemFeatures()).resolves.toEqual({ deployment_edition: 'CLOUD' })
await expect(getOptionalSystemFeatures()).resolves.toEqual({ deployment_edition: 'CLOUD' })
expect(mocks.getSystemFeatures).not.toHaveBeenCalled()
establishConnection()
await expect(result).resolves.toEqual({ deployment_edition: 'CLOUD' })
expect(mocks.getSystemFeatures).toHaveBeenCalledOnce()
expect(queryClient.getQueryData(['console', 'system-features'])).toEqual({
deployment_edition: 'CLOUD',
})
},
)
it('keeps prefetch failures soft', async () => {
mocks.getSystemFeatures.mockRejectedValue(new Error('System Features unavailable'))
const { prefetchSystemFeatures } = await import('../server')
await expect(prefetchSystemFeatures()).resolves.toBeUndefined()
expect(queryClient.getQueryData(['console', 'system-features'])).toBeUndefined()
expect(mocks.getSystemFeatures).toHaveBeenCalledOnce()
expect(mocks.getQueryClient).toHaveBeenCalledOnce()
expect(dehydrateSystemFeatures().queries).toEqual([
expect.objectContaining({
queryKey: mocks.queryKey,
state: expect.objectContaining({
data: { deployment_edition: 'CLOUD' },
status: 'success',
}),
}),
])
})
it('preserves ensure failures for hard route gates', async () => {
it('keeps optional failures soft and lets required consumers retry', async () => {
mocks.getSystemFeatures
.mockRejectedValueOnce(new Error('System Features unavailable'))
.mockResolvedValueOnce({ deployment_edition: 'CLOUD' })
const { getOptionalSystemFeatures, getSystemFeatures } = await import('../server')
await expect(getOptionalSystemFeatures()).resolves.toBeUndefined()
await expect(getOptionalSystemFeatures()).resolves.toBeUndefined()
await expect(getSystemFeatures()).resolves.toEqual({ deployment_edition: 'CLOUD' })
expect(mocks.getSystemFeatures).toHaveBeenCalledTimes(2)
})
it('preserves required failures for hard route gates', async () => {
const error = new Error('System Features unavailable')
mocks.getSystemFeatures.mockRejectedValue(error)
const { ensureSystemFeatures } = await import('../server')
const { getSystemFeatures } = await import('../server')
await expect(ensureSystemFeatures()).rejects.toBe(error)
await expect(getSystemFeatures()).rejects.toBe(error)
})
})
+12 -14
View File
@@ -1,31 +1,29 @@
import { dehydrate } from '@tanstack/react-query'
import { cache } from 'react'
import { getQueryClient } from '@/app/get-query-client'
import { connection } from '@/next/server'
import { serverConsoleQuery } from '@/service/server'
import 'server-only'
export const getSystemFeaturesQueryClient = cache(getQueryClient)
const getRequestQueryClient = cache(getQueryClient)
const systemFeaturesServerQueryOptions = () => serverConsoleQuery.systemFeatures.get.queryOptions()
const systemFeaturesServerQueryOptions = () =>
serverConsoleQuery.systemFeatures.get.queryOptions({ staleTime: 'static' })
export const getCachedSystemFeatures = () => {
const queryClient = getSystemFeaturesQueryClient()
const queryOptions = systemFeaturesServerQueryOptions()
return queryClient.getQueryData(queryOptions.queryKey)
}
export const prefetchSystemFeatures = async () => {
export const getOptionalSystemFeatures = async () => {
await connection()
const queryClient = getSystemFeaturesQueryClient()
const queryClient = getRequestQueryClient()
const queryOptions = systemFeaturesServerQueryOptions()
const queryState = queryClient.getQueryState(queryOptions.queryKey)
if (!queryState || queryState.status === 'pending') await queryClient.prefetchQuery(queryOptions)
if (queryState?.status === 'error' && queryState.data === undefined) return undefined
return queryClient.getQueryData(queryOptions.queryKey)
return queryClient.query(queryOptions).catch(() => undefined)
}
export const ensureSystemFeatures = async () => {
export const getSystemFeatures = async () => {
await connection()
return getSystemFeaturesQueryClient().ensureQueryData(systemFeaturesServerQueryOptions())
return getRequestQueryClient().query(systemFeaturesServerQueryOptions())
}
export const dehydrateSystemFeatures = () => dehydrate(getRequestQueryClient())
+65 -1
View File
@@ -584,7 +584,7 @@ describe('consoleQuery education defaults', () => {
})
await expect(
queryClient.fetchQuery(consoleQuery.account.education.get.queryOptions()),
queryClient.query(consoleQuery.account.education.get.queryOptions()),
).rejects.toThrow('education status failed')
expect(request).toHaveBeenCalledTimes(1)
@@ -1602,6 +1602,70 @@ describe('consoleQuery Web app access mutation defaults', () => {
})
})
describe('consoleQuery App Instance mutation defaults', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('should bypass a fresh list cache while waiting for a created app instance', async () => {
const appInstance = {
id: 'app-instance-1',
tenantId: 'tenant-1',
displayName: 'Production App',
description: '',
createdBy: { id: 'user-1', displayName: 'Ada' },
updatedBy: { id: 'user-1', displayName: 'Ada' },
createdAt: '2026-09-03T00:00:00Z',
updatedAt: '2026-09-03T00:00:00Z',
}
const request = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ appInstances: [], pagination: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ appInstances: [appInstance], pagination: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
const consoleQuery = await loadConsoleQueryWithRequest(request)
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
},
})
const listInput = {
query: {
pageNumber: 1,
resultsPerPage: 100,
},
}
const listOptions = consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
input: listInput,
})
queryClient.setQueryData(listOptions.queryKey, {
appInstances: [],
pagination: {},
})
const onSuccess =
consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions().onSuccess
const completion = onSuccess?.(
{ appInstance },
{ body: { displayName: appInstance.displayName } },
undefined,
createMutationContext(queryClient),
)
await completion
expect(request).toHaveBeenCalledTimes(2)
})
})
// Scenario: legacy App Deploy mutations share cache behavior through oRPC defaults.
describe('consoleQuery App Deploy mutation defaults', () => {
afterEach(() => {
+11 -8
View File
@@ -1376,16 +1376,19 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
const listResponse = await context.client
.fetchQuery(
consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
input: {
query: {
pageNumber: 1,
resultsPerPage: APP_DEPLOY_SOURCE_APPS_PAGE_SIZE,
.query({
...consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions(
{
input: {
query: {
pageNumber: 1,
resultsPerPage: APP_DEPLOY_SOURCE_APPS_PAGE_SIZE,
},
},
},
}),
)
),
staleTime: 0,
})
.catch(() => undefined)
if (listResponse?.appInstances?.some((app) => app.id === appInstanceId)) break
+7 -4
View File
@@ -19,6 +19,7 @@ import type { ReactElement, ReactNode } from 'react'
import type { UserProfileWithMeta } from '@/features/account-profile/client'
import type { DeepPartial } from '@/test/console/system-features'
import { zGetFeaturesResponse } from '@dify/contracts/api/console/features/zod.gen'
import { noop } from '@tanstack/react-query'
import { render, renderHook } from '@testing-library/react'
import { consoleQuery } from '@/service/client'
import { ensureAccountProfileQuery, seedAccountProfileQuery } from '@/test/console/account-profile'
@@ -143,10 +144,12 @@ const ensureSystemFeatures = (queryClient: QueryClient) => {
}
const seedPendingSystemFeatures = (queryClient: QueryClient) => {
void queryClient.prefetchQuery({
queryKey: consoleQuery.systemFeatures.get.queryKey(),
queryFn: () => new Promise<GetSystemFeaturesResponse>(() => {}),
})
void queryClient
.query({
queryKey: consoleQuery.systemFeatures.get.queryKey(),
queryFn: () => new Promise<GetSystemFeaturesResponse>(() => {}),
})
.catch(noop)
}
const seedTrialModels = (queryClient: QueryClient, trialModels: readonly string[] = []) => {