← ./articles-ja

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 });
}

ユーザーが素早く二重クリックすると、isRunningtrue になる前に両方の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段階あります。

  1. workerを止める
  2. 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可能かどうかを伝える

参考