Tauriアプリの二重クリック競合はoptimistic UI guardで防ぐ
Tauriアプリでは、start process、open serial port、write file、import data、stop worker、close resourceなど、多くのUI actionがasync IPCでRustを呼びます。
よくあるミスは、Rust commandの完了を待ってからUI状態を更新することです。その短い間に同じbuttonを二重クリックでき、background workerの重複、file writeの重複、lockされたfile、閉じきれないportにつながります。
対策は単純です。IPCをawaitする前にlocal UI stateを同期的に更新し、失敗した時だけrollbackします。
race: disabled stateが遅れて変わる
この形は危険です。
async function startJob() {
await invoke("start_job");
set({ isRunning: true });
}
ユーザーが素早く二重クリックすると、isRunning が true になる前に両方のhandlerが入り、両方が start_job を呼びます。
Rust側でもidempotencyを守るべきですが、UIも重複commandを送らないようにします。
await前にguardを立てる
boolean guardを先にsetします。
export const useJobStore = create<JobState>((set) => ({
isRunning: false,
error: null,
startJob: async () => {
set({ isRunning: true, error: null });
try {
await invoke("start_job");
} catch (error) {
set({ isRunning: false, error: String(error) });
}
},
stopJob: async () => {
set({ isRunning: false, error: null });
try {
await invoke("stop_job");
} catch (error) {
set({ error: String(error) });
}
},
}));
buttonはlocal stateにbindします。
<button onClick={startJob} disabled={isRunning}>Start</button>
<button onClick={stopJob} disabled={!isRunning}>Stop</button>
2回目のclickは、最初のIPC完了前でも新しいdisabled stateを見ます。
cleanupを最初のcommand成功に依存させない
stop flowでは、よく2段階あります。
- workerを止める
- resourceをreleaseする
2番目が必須なら、1つの try blockに閉じ込めないほうが安全です。
async function stopAndClose() {
set({ isRunning: false });
try {
await invoke("stop_worker");
} catch (error) {
set({ error: String(error) });
}
try {
await invoke("close_resource");
} catch (error) {
set({ closeError: String(error) });
}
}
serial port、file handle、background thread、database connection、subprocessでは特に重要です。
cheap preconditionはIPC前に見る
Rust round tripが不要なvalidationは先に行います。
async function sendBytes(bytes: number[], intervalMs: number) {
if (bytes.length === 0) {
throw new Error("Command bytes cannot be empty.");
}
if (intervalMs < 10) {
throw new Error("Interval must be at least 10 ms.");
}
await invoke("send_bytes", { bytes, intervalMs });
}
Rust側でもvalidationしますが、UIはユーザーが直せるerrorを早く明確に返せます。
recoverable errorとfatal errorを分ける
全部を同じfailed stateにしないようにします。
type UiError = {
message: string;
recoverable: boolean;
};
recoverableなら現在のformへ戻し、fatalなら先に必要なsetupへ誘導します。
検証チェック
- local busy/running flagが
await前にsetされる - button disabledがRust responseではなくlocal stateから決まる
- Rust側もduplicate startを拒否またはidempotentに扱う
- stop/cancel失敗時もcleanupが走る
- cheap validationがIPC前にある
- error stateがretry可能かどうかを伝える