) => {
+ const { mutate: globalMutate, cache } = useSWRConfig();
const { data, error, isLoading } = useSWR
(
pageId ? `${PAGES_BASE_PATH}/${pageId}/blocks` : null,
async (url: string) => {
const res = await fetcher(url);
- return z.array(blockFromApi).parse(res);
+ const parsed = z.array(blockFromApi).parse(res);
+ // list fetch で得た block を個別 key にも撒いて useBlock(id) の重複 fetch を避ける
+ // (通知タップの deep link 解決経路で効く)。ただし既存値は上書きしない:
+ // 楽観更新中の値を list revalidation の古いサーバ値で巻き戻すのを防ぐため。
+ for (const block of parsed) {
+ const individualKey = `${BLOCKS_BASE_PATH}/${block.id}`;
+ if (cache.get(individualKey)?.data === undefined) {
+ globalMutate(individualKey, block, { revalidate: false });
+ }
+ }
+ return parsed;
},
options
);
diff --git a/frontend/src/hooks/useFcmNavigationListener.test.tsx b/frontend/src/hooks/useFcmNavigationListener.test.tsx
new file mode 100644
index 00000000..b249720b
--- /dev/null
+++ b/frontend/src/hooks/useFcmNavigationListener.test.tsx
@@ -0,0 +1,118 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', () => ({
+ useNavigate: () => mockNavigate,
+}));
+
+import { useFcmNavigationListener } from '@/hooks/useFcmNavigationListener';
+
+type SwEventListener = (event: MessageEvent) => void;
+
+const swListeners = new Set();
+
+const swStub = {
+ addEventListener: (type: string, listener: SwEventListener) => {
+ if (type === 'message') swListeners.add(listener);
+ },
+ removeEventListener: (type: string, listener: SwEventListener) => {
+ if (type === 'message') swListeners.delete(listener);
+ },
+};
+
+const dispatchSwMessage = (data: unknown) => {
+ const event = { data } as MessageEvent;
+ for (const listener of swListeners) listener(event);
+};
+
+describe('useFcmNavigationListener', () => {
+ beforeEach(() => {
+ swListeners.clear();
+ mockNavigate.mockClear();
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: swStub,
+ });
+ });
+
+ afterEach(() => {
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: undefined,
+ });
+ });
+
+ it('FCM_NAVIGATE メッセージ受信で navigate() を pathname+search+hash 付きで呼ぶ', () => {
+ renderHook(() => useFcmNavigationListener());
+
+ dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'http://localhost:3000/trip/abc?focusBlock=42#top' });
+
+ expect(mockNavigate).toHaveBeenCalledExactlyOnceWith('/trip/abc?focusBlock=42#top');
+ });
+
+ it('FCM_NAVIGATE 以外のメッセージは無視する', () => {
+ renderHook(() => useFcmNavigationListener());
+
+ dispatchSwMessage({ type: 'SOME_OTHER', url: 'http://localhost:3000/trip/xxx' });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('別 origin の URL は無視する (open-redirect 対策)', () => {
+ renderHook(() => useFcmNavigationListener());
+
+ dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'https://evil.example.com/trip/abc' });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('非 http scheme (javascript:) は同 origin にならず遷移しない', () => {
+ renderHook(() => useFcmNavigationListener());
+
+ dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'javascript:alert(1)' });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('url が string でなければ無視する', () => {
+ renderHook(() => useFcmNavigationListener());
+
+ dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 42 });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('data が undefined でも throw しない', () => {
+ renderHook(() => useFcmNavigationListener());
+
+ expect(() => dispatchSwMessage(undefined)).not.toThrow();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('unmount 時に listener を解除する', () => {
+ const { unmount } = renderHook(() => useFcmNavigationListener());
+
+ expect(swListeners.size).toBe(1);
+ unmount();
+ expect(swListeners.size).toBe(0);
+ });
+
+ it('navigator.serviceWorker が無い環境でも throw しない', () => {
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: undefined,
+ });
+
+ expect(() => renderHook(() => useFcmNavigationListener())).not.toThrow();
+ expect(swListeners.size).toBe(0);
+ });
+
+ it('現在 URL と一致するときは navigate しない', () => {
+ renderHook(() => useFcmNavigationListener());
+
+ dispatchSwMessage({ type: 'FCM_NAVIGATE', url: window.location.href });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts
new file mode 100644
index 00000000..f7edc5ee
--- /dev/null
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -0,0 +1,89 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { DEBUG_LOG_VERSION, debugLog } from '@/lib/debugLogger';
+
+/**
+ * firebase-messaging-sw.js の notificationclick が伝えてくる遷移先 URL を受けて
+ * React Router で navigate する。
+ * App.tsx で 1 回のみ呼ぶ (Router の内側必須)。
+ *
+ * [Round 2] Round 1 では registration.update() まで削除して壊れた
+ * (実機で FCM SW が更新されず古い版が残り続けた)。
+ * FCM SW は root scope 外で navigation 由来の update check が起きず、register() の
+ * byte-diff check も初回 1 回のみのため、明示 update() が唯一の SW 更新経路になる。
+ * Cache Storage safety net と dedupe は Round 2 段階でもまだ削除継続。
+ */
+
+const parseSameOriginPath = (rawUrl: string): string | null => {
+ let parsed: URL;
+ try {
+ parsed = new URL(rawUrl, window.location.origin);
+ } catch {
+ return null;
+ }
+ if (parsed.origin !== window.location.origin) return null;
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
+};
+
+// PWA セッション中 1 回だけ SW 更新を promote する。App の unmount/remount で hook が
+// 何度も走っても update() は 1 回に絞る。
+let fcmSwUpdateTriggered = false;
+
+const promoteFcmSwUpdate = async (sw: ServiceWorkerContainer): Promise => {
+ if (fcmSwUpdateTriggered) return;
+ fcmSwUpdateTriggered = true;
+ try {
+ const reg = await sw.getRegistration('/firebase-cloud-messaging-push-scope');
+ if (!reg) {
+ void debugLog('CL', 'fcm registration not found');
+ return;
+ }
+ await reg.update();
+ void debugLog('CL', 'fcm update() done', {
+ activeUrl: reg.active?.scriptURL ?? null,
+ waitingUrl: reg.waiting?.scriptURL ?? null,
+ installingUrl: reg.installing?.scriptURL ?? null,
+ });
+ } catch (err) {
+ void debugLog('CL', 'fcm update fail', { err: String(err) });
+ }
+};
+
+export const useFcmNavigationListener = () => {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ // 参照を effect スコープに固定: cleanup 時に navigator.serviceWorker が消えていても
+ // (テスト環境で差し替えると起こる) removeEventListener で crash させない。
+ const sw = typeof navigator !== 'undefined' ? navigator.serviceWorker : undefined;
+
+ const handler = (event: MessageEvent) => {
+ void debugLog('CL', 'sw message', { data: event.data });
+ if (event.data?.type !== 'FCM_NAVIGATE') return;
+ const rawUrl = event.data.url;
+ if (typeof rawUrl !== 'string') return;
+ const target = parseSameOriginPath(rawUrl);
+ if (!target) return;
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
+ if (target === current) {
+ void debugLog('CL', 'navigate skip (== current)', { target });
+ return;
+ }
+ void debugLog('CL', 'navigate() called', { target });
+ navigate(target);
+ };
+
+ sw?.addEventListener('message', handler);
+ void debugLog('CL', 'listener mounted', {
+ hasSw: !!sw,
+ swControllerUrl: sw?.controller?.scriptURL ?? null,
+ currentUrl: window.location.href,
+ clientVersion: DEBUG_LOG_VERSION,
+ });
+ if (sw) void promoteFcmSwUpdate(sw);
+
+ return () => {
+ sw?.removeEventListener('message', handler);
+ };
+ }, [navigate]);
+};
diff --git a/frontend/src/hooks/useFocusBlockOnMount.test.tsx b/frontend/src/hooks/useFocusBlockOnMount.test.tsx
new file mode 100644
index 00000000..cff6020e
--- /dev/null
+++ b/frontend/src/hooks/useFocusBlockOnMount.test.tsx
@@ -0,0 +1,284 @@
+import { act, render, waitFor } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
+import { HttpResponse, http } from 'msw';
+import type { ReactNode } from 'react';
+import { BrowserRouter, MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { selectedPageIdAtom } from '@/atoms/tripPage';
+import { useFocusBlockOnMount } from '@/hooks/useFocusBlockOnMount';
+import { appStore } from '@/lib/store';
+import { server } from '../../tests/msw/server';
+
+// jsdom は scrollIntoView を提供しないので prototype に stub を生やす
+const scrollIntoViewMock = vi.fn();
+Element.prototype.scrollIntoView = scrollIntoViewMock as unknown as Element['scrollIntoView'];
+
+const scheduleBlockJson = (id: number, pageId: number) => ({
+ id,
+ page_id: pageId,
+ block_type: 'event' as const,
+ title: 'schedule block',
+ start_time: '2026-01-01T09:00:00',
+ end_time: '2026-01-01T10:00:00',
+ detail: null,
+ location_id: null,
+ location: null,
+});
+
+const LocationCapture = ({ onLocation }: { onLocation: (search: string) => void }) => {
+ const location = useLocation();
+ onLocation(location.search);
+ return null;
+};
+
+const HookHost = () => {
+ useFocusBlockOnMount();
+ return target
;
+};
+
+const renderWithRouter = (initialPath: string, onLocation: (search: string) => void) => {
+ const Wrapper = ({ children }: { children: ReactNode }) => (
+
+
+
+
+
+
+
+
+ );
+ return render(, { wrapper: Wrapper });
+};
+
+describe('useFocusBlockOnMount', () => {
+ beforeEach(() => {
+ appStore.set(selectedPageIdAtom, undefined);
+ scrollIntoViewMock.mockReset();
+ });
+
+ it('?focusBlock が無ければ何もしない', async () => {
+ let currentSearch = '';
+ renderWithRouter('/trip/abc', s => {
+ currentSearch = s;
+ });
+
+ // マウント直後に副作用が完了する
+ await waitFor(() => {
+ expect(currentSearch).toBe('');
+ });
+ expect(scrollIntoViewMock).not.toHaveBeenCalled();
+ expect(appStore.get(selectedPageIdAtom)).toBeUndefined();
+ });
+
+ it('非数値の focusBlock は block fetch せず query param のみ掃除する', async () => {
+ let apiCalled = false;
+ server.use(
+ http.get('*/blocks/*', () => {
+ apiCalled = true;
+ return HttpResponse.json({});
+ })
+ );
+
+ let currentSearch = 'initial';
+ renderWithRouter('/trip/abc?focusBlock=abc', s => {
+ currentSearch = s;
+ });
+
+ await waitFor(() => {
+ expect(currentSearch).toBe('');
+ });
+ expect(apiCalled).toBe(false);
+ expect(scrollIntoViewMock).not.toHaveBeenCalled();
+ expect(appStore.get(selectedPageIdAtom)).toBeUndefined();
+ });
+
+ it('block ロード成功時に selectedPageId 切り替え + scrollIntoView + query param 掃除を行う', async () => {
+ server.use(http.get('*/blocks/42', () => HttpResponse.json(scheduleBlockJson(42, 7))));
+
+ let currentSearch = 'initial';
+ renderWithRouter('/trip/abc?focusBlock=42', s => {
+ currentSearch = s;
+ });
+
+ await waitFor(() => {
+ expect(appStore.get(selectedPageIdAtom)).toBe(7);
+ });
+ await waitFor(() => {
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
+ });
+ await waitFor(() => {
+ expect(currentSearch).toBe('');
+ });
+ });
+
+ it('block が 404 の場合は selectedPageId を変えず query param のみ掃除する', async () => {
+ server.use(http.get('*/blocks/999', () => new HttpResponse(null, { status: 404 })));
+
+ let currentSearch = 'initial';
+ renderWithRouter('/trip/abc?focusBlock=999', s => {
+ currentSearch = s;
+ });
+
+ await waitFor(() => {
+ expect(currentSearch).toBe('');
+ });
+ expect(scrollIntoViewMock).not.toHaveBeenCalled();
+ expect(appStore.get(selectedPageIdAtom)).toBeUndefined();
+ });
+
+ it('同一マウント中に focusBlock が 42→99 と変わったら 2 回目も再処理する', async () => {
+ server.use(
+ http.get('*/blocks/42', () => HttpResponse.json(scheduleBlockJson(42, 7))),
+ http.get('*/blocks/99', () => HttpResponse.json(scheduleBlockJson(99, 11)))
+ );
+
+ // SW → postMessage → useFcmNavigationListener の navigate() で同一マウント中に
+ // URL の focusBlock が差し替わるフローを再現するため、テスト内で navigate を外に取り出す。
+ const navigateHandle: { current: ((to: string) => void) | null } = { current: null };
+ const NavigateHandle = () => {
+ navigateHandle.current = useNavigate();
+ return null;
+ };
+
+ const searchRef = { current: '' };
+ const captureSearch = (s: string) => {
+ searchRef.current = s;
+ };
+
+ // 初期 URL を含めて BrowserRouter に載せる (window.history 経由で navigate 可能)
+ window.history.replaceState({}, '', '/trip/abc?focusBlock=42');
+
+ const Wrapper = ({ children }: { children: ReactNode }) => (
+
+
+
+
+
+
+
+
+
+ );
+
+ render(
+ <>
+
+ target-99
+ >,
+ { wrapper: Wrapper }
+ );
+
+ // 1 回目: focusBlock=42 の処理完了を待つ
+ await waitFor(() => {
+ expect(appStore.get(selectedPageIdAtom)).toBe(7);
+ });
+ await waitFor(() => {
+ expect(searchRef.current).toBe('');
+ });
+ await waitFor(() => {
+ expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
+ });
+
+ // 2 回目: SPA navigate で URL に別の focusBlock を差し込む
+ scrollIntoViewMock.mockClear();
+ act(() => {
+ navigateHandle.current?.('/trip/abc?focusBlock=99');
+ });
+
+ await waitFor(() => {
+ expect(appStore.get(selectedPageIdAtom)).toBe(11);
+ });
+ await waitFor(() => {
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
+ });
+ await waitFor(() => {
+ expect(searchRef.current).toBe('');
+ });
+ });
+
+ it('focusBlock=0 は block ID として無効なので query 掃除だけ行う', async () => {
+ let apiCalled = false;
+ server.use(
+ http.get('*/blocks/*', () => {
+ apiCalled = true;
+ return HttpResponse.json({});
+ })
+ );
+
+ let currentSearch = 'initial';
+ renderWithRouter('/trip/abc?focusBlock=0', s => {
+ currentSearch = s;
+ });
+
+ await waitFor(() => {
+ expect(currentSearch).toBe('');
+ });
+ expect(apiCalled).toBe(false);
+ expect(scrollIntoViewMock).not.toHaveBeenCalled();
+ expect(appStore.get(selectedPageIdAtom)).toBeUndefined();
+ });
+
+ it('同じ focusBlock=42 が clearParam 後にもう一度来ても再処理する', async () => {
+ server.use(http.get('*/blocks/42', () => HttpResponse.json(scheduleBlockJson(42, 7))));
+
+ const navigateHandle: { current: ((to: string) => void) | null } = { current: null };
+ const NavigateHandle = () => {
+ navigateHandle.current = useNavigate();
+ return null;
+ };
+
+ const searchRef = { current: '' };
+ const captureSearch = (s: string) => {
+ searchRef.current = s;
+ };
+
+ window.history.replaceState({}, '', '/trip/abc?focusBlock=42');
+
+ const Wrapper = ({ children }: { children: ReactNode }) => (
+
+
+
+
+
+
+
+
+
+ );
+
+ render(
+ <>
+
+ >,
+ { wrapper: Wrapper }
+ );
+
+ // 1 回目: focusBlock=42 の処理完了 (page 切替 + scroll + clearParam) を待つ
+ await waitFor(() => {
+ expect(appStore.get(selectedPageIdAtom)).toBe(7);
+ });
+ await waitFor(() => {
+ expect(searchRef.current).toBe('');
+ });
+ await waitFor(() => {
+ expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
+ });
+
+ // 2 回目: URL が /trip/abc に戻った状態から、同じ block へ再通知タップを再現
+ scrollIntoViewMock.mockClear();
+ appStore.set(selectedPageIdAtom, undefined);
+ act(() => {
+ navigateHandle.current?.('/trip/abc?focusBlock=42');
+ });
+
+ await waitFor(() => {
+ expect(appStore.get(selectedPageIdAtom)).toBe(7);
+ });
+ await waitFor(() => {
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
+ });
+ await waitFor(() => {
+ expect(searchRef.current).toBe('');
+ });
+ });
+});
diff --git a/frontend/src/hooks/useFocusBlockOnMount.ts b/frontend/src/hooks/useFocusBlockOnMount.ts
new file mode 100644
index 00000000..0ae7d718
--- /dev/null
+++ b/frontend/src/hooks/useFocusBlockOnMount.ts
@@ -0,0 +1,131 @@
+import { useSetAtom } from 'jotai';
+import { useEffect, useRef } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { selectedPageIdAtom } from '@/atoms/tripPage';
+import { useBlock } from '@/hooks/useBlocks';
+import { debugLog } from '@/lib/debugLogger';
+
+/**
+ * `?focusBlock={id}` を消費して該当 block まで scroll する。
+ *
+ * block が 404 (通知送信 → タップ間で削除) や非数値ゴミの場合は黙って query param 掃除だけ行う。
+ * urlId は URL path で保持されているので trip 画面自体は通常表示される。
+ */
+
+const FOCUS_BLOCK_PARAM = 'focusBlock';
+// 通知タップ → block 描画までに 2 段の非同期がある:
+// 1. useBlock(id) 単発 fetch → block.pageId 判明 → selectedPageId 切替
+// 2. useBlocks(pageId) list fetch 完了 → ViewTripLayout の Skeleton が Timeline に差し替わる
+// (2) が cold start で遅れると DOM に data-block-id が出るのに数秒かかることがある。
+// rAF ポーリングだと 1 フレーム単位で無駄回転するので MutationObserver で DOM 変化を待つ。
+const SCROLL_WAIT_MAX_MS = 8000;
+
+const waitForBlockElement = (blockId: number, maxMs: number, isCancelled: () => boolean): Promise =>
+ new Promise(resolve => {
+ const selector = `[data-block-id="${blockId}"]`;
+
+ const found = document.querySelector(selector);
+ if (found) return resolve(found);
+
+ let timeoutId: ReturnType | null = null;
+ const finish = (el: HTMLElement | null) => {
+ observer.disconnect();
+ if (timeoutId !== null) clearTimeout(timeoutId);
+ resolve(el);
+ };
+
+ const observer = new MutationObserver(() => {
+ if (isCancelled()) return finish(null);
+ const el = document.querySelector(selector);
+ if (el) finish(el);
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ timeoutId = setTimeout(() => finish(null), maxMs);
+ });
+
+// layout flush を 1 フレーム待ってから scroll する。Timeline が差し替わった直後は要素の
+// 位置計算がまだ確定していないことがあり、そのタイミングで scrollIntoView すると外れる。
+const scrollIntoViewOnNextFrame = (el: HTMLElement) => {
+ requestAnimationFrame(() => {
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ });
+};
+
+// block ID は BIGSERIAL PRIMARY KEY (正の整数) のみ有効。"0" / 桁溢れ / 非数値は不正値扱いで
+// query 掃除だけ行う。useBlock(0) が SWR の falsy key で fetch を止めるため、そのまま 0 を渡すと
+// block も error も来ず ref も query も更新されずスタックする。
+const parseFocusBlockId = (raw: string | null): number | null => {
+ if (raw == null || !/^[1-9]\d*$/.test(raw)) return null;
+ const n = Number(raw);
+ return Number.isSafeInteger(n) ? n : null;
+};
+
+export const useFocusBlockOnMount = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const rawFocusBlock = searchParams.get(FOCUS_BLOCK_PARAM);
+ const focusBlockId = parseFocusBlockId(rawFocusBlock);
+ const hasInvalidParam = rawFocusBlock != null && focusBlockId == null;
+
+ const setSelectedPageId = useSetAtom(selectedPageIdAtom);
+ const { block, error } = useBlock(focusBlockId);
+ // 直前の rawFocusBlock を保持し、値が変わるまで再処理をブロックする。ref なので rerender は起こさない。
+ // - TripPage は SPA navigate で unmount しないので、連続通知 (42 → 99) の 2 回目を処理する
+ // - 消費済み記録は uncancelled 完了後 (async 内) にセット。StrictMode の double-fire で最初の async が
+ // cancelled になっても 2 度目で確実に完走するため
+ // - query 掃除後 (rawFocusBlock === null) は ref をリセット。同一 block の再通知にも応答するため
+ const consumedKeyRef = useRef(null);
+
+ useEffect(() => {
+ void debugLog('FB', 'effect', {
+ rawFocusBlock,
+ focusBlockId,
+ hasBlock: !!block,
+ hasError: !!error,
+ consumed: consumedKeyRef.current,
+ });
+ if (rawFocusBlock === null) {
+ consumedKeyRef.current = null;
+ return;
+ }
+ if (consumedKeyRef.current === rawFocusBlock) return;
+
+ const clearParam = () =>
+ setSearchParams(
+ prev => {
+ const next = new URLSearchParams(prev);
+ next.delete(FOCUS_BLOCK_PARAM);
+ return next;
+ },
+ { replace: true }
+ );
+
+ if (hasInvalidParam) {
+ consumedKeyRef.current = rawFocusBlock;
+ clearParam();
+ return;
+ }
+ if (focusBlockId == null) return;
+ if (error) {
+ consumedKeyRef.current = rawFocusBlock;
+ clearParam();
+ return;
+ }
+ if (!block) return;
+
+ setSelectedPageId(block.pageId);
+
+ let cancelled = false;
+ (async () => {
+ const el = await waitForBlockElement(focusBlockId, SCROLL_WAIT_MAX_MS, () => cancelled);
+ void debugLog('FB', 'wait done', { found: !!el, cancelled });
+ if (cancelled) return;
+ if (el) scrollIntoViewOnNextFrame(el);
+ clearParam();
+ consumedKeyRef.current = rawFocusBlock;
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [rawFocusBlock, focusBlockId, hasInvalidParam, block, error, setSelectedPageId, setSearchParams]);
+};
diff --git a/frontend/src/lib/debugLogger.ts b/frontend/src/lib/debugLogger.ts
new file mode 100644
index 00000000..3f8ce5f5
--- /dev/null
+++ b/frontend/src/lib/debugLogger.ts
@@ -0,0 +1,131 @@
+/**
+ * Android PWA など console が取りづらい環境向けの診断ロガー。
+ *
+ * IndexedDB に ring buffer (最大 500 件) で書き、client 側から readAll で
+ * 一括取得できる。SW からも同じ DB / store に書けるよう、SW 側は
+ * `firebase-messaging-sw.js` に同じロジックを inline 実装している (importScripts
+ * 経由の shared module にすると build 側の設定が要るため duplicate で運用)。
+ */
+
+const DB_NAME = 'fcm-debug-log';
+const STORE_NAME = 'entries';
+const MAX_ENTRIES = 500;
+
+/**
+ * 診断コード修正のたびに手動で bump する。firebase-messaging-sw.js 側の DEBUG_LOG_VERSION と
+ * 揃えて更新。ログの各行に埋め込まれるので、共有されたログのバージョンが古い環境か新しい環境か
+ * を確認しやすくする。
+ */
+export const DEBUG_LOG_VERSION = 'v09-cl-2026-08-01';
+
+interface LogEntry {
+ ts: number;
+ version: string;
+ tag: string;
+ message: string;
+ data: unknown;
+}
+
+let dbPromise: Promise | null = null;
+
+const getDb = (): Promise => {
+ if (dbPromise) return dbPromise;
+ dbPromise = new Promise((resolve, reject) => {
+ if (typeof indexedDB === 'undefined') return reject(new Error('no idb'));
+ const req = indexedDB.open(DB_NAME, 1);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME, { autoIncrement: true });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ }).catch(err => {
+ dbPromise = null;
+ throw err;
+ });
+ return dbPromise;
+};
+
+const safeSerialize = (data: unknown): unknown => {
+ if (data === undefined) return null;
+ try {
+ return JSON.parse(JSON.stringify(data));
+ } catch {
+ return String(data);
+ }
+};
+
+export const debugLog = async (tag: string, message: string, data?: unknown): Promise => {
+ try {
+ const db = await getDb();
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const store = tx.objectStore(STORE_NAME);
+ const entry: LogEntry = {
+ ts: Date.now(),
+ version: DEBUG_LOG_VERSION,
+ tag,
+ message,
+ data: safeSerialize(data),
+ };
+ store.add(entry);
+ const countReq = store.count();
+ countReq.onsuccess = () => {
+ const excess = countReq.result - MAX_ENTRIES;
+ if (excess <= 0) return;
+ const cursorReq = store.openCursor();
+ let remaining = excess;
+ cursorReq.onsuccess = e => {
+ const cursor = (e.target as IDBRequest).result;
+ if (!cursor || remaining <= 0) return;
+ cursor.delete();
+ remaining -= 1;
+ cursor.continue();
+ };
+ };
+ } catch {
+ // logger must not throw
+ }
+};
+
+const formatEntry = (e: LogEntry): string => {
+ const t = new Date(e.ts).toISOString();
+ const data = e.data == null ? '' : ` | ${JSON.stringify(e.data)}`;
+ const version = e.version ?? '?';
+ return `[${t}] [${version}] [${e.tag}] ${e.message}${data}`;
+};
+
+export const readAllLogs = async (): Promise => {
+ try {
+ const db = await getDb();
+ const tx = db.transaction(STORE_NAME, 'readonly');
+ const store = tx.objectStore(STORE_NAME);
+ return await new Promise((resolve, reject) => {
+ const entries: LogEntry[] = [];
+ const cursorReq = store.openCursor();
+ cursorReq.onsuccess = e => {
+ const cursor = (e.target as IDBRequest).result;
+ if (cursor) {
+ entries.push(cursor.value as LogEntry);
+ cursor.continue();
+ } else {
+ resolve(entries.map(formatEntry).join('\n'));
+ }
+ };
+ cursorReq.onerror = () => reject(cursorReq.error);
+ });
+ } catch {
+ return '';
+ }
+};
+
+export const clearAllLogs = async (): Promise => {
+ try {
+ const db = await getDb();
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ tx.objectStore(STORE_NAME).clear();
+ } catch {
+ // ignore
+ }
+};
diff --git a/frontend/src/pages/TripPage.tsx b/frontend/src/pages/TripPage.tsx
index 6b3c4c19..c9d0397f 100644
--- a/frontend/src/pages/TripPage.tsx
+++ b/frontend/src/pages/TripPage.tsx
@@ -14,6 +14,7 @@ import { TimelineSkeleton } from '@/components/timeline';
import { Button } from '@/components/ui/button';
import { useActivePage } from '@/hooks/useActivePage';
import { useDragAutoScroll } from '@/hooks/useDragAutoScroll';
+import { useFocusBlockOnMount } from '@/hooks/useFocusBlockOnMount';
import { usePages } from '@/hooks/usePages';
import { useTripByUrlId } from '@/hooks/useTrips';
import { useVisitedTrips } from '@/hooks/useVisitedTrips';
@@ -44,6 +45,7 @@ const TripPage = () => {
const { pages, error: pagesError, isLoading: isPagesLoading } = usePages(trip?.id ?? null, { refreshInterval });
const { addVisitedTrip } = useVisitedTrips();
const { storedPageId, isActivePageInitialized, saveActivePageId } = useActivePage(trip?.id ?? null);
+ useFocusBlockOnMount();
const isLoading = isTripLoading || isPagesLoading || !minLoadingComplete;
const isError = tripError || pagesError;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index fca6f338..76de3d43 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -64,6 +64,12 @@ export default defineConfig({
workbox: {
navigateFallback: '/index.html',
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
+ // firebase-messaging-sw.js は Firebase JS SDK が別 scope で管理する SW なので、
+ // VitePWA の precache に入れると Chrome が register 時に古い版を cache から受け取り、
+ // 新版 handler が実機で永遠に active にならない (実機で observed)。除外必須。
+ globIgnores: ['**/firebase-messaging-sw.js'],
+ // 通常の runtime caching で /firebase-messaging-sw.js を横取りしないよう明示。
+ navigateFallbackDenylist: [/^\/firebase-messaging-sw\.js$/],
},
}),
],