From 47ad64458f21f74699c6c1f549bd9f97dcf36d49 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Sat, 13 Jun 2026 20:28:13 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E3=82=BF=E3=83=96=E5=BE=A9=E5=B8=B0?= =?UTF-8?q?=E6=99=82=E3=81=AE=E3=82=AA=E3=83=95=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E8=AA=A4=E6=A4=9C=E7=9F=A5=E3=82=92=E9=98=B2=E6=AD=A2=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit モバイルでタブを切り替えて戻った際、visibilitychange でのネットワーク 判定が初回しきい値(5000ms)で実行され、無線復帰遅延により実際はオンライン でも誤ってオフライン判定されることがあった。 - evaluateNetwork に allowRetry オプションを追加 - タブ復帰(visibilitychange)/再接続(online)時、オンライン→オフラインへ 反転しそうな場合に少し待ってから緩和しきい値(10000ms)で1度だけ再判定 - navigator-offline はブラウザ確定のため再判定せず即オフライン - 強制オフライン(forcedOffline)は従来通り別経路で維持 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/networkDetection.test.ts | 98 ++++++++++++++++++++++- frontend/src/lib/networkDetection.ts | 37 ++++++++- frontend/src/main.tsx | 4 +- 3 files changed, 131 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/networkDetection.test.ts b/frontend/src/lib/networkDetection.test.ts index 7a594c09..9f9866f9 100644 --- a/frontend/src/lib/networkDetection.test.ts +++ b/frontend/src/lib/networkDetection.test.ts @@ -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); @@ -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回のみ) + }); +}); diff --git a/frontend/src/lib/networkDetection.ts b/frontend/src/lib/networkDetection.ts index d044ef24..244c3be1 100644 --- a/frontend/src/lib/networkDetection.ts +++ b/frontend/src/lib/networkDetection.ts @@ -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 => new Promise(resolve => setTimeout(resolve, ms)); // --- 型 --- @@ -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 => { // Step 1: navigator.onLine チェック if (!navigator.onLine) { @@ -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 @@ -89,9 +95,32 @@ export const checkNetworkStatus = async ( // --- atom 更新 --- -export const evaluateNetwork = async (store: ReturnType): Promise => { +export type EvaluateNetworkOptions = { + /** + * タブ復帰(visibilitychange)・再接続(online)など、ネットワークが不安定に + * なりやすいタイミングで true にする。オンライン→オフラインへ反転しそうな + * 場合に、緩和しきい値で1度だけ再判定してから確定させ、誤検知を防ぐ。 + */ + allowRetry?: boolean; +}; + +export const evaluateNetwork = async ( + store: ReturnType, + options: EvaluateNetworkOptions = {} +): Promise => { 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' }; + } const changed = wasPreviouslyOffline !== result.isOffline; if (changed) { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0c6ccb70..984f0a96 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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 }); } });