Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 96 additions & 2 deletions frontend/src/lib/networkDetection.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createStore } from 'jotai';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { isOfflineAtom } from '@/atoms/network';
import { checkNetworkStatus, evaluateNetwork, thresholdManager } from './networkDetection';
import { checkNetworkStatus, evaluateNetwork, RESUME_RETRY_DELAY_MS, thresholdManager } from './networkDetection';

const mockNavigatorOnLine = (value: boolean) => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value);
Expand Down Expand Up @@ -187,3 +187,97 @@ describe('evaluateNetwork', () => {
expect(thresholdManager.get()).toBe(5000); // 変更なし
});
});

describe('evaluateNetwork - タブ復帰時の誤検知対策 (allowRetry)', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.useFakeTimers();
thresholdManager.reset();
});

afterEach(() => {
vi.useRealTimers();
});

it('allowRetry: 初回 rtt-exceeded でも再判定でオンラインなら誤オフライン判定しない', async () => {
mockNavigatorOnLine(true);
// 1回目: しきい値超過で返らない / 2回目: 即成功
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockReturnValueOnce(
new Promise(() => {
// 1回目は解決しない(しきい値タイマーが先に発火)
})
);
fetchSpy.mockResolvedValueOnce(new Response('ok', { status: 200 }));
// 2回目の RTT 計測用
vi.spyOn(performance, 'now').mockReturnValue(0);

const store = createStore();
store.set(isOfflineAtom, false); // 以前はオンライン

const promise = evaluateNetwork(store, { allowRetry: true });
// 1回目の RTT しきい値(5000ms) 超過 → 待機(1500ms) → 2回目チェック
await vi.advanceTimersByTimeAsync(5000 + 1500);
await promise;

expect(store.get(isOfflineAtom)).toBe(false); // 誤オフラインにならない
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

it('allowRetry: 再判定でも失敗すれば最終的にオフライン判定する', async () => {
mockNavigatorOnLine(true);
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Network error'));

const store = createStore();
store.set(isOfflineAtom, false);

const promise = evaluateNetwork(store, { allowRetry: true });
await vi.advanceTimersByTimeAsync(RESUME_RETRY_DELAY_MS);
await promise;

expect(store.get(isOfflineAtom)).toBe(true);
});

it('allowRetry: 待機中に navigator.onLine が false になればオフライン確定', async () => {
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Network error'));

const store = createStore();
store.set(isOfflineAtom, false);

const promise = evaluateNetwork(store, { allowRetry: true });
// 待機中にブラウザがオフライン確定
onLineSpy.mockReturnValue(false);
await vi.advanceTimersByTimeAsync(RESUME_RETRY_DELAY_MS);
await promise;

expect(store.get(isOfflineAtom)).toBe(true);
});

it('allowRetry: navigator-offline は再判定せず即オフライン', async () => {
mockNavigatorOnLine(false);
const fetchSpy = mockFetchSuccess();

const store = createStore();
store.set(isOfflineAtom, false);

await evaluateNetwork(store, { allowRetry: true });

expect(store.get(isOfflineAtom)).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
});

it('allowRetry: 以前オフラインだった場合は再判定しない(オフライン→オフライン)', async () => {
mockNavigatorOnLine(true);
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockRejectedValue(new TypeError('Network error'));

const store = createStore();
store.set(isOfflineAtom, true); // 以前からオフライン

await evaluateNetwork(store, { allowRetry: true });

expect(store.get(isOfflineAtom)).toBe(true);
expect(fetchSpy).toHaveBeenCalledTimes(1); // 再判定なし(1回のみ)
});
});
37 changes: 33 additions & 4 deletions frontend/src/lib/networkDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ const DEBUG_DELAY_PARAM =
: null;
const INITIAL_RTT_THRESHOLD_MS = 5000;
const RELAXED_RTT_THRESHOLD_MS = 10000;
// タブ復帰・再接続直後はモバイル無線の再接続遅延で初回ヘルスチェックが失敗しやすい。
// 一度だけ待ってから緩和しきい値で再判定するための待機時間。
export const RESUME_RETRY_DELAY_MS = 1500;

const delay = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));

// --- 型 ---

