mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-03 05:22:09 +08:00
fix(frontend): preserve queued intents and fresh runtime snapshots
This commit is contained in:
@@ -23,7 +23,7 @@ vi.mock('react-i18next', () => ({
|
||||
vi.mock('@mui/material', () => ({ Alert: 'div' }))
|
||||
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
|
||||
vi.mock('@/store/app-store-context', () => ({
|
||||
useAppDispatch: () => store.dispatch,
|
||||
useAppReads: () => ({ readRunState: getRuntimeState }),
|
||||
useRunState: () => store.runState,
|
||||
}))
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseDialog } from '@/components/base'
|
||||
import {
|
||||
continueWithSidecar,
|
||||
getRuntimeState,
|
||||
installService,
|
||||
reinstallService,
|
||||
repairService,
|
||||
@@ -14,14 +13,14 @@ import {
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useAppDispatch, useRunState } from '@/store/app-store-context'
|
||||
import { useAppReads, useRunState } from '@/store/app-store-context'
|
||||
|
||||
export const ServiceMigrationDialog = () => {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stateRefreshFailed, setStateRefreshFailed] = useState(false)
|
||||
const [workflowIncomplete, setWorkflowIncomplete] = useState(false)
|
||||
const dispatch = useAppDispatch()
|
||||
const { readRunState } = useAppReads()
|
||||
const runState = useRunState()
|
||||
// Whether the service needs a decision is derived once, in Rust, and travels with the
|
||||
// snapshot; a failed refresh is treated as needing one, since we cannot tell otherwise.
|
||||
@@ -59,8 +58,7 @@ export const ServiceMigrationDialog = () => {
|
||||
// One store entry to refresh, so there is nothing left to keep coherent by hand.
|
||||
const refreshRunState = async () => {
|
||||
try {
|
||||
const data = await getRuntimeState()
|
||||
dispatch({ type: 'runState/loaded', runState: data })
|
||||
const data = await readRunState()
|
||||
setStateRefreshFailed(false)
|
||||
return data
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseDialog } from '@/components/base'
|
||||
import { useDialogFailure } from '@/pages/_layout/hooks'
|
||||
import {
|
||||
getRuntimeState,
|
||||
installService,
|
||||
patchVergeConfig,
|
||||
reinstallService,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
type ServiceRequest,
|
||||
type ServiceRequestReason,
|
||||
} from '@/services/service-request'
|
||||
import { useAppReads } from '@/store/app-store-context'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly'
|
||||
@@ -84,6 +84,7 @@ const STEP_MESSAGE = {
|
||||
} as const
|
||||
|
||||
export const SysproxyPrivilegeDialog = () => {
|
||||
const { readRunState } = useAppReads()
|
||||
const { t } = useTranslation()
|
||||
const { failure, dismiss } = useDialogFailure()
|
||||
const asked = useSyncExternalStore(subscribeServiceRequest, getServiceRequest)
|
||||
@@ -135,7 +136,7 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
const runState = await getRuntimeState()
|
||||
const runState = await readRunState()
|
||||
const usingAdminFallback =
|
||||
remedy !== 'reinstallAndRestart' &&
|
||||
getSystem() === 'windows' &&
|
||||
|
||||
@@ -458,7 +458,7 @@ export const ProxyChain = ({
|
||||
if (targetGroup) {
|
||||
try {
|
||||
await mutate(() => selectNodeForGroup(targetGroup, 'DIRECT'), {
|
||||
id: 'select-proxy-node',
|
||||
id: `select-proxy-node:${targetGroup}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
recordSelection(targetGroup, 'DIRECT')
|
||||
@@ -469,7 +469,7 @@ export const ProxyChain = ({
|
||||
() =>
|
||||
selectNodeForGroup(targetGroup, currentProxyChain[0].name),
|
||||
{
|
||||
id: 'select-proxy-node',
|
||||
id: `select-proxy-node:${targetGroup}`,
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
@@ -539,7 +539,7 @@ export const ProxyChain = ({
|
||||
await mutate(
|
||||
() => selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name),
|
||||
{
|
||||
id: 'select-proxy-node',
|
||||
id: `select-proxy-node:${targetGroup || 'GLOBAL'}`,
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -257,7 +257,10 @@ export const BackupHistoryViewer = ({
|
||||
showNotice.success('settings.modals.backup.messages.restoreSuccess')
|
||||
setIsRestarting(true)
|
||||
window.setTimeout(() => {
|
||||
void restartApp().catch((err: unknown) => {
|
||||
void mutate(() => restartApp(), {
|
||||
id: 'restart-app',
|
||||
errorNotice: false,
|
||||
}).catch((err: unknown) => {
|
||||
setIsRestarting(false)
|
||||
showNotice.error(err)
|
||||
})
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
saveDnsConfig,
|
||||
validateDnsConfig,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
import type { MonacoEditorInstance } from '@/types/monaco'
|
||||
@@ -519,7 +520,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
config = parsedConfig as Record<string, any>
|
||||
}
|
||||
|
||||
await saveDnsConfig(config)
|
||||
await mutate(() => saveDnsConfig(config), {
|
||||
id: 'save-dns-config',
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
const validation = await validateDnsConfig()
|
||||
|
||||
@@ -558,7 +562,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
}
|
||||
|
||||
if (dnsEnabled) {
|
||||
await applyDnsConfig(true)
|
||||
await mutate(() => applyDnsConfig(true), {
|
||||
id: 'apply-dns-config',
|
||||
errorNotice: false,
|
||||
})
|
||||
mutateClash()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DEFAULT_HOVER_DELAY } from '@/components/proxy/proxy-group-navigator'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useWindowDecorations } from '@/hooks/use-window'
|
||||
import { copyIconFile, getAppDir } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
@@ -536,7 +537,9 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
|
||||
})
|
||||
|
||||
if (selected) {
|
||||
await copyIconFile(`${selected}`, 'common')
|
||||
await mutate(() => copyIconFile(`${selected}`, 'common'), {
|
||||
id: 'copy-icon:common',
|
||||
})
|
||||
await initIconPath()
|
||||
onChangeData({ common_tray_icon: true })
|
||||
patchVerge({ common_tray_icon: true })
|
||||
@@ -588,7 +591,10 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
|
||||
],
|
||||
})
|
||||
if (selected) {
|
||||
await copyIconFile(`${selected}`, 'sysproxy')
|
||||
await mutate(
|
||||
() => copyIconFile(`${selected}`, 'sysproxy'),
|
||||
{ id: 'copy-icon:sysproxy' },
|
||||
)
|
||||
await initIconPath()
|
||||
onChangeData({ sysproxy_tray_icon: true })
|
||||
patchVerge({ sysproxy_tray_icon: true })
|
||||
@@ -636,7 +642,9 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
|
||||
],
|
||||
})
|
||||
if (selected) {
|
||||
await copyIconFile(`${selected}`, 'tun')
|
||||
await mutate(() => copyIconFile(`${selected}`, 'tun'), {
|
||||
id: 'copy-icon:tun',
|
||||
})
|
||||
await initIconPath()
|
||||
onChangeData({ tun_tray_icon: true })
|
||||
patchVerge({ tun_tray_icon: true })
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
getSystemProxy,
|
||||
patchVergeConfig,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import getSystem from '@/utils/get-system'
|
||||
@@ -374,9 +375,14 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
: currentSysProxy?.enable
|
||||
|
||||
if (isProxyActive) {
|
||||
await patchVergeConfig({ enable_system_proxy: false })
|
||||
await sleep(50)
|
||||
await patchVergeConfig({ enable_system_proxy: true })
|
||||
await mutate(
|
||||
async () => {
|
||||
await patchVergeConfig({ enable_system_proxy: false })
|
||||
await sleep(50)
|
||||
await patchVergeConfig({ enable_system_proxy: true })
|
||||
},
|
||||
{ id: 'patch-verge-config', errorNotice: false },
|
||||
)
|
||||
await invalidateProxyState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,11 @@ export const useProfiles = () => {
|
||||
const patchCurrent = useCallback(
|
||||
async (value: Partial<IProfileItem>) => {
|
||||
if (profiles?.current) {
|
||||
await patchProfile(profiles.current, value)
|
||||
const uid = profiles.current
|
||||
await mutate(() => patchProfile(uid, value), {
|
||||
id: `patch-profile:${uid}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
void mutateProfiles()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { selectNodeForGroup } from 'tauri-plugin-mihomo-api'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import { useProxySelection } from './use-proxy-selection'
|
||||
|
||||
const record = vi.hoisted(() => vi.fn())
|
||||
vi.mock('react', () => ({
|
||||
useCallback: (callback: unknown) => callback,
|
||||
useRef: (current: unknown) => ({ current }),
|
||||
}))
|
||||
vi.mock('@/hooks/use-record-selection', () => ({
|
||||
useRecordSelection: () => record,
|
||||
useForgetSelection: () => vi.fn(),
|
||||
}))
|
||||
vi.mock('@/hooks/use-verge', () => ({ useVerge: () => ({ verge: {} }) }))
|
||||
vi.mock('tauri-plugin-mihomo-api', () => ({
|
||||
selectNodeForGroup: vi.fn(),
|
||||
unfixedProxy: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/services/cmds', () => ({ syncTrayProxySelection: vi.fn() }))
|
||||
vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } }))
|
||||
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
|
||||
|
||||
it('applies and records independent group selections while another group is pending', async () => {
|
||||
let finishFirst!: () => void
|
||||
vi.mocked(selectNodeForGroup)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishFirst = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const firstSuccess = vi.fn()
|
||||
const secondSuccess = vi.fn()
|
||||
const first = useProxySelection({ onSuccess: firstSuccess })
|
||||
const second = useProxySelection({ onSuccess: secondSuccess })
|
||||
first.changeProxy('Group A', 'Node A')
|
||||
second.changeProxy('Group B', 'Node B')
|
||||
await vi.waitFor(() =>
|
||||
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
|
||||
)
|
||||
expect(selectNodeForGroup).toHaveBeenCalledTimes(2)
|
||||
expect(firstSuccess).not.toHaveBeenCalled()
|
||||
expect(secondSuccess).toHaveBeenCalledOnce()
|
||||
finishFirst()
|
||||
await vi.waitFor(() =>
|
||||
expect(record).toHaveBeenCalledWith('Group A', 'Node A'),
|
||||
)
|
||||
expect(firstSuccess).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -78,17 +78,14 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (isFixedProxy) {
|
||||
await mutate(() => unfixedProxy(groupName), {
|
||||
id: 'select-proxy-node',
|
||||
errorNotice: false,
|
||||
})
|
||||
} else {
|
||||
await mutate(() => selectNodeForGroup(groupName, proxyName), {
|
||||
id: 'select-proxy-node',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
const result = await mutate(
|
||||
() =>
|
||||
isFixedProxy
|
||||
? unfixedProxy(groupName)
|
||||
: selectNodeForGroup(groupName, proxyName),
|
||||
{ id: `select-proxy-node:${groupName}`, errorNotice: false },
|
||||
)
|
||||
if (!result.ok) return
|
||||
onSuccess?.()
|
||||
syncTraySelection()
|
||||
if (isFixedProxy) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useRecordSelection = () => {
|
||||
return useCallback(async (groupName: string, proxyName: string) => {
|
||||
try {
|
||||
await mutate(() => recordSelectedNode(groupName, proxyName), {
|
||||
id: 'record-selected-node',
|
||||
id: `persist-selected-node:${groupName}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -24,7 +24,7 @@ export const useForgetSelection = () => {
|
||||
return useCallback(async (groupName: string) => {
|
||||
try {
|
||||
await mutate(() => forgetSelectedNode(groupName), {
|
||||
id: 'forget-selected-node',
|
||||
id: `persist-selected-node:${groupName}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
getAppUptime,
|
||||
getRuntimeState,
|
||||
type RunState,
|
||||
type RunningMode,
|
||||
} from '@/services/cmds'
|
||||
import { getAppUptime, type RunState, type RunningMode } from '@/services/cmds'
|
||||
import { useQuery } from '@/services/query-client'
|
||||
import { useAppDispatch, useRunState } from '@/store/app-store-context'
|
||||
import { useAppReads, useRunState } from '@/store/app-store-context'
|
||||
|
||||
const appUptimeQueryKey = ['appUptime'] as const
|
||||
|
||||
@@ -25,7 +20,7 @@ const unknownRunState: RunState = {
|
||||
|
||||
/** Event-driven run state; Rust owns all derived availability decisions. */
|
||||
export function useSystemState() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { readRunState } = useAppReads()
|
||||
const snapshot = useRunState()
|
||||
const runState = snapshot ?? unknownRunState
|
||||
|
||||
@@ -37,11 +32,7 @@ export function useSystemState() {
|
||||
isServiceMode: runState.mode === 'Service',
|
||||
isTunModeAvailable: runState.tunCapable,
|
||||
serviceNeedsAttention: runState.serviceNeedsAttention,
|
||||
mutateSystemState: async () => {
|
||||
const fresh = await getRuntimeState()
|
||||
dispatch({ type: 'runState/loaded', runState: fresh })
|
||||
return fresh
|
||||
},
|
||||
mutateSystemState: readRunState,
|
||||
isLoading: snapshot == null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createStoreReads } from '@/store/store-reads'
|
||||
|
||||
import { useEventBus } from './use-event-bus'
|
||||
|
||||
const listen = vi.hoisted(() =>
|
||||
@@ -25,6 +27,7 @@ vi.mock('react', async (importOriginal) => {
|
||||
})
|
||||
vi.mock('@/store/app-store-context', () => ({
|
||||
useAppDispatch: () => dispatch,
|
||||
useAppReads: () => createStoreReads(dispatch),
|
||||
}))
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
getPendingFailures: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -2,13 +2,8 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {
|
||||
getPendingFailures,
|
||||
getRuntimeState,
|
||||
getSidecarFailure,
|
||||
} from '@/services/cmds'
|
||||
import { revalidateQueries } from '@/services/query-client'
|
||||
import { useAppDispatch } from '@/store/app-store-context'
|
||||
import { useAppDispatch, useAppReads } from '@/store/app-store-context'
|
||||
|
||||
import { createEventBus } from './create-bus-handlers'
|
||||
|
||||
@@ -21,31 +16,15 @@ export const useEventBus = (
|
||||
handleNotice: (payload: [string, string]) => void,
|
||||
) => {
|
||||
const dispatch = useAppDispatch()
|
||||
const reads = useAppReads()
|
||||
|
||||
useEffect(() => {
|
||||
const revalidateKeys = (keys: readonly string[]) => {
|
||||
void revalidateQueries(keys.map((key) => [key]))
|
||||
}
|
||||
const readPendingFailures = () => {
|
||||
getSidecarFailure()
|
||||
.then((snapshot) =>
|
||||
dispatch({ type: 'sidecarFailure/loaded', snapshot }),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.warn('[bus] Sidecar failure could not be read:', error)
|
||||
})
|
||||
getPendingFailures()
|
||||
.then((failures) =>
|
||||
dispatch({ type: 'pendingFailures/loaded', failures }),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.warn('[bus] pending failures could not be read:', error)
|
||||
})
|
||||
}
|
||||
const { readPendingFailures } = reads
|
||||
const readRunState = () => {
|
||||
getRuntimeState()
|
||||
.then((runState) => dispatch({ type: 'runState/loaded', runState }))
|
||||
.catch(() => {})
|
||||
void reads.readRunState().catch(() => {})
|
||||
}
|
||||
|
||||
const bus = createEventBus({
|
||||
@@ -91,5 +70,5 @@ export const useEventBus = (
|
||||
for (const unlisten of unlisteners.splice(0)) unlisten()
|
||||
void unlistenFocus.then((unlisten) => unlisten())
|
||||
}
|
||||
}, [dispatch, handleNotice])
|
||||
}, [dispatch, handleNotice, reads])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import { mutate } from './mutate'
|
||||
|
||||
vi.mock('@/services/notice-service', () => ({
|
||||
showNotice: { error: vi.fn(), warning: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
|
||||
|
||||
it('executes every queued setting write even when its predecessor fails', async () => {
|
||||
let reject!: (error: Error) => void
|
||||
const first = mutate(
|
||||
() =>
|
||||
new Promise<void>((_resolve, rejectFirst) => {
|
||||
reject = rejectFirst
|
||||
}),
|
||||
{ id: 'patch-verge-config', errorNotice: false },
|
||||
).catch((error) => error)
|
||||
const writeSecond = vi.fn().mockResolvedValue(undefined)
|
||||
const onFulfilled = vi.fn()
|
||||
const second = mutate(writeSecond, {
|
||||
id: 'patch-verge-config',
|
||||
onFulfilled,
|
||||
errorNotice: false,
|
||||
})
|
||||
expect(writeSecond).not.toHaveBeenCalled()
|
||||
const error = new Error('first write failed')
|
||||
reject(error)
|
||||
expect(await first).toBe(error)
|
||||
expect(await second).toEqual({ ok: true, value: undefined })
|
||||
expect(writeSecond).toHaveBeenCalledOnce()
|
||||
expect(onFulfilled).toHaveBeenCalledOnce()
|
||||
})
|
||||
+12
-6
@@ -6,7 +6,7 @@ import type { AppStoreAction } from '@/store/app-state'
|
||||
type MutateNoticeInput = Parameters<typeof showNotice.error>[0]
|
||||
|
||||
export interface MutateOptions<T> {
|
||||
/** Busy/in-flight identity; a second intent with the same id resolves `{ busy: true }`. */
|
||||
/** Commands with the same identity execute in request order. */
|
||||
id: string
|
||||
/** Applied before the command runs; undone by `rollback` on failure. */
|
||||
optimistic?: () => void
|
||||
@@ -34,7 +34,7 @@ const isBusyOutcome = (value: unknown): boolean =>
|
||||
value !== null &&
|
||||
(value as { status?: unknown }).status === 'busy'
|
||||
|
||||
const inFlight = new Set<string>()
|
||||
const inFlight = new Map<string, Promise<void>>()
|
||||
let storeDispatch: ((action: AppStoreAction) => void) | null = null
|
||||
|
||||
/** The app store registers its dispatcher so the funnel can record busy state. */
|
||||
@@ -54,12 +54,17 @@ export async function mutate<T>(
|
||||
options: MutateOptions<T>,
|
||||
): Promise<MutateResult<T>> {
|
||||
const { id } = options
|
||||
if (inFlight.has(id)) return { ok: false, busy: true }
|
||||
inFlight.add(id)
|
||||
const previous = inFlight.get(id)
|
||||
let release!: () => void
|
||||
const settled = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
inFlight.set(id, settled)
|
||||
if (previous) await previous
|
||||
storeDispatch?.({ type: 'busy/started', id })
|
||||
|
||||
options.optimistic?.()
|
||||
try {
|
||||
options.optimistic?.()
|
||||
const value = await invoke()
|
||||
if (isBusyOutcome(value)) {
|
||||
if (options.busyNotice !== undefined) {
|
||||
@@ -84,7 +89,8 @@ export async function mutate<T>(
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
inFlight.delete(id)
|
||||
release()
|
||||
if (inFlight.get(id) === settled) inFlight.delete(id)
|
||||
storeDispatch?.({ type: 'busy/settled', id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,22 @@ import type {
|
||||
AppStoreState,
|
||||
ProfileUpdatesState,
|
||||
} from './app-state'
|
||||
import type { createStoreReads } from './store-reads'
|
||||
|
||||
export const AppStateContext = createContext<AppStoreState | null>(null)
|
||||
export const AppDispatchContext =
|
||||
createContext<AppStoreActionDispatcher | null>(null)
|
||||
|
||||
export const AppReadsContext = createContext<ReturnType<
|
||||
typeof createStoreReads
|
||||
> | null>(null)
|
||||
|
||||
export const useAppReads = () => {
|
||||
const reads = use(AppReadsContext)
|
||||
if (!reads) throw new Error('app store is missing its provider')
|
||||
return reads
|
||||
}
|
||||
|
||||
export type AppStoreActionDispatcher = (action: AppStoreAction) => void
|
||||
|
||||
const useSelector = <T>(select: (state: AppStoreState) => T): T => {
|
||||
|
||||
+31
-29
@@ -1,15 +1,19 @@
|
||||
import { useEffect, useReducer } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useReducer } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
getPendingFailures,
|
||||
getRuntimeState,
|
||||
getSidecarFailure,
|
||||
} from '@/services/cmds'
|
||||
import { bindStoreDispatch } from '@/services/mutate'
|
||||
|
||||
import { appStoreReducer, initialAppStoreState } from './app-state'
|
||||
import { AppDispatchContext, AppStateContext } from './app-store-context'
|
||||
import {
|
||||
type AppStoreAction,
|
||||
appStoreReducer,
|
||||
initialAppStoreState,
|
||||
} from './app-state'
|
||||
import {
|
||||
AppDispatchContext,
|
||||
AppReadsContext,
|
||||
AppStateContext,
|
||||
} from './app-store-context'
|
||||
import { createStoreReads } from './store-reads'
|
||||
|
||||
/**
|
||||
* Single app store (React context + reducer, per the frozen design). Event-bus
|
||||
@@ -20,6 +24,16 @@ import { AppDispatchContext, AppStateContext } from './app-store-context'
|
||||
export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
|
||||
const [state, dispatch] = useReducer(appStoreReducer, initialAppStoreState)
|
||||
|
||||
const reads = useMemo(() => createStoreReads(dispatch), [dispatch])
|
||||
const dispatchEvent = useCallback(
|
||||
(action: AppStoreAction) => {
|
||||
if (action.type === 'runState/loaded')
|
||||
reads.acceptRunState(action.runState)
|
||||
else dispatch(action)
|
||||
},
|
||||
[reads],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
bindStoreDispatch(dispatch)
|
||||
return () => bindStoreDispatch(null)
|
||||
@@ -27,26 +41,9 @@ export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const readRunState = () => {
|
||||
getRuntimeState()
|
||||
.then((runState) => dispatch({ type: 'runState/loaded', runState }))
|
||||
.catch(() => {})
|
||||
}
|
||||
const readPendingFailures = () => {
|
||||
getSidecarFailure()
|
||||
.then((snapshot) =>
|
||||
dispatch({ type: 'sidecarFailure/loaded', snapshot }),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.warn('[app-store] Sidecar failure could not be read:', error)
|
||||
})
|
||||
getPendingFailures()
|
||||
.then((failures) =>
|
||||
dispatch({ type: 'pendingFailures/loaded', failures }),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.warn('[app-store] pending failures could not be read:', error)
|
||||
})
|
||||
void reads.readRunState().catch(() => {})
|
||||
}
|
||||
const { readPendingFailures } = reads
|
||||
|
||||
readRunState()
|
||||
readPendingFailures()
|
||||
@@ -70,12 +67,17 @@ export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
|
||||
window.removeEventListener('focus', readWhenFocused)
|
||||
document.removeEventListener('visibilitychange', readWhenFocused)
|
||||
window.clearInterval(timer)
|
||||
reads.invalidate()
|
||||
}
|
||||
}, [])
|
||||
}, [reads])
|
||||
|
||||
return (
|
||||
<AppStateContext value={state}>
|
||||
<AppDispatchContext value={dispatch}>{children}</AppDispatchContext>
|
||||
<AppReadsContext value={reads}>
|
||||
<AppDispatchContext value={dispatchEvent}>
|
||||
{children}
|
||||
</AppDispatchContext>
|
||||
</AppReadsContext>
|
||||
</AppStateContext>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getPendingFailures,
|
||||
getRuntimeState,
|
||||
type RunState,
|
||||
} from '@/services/cmds'
|
||||
|
||||
import { appStoreReducer, initialAppStoreState } from './app-state'
|
||||
import { createStoreReads } from './store-reads'
|
||||
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
getRuntimeState: vi.fn(),
|
||||
getPendingFailures: vi.fn().mockResolvedValue([]),
|
||||
getSidecarFailure: vi.fn().mockResolvedValue({ revision: 0, detail: null }),
|
||||
}))
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps a newer run-state event when an older snapshot returns', async () => {
|
||||
let resolve!: (state: RunState) => void
|
||||
vi.mocked(getRuntimeState).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve = r
|
||||
}),
|
||||
)
|
||||
let state = initialAppStoreState
|
||||
const reads = createStoreReads((action) => {
|
||||
state = appStoreReducer(state, action)
|
||||
})
|
||||
const pending = reads.readRunState()
|
||||
const running = { mode: 'Service' } as RunState
|
||||
reads.acceptRunState(running)
|
||||
resolve({ mode: 'NotRunning' } as RunState)
|
||||
expect(await pending).toBe(running)
|
||||
expect(state.runState).toBe(running)
|
||||
})
|
||||
|
||||
it('keeps the newer read when run-state replies arrive in reverse order', async () => {
|
||||
let resolveFirst!: (state: RunState) => void
|
||||
vi.mocked(getRuntimeState)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveFirst = r
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ mode: 'Service' } as RunState)
|
||||
let state = initialAppStoreState
|
||||
const reads = createStoreReads((action) => {
|
||||
state = appStoreReducer(state, action)
|
||||
})
|
||||
const first = reads.readRunState()
|
||||
const second = reads.readRunState()
|
||||
await second
|
||||
resolveFirst({ mode: 'NotRunning' } as RunState)
|
||||
expect((await first).mode).toBe('Service')
|
||||
expect(state.runState?.mode).toBe('Service')
|
||||
})
|
||||
|
||||
it('does not restore cleared pending failures from an older read', async () => {
|
||||
let resolveFirst!: (
|
||||
failures: Awaited<ReturnType<typeof getPendingFailures>>,
|
||||
) => void
|
||||
vi.mocked(getPendingFailures)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveFirst = r
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce([])
|
||||
let state = initialAppStoreState
|
||||
const reads = createStoreReads((action) => {
|
||||
state = appStoreReducer(state, action)
|
||||
})
|
||||
reads.readPendingFailures()
|
||||
reads.readPendingFailures()
|
||||
await Promise.resolve()
|
||||
resolveFirst([
|
||||
{
|
||||
code: 'proxy',
|
||||
detail: 'old',
|
||||
operation: 'systemProxyEnable',
|
||||
sequence: 1,
|
||||
},
|
||||
])
|
||||
await Promise.resolve()
|
||||
expect(state.pendingFailures).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
getPendingFailures,
|
||||
getRuntimeState,
|
||||
getSidecarFailure,
|
||||
type RunState,
|
||||
} from '@/services/cmds'
|
||||
|
||||
import type { AppStoreAction } from './app-state'
|
||||
|
||||
export const createStoreReads = (
|
||||
dispatch: (action: AppStoreAction) => void,
|
||||
) => {
|
||||
let runRevision = 0
|
||||
let failureRevision = 0
|
||||
let current: RunState | null = null
|
||||
let latestRead: Promise<RunState> | null = null
|
||||
|
||||
const acceptRunState = (runState: RunState) => {
|
||||
runRevision += 1
|
||||
latestRead = null
|
||||
current = runState
|
||||
dispatch({ type: 'runState/loaded', runState })
|
||||
}
|
||||
|
||||
const readRunState = (): Promise<RunState> => {
|
||||
const revision = ++runRevision
|
||||
const read: Promise<RunState> = getRuntimeState().then((runState) => {
|
||||
if (revision === runRevision) {
|
||||
current = runState
|
||||
dispatch({ type: 'runState/loaded', runState })
|
||||
}
|
||||
if (latestRead && latestRead !== read) return latestRead
|
||||
return current ?? runState
|
||||
})
|
||||
latestRead = read
|
||||
return read
|
||||
}
|
||||
|
||||
const readPendingFailures = () => {
|
||||
const revision = ++failureRevision
|
||||
getSidecarFailure()
|
||||
.then((snapshot) => {
|
||||
if (revision === failureRevision) {
|
||||
dispatch({ type: 'sidecarFailure/loaded', snapshot })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[app-store] Sidecar failure could not be read:', error)
|
||||
})
|
||||
getPendingFailures()
|
||||
.then((failures) => {
|
||||
if (revision === failureRevision) {
|
||||
dispatch({ type: 'pendingFailures/loaded', failures })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[app-store] pending failures could not be read:', error)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
acceptRunState,
|
||||
readRunState,
|
||||
readPendingFailures,
|
||||
invalidate: () => {
|
||||
runRevision += 1
|
||||
failureRevision += 1
|
||||
latestRead = null
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user