diff --git a/src/components/layout/service-migration-dialog.test.ts b/src/components/layout/service-migration-dialog.test.ts index c05b9b508..8c292864d 100644 --- a/src/components/layout/service-migration-dialog.test.ts +++ b/src/components/layout/service-migration-dialog.test.ts @@ -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', () => ({ diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx index 084a28fe4..756ceff24 100644 --- a/src/components/layout/service-migration-dialog.tsx +++ b/src/components/layout/service-migration-dialog.tsx @@ -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) { diff --git a/src/components/layout/sysproxy-privilege-dialog.tsx b/src/components/layout/sysproxy-privilege-dialog.tsx index 87a540a6d..fb493157c 100644 --- a/src/components/layout/sysproxy-privilege-dialog.tsx +++ b/src/components/layout/sysproxy-privilege-dialog.tsx @@ -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' && diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index d29d9ffc5..69e718486 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -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, }, ) diff --git a/src/components/setting/mods/backup-history-viewer.tsx b/src/components/setting/mods/backup-history-viewer.tsx index 64522d496..6147ab4be 100644 --- a/src/components/setting/mods/backup-history-viewer.tsx +++ b/src/components/setting/mods/backup-history-viewer.tsx @@ -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) }) diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 0e9a2bb6b..15493da24 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -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 }) { config = parsedConfig as Record } - 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 }) { } if (dnsEnabled) { - await applyDnsConfig(true) + await mutate(() => applyDnsConfig(true), { + id: 'apply-dns-config', + errorNotice: false, + }) mutateClash() } diff --git a/src/components/setting/mods/layout-viewer.tsx b/src/components/setting/mods/layout-viewer.tsx index df13cc45c..f818d61de 100644 --- a/src/components/setting/mods/layout-viewer.tsx +++ b/src/components/setting/mods/layout-viewer.tsx @@ -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((_, 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((_, 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((_, 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 }) diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 9545d8dd6..8c4db006c 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -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((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() } } diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 8b8e08dc3..8cf654765 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -74,7 +74,11 @@ export const useProfiles = () => { const patchCurrent = useCallback( async (value: Partial) => { 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() } }, diff --git a/src/hooks/use-proxy-selection.test.ts b/src/hooks/use-proxy-selection.test.ts new file mode 100644 index 000000000..d176baf66 --- /dev/null +++ b/src/hooks/use-proxy-selection.test.ts @@ -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() +}) diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index 8300c3bf2..e3a1d40e6 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -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) { diff --git a/src/hooks/use-record-selection.ts b/src/hooks/use-record-selection.ts index 6e0af590c..66e5736a8 100644 --- a/src/hooks/use-record-selection.ts +++ b/src/hooks/use-record-selection.ts @@ -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) { diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index 15aa4ceee..a2079c882 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -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, } } diff --git a/src/services/bus/use-event-bus.test.ts b/src/services/bus/use-event-bus.test.ts index a0deed42c..13ba8f138 100644 --- a/src/services/bus/use-event-bus.test.ts +++ b/src/services/bus/use-event-bus.test.ts @@ -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([]), diff --git a/src/services/bus/use-event-bus.ts b/src/services/bus/use-event-bus.ts index 4a3fe2bb0..8c22e10ed 100644 --- a/src/services/bus/use-event-bus.ts +++ b/src/services/bus/use-event-bus.ts @@ -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]) } diff --git a/src/services/mutate.test.ts b/src/services/mutate.test.ts new file mode 100644 index 000000000..ce6299340 --- /dev/null +++ b/src/services/mutate.test.ts @@ -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((_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() +}) diff --git a/src/services/mutate.ts b/src/services/mutate.ts index 546a62056..ec59552ea 100644 --- a/src/services/mutate.ts +++ b/src/services/mutate.ts @@ -6,7 +6,7 @@ import type { AppStoreAction } from '@/store/app-state' type MutateNoticeInput = Parameters[0] export interface MutateOptions { - /** 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() +const inFlight = new Map>() 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( options: MutateOptions, ): Promise> { 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((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( } throw error } finally { - inFlight.delete(id) + release() + if (inFlight.get(id) === settled) inFlight.delete(id) storeDispatch?.({ type: 'busy/settled', id }) } } diff --git a/src/store/app-store-context.ts b/src/store/app-store-context.ts index 3e9ae1f72..aa2ca7351 100644 --- a/src/store/app-store-context.ts +++ b/src/store/app-store-context.ts @@ -5,11 +5,22 @@ import type { AppStoreState, ProfileUpdatesState, } from './app-state' +import type { createStoreReads } from './store-reads' export const AppStateContext = createContext(null) export const AppDispatchContext = createContext(null) +export const AppReadsContext = createContext | 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 = (select: (state: AppStoreState) => T): T => { diff --git a/src/store/app-store.tsx b/src/store/app-store.tsx index b053b910e..5571e8de4 100644 --- a/src/store/app-store.tsx +++ b/src/store/app-store.tsx @@ -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 ( - {children} + + + {children} + + ) } diff --git a/src/store/store-reads.test.ts b/src/store/store-reads.test.ts new file mode 100644 index 000000000..c8eebd51e --- /dev/null +++ b/src/store/store-reads.test.ts @@ -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>, + ) => 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([]) +}) diff --git a/src/store/store-reads.ts b/src/store/store-reads.ts new file mode 100644 index 000000000..3af3e1a03 --- /dev/null +++ b/src/store/store-reads.ts @@ -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 | null = null + + const acceptRunState = (runState: RunState) => { + runRevision += 1 + latestRead = null + current = runState + dispatch({ type: 'runState/loaded', runState }) + } + + const readRunState = (): Promise => { + const revision = ++runRevision + const read: Promise = 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 + }, + } +}