refactor(web): inline tag query defaults (#35883)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
yyh 2026-05-08 10:06:10 +08:00 committed by GitHub
parent 76a7f5f4b9
commit 9a8aa6a0c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 438 additions and 276 deletions

View File

@ -1,6 +1,6 @@
---
name: frontend-query-mutation
description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions() directly or extract a helper or use-* hook, handling conditional queries, cache invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers.
description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions()/mutationOptions() directly or extract a helper or use-* hook, configuring oRPC experimental_defaults/default options, handling conditional queries, cache updates/invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers.
---
# Frontend Query & Mutation
@ -9,22 +9,24 @@ description: Guide for implementing Dify frontend query and mutation patterns wi
- Keep contract as the single source of truth in `web/contract/*`.
- Prefer contract-shaped `queryOptions()` and `mutationOptions()`.
- Keep invalidation and mutation flow knowledge in the service layer.
- Keep default cache behavior with `consoleQuery`/`marketplaceQuery` setup, and keep business orchestration in feature vertical hooks when direct contract calls are not enough.
- Treat `web/service/use-*` query or mutation wrappers as legacy migration targets, not the preferred destination.
- Keep abstractions minimal to preserve TypeScript inference.
## Workflow
1. Identify the change surface.
- Read `references/contract-patterns.md` for contract files, router composition, client helpers, and query or mutation call-site shape.
- Read `references/runtime-rules.md` for conditional queries, invalidation, error handling, and legacy migrations.
- Read `references/runtime-rules.md` for conditional queries, default options, cache updates/invalidation, error handling, and legacy migrations.
- Read both references when a task spans contract shape and runtime behavior.
2. Implement the smallest abstraction that fits the task.
- Default to direct `useQuery(...)` or `useMutation(...)` calls with oRPC helpers at the call site.
- Extract a small shared query helper only when multiple call sites share the same extra options.
- Create `web/service/use-{domain}.ts` only for orchestration or shared domain behavior.
- Create or keep feature hooks only for real orchestration or shared domain behavior.
- When touching thin `web/service/use-*` wrappers, migrate them away when feasible.
3. Preserve Dify conventions.
- Keep contract inputs in `{ params, query?, body? }` shape.
- Bind invalidation in the service-layer mutation definition.
- Bind default cache updates/invalidation in `createTanstackQueryUtils(...experimental_defaults...)`; use feature hooks only for workflows that cannot be expressed as default operation behavior.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required.
## Files Commonly Touched
@ -33,7 +35,7 @@ description: Guide for implementing Dify frontend query and mutation patterns wi
- `web/contract/marketplace.ts`
- `web/contract/router.ts`
- `web/service/client.ts`
- `web/service/use-*.ts`
- legacy `web/service/use-*.ts` files when migrating wrappers away
- component and hook call sites using `consoleQuery` or `marketplaceQuery`
## References

View File

@ -1,4 +1,4 @@
interface:
display_name: "Frontend Query & Mutation"
short_description: "Dify TanStack Query and oRPC patterns"
default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, conditional queries, invalidation, or legacy query/mutation migrations."
short_description: "Dify TanStack Query, oRPC, and default option patterns"
default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, oRPC default options, conditional queries, cache updates/invalidation, or legacy query/mutation migrations."

View File

@ -7,6 +7,7 @@
- Core workflow
- Query usage decision rule
- Mutation usage decision rule
- Thin hook decision rule
- Anti-patterns
- Contract rules
- Type export
@ -55,9 +56,13 @@ const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({
1. Default to direct `*.queryOptions(...)` usage at the call site.
2. If 3 or more call sites share the same extra options, extract a small query helper, not a `use-*` passthrough hook.
3. Create `web/service/use-{domain}.ts` only for orchestration.
3. Create or keep feature hooks only for orchestration.
- Combine multiple queries or mutations.
- Share domain-level derived state or invalidation helpers.
- Prefer `web/features/{domain}/hooks/*` for feature-owned workflows.
4. Treat `web/service/use-{domain}.ts` as legacy.
- Do not create new thin service wrappers for oRPC contracts.
- When touching existing wrappers, inline direct `consoleQuery` or `marketplaceQuery` consumption when the wrapper is only a passthrough.
```typescript
const invoicesBaseQueryOptions = () =>
@ -74,11 +79,37 @@ const invoiceQuery = useQuery({
1. Default to mutation helpers from `consoleQuery` or `marketplaceQuery`, for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))`.
2. If the mutation flow is heavily custom, use oRPC clients as `mutationFn`, for example `consoleClient.xxx` or `marketplaceClient.xxx`, instead of handwritten non-oRPC mutation logic.
```typescript
const createTagMutation = useMutation(consoleQuery.tags.create.mutationOptions())
```
## Thin Hook Decision Rule
Remove thin hooks when they only rename a single oRPC query or mutation helper.
Keep hooks when they orchestrate business behavior across multiple operations, own local workflow state, or normalize a feature-specific API.
Prefer feature vertical hooks for kept orchestration. Do not move new contract-first wrappers into `web/service/use-*`.
Use:
```typescript
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
```
Keep:
```typescript
const applyTagBindingsMutation = useApplyTagBindingsMutation()
```
`useApplyTagBindingsMutation` is acceptable because it coordinates bind and unbind requests, computes deltas, and exposes a feature-level workflow rather than a single endpoint passthrough.
## Anti-Patterns
- Do not wrap `useQuery` with `options?: Partial<UseQueryOptions>`.
- Do not split local `queryKey` and `queryFn` when oRPC `queryOptions` already exists and fits the use case.
- Do not create thin `use-*` passthrough hooks for a single endpoint.
- Do not create business-layer helpers whose only purpose is to call `consoleQuery.xxx.mutationOptions()` or `queryOptions()`.
- Do not introduce new `web/service/use-*` files for oRPC contract passthroughs.
- These patterns can degrade inference, especially around `throwOnError` and `select`, and add unnecessary indirection.
## Contract Rules

View File

@ -3,6 +3,7 @@
## Table of Contents
- Conditional queries
- oRPC default options
- Cache invalidation
- Key API guide
- `mutate` vs `mutateAsync`
@ -35,9 +36,50 @@ function useBadAccessMode(appId: string | undefined) {
}
```
## oRPC Default Options
Use `experimental_defaults` in `createTanstackQueryUtils` when a contract operation should always carry shared TanStack Query behavior, such as default stale time, mutation cache writes, or invalidation.
Place defaults at the query utility creation point in `web/service/client.ts`:
```typescript
export const consoleQuery = createTanstackQueryUtils(consoleClient, {
path: ['console'],
experimental_defaults: {
tags: {
create: {
mutationOptions: {
onSuccess: (tag, _variables, _result, context) => {
context.client.setQueryData(
consoleQuery.tags.list.queryKey({
input: {
query: {
type: tag.type,
},
},
}),
(oldTags: Tag[] | undefined) => oldTags ? [tag, ...oldTags] : oldTags,
)
},
},
},
},
},
})
```
Rules:
- Keep defaults inline in the `consoleQuery` or `marketplaceQuery` initialization when they need sibling oRPC key builders.
- Do not create a wrapper function solely to host `createTanstackQueryUtils`.
- Do not split defaults into a vertical feature file if that forces handwritten operation paths such as `generateOperationKey(['console', ...])`.
- Keep feature-level orchestration in the feature vertical; keep query utility lifecycle defaults with the query utility.
- Prefer call-site callbacks for UI feedback only; shared cache behavior belongs in oRPC defaults when it is tied to a contract operation.
## Cache Invalidation
Bind invalidation in the service-layer mutation definition.
Bind shared invalidation in oRPC defaults when it is tied to a contract operation.
Use feature vertical hooks only for multi-operation workflows or domain orchestration that cannot live in a single operation default.
Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate.
Use:
@ -49,7 +91,7 @@ Use:
Do not use deprecated `useInvalid` from `use-base.ts`.
```typescript
// Service layer owns cache invalidation.
// Feature orchestration owns cache invalidation only when defaults are not enough.
export const useUpdateAccessMode = () => {
const queryClient = useQueryClient()
@ -124,7 +166,7 @@ When touching old code, migrate it toward these rules:
| Old pattern | New pattern |
|---|---|
| `useInvalid(key)` in service layer | `queryClient.invalidateQueries(...)` inside mutation `onSuccess` |
| component-triggered invalidation after mutation | move invalidation into the service-layer mutation definition |
| `useInvalid(key)` in service wrappers | oRPC defaults, or a feature vertical hook for real orchestration |
| component-triggered invalidation after mutation | move invalidation into oRPC defaults or a feature vertical hook |
| imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` |
| `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` |

View File

@ -23,24 +23,34 @@ const tagMocks = vi.hoisted(() => {
}
})
vi.mock('../hooks/use-tag-mutations', () => ({
useUpdateTagMutation: () => ({
mutate: ({ params, body }: { params: { tagId: string }, body: { name: string } }, options?: { onSuccess?: () => void, onError?: () => void }) => {
Promise.resolve(tagMocks.updateTag(params.tagId, body.name))
.then(() => options?.onSuccess?.())
.catch(() => options?.onError?.())
},
}),
useDeleteTagMutation: () => ({
vi.mock('@tanstack/react-query', () => ({
useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise<unknown> }) => ({
isPending: false,
mutate: ({ params }: { params: { tagId: string } }, options?: { onSuccess?: () => void, onError?: () => void }) => {
Promise.resolve(tagMocks.deleteTag(params.tagId))
mutate: (input: unknown, options?: { onSuccess?: () => void, onError?: () => void }) => {
Promise.resolve(mutationOptions.mutationFn(input))
.then(() => options?.onSuccess?.())
.catch(() => options?.onError?.())
},
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
tags: {
update: {
mutationOptions: () => ({
mutationFn: ({ params, body }: { params: { tagId: string }, body: { name: string } }) => tagMocks.updateTag(params.tagId, body.name),
}),
},
delete: {
mutationOptions: () => ({
mutationFn: ({ params }: { params: { tagId: string } }) => tagMocks.deleteTag(params.tagId),
}),
},
},
},
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: tagMocks.api,
}))

View File

@ -30,29 +30,39 @@ const { mockUseQueryData, createTag } = vi.hoisted(() => ({
vi.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: mockUseQueryData.current }),
}))
vi.mock('../hooks/use-tag-mutations', () => ({
useCreateTagMutation: () => ({
useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise<unknown> }) => ({
isPending: false,
mutate: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }, options?: { onSuccess?: (tag: Tag) => void, onError?: () => void }) => {
const tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 } as Tag
Promise.resolve(createTag(body.name, body.type))
.then(() => options?.onSuccess?.(tag))
mutate: (input: unknown, options?: { onSuccess?: () => void, onError?: () => void }) => {
Promise.resolve(mutationOptions.mutationFn(input))
.then(() => options?.onSuccess?.())
.catch(() => options?.onError?.())
},
}),
useUpdateTagMutation: () => ({
mutate: (_input: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
tags: {
list: {
queryOptions: () => ({}),
},
create: {
mutationOptions: () => ({
mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }) => createTag(body.name, body.type),
}),
},
update: {
mutationOptions: () => ({
mutationFn: () => Promise.resolve(undefined),
}),
},
delete: {
mutationOptions: () => ({
mutationFn: () => Promise.resolve(undefined),
}),
},
},
}),
useDeleteTagMutation: () => ({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
},
}),
},
}))
const mockTags: Tag[] = [

View File

@ -31,18 +31,32 @@ const { mockUseQueryData, createTag, bindTag, unBindTag } = vi.hoisted(() => {
vi.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: mockUseQueryData.current }),
}))
vi.mock('../hooks/use-tag-mutations', () => ({
useCreateTagMutation: () => ({
useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise<unknown> }) => ({
isPending: false,
mutate: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }, options?: { onSuccess?: (tag: Tag) => void, onError?: () => void }) => {
const tag: Tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 }
Promise.resolve(createTag(body.name, body.type))
.then(() => options?.onSuccess?.(tag))
mutate: (input: unknown, options?: { onSuccess?: () => void, onError?: () => void }) => {
Promise.resolve(mutationOptions.mutationFn(input))
.then(() => options?.onSuccess?.())
.catch(() => options?.onError?.())
},
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
tags: {
list: {
queryOptions: () => ({}),
},
create: {
mutationOptions: () => ({
mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }) => createTag(body.name, body.type),
}),
},
},
},
}))
vi.mock('../hooks/use-tag-mutations', () => ({
useApplyTagBindingsMutation: () => ({
mutate: (
{ currentTagIds, nextTagIds, targetId, type }: { currentTagIds: string[], nextTagIds: string[], targetId: string, type: 'app' | 'knowledge' },

View File

@ -15,10 +15,11 @@ import {
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { useMutation } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDeleteTagMutation, useUpdateTagMutation } from '../hooks/use-tag-mutations'
import { consoleQuery } from '@/service/client'
type TagItemEditorProps = {
tag: Tag
@ -26,8 +27,8 @@ type TagItemEditorProps = {
}
export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
const { t } = useTranslation()
const updateTagMutation = useUpdateTagMutation(tag.type)
const deleteTagMutation = useDeleteTagMutation(tag.type)
const updateTagMutation = useMutation(consoleQuery.tags.update.mutationOptions())
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(tag.name)
const editTag = (tagId: string, name: string) => {

View File

@ -1,11 +1,10 @@
'use client'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { useCreateTagMutation } from '../hooks/use-tag-mutations'
import { TagItemEditor } from './tag-item-editor'
type TagManagementModalProps = {
@ -24,7 +23,7 @@ export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagMan
},
enabled: show,
}))
const createTagMutation = useCreateTagMutation()
const createTagMutation = useMutation(consoleQuery.tags.create.mutationOptions())
const [name, setName] = useState<string>('')
const createNewTag = () => {
@ -55,8 +54,8 @@ export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagMan
return (
<Dialog open={show} onOpenChange={open => !open && handleClose()}>
<DialogContent className="w-[600px]! max-w-[600px]! rounded-xl! p-8!">
<div className="relative pb-2 text-xl leading-[30px] font-semibold text-text-primary">{t('tag.manageTags', { ns: 'common' })}</div>
<DialogContent className="w-150 max-w-150 rounded-xl p-8">
<div className="relative pb-2 text-xl leading-7.5 font-semibold text-text-primary">{t('tag.manageTags', { ns: 'common' })}</div>
<DialogCloseButton data-testid="tag-management-modal-close-button" className="top-4 right-4" />
<div className="mt-3 flex flex-wrap gap-2">
<input className="w-25 shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm leading-5 text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary focus:border-solid" placeholder={t('tag.addNew', { ns: 'common' }) || ''} autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} />

View File

@ -5,11 +5,11 @@ import type { Tag, TagType } from '@/contract/console/tags'
import { cn } from '@langgenius/dify-ui/cn'
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { useApplyTagBindingsMutation, useCreateTagMutation } from '../hooks/use-tag-mutations'
import { useApplyTagBindingsMutation } from '../hooks/use-tag-mutations'
import { isCreateTagOption } from './tag-combobox-item'
import { TagPanel } from './tag-panel'
import { TagTrigger } from './tag-trigger'
@ -67,7 +67,10 @@ export const TagSelector = ({
const [draftTags, setDraftTags] = useState<Tag[]>(value)
const [inputValue, setInputValue] = useState('')
const applyTagBindingsMutation = useApplyTagBindingsMutation()
const createTagMutation = useCreateTagMutation()
const {
isPending: isCreatingTag,
mutate: createTag,
} = useMutation(consoleQuery.tags.create.mutationOptions())
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
input: {
query: {
@ -164,10 +167,10 @@ export const TagSelector = ({
}, [applyTagBindings, value])
const createNewTag = useCallback((name: string) => {
if (!name || createTagMutation.isPending)
if (!name || isCreatingTag)
return
createTagMutation.mutate({
createTag({
body: {
name,
type,
@ -181,7 +184,7 @@ export const TagSelector = ({
toast.error(t('tag.failed', { ns: 'common' }))
},
})
}, [createTagMutation, t, type])
}, [createTag, isCreatingTag, t, type])
const handleValueChange = useCallback((nextTags: TagComboboxItem[]) => {
const createOption = nextTags.find(isCreateTagOption)

View File

@ -1,31 +1,17 @@
import type { ReactNode } from 'react'
import type { Tag } from '@/contract/console/tags'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
useApplyTagBindingsMutation,
useCreateTagMutation,
useDeleteTagMutation,
useUpdateTagMutation,
} from '../use-tag-mutations'
import { useApplyTagBindingsMutation } from '../use-tag-mutations'
const {
bindTag,
createTagMutationOptions,
deleteTagMutationOptions,
listQueryOptions,
listKey,
unbindTag,
updateTagMutationOptions,
} = vi.hoisted(() => ({
bindTag: vi.fn(),
createTagMutationOptions: vi.fn(),
deleteTagMutationOptions: vi.fn(),
listQueryOptions: vi.fn((options: { input: { query: { type: string } } }) => ({
queryKey: ['console', 'tags', 'list', options.input.query.type],
})),
listKey: vi.fn((options: { type: 'query', input: { query: { type: string } } }) => ['console', 'tags', 'list', 'query', options.input.query.type]),
unbindTag: vi.fn(),
updateTagMutationOptions: vi.fn(),
}))
vi.mock('@/service/client', () => ({
@ -37,30 +23,13 @@ vi.mock('@/service/client', () => ({
},
consoleQuery: {
tags: {
create: {
mutationOptions: createTagMutationOptions,
},
update: {
mutationOptions: updateTagMutationOptions,
},
delete: {
mutationOptions: deleteTagMutationOptions,
},
list: {
queryOptions: listQueryOptions,
key: listKey,
},
},
},
}))
const appTag = (overrides: Partial<Tag> = {}): Tag => ({
id: 'tag-1',
name: 'Frontend',
type: 'app',
binding_count: 1,
...overrides,
})
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
@ -91,127 +60,6 @@ describe('useTagMutations', () => {
vi.clearAllMocks()
bindTag.mockResolvedValue(undefined)
unbindTag.mockResolvedValue(undefined)
createTagMutationOptions.mockImplementation((options: Record<string, unknown>) => ({
mutationFn: ({ body }: { body: { name: string, type: Tag['type'] } }) => Promise.resolve(appTag({
id: 'created-tag',
name: body.name,
type: body.type,
binding_count: 0,
})),
...options,
}))
updateTagMutationOptions.mockImplementation((options: Record<string, unknown>) => ({
mutationFn: () => Promise.resolve({ result: 'success' }),
...options,
}))
deleteTagMutationOptions.mockImplementation((options: Record<string, unknown>) => ({
mutationFn: () => Promise.resolve({ result: 'success' }),
...options,
}))
})
describe('Create Tag', () => {
it('should prepend the created tag to the matching tag list cache', async () => {
const { queryClient, result } = renderMutationHook(() => useCreateTagMutation())
const cacheKey = ['console', 'tags', 'list', 'app']
queryClient.setQueryData<Tag[]>(cacheKey, [
appTag({ id: 'existing-tag', name: 'Existing' }),
])
await act(async () => {
await result.current.mutateAsync({
body: {
name: 'Created',
type: 'app',
},
})
})
expect(queryClient.getQueryData<Tag[]>(cacheKey)).toEqual([
appTag({ id: 'created-tag', name: 'Created', binding_count: 0 }),
appTag({ id: 'existing-tag', name: 'Existing' }),
])
expect(listQueryOptions).toHaveBeenCalledWith({
input: {
query: {
type: 'app',
},
},
})
})
it('should leave an absent tag list cache absent after creating a tag', async () => {
const { queryClient, result } = renderMutationHook(() => useCreateTagMutation())
const cacheKey = ['console', 'tags', 'list', 'knowledge']
await act(async () => {
await result.current.mutateAsync({
body: {
name: 'Knowledge',
type: 'knowledge',
},
})
})
expect(queryClient.getQueryData<Tag[]>(cacheKey)).toBeUndefined()
})
})
describe('Update Tag', () => {
it('should rename only the matching tag in the matching tag list cache', async () => {
const { queryClient, result } = renderMutationHook(() => useUpdateTagMutation('app'))
const appCacheKey = ['console', 'tags', 'list', 'app']
const knowledgeCacheKey = ['console', 'tags', 'list', 'knowledge']
queryClient.setQueryData<Tag[]>(appCacheKey, [
appTag({ id: 'tag-1', name: 'Old name' }),
appTag({ id: 'tag-2', name: 'Unchanged' }),
])
queryClient.setQueryData<Tag[]>(knowledgeCacheKey, [
appTag({ id: 'tag-1', name: 'Old knowledge name', type: 'knowledge' }),
])
await act(async () => {
await result.current.mutateAsync({
params: {
tagId: 'tag-1',
},
body: {
name: 'Renamed',
},
})
})
expect(queryClient.getQueryData<Tag[]>(appCacheKey)).toEqual([
appTag({ id: 'tag-1', name: 'Renamed' }),
appTag({ id: 'tag-2', name: 'Unchanged' }),
])
expect(queryClient.getQueryData<Tag[]>(knowledgeCacheKey)).toEqual([
appTag({ id: 'tag-1', name: 'Old knowledge name', type: 'knowledge' }),
])
})
})
describe('Delete Tag', () => {
it('should remove the deleted tag from the matching tag list cache', async () => {
const { queryClient, result } = renderMutationHook(() => useDeleteTagMutation('app'))
const cacheKey = ['console', 'tags', 'list', 'app']
queryClient.setQueryData<Tag[]>(cacheKey, [
appTag({ id: 'tag-1' }),
appTag({ id: 'tag-2', name: 'Backend' }),
])
await act(async () => {
await result.current.mutateAsync({
params: {
tagId: 'tag-1',
},
})
})
expect(queryClient.getQueryData<Tag[]>(cacheKey)).toEqual([
appTag({ id: 'tag-2', name: 'Backend' }),
])
})
})
describe('Apply Tag Bindings', () => {
@ -244,9 +92,17 @@ describe('useTagMutations', () => {
})
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'tags', 'list', 'app'],
queryKey: ['console', 'tags', 'list', 'query', 'app'],
})
})
expect(listKey).toHaveBeenCalledWith({
type: 'query',
input: {
query: {
type: 'app',
},
},
})
})
it('should skip network requests when tag bindings do not change but still invalidate tags', async () => {
@ -266,9 +122,17 @@ describe('useTagMutations', () => {
expect(unbindTag).not.toHaveBeenCalled()
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['console', 'tags', 'list', 'knowledge'],
queryKey: ['console', 'tags', 'list', 'query', 'knowledge'],
})
})
expect(listKey).toHaveBeenCalledWith({
type: 'query',
input: {
query: {
type: 'knowledge',
},
},
})
})
})
})

View File

@ -2,58 +2,6 @@ import type { TagType } from '@/contract/console/tags'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { consoleClient, consoleQuery } from '@/service/client'
const getTagsListQueryOptions = (tagType: TagType) => consoleQuery.tags.list.queryOptions({
input: {
query: {
type: tagType,
},
},
})
export const useCreateTagMutation = () => {
const queryClient = useQueryClient()
return useMutation(consoleQuery.tags.create.mutationOptions({
onSuccess: (tag) => {
queryClient.setQueryData(
getTagsListQueryOptions(tag.type).queryKey,
oldTags => oldTags ? [tag, ...oldTags] : oldTags,
)
},
}))
}
export const useUpdateTagMutation = (tagType: TagType) => {
const queryClient = useQueryClient()
return useMutation(consoleQuery.tags.update.mutationOptions({
onSuccess: (_data, variables) => {
queryClient.setQueryData(
getTagsListQueryOptions(tagType).queryKey,
oldTags => oldTags?.map(tag => tag.id === variables.params.tagId
? {
...tag,
name: variables.body.name,
}
: tag),
)
},
}))
}
export const useDeleteTagMutation = (tagType: TagType) => {
const queryClient = useQueryClient()
return useMutation(consoleQuery.tags.delete.mutationOptions({
onSuccess: (_data, variables) => {
queryClient.setQueryData(
getTagsListQueryOptions(tagType).queryKey,
oldTags => oldTags?.filter(tag => tag.id !== variables.params.tagId),
)
},
}))
}
type ApplyTagBindingsInput = {
currentTagIds: string[]
nextTagIds: string[]
@ -95,7 +43,14 @@ export const useApplyTagBindingsMutation = () => {
},
onSettled: (_data, _error, variables) => {
void queryClient.invalidateQueries({
queryKey: getTagsListQueryOptions(variables.type).queryKey,
queryKey: consoleQuery.tags.list.key({
type: 'query',
input: {
query: {
type: variables.type,
},
},
}),
})
},
})

View File

@ -1,3 +1,6 @@
import type { MutationFunctionContext } from '@tanstack/react-query'
import type { Tag } from '@/contract/console/tags'
import { QueryClient } from '@tanstack/react-query'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const loadGetBaseURL = async (isClientValue: boolean) => {
@ -10,6 +13,28 @@ const loadGetBaseURL = async (isClientValue: boolean) => {
return { getBaseURL: module.getBaseURL, warnSpy }
}
const loadConsoleQuery = async () => {
vi.resetModules()
vi.doMock('@/utils/client', () => ({ isClient: true, isServer: false }))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const module = await import('./client')
warnSpy.mockRestore()
return module.consoleQuery
}
const createMutationContext = (queryClient: QueryClient): MutationFunctionContext => ({
client: queryClient,
meta: undefined,
})
const createTag = (overrides: Partial<Tag> = {}): Tag => ({
id: 'tag-1',
name: 'Frontend',
type: 'app',
binding_count: 1,
...overrides,
})
// Scenario: base URL selection and warnings.
describe('getBaseURL', () => {
beforeEach(() => {
@ -78,3 +103,156 @@ describe('getBaseURL', () => {
expect(warnSpy).not.toHaveBeenCalled()
})
})
// Scenario: oRPC mutation defaults own shared tag cache behavior.
describe('consoleQuery tag mutation defaults', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should add created tags to the matching list query cache', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const appListKey = consoleQuery.tags.list.queryKey({
input: {
query: {
type: 'app',
},
},
})
const knowledgeListKey = consoleQuery.tags.list.queryKey({
input: {
query: {
type: 'knowledge',
},
},
})
const existingAppTag = createTag({ id: 'tag-1', name: 'Existing' })
const existingKnowledgeTag = createTag({
id: 'knowledge-tag-1',
name: 'Knowledge',
type: 'knowledge',
})
const createdTag = createTag({ id: 'tag-2', name: 'Created' })
queryClient.setQueryData(appListKey, [existingAppTag])
queryClient.setQueryData(knowledgeListKey, [existingKnowledgeTag])
const mutationOptions = consoleQuery.tags.create.mutationOptions()
await mutationOptions.onSuccess?.(
createdTag,
{
body: {
name: createdTag.name,
type: createdTag.type,
},
},
undefined,
createMutationContext(queryClient),
)
expect(queryClient.getQueryData(appListKey)).toEqual([createdTag, existingAppTag])
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([existingKnowledgeTag])
})
it('should update matching tags across cached list queries', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const appListKey = consoleQuery.tags.list.queryKey({
input: {
query: {
type: 'app',
},
},
})
const knowledgeListKey = consoleQuery.tags.list.queryKey({
input: {
query: {
type: 'knowledge',
},
},
})
const targetTag = createTag({ id: 'tag-1', name: 'Before' })
const otherTag = createTag({ id: 'tag-2', name: 'Other' })
const knowledgeTag = createTag({
id: 'knowledge-tag-1',
name: 'Knowledge',
type: 'knowledge',
})
queryClient.setQueryData(appListKey, [targetTag, otherTag])
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
const mutationOptions = consoleQuery.tags.update.mutationOptions()
await mutationOptions.onSuccess?.(
undefined,
{
params: {
tagId: targetTag.id,
},
body: {
name: 'After',
},
},
undefined,
createMutationContext(queryClient),
)
expect(queryClient.getQueryData(appListKey)).toEqual([
{
...targetTag,
name: 'After',
},
otherTag,
])
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
})
it('should remove deleted tags across cached list queries', async () => {
const consoleQuery = await loadConsoleQuery()
const queryClient = new QueryClient()
const appListKey = consoleQuery.tags.list.queryKey({
input: {
query: {
type: 'app',
},
},
})
const knowledgeListKey = consoleQuery.tags.list.queryKey({
input: {
query: {
type: 'knowledge',
},
},
})
const deletedTag = createTag({ id: 'tag-1', name: 'Delete me' })
const remainingTag = createTag({ id: 'tag-2', name: 'Keep me' })
const knowledgeTag = createTag({
id: 'knowledge-tag-1',
name: 'Knowledge',
type: 'knowledge',
})
queryClient.setQueryData(appListKey, [deletedTag, remainingTag])
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
const mutationOptions = consoleQuery.tags.delete.mutationOptions()
await mutationOptions.onSuccess?.(
undefined,
{
params: {
tagId: deletedTag.id,
},
},
undefined,
createMutationContext(queryClient),
)
expect(queryClient.getQueryData(appListKey)).toEqual([remainingTag])
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
})
})

View File

@ -1,5 +1,6 @@
import type { ContractRouterClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi-client'
import type { Tag } from '@/contract/console/tags'
import { createORPCClient, onError } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
@ -84,4 +85,56 @@ const consoleLink = new OpenAPILink(consoleRouterContract, {
})
export const consoleClient: JsonifiedClient<ContractRouterClient<typeof consoleRouterContract>> = createORPCClient(consoleLink)
export const consoleQuery = createTanstackQueryUtils(consoleClient, { path: ['console'] })
export const consoleQuery = createTanstackQueryUtils(consoleClient, {
path: ['console'],
experimental_defaults: {
tags: {
create: {
mutationOptions: {
onSuccess: (tag, _variables, _onMutateResult, context) => {
context.client.setQueryData(
consoleQuery.tags.list.queryKey({
input: {
query: {
type: tag.type,
},
},
}),
(oldTags: Tag[] | undefined) => oldTags ? [tag, ...oldTags] : oldTags,
)
},
},
},
update: {
mutationOptions: {
onSuccess: (_data, variables, _onMutateResult, context) => {
context.client.setQueriesData(
{
queryKey: consoleQuery.tags.list.key({ type: 'query' }),
},
(oldTags: Tag[] | undefined) => oldTags?.map(tag => tag.id === variables.params.tagId
? {
...tag,
name: variables.body.name,
}
: tag),
)
},
},
},
delete: {
mutationOptions: {
onSuccess: (_data, variables, _onMutateResult, context) => {
context.client.setQueriesData(
{
queryKey: consoleQuery.tags.list.key({ type: 'query' }),
},
(oldTags: Tag[] | undefined) => oldTags?.filter(tag => tag.id !== variables.params.tagId),
)
},
},
},
},
},
})