feat(mutate): single funnel for user-intent commands and invoke gate

Adds src/services/mutate.ts: one path for optimistic apply, services-layer
invoke, Busy handling, failure rollback, and the error toast, with busy
bookkeeping mirrored into the app store. The patch hooks (verge, clash,
profiles) now route through it. Moves the remaining direct invokes
(unlock ×3, DNS ×4) into cmds.ts and turns on the invoke-only-in-services
architecture gate in CI.
This commit is contained in:
Tunglies
2026-10-01 14:34:17 +08:00
committed by GitHub
parent 5b53c32de1
commit 763338dade
10 changed files with 204 additions and 48 deletions
+9 -5
View File
@@ -8,6 +8,7 @@ import {
getRuntimeConfig,
patchClashConfig,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import {
revalidateQuery,
setCacheData,
@@ -104,8 +105,10 @@ export const useClash = () => {
}
const patchClash = useLockFn(async (patch: Partial<IConfigData>) => {
await patchClashConfig(patch)
mutateClash()
await mutate(() => patchClashConfig(patch), {
id: 'patch-clash-config',
revalidate: [['getRuntimeConfig']],
})
})
const version = versionData?.meta
@@ -132,9 +135,10 @@ export const useClashInfo = () => {
validatePorts(patch)
await patchClashConfig(patch)
mutateInfo()
revalidateQuery(['getClashConfig'])
await mutate(() => patchClashConfig(patch), {
id: 'patch-clash-info',
revalidate: [['getClashInfo'], ['getClashConfig']],
})
})
const invalidateClashConfig = () => revalidateQuery(['getClashConfig'])
+17 -7
View File
@@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react'
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client'
import { debugLog } from '@/utils/debug'
@@ -42,16 +43,25 @@ export const useProfiles = () => {
const patchProfiles = useCallback(
async (value: Partial<IProfilesConfig>) => {
try {
const outcome = await patchProfilesConfig(value)
const result = await mutate(() => patchProfilesConfig(value), {
id: 'patch-profiles-config',
onFulfilled: (outcome) => {
if (outcome.status === 'valid') {
void setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
current ? { ...current, ...value } : current,
)
}
},
})
if (outcome.status === 'valid') {
await setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
current ? { ...current, ...value } : current,
)
} else if (outcome.status !== 'busy') {
if (!result.ok) {
// Backend Busy keeps local state untouched, as before.
return result.value ?? { status: 'busy' }
}
const outcome = result.value
if (outcome.status !== 'valid' && outcome.status !== 'busy') {
await mutateProfiles()
}
return outcome
} catch (error) {
await mutateProfiles()
+7 -7
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { getVergeConfig, patchVergeConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { getPreloadConfig, setPreloadConfig } from '@/services/preload'
import { setCacheData, useQuery } from '@/services/query-client'
@@ -38,13 +39,12 @@ export const useVerge = () => {
)
}
const patchVerge = useCallback(
async (value: Partial<IVergeConfig>) => {
await patchVergeConfig(value)
await refetch()
},
[refetch],
)
const patchVerge = useCallback(async (value: Partial<IVergeConfig>) => {
await mutate(() => patchVergeConfig(value), {
id: 'patch-verge-config',
revalidate: [['getVergeConfig']],
})
}, [])
return {
verge,