Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# dependencies
node_modules/
package-lock.json

# Expo
.expo/
Expand Down
4 changes: 2 additions & 2 deletions components/location-card.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 37 additions & 10 deletions hooks/use-line-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ let gqlUrl = Constants.expoConfig?.extra?.trainlcdGqlUrl || "";
// モジュールレベルのキャッシュ(アプリ全体で共有)
let cache: Record<string, string> = {};
let colorCache: Record<string, string> = {};
const fetchedIds = new Set<string>();
const resolvedIds = new Set<string>(); // 取得成功済みのID
const pendingIds = new Set<string>(); // 取得中のID(重複リクエスト防止)
const listeners = new Set<() => void>();

// リトライ設定
const MAX_RETRIES = 3;
const RETRY_DELAYS = [2000, 4000, 8000]; // 指数バックオフ(ms)

function getSnapshot(): Record<string, string> {
return cache;
}
Expand All @@ -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",
Expand All @@ -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 };
Expand All @@ -59,31 +67,49 @@ 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<string, string> {
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;
}

export function useLineColors(lineIds: string[]): Record<string, string> {
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;
}
Expand All @@ -96,11 +122,12 @@ export function formatLineName(
return name ? `${name}(${lineId})` : lineId;
}

/** テスト用: キャッシュとfetchedIdsをリセット */
/** テスト用: キャッシュとID管理をリセット */
export function _resetCache() {
cache = {};
colorCache = {};
fetchedIds.clear();
resolvedIds.clear();
pendingIds.clear();
listeners.clear();
}

Expand Down
134 changes: 121 additions & 13 deletions lib/__tests__/use-line-names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ beforeEach(() => {
_resetCache();
_setGqlUrl("https://gql-stg.trainlcd.app/");
fetchMock.mockReset();
vi.useFakeTimers();
});

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

Expand All @@ -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": "山手線" };
Expand All @@ -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);
Expand All @@ -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は呼ばれない
Expand All @@ -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);
Expand All @@ -102,33 +109,134 @@ 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: "山手線" },
{ id: 99999, nameShort: "" },
]);

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 () => {
Expand All @@ -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("山手線");
});
Expand All @@ -153,7 +261,7 @@ describe("fetchLineNames", () => {
]);

fetchLineNames(["11302", "24006"]);
await vi.waitFor(() => expect(_getColorCache()).toHaveProperty("11302"));
await flushPromises();

expect(_getColorCache()).toEqual({
"11302": "#80C241",
Expand All @@ -167,7 +275,7 @@ describe("fetchLineNames", () => {
]);

fetchLineNames(["11302"]);
await vi.waitFor(() => expect(_getColorCache()).toHaveProperty("11302"));
await flushPromises();

expect(_getColorCache()).toEqual({ "11302": "#80C241" });
});
Expand All @@ -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");
Expand Down