Expand Down Expand Up @@ -39,7 +44,8 @@ export const thresholdManager = (() => {
// --- 2段階判定ロジック ---

export const checkNetworkStatus = async (
baseUrl: string = import.meta.env.VITE_API_BASE_URL ?? ''
baseUrl: string = import.meta.env.VITE_API_BASE_URL ?? '',
thresholdMs?: number
): Promise<NetworkCheckResult> => {
// Step 1: navigator.onLine チェック
if (!navigator.onLine) {
Expand All @@ -48,7 +54,7 @@ export const checkNetworkStatus = async (

// Step 2: fetch と RTTしきい値タイマーを Promise.race で競わせる
const controller = new AbortController();
const rttThresholdMs = thresholdManager.get();
const rttThresholdMs = thresholdMs ?? thresholdManager.get();
const start = performance.now();

const healthUrl = DEBUG_DELAY_PARAM
Expand Down Expand Up @@ -89,9 +95,32 @@ export const checkNetworkStatus = async (

// --- atom 更新 ---

export const evaluateNetwork = async (store: ReturnType<typeof createStore>): Promise<void> => {
export type EvaluateNetworkOptions = {
/**
* タブ復帰(visibilitychange)・再接続(online)など、ネットワークが不安定に
* なりやすいタイミングで true にする。オンライン→オフラインへ反転しそうな
* 場合に、緩和しきい値で1度だけ再判定してから確定させ、誤検知を防ぐ。
*/
allowRetry?: boolean;
};

export const evaluateNetwork = async (
store: ReturnType<typeof createStore>,
options: EvaluateNetworkOptions = {}
): Promise<void> => {
const wasPreviouslyOffline = store.get(isOfflineAtom);
const result = await checkNetworkStatus();
let result = await checkNetworkStatus();

// タブ復帰・再接続時の誤検知対策:
// それまでオンラインだったのに navigator.onLine は true のままヘルスチェックだけ
// 失敗した場合(モバイル無線の復帰遅延など)、少し待ってから緩和しきい値で再判定する。
// navigator-offline はブラウザ確定のオフラインなので再判定しない。
if (options.allowRetry && !wasPreviouslyOffline && result.isOffline && result.reason !== 'navigator-offline') {
await delay(RESUME_RETRY_DELAY_MS);
result = navigator.onLine
? await checkNetworkStatus(undefined, RELAXED_RTT_THRESHOLD_MS)
: { isOffline: true, reason: 'navigator-offline' };
}
Comment on lines +107 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

並行実行で古いネットワーク判定が上書きされる競合があります。

Line 118-123 の待機付き再判定により、evaluateNetwork が重複起動すると完了順が逆転し、古い呼び出しが後から isOfflineAtom を上書きできます(online / visibilitychange の同時発火経路で再現可能)。最新リクエストのみ反映するガードを入れてください。

差分案
+let latestEvaluateRequestId = 0;
+
 export const evaluateNetwork = async (
   store: ReturnType<typeof createStore>,
   options: EvaluateNetworkOptions = {}
 ): Promise<void> => {
+  const requestId = ++latestEvaluateRequestId;
   const wasPreviouslyOffline = store.get(isOfflineAtom);
   let result = await checkNetworkStatus();
@@
   if (options.allowRetry && !wasPreviouslyOffline && result.isOffline && result.reason !== 'navigator-offline') {
     await delay(RESUME_RETRY_DELAY_MS);
     result = navigator.onLine
       ? await checkNetworkStatus(undefined, RELAXED_RTT_THRESHOLD_MS)
       : { isOffline: true, reason: 'navigator-offline' };
   }
+
+  // より新しい evaluateNetwork が開始済みなら、この結果は破棄する
+  if (requestId !== latestEvaluateRequestId) return;
 
   const changed = wasPreviouslyOffline !== result.isOffline;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/networkDetection.ts` around lines 107 - 123, Concurrent
evaluateNetwork calls can race so a delayed retry may overwrite a newer result;
add a per-call sequence guard to ensure only the latest invocation writes state.
Implement by generating a monotonically-incrementing token (e.g. local seq
number or timestamp) at the start of evaluateNetwork, store it in a
module-scoped variable like currentEvalSeq, and capture it in the async flow;
before applying the final result (after delay/recheck and before updating
isOfflineAtom or any store writes), compare the captured token with
currentEvalSeq and bail out if it no longer matches. Update only inside the
block where token matches so older/slow evaluations cannot overwrite newer ones;
keep existing logic around options.allowRetry, RESUME_RETRY_DELAY_MS,
checkNetworkStatus and navigator.offLine unchanged except for this guard.


const changed = wasPreviouslyOffline !== result.isOffline;
if (changed) {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ window.addEventListener('offline', () => {

window.addEventListener('online', () => {
if (jotaiStore.get(isForcedOfflineAtom)) return;
evaluateNetwork(jotaiStore);
evaluateNetwork(jotaiStore, { allowRetry: true });
});

document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (jotaiStore.get(isForcedOfflineAtom)) return;
evaluateNetwork(jotaiStore);
evaluateNetwork(jotaiStore, { allowRetry: true });
}
});

Expand Down
Loading