refactor:replace state machine with reducer

This commit is contained in:
Slinetrac
2025-10-25 10:35:35 +08:00
Unverified
parent b627da32ac
commit 1beeb3f804

View File

@@ -31,7 +31,14 @@ import { readTextFile } from "@tauri-apps/plugin-fs";
import { useLockFn } from "ahooks";
import type { TFunction } from "i18next";
import { throttle } from "lodash-es";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
@@ -77,12 +84,81 @@ type SwitchRequest = {
notifySuccess: boolean;
};
interface ProfileSwitchMachineOptions {
interface SwitchState {
switching: SwitchRequest | null;
queued: SwitchRequest | null;
status: "idle" | "running";
lastError: string | null;
}
type SwitchAction =
| { type: "REQUEST"; payload: SwitchRequest }
| { type: "RUN_FINISHED"; payload: { error?: string | null } };
const initialSwitchState: SwitchState = {
switching: null,
queued: null,
status: "idle",
lastError: null,
};
const switchReducer = (
state: SwitchState,
action: SwitchAction,
): SwitchState => {
switch (action.type) {
case "REQUEST": {
const payload = action.payload;
if (state.switching) {
if (state.switching.profile === payload.profile) {
const notifySuccess =
state.switching.notifySuccess || payload.notifySuccess;
return {
...state,
switching: { profile: payload.profile, notifySuccess },
lastError: null,
};
}
return {
...state,
queued: payload,
lastError: null,
};
}
return {
switching: payload,
queued: null,
status: "running",
lastError: null,
};
}
case "RUN_FINISHED": {
const nextError = action.payload.error ?? null;
if (state.queued) {
return {
switching: state.queued,
queued: null,
status: "running",
lastError: nextError,
};
}
return {
switching: null,
queued: null,
status: "idle",
lastError: nextError,
};
}
default:
return state;
}
};
interface ProfileSwitchControllerOptions {
getCurrentProfileId: () => string | undefined;
patchProfiles: (
value: Partial<IProfilesConfig>,
signal: AbortSignal,
) => Promise<boolean>;
patchProfiles: (value: Partial<IProfilesConfig>) => Promise<void>;
mutateLogs: () => Promise<void>;
closeAllConnections: () => Promise<void>;
activateSelected: () => Promise<void>;
@@ -90,7 +166,7 @@ interface ProfileSwitchMachineOptions {
t: TFunction;
}
const useProfileSwitchMachine = ({
const useProfileSwitchController = ({
getCurrentProfileId,
patchProfiles,
mutateLogs,
@@ -98,142 +174,96 @@ const useProfileSwitchMachine = ({
activateSelected,
setActivatings,
t,
}: ProfileSwitchMachineOptions) => {
const switchingProfileRef = useRef<string | null>(null);
const pendingProfileRef = useRef<SwitchRequest | null>(null);
const processingQueueRef = useRef(false);
const requestSequenceRef = useRef(0);
const cleanupSwitchState = useCallback(
(profile: string, sequence: number) => {
setActivatings((prev) => prev.filter((id) => id !== profile));
if (switchingProfileRef.current === profile) {
switchingProfileRef.current = null;
}
debugProfileSwitch("SWITCH_END", profile, `序列号: ${sequence}`);
},
[setActivatings],
);
const runProfileSwitch = useCallback(
async (request: SwitchRequest) => {
const { profile, notifySuccess } = request;
const currentSequence = ++requestSequenceRef.current;
debugProfileSwitch("NEW_REQUEST", profile, `序列号: ${currentSequence}`);
switchingProfileRef.current = profile;
debugProfileSwitch("SWITCH_START", profile, `序列号: ${currentSequence}`);
setActivatings((prev) => {
if (prev.includes(profile)) return prev;
return [...prev, profile];
});
const isOutdated = () => currentSequence !== requestSequenceRef.current;
const controller = new AbortController();
try {
if (isOutdated()) {
return;
}
const requestPromise = patchProfiles(
{ current: profile },
controller.signal,
);
const success = await requestPromise;
if (isOutdated()) {
return;
}
await mutateLogs();
await closeAllConnections();
if (notifySuccess && success) {
showNotice("success", t("Profile Switched"), 1000);
}
setTimeout(() => {
if (isOutdated()) return;
activateSelected().catch((err) =>
console.warn("Failed to activate selected proxies:", err),
);
}, 50);
} catch (error: any) {
console.error(`[Profile] 切换失败:`, error);
showNotice("error", error?.message || error.toString(), 4000);
} finally {
cleanupSwitchState(profile, currentSequence);
}
},
[
activateSelected,
cleanupSwitchState,
closeAllConnections,
mutateLogs,
patchProfiles,
setActivatings,
t,
],
);
const processSwitchQueue = useCallback(() => {
if (processingQueueRef.current) return;
const run = async () => {
processingQueueRef.current = true;
try {
while (true) {
const next = pendingProfileRef.current;
if (!next) break;
pendingProfileRef.current = null;
await runProfileSwitch(next);
}
} finally {
processingQueueRef.current = false;
}
};
run().catch((error) => {
console.error("[Profile] 处理切换队列失败:", error);
});
}, [runProfileSwitch]);
}: ProfileSwitchControllerOptions) => {
const [state, dispatch] = useReducer(switchReducer, initialSwitchState);
const runningProfileRef = useRef<string | null>(null);
const requestSwitch = useCallback(
(profile: string, notifySuccess: boolean) => {
const currentProfileId = getCurrentProfileId();
if (currentProfileId === profile && !notifySuccess) {
const currentId = getCurrentProfileId();
if (!notifySuccess && currentId === profile && state.status === "idle") {
debugProfileSwitch("ALREADY_CURRENT_IGNORED", profile);
return;
}
const currentSwitching = switchingProfileRef.current;
if (currentSwitching === profile) {
debugProfileSwitch("DUPLICATE_SWITCH_BLOCKED", profile);
return;
}
if (currentSwitching && currentSwitching !== profile) {
pendingProfileRef.current = { profile, notifySuccess };
return;
}
if (pendingProfileRef.current?.profile === profile) {
pendingProfileRef.current = { profile, notifySuccess };
return;
}
pendingProfileRef.current = { profile, notifySuccess };
processSwitchQueue();
dispatch({ type: "REQUEST", payload: { profile, notifySuccess } });
},
[getCurrentProfileId, processSwitchQueue],
[getCurrentProfileId, state.status],
);
useEffect(() => {
if (state.status !== "running" || !state.switching) return;
const { profile, notifySuccess } = state.switching;
if (runningProfileRef.current === profile) return;
runningProfileRef.current = profile;
let cancelled = false;
let delayTimer: number | null = null;
setActivatings((prev) => {
if (prev.includes(profile)) return prev;
return [...prev, profile];
});
(async () => {
let errorMessage: string | null = null;
try {
await patchProfiles({ current: profile });
if (cancelled) return;
await mutateLogs();
await closeAllConnections();
if (!cancelled && notifySuccess) {
showNotice("success", t("Profile Switched"), 1000);
}
if (!cancelled) {
await new Promise<void>((resolve) => {
delayTimer = window.setTimeout(() => {
delayTimer = null;
resolve();
}, 50);
});
await activateSelected();
}
} catch (error: any) {
if (!cancelled) {
errorMessage = error?.message || String(error);
showNotice("error", errorMessage, 4000);
}
} finally {
setActivatings((prev) => prev.filter((id) => id !== profile));
runningProfileRef.current = null;
if (!cancelled) {
dispatch({ type: "RUN_FINISHED", payload: { error: errorMessage } });
}
}
})();
return () => {
cancelled = true;
if (delayTimer !== null) {
clearTimeout(delayTimer);
delayTimer = null;
}
};
}, [
state.status,
state.switching,
patchProfiles,
mutateLogs,
closeAllConnections,
activateSelected,
setActivatings,
t,
]);
return {
requestSwitch,
switchingProfileRef,
switchingProfile: state.switching?.profile ?? null,
status: state.status,
lastError: state.lastError,
};
};
@@ -255,7 +285,7 @@ const normalizeProfileUrl = (value?: string) => {
} catch {
const schemeNormalized = trimmed.replace(
/^([a-z]+):\/\//i,
(match, scheme: string) => `${scheme.toLowerCase()}://`,
(_match, scheme: string) => `${scheme.toLowerCase()}://`,
);
return schemeNormalized.replace(/\/+$/, "");
}
@@ -553,15 +583,16 @@ const ProfilePage = () => {
await closeAllConnections();
}, []);
const { requestSwitch, switchingProfileRef } = useProfileSwitchMachine({
getCurrentProfileId,
patchProfiles,
mutateLogs: memoizedMutateLogs,
closeAllConnections: closeConnections,
activateSelected,
setActivatings,
t,
});
const { requestSwitch, switchingProfile: switchingProfileId } =
useProfileSwitchController({
getCurrentProfileId,
patchProfiles: (payload) => patchProfiles(payload),
mutateLogs: memoizedMutateLogs,
closeAllConnections: closeConnections,
activateSelected,
setActivatings,
t,
});
// 强化的刷新策略
const performRobustRefresh = async (
@@ -651,7 +682,7 @@ const ProfilePage = () => {
const onSelect = (current: string, force: boolean) => {
// 阻止重复点击或已激活的profile
if (switchingProfileRef.current === current) {
if (switchingProfileId === current) {
debugProfileSwitch("DUPLICATE_CLICK_IGNORED", current);
return;
}
@@ -672,9 +703,9 @@ const ProfilePage = () => {
}, [current, mutateProfiles, requestSwitch]);
const onEnhance = useLockFn(async (notifySuccess: boolean) => {
if (switchingProfileRef.current) {
if (switchingProfileId) {
console.log(
`[Profile] 有profile正在切换中(${switchingProfileRef.current})跳过enhance操作`,
`[Profile] 有profile正在切换中(${switchingProfileId})跳过enhance操作`,
);
return;
}
@@ -693,7 +724,9 @@ const ProfilePage = () => {
} finally {
// 保留正在切换的profile清除其他状态
setActivatings((prev) =>
prev.filter((id) => id === switchingProfileRef.current),
switchingProfileId
? prev.filter((id) => id === switchingProfileId)
: [],
);
}
});
@@ -834,7 +867,7 @@ const ProfilePage = () => {
});
const mode = useThemeMode();
const islight = mode === "light" ? true : false;
const islight = mode === "light";
const dividercolor = islight
? "rgba(0, 0, 0, 0.06)"
: "rgba(255, 255, 255, 0.06)";