From 28977c1031addde394ae5dfd2f5bae58f9f9cecc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Feb 2026 11:19:04 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E8=B7=AF=E7=B7=9A=E3=83=87=E3=83=BC?= =?UTF-8?q?=E3=82=BF=E5=8F=96=E5=BE=97=E5=A4=B1=E6=95=97=E6=99=82=E3=81=AE?= =?UTF-8?q?=E3=83=AA=E3=83=88=E3=83=A9=E3=82=A4=E6=A9=9F=E6=A7=8B=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchLineNamesで取得に失敗したIDが永続的にfetchedIdsに残り、 再取得されない問題を修正。resolvedIds(取得成功済み)と pendingIds(取得中)に分離し、失敗時は指数バックオフで 最大3回リトライする。全リトライ失敗後も新しい路線追加時に 再取得が可能になる。 https://claude.ai/code/session_01AeztrVJGigrhtYXRwbWx3r --- hooks/use-line-names.ts | 38 ++++++-- lib/__tests__/use-line-names.test.ts | 134 ++++++++++++++++++++++++--- 2 files changed, 151 insertions(+), 21 deletions(-) diff --git a/hooks/use-line-names.ts b/hooks/use-line-names.ts index e3fe8a0..0da9bfa 100644 --- a/hooks/use-line-names.ts +++ b/hooks/use-line-names.ts @@ -6,9 +6,14 @@ let gqlUrl = Constants.expoConfig?.extra?.trainlcdGqlUrl || ""; // モジュールレベルのキャッシュ(アプリ全体で共有) let cache: Record = {}; let colorCache: Record = {}; -const fetchedIds = new Set(); +const resolvedIds = new Set(); // 取得成功済みのID +const pendingIds = new Set(); // 取得中のID(重複リクエスト防止) const listeners = new Set<() => void>(); +// リトライ設定 +const MAX_RETRIES = 3; +const RETRY_DELAYS = [2000, 4000, 8000]; // 指数バックオフ(ms) + function getSnapshot(): Record { return cache; } @@ -26,11 +31,11 @@ function notify() { for (const l of listeners) l(); } -export function fetchLineNames(ids: string[]) { - const newIds = ids.filter((id) => !fetchedIds.has(id)); +export function fetchLineNames(ids: string[], _retryCount = 0) { + const newIds = ids.filter((id) => !resolvedIds.has(id) && !pendingIds.has(id)); if (newIds.length === 0 || !gqlUrl) return; - for (const id of newIds) fetchedIds.add(id); + for (const id of newIds) pendingIds.add(id); fetch(gqlUrl, { method: "POST", @@ -45,7 +50,10 @@ export function fetchLineNames(ids: string[]) { const lines = json?.data?.lines as | { id: number; nameShort: string; color: string | null }[] | undefined; - if (!lines) return; + if (!lines) { + // レスポンスにlinesがない場合もリトライ対象 + throw new Error("No lines data in response"); + } let updated = false; const nextNames = { ...cache }; const nextColors = { ...colorCache }; @@ -59,13 +67,26 @@ export function fetchLineNames(ids: string[]) { updated = true; } } + // 取得成功: resolvedIdsに追加してpendingIdsから削除 + for (const id of newIds) { + resolvedIds.add(id); + pendingIds.delete(id); + } if (updated) { cache = nextNames; colorCache = nextColors; notify(); } }) - .catch(() => {}); + .catch(() => { + // 取得失敗: pendingIdsから削除してリトライ + for (const id of newIds) pendingIds.delete(id); + if (_retryCount < MAX_RETRIES) { + setTimeout(() => { + fetchLineNames(newIds, _retryCount + 1); + }, RETRY_DELAYS[_retryCount]); + } + }); } export function useLineNames(lineIds: string[]): Record { @@ -96,11 +117,12 @@ export function formatLineName( return name ? `${name}(${lineId})` : lineId; } -/** テスト用: キャッシュとfetchedIdsをリセット */ +/** テスト用: キャッシュとID管理をリセット */ export function _resetCache() { cache = {}; colorCache = {}; - fetchedIds.clear(); + resolvedIds.clear(); + pendingIds.clear(); listeners.clear(); } diff --git a/lib/__tests__/use-line-names.test.ts b/lib/__tests__/use-line-names.test.ts index 613c795..2bebaab 100644 --- a/lib/__tests__/use-line-names.test.ts +++ b/lib/__tests__/use-line-names.test.ts @@ -22,9 +22,11 @@ beforeEach(() => { _resetCache(); _setGqlUrl("https://gql-stg.trainlcd.app/"); fetchMock.mockReset(); + vi.useFakeTimers(); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -34,6 +36,11 @@ function mockFetchResponse(lines: { id: number; nameShort: string; color?: strin }); } +/** Promiseのマイクロタスクをフラッシュする */ +async function flushPromises() { + await vi.advanceTimersByTimeAsync(0); +} + describe("formatLineName", () => { it("路線名がある場合は「路線名(ID)」形式で返す", () => { const names = { "11302": "山手線" }; @@ -53,7 +60,7 @@ describe("fetchLineNames", () => { ]); fetchLineNames(["11302", "24006"]); - await vi.waitFor(() => expect(_getCache()).toHaveProperty("11302")); + await flushPromises(); expect(fetchMock).toHaveBeenCalledTimes(1); const body = JSON.parse(fetchMock.mock.calls[0][1].body); @@ -67,7 +74,7 @@ describe("fetchLineNames", () => { it("取得済みIDはスキップする(キャッシュ済み)", async () => { mockFetchResponse([{ id: 11302, nameShort: "山手線" }]); fetchLineNames(["11302"]); - await vi.waitFor(() => expect(_getCache()).toHaveProperty("11302")); + await flushPromises(); fetchLineNames(["11302"]); // 2回目のfetchは呼ばれない @@ -77,11 +84,11 @@ describe("fetchLineNames", () => { it("新規IDのみを差分リクエストする", async () => { mockFetchResponse([{ id: 11302, nameShort: "山手線" }]); fetchLineNames(["11302"]); - await vi.waitFor(() => expect(_getCache()).toHaveProperty("11302")); + await flushPromises(); mockFetchResponse([{ id: 24006, nameShort: "京王井の頭線" }]); fetchLineNames(["11302", "24006"]); - await vi.waitFor(() => expect(_getCache()).toHaveProperty("24006")); + await flushPromises(); expect(fetchMock).toHaveBeenCalledTimes(2); const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body); @@ -102,12 +109,103 @@ describe("fetchLineNames", () => { it("fetchエラー時はキャッシュが変更されない", async () => { fetchMock.mockRejectedValueOnce(new Error("Network error")); fetchLineNames(["99999"]); + await flushPromises(); - // エラー処理を待つ - await new Promise((r) => setTimeout(r, 10)); expect(_getCache()).toEqual({}); }); + it("fetchエラー時にリトライする", async () => { + fetchMock.mockRejectedValueOnce(new Error("Network error")); + mockFetchResponse([{ id: 99999, nameShort: "テスト線" }]); + + fetchLineNames(["99999"]); + await flushPromises(); + + // 初回失敗後、キャッシュは空 + expect(_getCache()).toEqual({}); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // 2秒後にリトライ + await vi.advanceTimersByTimeAsync(2000); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(_getCache()).toEqual({ "99999": "テスト線" }); + }); + + it("リトライが最大回数に達した後はリトライしない", async () => { + // 4回連続で失敗(初回 + リトライ3回) + fetchMock.mockRejectedValue(new Error("Network error")); + + fetchLineNames(["99999"]); + await flushPromises(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // 1回目リトライ(2秒後) + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // 2回目リトライ(4秒後) + await vi.advanceTimersByTimeAsync(4000); + expect(fetchMock).toHaveBeenCalledTimes(3); + + // 3回目リトライ(8秒後) + await vi.advanceTimersByTimeAsync(8000); + expect(fetchMock).toHaveBeenCalledTimes(4); + + // これ以上リトライしない + await vi.advanceTimersByTimeAsync(16000); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it("リトライ全失敗後に再度fetchLineNamesを呼ぶとリトライ可能になる", async () => { + // 4回連続で失敗(初回 + リトライ3回) + fetchMock.mockRejectedValue(new Error("Network error")); + + fetchLineNames(["99999"]); + await flushPromises(); + + // 全リトライ完了まで進める + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(8000); + expect(fetchMock).toHaveBeenCalledTimes(4); + + // リセットして成功するモックに差し替え + fetchMock.mockReset(); + mockFetchResponse([{ id: 99999, nameShort: "テスト線" }]); + + // 再度呼び出し可能(resolvedIdsに入っていないため) + fetchLineNames(["99999"]); + await flushPromises(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(_getCache()).toEqual({ "99999": "テスト線" }); + }); + + it("取得中のIDは重複リクエストされない", async () => { + // resolveしないPromiseでペンディング状態を維持 + let resolveFirst!: (value: unknown) => void; + fetchMock.mockReturnValueOnce( + new Promise((resolve) => { resolveFirst = resolve; }) + ); + + fetchLineNames(["11302"]); + + // 同じIDで再度呼び出し + fetchLineNames(["11302"]); + + // fetchは1回だけ呼ばれる + expect(fetchMock).toHaveBeenCalledTimes(1); + + // 最初のリクエストを完了 + resolveFirst({ + json: () => Promise.resolve({ data: { lines: [{ id: 11302, nameShort: "山手線" }] } }), + }); + await flushPromises(); + + expect(_getCache()).toEqual({ "11302": "山手線" }); + }); + it("nameShortが空のlineはキャッシュに入れない", async () => { mockFetchResponse([ { id: 11302, nameShort: "山手線" }, @@ -115,20 +213,30 @@ describe("fetchLineNames", () => { ]); fetchLineNames(["11302", "99999"]); - await vi.waitFor(() => expect(_getCache()).toHaveProperty("11302")); + await flushPromises(); expect(_getCache()).toEqual({ "11302": "山手線" }); expect(_getCache()).not.toHaveProperty("99999"); }); - it("APIレスポンスのlinesがundefinedの場合はキャッシュが変更されない", async () => { + it("APIレスポンスのlinesがundefinedの場合はキャッシュが変更されずリトライする", async () => { fetchMock.mockResolvedValueOnce({ json: () => Promise.resolve({ data: {} }), }); + mockFetchResponse([{ id: 11302, nameShort: "山手線" }]); fetchLineNames(["11302"]); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); + + // 初回はlinesがundefinedなのでキャッシュは空 expect(_getCache()).toEqual({}); + + // 2秒後にリトライ + await vi.advanceTimersByTimeAsync(2000); + + // リトライで成功 + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(_getCache()).toEqual({ "11302": "山手線" }); }); it("subscribeでリスナーが通知される", async () => { @@ -141,7 +249,7 @@ describe("fetchLineNames", () => { // fetchLineNamesの内部でnotifyが呼ばれることを間接的にテスト mockFetchResponse([{ id: 11302, nameShort: "山手線" }]); fetchLineNames(["11302"]); - await vi.waitFor(() => expect(_getCache()).toHaveProperty("11302")); + await flushPromises(); // キャッシュが更新されたことで正しい値が入っている expect(_getCache()["11302"]).toBe("山手線"); }); @@ -153,7 +261,7 @@ describe("fetchLineNames", () => { ]); fetchLineNames(["11302", "24006"]); - await vi.waitFor(() => expect(_getColorCache()).toHaveProperty("11302")); + await flushPromises(); expect(_getColorCache()).toEqual({ "11302": "#80C241", @@ -167,7 +275,7 @@ describe("fetchLineNames", () => { ]); fetchLineNames(["11302"]); - await vi.waitFor(() => expect(_getColorCache()).toHaveProperty("11302")); + await flushPromises(); expect(_getColorCache()).toEqual({ "11302": "#80C241" }); }); @@ -179,7 +287,7 @@ describe("fetchLineNames", () => { ]); fetchLineNames(["11302", "24006"]); - await vi.waitFor(() => expect(_getColorCache()).toHaveProperty("11302")); + await flushPromises(); expect(_getColorCache()).toEqual({ "11302": "#80C241" }); expect(_getColorCache()).not.toHaveProperty("24006"); From 756bb50a67662be2a90300d0e6111d7c787fe56a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Feb 2026 11:20:02 +0000 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20package-lock.json=E3=82=92.gitigno?= =?UTF-8?q?re=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm管理のリポジトリでnpm installにより生成された package-lock.jsonを無視するように設定。 https://claude.ai/code/session_01AeztrVJGigrhtYXRwbWx3r --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f45815e..e083b7c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies node_modules/ +package-lock.json # Expo .expo/ From 3fefa4c928b312498eba99f34f69cbb7f0ac1939 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Feb 2026 14:14:27 +0000 Subject: [PATCH 3/3] =?UTF-8?q?perf:=20lineIds=E9=85=8D=E5=88=97=E3=81=AE?= =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AA=E5=86=8D=E7=94=9F=E6=88=90=E3=81=AB?= =?UTF-8?q?=E3=82=88=E3=82=8B=E7=84=A1=E9=A7=84=E3=81=AAfetch=E5=91=BC?= =?UTF-8?q?=E3=81=B3=E5=87=BA=E3=81=97=E3=82=92=E6=8A=91=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useLineNames/useLineColorsのuseEffect依存配列をlineIds.join(",")に 変更し、配列参照ではなく値ベースで比較するように改善 - LocationCardのlineIds配列をuseMemoでメモ化し、毎レンダーの 新規配列生成を防止 https://claude.ai/code/session_01AeztrVJGigrhtYXRwbWx3r --- components/location-card.tsx | 4 ++-- hooks/use-line-names.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/components/location-card.tsx b/components/location-card.tsx index d9e0b40..403a3c3 100644 --- a/components/location-card.tsx +++ b/components/location-card.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useMemo } from "react"; import { View, Text } from "react-native"; import type { LocationUpdate, MovingState, BatteryState } from "@/lib/types/location"; import { cn } from "@/lib/utils"; @@ -88,7 +88,7 @@ function formatBatteryState(state: BatteryState | number | null): string { export const LocationCard = memo(function LocationCard({ update }: LocationCardProps) { const colors = useColors(); - const lineIds = update.line_id ? [update.line_id] : []; + const lineIds = useMemo(() => (update.line_id ? [update.line_id] : []), [update.line_id]); const lineNames = useLineNames(lineIds); // stateConfigに存在しない値の場合はフォールバックを使用 const stateConf = stateConfig[update.state as MovingState] || defaultStateConfig; diff --git a/hooks/use-line-names.ts b/hooks/use-line-names.ts index 0da9bfa..38c352b 100644 --- a/hooks/use-line-names.ts +++ b/hooks/use-line-names.ts @@ -92,9 +92,12 @@ export function fetchLineNames(ids: string[], _retryCount = 0) { export function useLineNames(lineIds: string[]): Record { const lineNames = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + // lineIdsの中身が同じなら再取得しないよう、値ベースで比較 + const lineIdsKey = lineIds.join(","); useEffect(() => { fetchLineNames(lineIds); - }, [lineIds]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lineIdsKey]); return lineNames; } @@ -102,9 +105,11 @@ export function useLineNames(lineIds: string[]): Record { export function useLineColors(lineIds: string[]): Record { const lineColors = useSyncExternalStore(subscribe, getColorSnapshot, getColorSnapshot); + const lineIdsKey = lineIds.join(","); useEffect(() => { fetchLineNames(lineIds); - }, [lineIds]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lineIdsKey]); return lineColors; }