From 169b6b5a6d693a923d7889ce90c3aca9d3c2703b Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Thu, 30 Jul 2026 19:05:38 +0000
Subject: [PATCH 01/17] =?UTF-8?q?feat(#207):=20SW=20notificationclick=20?=
=?UTF-8?q?=E3=81=A7=20trip=20/=20block=20=E3=81=B8=20deep=20link=20?=
=?UTF-8?q?=E9=81=B7=E7=A7=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
通知タップ時の挙動を Firebase デフォルトから明示ハンドラに置き換え、既存
PWA/tab を単一 tab のまま該当 block まで scroll できるようにする。
- SW: notificationclick で clients.matchAll → focused > visible > 任意
の優先度で client を選び、postMessage で SPA navigate。無ければ
clients.openWindow。別 origin URL は payload 経路含め弾く
- client 側: useFcmNavigationListener が SW message を受け React Router
で navigate。WindowClient.navigate() のフルリロードを避けて atom /
SWR / scroll 位置を保持
- useFocusBlockOnMount: ?focusBlock={id} を useBlock で解決 → pageId
切替 → data-block-id の DOM を rAF 待機して scrollIntoView (center)
→ replaceState で query 除去。404 / 非数値ゴミは黙って掃除
- useBlocks: list fetch 時に個別 key を populate し useBlock の重複
fetch を回避 (楽観更新値がある場合は skip して巻き戻し防止)
- View block 2 種に data-block-id を付与 (edit モードは対象外)
---
frontend/public/firebase-messaging-sw.js | 56 +++++++++++++
frontend/src/App.tsx | 2 +
.../blocks/view/BlockScheduleView.tsx | 1 +
.../blocks/view/BlockTransportationView.tsx | 1 +
frontend/src/hooks/useBlocks.ts | 13 ++-
.../src/hooks/useFcmNavigationListener.ts | 40 +++++++++
frontend/src/hooks/useFocusBlockOnMount.ts | 84 +++++++++++++++++++
frontend/src/pages/TripPage.tsx | 2 +
8 files changed, 198 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/hooks/useFcmNavigationListener.ts
create mode 100644 frontend/src/hooks/useFocusBlockOnMount.ts
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index 21174839..58492481 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -1,6 +1,62 @@
// biome-ignore-all lint/correctness/noUndeclaredVariables: firebase / importScripts は SW context の global
// biome-ignore-all lint/correctness/noUnusedFunctionParameters: onBackgroundMessage の signature は Firebase 規定
+// notificationclick handler は Firebase SDK の import / init より前に登録する必要がある。
+// Firebase Messaging SDK は内部で notificationclick リスナーを付け stopImmediatePropagation
+// することがあり、後付けで addEventListener すると invocation されないケースがある。
+// (`firebase-js-sdk/packages/messaging/src/listeners/sw-listeners.ts` の実装参照)
+
+// Firebase Admin SDK の WebpushFCMOptions.link は data.FCM_MSG.fcmOptions.link に入る。
+// フォアグラウンド通知 (useForegroundNotificationToast) の自前 showNotification 経由は data.link。
+const extractDeepLink = notification => {
+ const data = notification?.data ?? {};
+ return data?.FCM_MSG?.fcmOptions?.link ?? data?.link ?? null;
+};
+
+// PWA / ブラウザ tab 両方 push 登録している端末で、ユーザが今触ってる方を選ぶための優先度。
+const pickTargetClient = clientsList => {
+ return (
+ clientsList.find(c => c.focused) ?? clientsList.find(c => c.visibilityState === 'visible') ?? clientsList[0] ?? null
+ );
+};
+
+self.addEventListener('notificationclick', event => {
+ event.notification.close();
+
+ const link = extractDeepLink(event.notification);
+ if (!link) return;
+
+ // payload 経路経由の open-redirect を防ぐ (別 origin URL は捨てる)。
+ let targetUrl;
+ try {
+ targetUrl = new URL(link, self.location.origin);
+ } catch {
+ return;
+ }
+ if (targetUrl.origin !== self.location.origin) return;
+
+ event.waitUntil(
+ (async () => {
+ const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ const target = pickTargetClient(clientsList);
+
+ if (target) {
+ // WindowClient.navigate() を使うとフルリロードで atom / SWR / scroll 位置が飛ぶので、
+ // 代わりに client 側で React Router の navigate を呼んでもらう。
+ target.postMessage({ type: 'FCM_NAVIGATE', url: targetUrl.href });
+ try {
+ await target.focus();
+ } catch {
+ // focus はユーザ操作起源でないと reject されるが、postMessage は届いてるので許容
+ }
+ return;
+ }
+
+ await self.clients.openWindow(targetUrl.href);
+ })()
+ );
+});
+
importScripts('https://www.gstatic.com/firebasejs/12.16.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/12.16.0/firebase-messaging-compat.js');
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b93d72e7..932faece 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom';
import { isOfflineReadAtom } from './atoms/network';
import { NoIndex } from './components/NoIndex';
import { Title } from './components/Title';
+import { useFcmNavigationListener } from './hooks/useFcmNavigationListener';
import { useForegroundNotificationToast } from './hooks/useForegroundNotificationToast';
import { useNetworkToast } from './hooks/useNetworkToast';
import { usePageTracking } from './hooks/usePageTracking';
@@ -19,6 +20,7 @@ const App = () => {
useNetworkToast();
usePageTracking();
useForegroundNotificationToast();
+ useFcmNavigationListener();
return (
<>
diff --git a/frontend/src/components/blocks/view/BlockScheduleView.tsx b/frontend/src/components/blocks/view/BlockScheduleView.tsx
index 061858c9..1153a842 100644
--- a/frontend/src/components/blocks/view/BlockScheduleView.tsx
+++ b/frontend/src/components/blocks/view/BlockScheduleView.tsx
@@ -55,6 +55,7 @@ export function BlockScheduleView({ block, isNow, className }: BlockScheduleView
return (
) => {
+ 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.ts b/frontend/src/hooks/useFcmNavigationListener.ts
new file mode 100644
index 00000000..d9b77f5d
--- /dev/null
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -0,0 +1,40 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+/**
+ * firebase-messaging-sw.js の notificationclick が postMessage する
+ * `{ type: 'FCM_NAVIGATE', url }` を受けて React Router で navigate する。
+ * App.tsx で 1 回のみ呼ぶ (Router の内側必須)。
+ */
+export const useFcmNavigationListener = () => {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ // 参照を effect スコープに固定: cleanup 時に navigator.serviceWorker が消えていても
+ // (テスト環境で差し替えると起こる) removeEventListener で crash させない。
+ const sw = typeof navigator !== 'undefined' ? navigator.serviceWorker : undefined;
+ if (!sw) return;
+
+ const handler = (event: MessageEvent) => {
+ if (event.data?.type !== 'FCM_NAVIGATE') return;
+ const rawUrl = event.data.url;
+ if (typeof rawUrl !== 'string') return;
+
+ let parsed: URL;
+ try {
+ parsed = new URL(rawUrl, window.location.origin);
+ } catch {
+ return;
+ }
+ // SW 側でも弾いているが、payload 経路の open-redirect 対策として二重防御。
+ if (parsed.origin !== window.location.origin) return;
+
+ navigate(`${parsed.pathname}${parsed.search}${parsed.hash}`);
+ };
+
+ sw.addEventListener('message', handler);
+ return () => {
+ sw.removeEventListener('message', handler);
+ };
+ }, [navigate]);
+};
diff --git a/frontend/src/hooks/useFocusBlockOnMount.ts b/frontend/src/hooks/useFocusBlockOnMount.ts
new file mode 100644
index 00000000..afc16b44
--- /dev/null
+++ b/frontend/src/hooks/useFocusBlockOnMount.ts
@@ -0,0 +1,84 @@
+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';
+
+/**
+ * `?focusBlock={id}` を消費して該当 block まで scroll する。
+ *
+ * block が 404 (通知送信 → タップ間で削除) や非数値ゴミの場合は黙って query param 掃除だけ行う。
+ * urlId は URL path で保持されているので trip 画面自体は通常表示される。
+ */
+
+const FOCUS_BLOCK_PARAM = 'focusBlock';
+const SCROLL_WAIT_MAX_MS = 3000;
+
+const waitForBlockElement = (blockId: number, maxMs: number, isCancelled: () => boolean): Promise =>
+ new Promise(resolve => {
+ const selector = `[data-block-id="${blockId}"]`;
+ const start = performance.now();
+ const tick = () => {
+ if (isCancelled()) return resolve(null);
+ const el = document.querySelector(selector);
+ if (el) return resolve(el);
+ if (performance.now() - start > maxMs) return resolve(null);
+ requestAnimationFrame(tick);
+ };
+ tick();
+ });
+
+export const useFocusBlockOnMount = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const rawFocusBlock = searchParams.get(FOCUS_BLOCK_PARAM);
+ const focusBlockId = rawFocusBlock != null && /^\d+$/.test(rawFocusBlock) ? Number(rawFocusBlock) : null;
+ const hasInvalidParam = rawFocusBlock != null && focusBlockId == null;
+
+ const setSelectedPageId = useSetAtom(selectedPageIdAtom);
+ const { block, error } = useBlock(focusBlockId);
+ // TripPage は unmount せず SPA navigate で URL だけ差し替わるため、boolean 一度きりゲートだと
+ // 通知連続タップ (42 → 99) の 2 回目が無視される。値ゲートにして rawFocusBlock が変わったら再処理する。
+ const consumedKeyRef = useRef(null);
+
+ useEffect(() => {
+ 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;
+
+ consumedKeyRef.current = rawFocusBlock;
+ setSelectedPageId(block.pageId);
+
+ let cancelled = false;
+ (async () => {
+ const el = await waitForBlockElement(focusBlockId, SCROLL_WAIT_MAX_MS, () => cancelled);
+ if (cancelled) return;
+ el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ clearParam();
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [rawFocusBlock, focusBlockId, hasInvalidParam, block, error, setSelectedPageId, setSearchParams]);
+};
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;
From 0226aeb61686e7bd36eaae8abc57d8b4872358e6 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Thu, 30 Jul 2026 19:05:48 +0000
Subject: [PATCH 02/17] =?UTF-8?q?test(#207):=20deep=20link=20=E7=B3=BB=20h?=
=?UTF-8?q?ook=20=E3=81=AE=20unit=20test=20=E3=82=92=E8=BF=BD=E5=8A=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- useFcmNavigationListener: FCM_NAVIGATE 受信で navigate 呼び出し / 別
origin & 非 http scheme & 型不正 payload の無視 / unmount cleanup /
serviceWorker 未定義環境
- useFocusBlockOnMount: block ロード成功で selectedPageId 切替 +
scrollIntoView + query 除去 / 404 → query 除去のみ / 非数値 → API
も叩かず query 除去 / focusBlock 無 → no-op
---
.../hooks/useFcmNavigationListener.test.tsx | 110 ++++++++++
.../src/hooks/useFocusBlockOnMount.test.tsx | 196 ++++++++++++++++++
2 files changed, 306 insertions(+)
create mode 100644 frontend/src/hooks/useFcmNavigationListener.test.tsx
create mode 100644 frontend/src/hooks/useFocusBlockOnMount.test.tsx
diff --git a/frontend/src/hooks/useFcmNavigationListener.test.tsx b/frontend/src/hooks/useFcmNavigationListener.test.tsx
new file mode 100644
index 00000000..01465b07
--- /dev/null
+++ b/frontend/src/hooks/useFcmNavigationListener.test.tsx
@@ -0,0 +1,110 @@
+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 が無い環境では何もしない', () => {
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: undefined,
+ });
+
+ expect(() => renderHook(() => useFcmNavigationListener())).not.toThrow();
+ expect(swListeners.size).toBe(0);
+ });
+});
diff --git a/frontend/src/hooks/useFocusBlockOnMount.test.tsx b/frontend/src/hooks/useFocusBlockOnMount.test.tsx
new file mode 100644
index 00000000..793117a8
--- /dev/null
+++ b/frontend/src/hooks/useFocusBlockOnMount.test.tsx
@@ -0,0 +1,196 @@
+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('');
+ });
+ 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('');
+ });
+ });
+});
From c22d5ce1544ab805d931a37408feec6192d780fd Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Thu, 30 Jul 2026 19:05:55 +0000
Subject: [PATCH 03/17] =?UTF-8?q?docs(#207):=20=E9=80=9A=E7=9F=A5=E3=83=89?=
=?UTF-8?q?=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88=E3=81=AB=20deep?=
=?UTF-8?q?=20link=20=E5=AE=9F=E8=A3=85=E3=82=92=E5=8F=8D=E6=98=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- docs/notifications.md: §5 Deep link を実装済み記述に更新 (SW ハンドラ
→ SPA navigate → useFocusBlockOnMount → scrollIntoView の経路と、
処理後の replaceState 除去まで明記)
- docs/memo/notification_roadmap.md: P6b を完了へ
---
docs/memo/notification_roadmap.md | 4 ++--
docs/notifications.md | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/memo/notification_roadmap.md b/docs/memo/notification_roadmap.md
index a15a0ff5..42139eb4 100644
--- a/docs/memo/notification_roadmap.md
+++ b/docs/memo/notification_roadmap.md
@@ -53,9 +53,9 @@ graph LR
- [x] Deep link URL 埋め込み (`?focusBlock={block_id}` を FCM `data.link` に付与)
- [x] フォアグラウンド OS 通知 (`useForegroundNotificationToast` → `registration.showNotification`)
-### Phase 6b-9: 未実装 (別 PR / Follow-up)
+### Phase 6b-9
-- [ ] SW `notificationclick` ハンドラ (tap 時に `focusBlock` を消費してブロックまでスクロール)
+- [x] SW `notificationclick` ハンドラ (#207): tap で既存 client に postMessage → SPA navigate、無ければ openWindow。client 側は `useFocusBlockOnMount` で block まで scroll
- [ ] Phase 7: stg 環境 E2E テスト (iOS/Android/Desktop 実機)
- [ ] Phase 8: prod リリース + Cloud Scheduler ジョブ作成
- [ ] Phase 9 (#173): `sent_notifications` 掃除 cron
diff --git a/docs/notifications.md b/docs/notifications.md
index 8248e0c2..da709c43 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -196,7 +196,7 @@ tick が 60 秒を超えると次 tick と重なる。閾値と対処:
- 素材: FA (frontend/src/assets/icons) + Lucide MapPin。オレンジバッジ (`#f4a261`) + 白抜き
- **Badge** (Android status bar 等の小モノクロアイコン、96px、透過 PNG): `badge.png` を全通知で共通使用。紙飛行機シルエット (favicon 由来)、OS 側でアクセントカラーへリカラーされる。icon と別 URL にしないと Android で四角い塗りになる
- 生成スクリプト: `scripts/gen_notification_icons.py`
-- **Deep link**: `/trip/{urlId}?focusBlock={blockId}` (該当ブロックにスクロール、Phase 2 で完全実装)
+- **Deep link**: `/trip/{urlId}?focusBlock={blockId}`。SW `notificationclick` (`frontend/public/firebase-messaging-sw.js`) が受け、既存 PWA/tab (focused > visible > 任意) に postMessage で client-side navigate、無ければ `clients.openWindow`。client 側は `useFocusBlockOnMount` で `useBlock(id)` から pageId を得て page 切替 → `[data-block-id]` を rAF 待機して `scrollIntoView` (center)。処理後 `focusBlock` は replaceState で除去。
## 6. プラットフォーム対応
From bc92db093d3ebb6620bad5847a4676d7234911e9 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 02:51:06 +0000
Subject: [PATCH 04/17] =?UTF-8?q?fix(#207):=20CodeRabbit=202nd=20round=20?=
=?UTF-8?q?=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C=20(focusBlock=3D0=20?=
=?UTF-8?q?=E5=BC=BE=E3=81=8D=20+=20=E5=90=8C=E5=80=A4=E5=86=8D=E9=80=9A?=
=?UTF-8?q?=E7=9F=A5)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- focusBlock を正の safe integer のみ許可する parse に変更。regex を
/^[1-9]\d*$/ + Number.isSafeInteger + >0 の三重チェックへ。0 が
通ると useBlock(0) が SWR falsy key で fetch を止め ref も query
も更新されずスタックしていた
- consumedKeyRef のセットを async の uncancelled 完了後に移動。
StrictMode dev double-fire で最初の async が cancel された時に
scroll が発火しなくなる問題を解消
- rawFocusBlock === null (query 掃除後) 時点で ref をリセット。同一
block の再通知が同一マウント中に来ても再処理できるようにする
- 回帰テスト 2 種追加 (focusBlock=0 / 42 → clearParam → 42 再処理)
---
.../src/hooks/useFocusBlockOnMount.test.tsx | 84 +++++++++++++++++++
frontend/src/hooks/useFocusBlockOnMount.ts | 24 +++++-
2 files changed, 104 insertions(+), 4 deletions(-)
diff --git a/frontend/src/hooks/useFocusBlockOnMount.test.tsx b/frontend/src/hooks/useFocusBlockOnMount.test.tsx
index 793117a8..f1510945 100644
--- a/frontend/src/hooks/useFocusBlockOnMount.test.tsx
+++ b/frontend/src/hooks/useFocusBlockOnMount.test.tsx
@@ -193,4 +193,88 @@ describe('useFocusBlockOnMount', () => {
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('');
+ });
+ 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
index afc16b44..f959dcc1 100644
--- a/frontend/src/hooks/useFocusBlockOnMount.ts
+++ b/frontend/src/hooks/useFocusBlockOnMount.ts
@@ -28,19 +28,35 @@ const waitForBlockElement = (blockId: number, maxMs: number, isCancelled: () =>
tick();
});
+// 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 = rawFocusBlock != null && /^\d+$/.test(rawFocusBlock) ? Number(rawFocusBlock) : null;
+ const focusBlockId = parseFocusBlockId(rawFocusBlock);
const hasInvalidParam = rawFocusBlock != null && focusBlockId == null;
const setSelectedPageId = useSetAtom(selectedPageIdAtom);
const { block, error } = useBlock(focusBlockId);
- // TripPage は unmount せず SPA navigate で URL だけ差し替わるため、boolean 一度きりゲートだと
- // 通知連続タップ (42 → 99) の 2 回目が無視される。値ゲートにして rawFocusBlock が変わったら再処理する。
+ // 直前の 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(() => {
+ if (rawFocusBlock === null) {
+ consumedKeyRef.current = null;
+ return;
+ }
if (consumedKeyRef.current === rawFocusBlock) return;
const clearParam = () =>
@@ -66,7 +82,6 @@ export const useFocusBlockOnMount = () => {
}
if (!block) return;
- consumedKeyRef.current = rawFocusBlock;
setSelectedPageId(block.pageId);
let cancelled = false;
@@ -75,6 +90,7 @@ export const useFocusBlockOnMount = () => {
if (cancelled) return;
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
clearParam();
+ consumedKeyRef.current = rawFocusBlock;
})();
return () => {
From cf02edafc0e7cdbda0bdd4e6bdae194e7717cba0 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 03:53:59 +0000
Subject: [PATCH 05/17] =?UTF-8?q?fix(#207):=20=E5=AF=BE=E8=B1=A1=E3=83=96?=
=?UTF-8?q?=E3=83=AD=E3=83=83=E3=82=AF=E6=8F=8F=E7=94=BB=E3=81=AE=E9=81=85?=
=?UTF-8?q?=E5=BB=B6=E3=81=A7=20scroll=20=E3=81=8C=E7=99=BA=E7=81=AB?=
=?UTF-8?q?=E3=81=97=E3=81=AA=E3=81=84=E3=83=AC=E3=83=BC=E3=82=B9=E3=82=92?=
=?UTF-8?q?=E4=BF=AE=E6=AD=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
useBlock(id) 単発 fetch → selectedPageId 切替の後、対象 page の
useBlocks(pageId) list fetch が終わるまで ViewTripLayout は Skeleton を
返し、data-block-id DOM がまだ存在しない。Cloud Run コールドスタート等で
list fetch が 3 秒を超えると rAF ポーリングが空振りして scroll が
発火しないケースがあった。
- rAF ポーリングを MutationObserver に変更。Skeleton が Timeline に
差し替わった瞬間に即発火するようになり無駄回転をなくす
- timeout を 3s → 8s に延長 (staging 実測でも 3s 超えるケースを確認)
- 要素発見後に 1 rAF 挟んでから scrollIntoView。Timeline 差し替え直後は
layout がまだ確定していないタイミングで scroll してもズレることがあった
---
.../src/hooks/useFocusBlockOnMount.test.tsx | 8 +++-
frontend/src/hooks/useFocusBlockOnMount.ts | 42 ++++++++++++++-----
2 files changed, 38 insertions(+), 12 deletions(-)
diff --git a/frontend/src/hooks/useFocusBlockOnMount.test.tsx b/frontend/src/hooks/useFocusBlockOnMount.test.tsx
index f1510945..cff6020e 100644
--- a/frontend/src/hooks/useFocusBlockOnMount.test.tsx
+++ b/frontend/src/hooks/useFocusBlockOnMount.test.tsx
@@ -175,7 +175,9 @@ describe('useFocusBlockOnMount', () => {
await waitFor(() => {
expect(searchRef.current).toBe('');
});
- expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
+ await waitFor(() => {
+ expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
+ });
// 2 回目: SPA navigate で URL に別の focusBlock を差し込む
scrollIntoViewMock.mockClear();
@@ -258,7 +260,9 @@ describe('useFocusBlockOnMount', () => {
await waitFor(() => {
expect(searchRef.current).toBe('');
});
- expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
+ await waitFor(() => {
+ expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
+ });
// 2 回目: URL が /trip/abc に戻った状態から、同じ block へ再通知タップを再現
scrollIntoViewMock.mockClear();
diff --git a/frontend/src/hooks/useFocusBlockOnMount.ts b/frontend/src/hooks/useFocusBlockOnMount.ts
index f959dcc1..e6be07c1 100644
--- a/frontend/src/hooks/useFocusBlockOnMount.ts
+++ b/frontend/src/hooks/useFocusBlockOnMount.ts
@@ -12,21 +12,43 @@ import { useBlock } from '@/hooks/useBlocks';
*/
const FOCUS_BLOCK_PARAM = 'focusBlock';
-const SCROLL_WAIT_MAX_MS = 3000;
+// 通知タップ → 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 start = performance.now();
- const tick = () => {
- if (isCancelled()) return resolve(null);
- const el = document.querySelector(selector);
- if (el) return resolve(el);
- if (performance.now() - start > maxMs) return resolve(null);
- requestAnimationFrame(tick);
+
+ 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);
};
- tick();
+
+ 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 を渡すと
@@ -88,7 +110,7 @@ export const useFocusBlockOnMount = () => {
(async () => {
const el = await waitForBlockElement(focusBlockId, SCROLL_WAIT_MAX_MS, () => cancelled);
if (cancelled) return;
- el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ if (el) scrollIntoViewOnNextFrame(el);
clearParam();
consumedKeyRef.current = rawFocusBlock;
})();
From a6638e7808905b650501d9f75e80c0c4303ca739 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 04:34:00 +0000
Subject: [PATCH 06/17] =?UTF-8?q?fix(#207):=20PWA=20background=20=E2=86=92?=
=?UTF-8?q?=20=E5=88=A5=20trip=20=E9=80=9A=E7=9F=A5=E3=82=BF=E3=83=83?=
=?UTF-8?q?=E3=83=97=E3=81=A7=E9=81=B7=E7=A7=BB=E3=81=97=E3=81=AA=E3=81=84?=
=?UTF-8?q?=E3=82=B1=E3=83=BC=E3=82=B9=E3=82=92=E8=A7=A3=E6=B6=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
現状は SW → client の postMessage 1 本に依存しており、以下 2 パターンで
navigate が発火せず PWA が前面化したまま URL が変わらない事故になる:
- Android で PWA が freeze/kill され、matchAll は phantom client を
返すが event loop で message が drain されない
- cold start で client 側 message listener 登録より前に SW が
postMessage を送出
Cache Storage を safety net にして両方救う。
- SW: 通知タップの target URL を Cache に put してから postMessage/
openWindow へ進む
- Client: useFcmNavigationListener を mount + visibilitychange で
Cache 内 intent を消化する経路に拡張。fast path (message) と safety
net (cache) の二重発火を lastNavigatedRef で dedupe
- 現在 URL と一致するときは navigate せず、履歴汚染を避ける
- Cache API 非対応環境 / QuotaExceeded は best effort として無視
---
frontend/public/firebase-messaging-sw.js | 22 ++++
.../hooks/useFcmNavigationListener.test.tsx | 85 ++++++++++++-
.../src/hooks/useFcmNavigationListener.ts | 114 +++++++++++++++---
3 files changed, 203 insertions(+), 18 deletions(-)
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index 58492481..febf14b6 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -20,6 +20,25 @@ const pickTargetClient = clientsList => {
);
};
+// postMessage 経路が届かない状況の safety net として target URL を Cache Storage に書き残す。
+// client 側 (useFcmNavigationListener) は mount + visibilitychange のたびに読んで navigate する。
+// 起こりうる postMessage 未着ケース:
+// - PWA が Android にキルされていて matchAll 上は phantom client、focus() では OS が task を
+// 復帰させるが postMessage は event loop で drain されない
+// - cold start で client 側 message listener 登録前に SW が postMessage 送出
+const INTENT_CACHE = 'fcm-nav-intent-v1';
+// Cache API は Request URL をキーに使うため、実在しない同 origin URL を予約して衝突を避ける。
+const INTENT_KEY = new URL('/__fcm_pending_nav__', self.location.origin).href;
+
+const storePendingIntent = async url => {
+ try {
+ const cache = await caches.open(INTENT_CACHE);
+ await cache.put(new Request(INTENT_KEY), new Response(url, { headers: { 'content-type': 'text/plain' } }));
+ } catch {
+ // Cache API 非対応 / QuotaExceeded 等は best effort として無視
+ }
+};
+
self.addEventListener('notificationclick', event => {
event.notification.close();
@@ -37,6 +56,9 @@ self.addEventListener('notificationclick', event => {
event.waitUntil(
(async () => {
+ // storage 完了を focus/openWindow より前に保証。client 側の visibilitychange で確実に拾える。
+ await storePendingIntent(targetUrl.href);
+
const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const target = pickTargetClient(clientsList);
diff --git a/frontend/src/hooks/useFcmNavigationListener.test.tsx b/frontend/src/hooks/useFcmNavigationListener.test.tsx
index 01465b07..a7d6d9e8 100644
--- a/frontend/src/hooks/useFcmNavigationListener.test.tsx
+++ b/frontend/src/hooks/useFcmNavigationListener.test.tsx
@@ -1,4 +1,4 @@
-import { renderHook } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockNavigate = vi.fn();
@@ -26,14 +26,45 @@ const dispatchSwMessage = (data: unknown) => {
for (const listener of swListeners) listener(event);
};
+// jsdom は Cache API 未実装なのでインメモリスタブを差し込む
+const cacheEntries = new Map();
+
+const cacheStub: Cache = {
+ put: async (request: RequestInfo | URL, response: Response) => {
+ const key = typeof request === 'string' ? request : (request as Request).url;
+ cacheEntries.set(key, await response.text());
+ },
+ match: async (request: RequestInfo | URL) => {
+ const key = typeof request === 'string' ? request : (request as Request).url;
+ const value = cacheEntries.get(key);
+ return value === undefined ? undefined : new Response(value);
+ },
+ delete: async (request: RequestInfo | URL) => {
+ const key = typeof request === 'string' ? request : (request as Request).url;
+ return cacheEntries.delete(key);
+ },
+} as unknown as Cache;
+
+const cachesStub: CacheStorage = {
+ open: async () => cacheStub,
+} as unknown as CacheStorage;
+
+const INTENT_KEY = 'http://localhost:3000/__fcm_pending_nav__';
+
+const seedPendingIntent = (url: string) => {
+ cacheEntries.set(INTENT_KEY, url);
+};
+
describe('useFcmNavigationListener', () => {
beforeEach(() => {
swListeners.clear();
+ cacheEntries.clear();
mockNavigate.mockClear();
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: swStub,
});
+ vi.stubGlobal('caches', cachesStub);
});
afterEach(() => {
@@ -41,6 +72,7 @@ describe('useFcmNavigationListener', () => {
configurable: true,
value: undefined,
});
+ vi.unstubAllGlobals();
});
it('FCM_NAVIGATE メッセージ受信で navigate() を pathname+search+hash 付きで呼ぶ', () => {
@@ -98,7 +130,7 @@ describe('useFcmNavigationListener', () => {
expect(swListeners.size).toBe(0);
});
- it('navigator.serviceWorker が無い環境では何もしない', () => {
+ it('navigator.serviceWorker が無い環境でも throw しない', () => {
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: undefined,
@@ -107,4 +139,53 @@ describe('useFcmNavigationListener', () => {
expect(() => renderHook(() => useFcmNavigationListener())).not.toThrow();
expect(swListeners.size).toBe(0);
});
+
+ it('mount 時に Cache Storage に intent があれば navigate する (postMessage 未着 fallback)', async () => {
+ seedPendingIntent('http://localhost:3000/trip/xyz?focusBlock=7');
+
+ renderHook(() => useFcmNavigationListener());
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledExactlyOnceWith('/trip/xyz?focusBlock=7');
+ });
+ expect(cacheEntries.has(INTENT_KEY)).toBe(false);
+ });
+
+ it('mount 時に intent が現在 URL と一致するなら navigate しない', async () => {
+ seedPendingIntent(window.location.href);
+
+ renderHook(() => useFcmNavigationListener());
+
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('visibilitychange (visible) で cache 内 intent を消化する', async () => {
+ renderHook(() => useFcmNavigationListener());
+
+ seedPendingIntent('http://localhost:3000/trip/later?focusBlock=99');
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
+ act(() => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledExactlyOnceWith('/trip/later?focusBlock=99');
+ });
+ });
+
+ it('postMessage で navigate した場合は cache 内 intent も掃除する (二重 navigate 防止)', async () => {
+ seedPendingIntent('http://localhost:3000/trip/abc?focusBlock=42');
+
+ renderHook(() => useFcmNavigationListener());
+
+ dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'http://localhost:3000/trip/abc?focusBlock=42' });
+
+ await waitFor(() => {
+ expect(cacheEntries.has(INTENT_KEY)).toBe(false);
+ });
+ // navigate は message 経由の 1 回のみ。mount 時 applyIntent は cache が既に空になってて no-op
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts
index d9b77f5d..62587caa 100644
--- a/frontend/src/hooks/useFcmNavigationListener.ts
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -1,40 +1,122 @@
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
/**
- * firebase-messaging-sw.js の notificationclick が postMessage する
- * `{ type: 'FCM_NAVIGATE', url }` を受けて React Router で navigate する。
+ * firebase-messaging-sw.js の notificationclick が伝えてくる遷移先 URL を受けて
+ * React Router で navigate する。
* App.tsx で 1 回のみ呼ぶ (Router の内側必須)。
+ *
+ * 経路は 2 系統:
+ * - fast path: SW からの postMessage `{ type: 'FCM_NAVIGATE', url }` を message event で受ける
+ * - safety net: SW が Cache Storage (INTENT_CACHE / INTENT_KEY) に書き残した URL を
+ * mount + visibilitychange のたびに読む。PWA が Android にキルされて postMessage が
+ * 届かないケース / cold start で listener 未登録のケースを両方救う
+ *
+ * 両方の経路が同じ URL に対して発火するので、直近の遷移 URL を ref に持たせて重複 navigate を防ぐ。
*/
+
+const INTENT_CACHE = 'fcm-nav-intent-v1';
+const INTENT_KEY_PATH = '/__fcm_pending_nav__';
+
+const intentKeyUrl = () => new URL(INTENT_KEY_PATH, window.location.origin).href;
+
+const openIntentCache = async (): Promise => {
+ if (typeof caches === 'undefined') return null;
+ try {
+ return await caches.open(INTENT_CACHE);
+ } catch {
+ return null;
+ }
+};
+
+const readAndClearPendingIntent = async (): Promise => {
+ const cache = await openIntentCache();
+ if (!cache) return null;
+ const key = intentKeyUrl();
+ try {
+ const res = await cache.match(key);
+ if (!res) return null;
+ const url = await res.text();
+ await cache.delete(key);
+ return url;
+ } catch {
+ return null;
+ }
+};
+
+const clearPendingIntent = async (): Promise => {
+ const cache = await openIntentCache();
+ if (!cache) return;
+ try {
+ await cache.delete(intentKeyUrl());
+ } catch {
+ // best effort
+ }
+};
+
+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}`;
+};
+
export const useFcmNavigationListener = () => {
const navigate = useNavigate();
+ // fast path (message) と safety net (cache) が同一 URL に対して二重発火するのを防ぐ。
+ // 直近の遷移先を保持し、同値なら skip する。
+ const lastNavigatedRef = useRef(null);
useEffect(() => {
+ let cancelled = false;
+
+ const navigateOnce = (target: string) => {
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
+ if (target === current) return;
+ if (lastNavigatedRef.current === target) return;
+ lastNavigatedRef.current = target;
+ navigate(target);
+ };
+
+ const applyIntent = async () => {
+ const url = await readAndClearPendingIntent();
+ if (cancelled || !url) return;
+ const target = parseSameOriginPath(url);
+ if (!target) return;
+ navigateOnce(target);
+ };
+
// 参照を effect スコープに固定: cleanup 時に navigator.serviceWorker が消えていても
// (テスト環境で差し替えると起こる) removeEventListener で crash させない。
const sw = typeof navigator !== 'undefined' ? navigator.serviceWorker : undefined;
- if (!sw) return;
const handler = (event: MessageEvent) => {
if (event.data?.type !== 'FCM_NAVIGATE') return;
const rawUrl = event.data.url;
if (typeof rawUrl !== 'string') return;
+ const target = parseSameOriginPath(rawUrl);
+ if (!target) return;
+ // message 経由で消化するので safety net の Cache は掃除する (二重 navigate 防止)
+ void clearPendingIntent();
+ navigateOnce(target);
+ };
- let parsed: URL;
- try {
- parsed = new URL(rawUrl, window.location.origin);
- } catch {
- return;
- }
- // SW 側でも弾いているが、payload 経路の open-redirect 対策として二重防御。
- if (parsed.origin !== window.location.origin) return;
-
- navigate(`${parsed.pathname}${parsed.search}${parsed.hash}`);
+ const onVisibility = () => {
+ if (document.visibilityState === 'visible') void applyIntent();
};
- sw.addEventListener('message', handler);
+ sw?.addEventListener('message', handler);
+ document.addEventListener('visibilitychange', onVisibility);
+ void applyIntent();
+
return () => {
- sw.removeEventListener('message', handler);
+ cancelled = true;
+ sw?.removeEventListener('message', handler);
+ document.removeEventListener('visibilitychange', onVisibility);
};
}, [navigate]);
};
From 71cb265d24aa47bf75e45c54b889e3c913f3b328 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 07:32:32 +0000
Subject: [PATCH 07/17] =?UTF-8?q?chore(#207):=20=E9=80=9A=E7=9F=A5?=
=?UTF-8?q?=E3=82=BF=E3=83=83=E3=83=97=E2=86=92=E9=81=B7=E7=A7=BB=E3=81=AE?=
=?UTF-8?q?=E5=88=87=E3=82=8A=E5=88=86=E3=81=91=E7=94=A8=20IndexedDB=20Log?=
=?UTF-8?q?ger=20=E3=82=92=E8=BF=BD=E5=8A=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Android PWA は console を remote inspect 経由でしか見られず、実機再現が
手間なので SW と client の checkpoint を IndexedDB に書き溜めて、URL に
?debug=1 で右下に出るボタンで一括 Copy できるようにする。運用機能では
なく本 issue の切り分け作業後に整理する前提。
- lib/debugLogger.ts: 500 件 ring buffer、readAll でフォーマット済み文字列
- firebase-messaging-sw.js: 同じ DB / store 名で inline 実装 (compat SDK
で importScripts 経由なので TS 版を共有できない)
- components/DebugLogPanel.tsx: 右下 floating。?debug=1 で localStorage
永続化、?debug=0 で解除。clipboard 拒否時は prompt にフォールバック
- SW の notificationclick 全経路 / client の message + intent 消化 /
useFocusBlockOnMount の effect + wait 完了に checkpoint 挿入
---
frontend/public/firebase-messaging-sw.js | 66 +++++++++-
frontend/src/App.tsx | 2 +
frontend/src/components/DebugLogPanel.tsx | 81 ++++++++++++
.../src/hooks/useFcmNavigationListener.ts | 22 +++-
frontend/src/hooks/useFocusBlockOnMount.ts | 9 ++
frontend/src/lib/debugLogger.ts | 116 ++++++++++++++++++
6 files changed, 288 insertions(+), 8 deletions(-)
create mode 100644 frontend/src/components/DebugLogPanel.tsx
create mode 100644 frontend/src/lib/debugLogger.ts
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index febf14b6..58896650 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -6,6 +6,50 @@
// することがあり、後付けで addEventListener すると invocation されないケースがある。
// (`firebase-js-sdk/packages/messaging/src/listeners/sw-listeners.ts` の実装参照)
+// --- 診断ロガー (client 側 lib/debugLogger.ts と同じ DB / store) ---
+// Android PWA から console を取れない環境向け。SW から IndexedDB に書いて client 側で
+// 読み出す。運用機能ではないので消しても実装挙動には影響しない。
+const DEBUG_DB = 'fcm-debug-log';
+const DEBUG_STORE = 'entries';
+const DEBUG_MAX = 500;
+const openDebugDb = () =>
+ new Promise((resolve, reject) => {
+ const req = indexedDB.open(DEBUG_DB, 1);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(DEBUG_STORE)) {
+ db.createObjectStore(DEBUG_STORE, { autoIncrement: true });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+const debugLog = async (tag, message, data) => {
+ try {
+ const db = await openDebugDb();
+ const tx = db.transaction(DEBUG_STORE, 'readwrite');
+ const store = tx.objectStore(DEBUG_STORE);
+ const safeData = data === undefined ? null : JSON.parse(JSON.stringify(data));
+ store.add({ ts: Date.now(), tag, message, data: safeData });
+ const countReq = store.count();
+ countReq.onsuccess = () => {
+ const excess = countReq.result - DEBUG_MAX;
+ if (excess <= 0) return;
+ const cursorReq = store.openCursor();
+ let remaining = excess;
+ cursorReq.onsuccess = e => {
+ const cursor = e.target.result;
+ if (!cursor || remaining <= 0) return;
+ cursor.delete();
+ remaining -= 1;
+ cursor.continue();
+ };
+ };
+ } catch (_) {
+ // logger must not throw
+ }
+};
+
// Firebase Admin SDK の WebpushFCMOptions.link は data.FCM_MSG.fcmOptions.link に入る。
// フォアグラウンド通知 (useForegroundNotificationToast) の自前 showNotification 経由は data.link。
const extractDeepLink = notification => {
@@ -41,39 +85,55 @@ const storePendingIntent = async url => {
self.addEventListener('notificationclick', event => {
event.notification.close();
+ void debugLog('SW', 'notificationclick', { data: event.notification.data });
const link = extractDeepLink(event.notification);
+ void debugLog('SW', 'link extracted', { link });
if (!link) return;
// payload 経路経由の open-redirect を防ぐ (別 origin URL は捨てる)。
let targetUrl;
try {
targetUrl = new URL(link, self.location.origin);
- } catch {
+ } catch (err) {
+ void debugLog('SW', 'URL parse fail', { err: String(err) });
+ return;
+ }
+ if (targetUrl.origin !== self.location.origin) {
+ void debugLog('SW', 'origin mismatch', { target: targetUrl.origin, self: self.location.origin });
return;
}
- if (targetUrl.origin !== self.location.origin) return;
event.waitUntil(
(async () => {
// storage 完了を focus/openWindow より前に保証。client 側の visibilitychange で確実に拾える。
await storePendingIntent(targetUrl.href);
+ void debugLog('SW', 'intent stored', { url: targetUrl.href });
const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ void debugLog('SW', 'matchAll', {
+ n: clientsList.length,
+ clients: clientsList.map(c => ({ url: c.url, focused: c.focused, vis: c.visibilityState })),
+ });
const target = pickTargetClient(clientsList);
+ void debugLog('SW', 'target picked', { url: target ? target.url : null });
if (target) {
// WindowClient.navigate() を使うとフルリロードで atom / SWR / scroll 位置が飛ぶので、
// 代わりに client 側で React Router の navigate を呼んでもらう。
target.postMessage({ type: 'FCM_NAVIGATE', url: targetUrl.href });
+ void debugLog('SW', 'postMessage sent', { url: targetUrl.href });
try {
await target.focus();
- } catch {
+ void debugLog('SW', 'focus ok');
+ } catch (err) {
// focus はユーザ操作起源でないと reject されるが、postMessage は届いてるので許容
+ void debugLog('SW', 'focus fail', { err: String(err) });
}
return;
}
+ void debugLog('SW', 'openWindow fallback', { url: targetUrl.href });
await self.clients.openWindow(targetUrl.href);
})()
);
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 932faece..8ef81c09 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,7 @@
import { useAtomValue } from 'jotai';
import { Route, Routes } from 'react-router-dom';
import { isOfflineReadAtom } from './atoms/network';
+import { DebugLogPanel } from './components/DebugLogPanel';
import { NoIndex } from './components/NoIndex';
import { Title } from './components/Title';
import { useFcmNavigationListener } from './hooks/useFcmNavigationListener';
@@ -54,6 +55,7 @@ const App = () => {
}
/>
+
>
);
};
diff --git a/frontend/src/components/DebugLogPanel.tsx b/frontend/src/components/DebugLogPanel.tsx
new file mode 100644
index 00000000..b9320fec
--- /dev/null
+++ b/frontend/src/components/DebugLogPanel.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react';
+import { toast } from 'sonner';
+import { clearAllLogs, readAllLogs } from '@/lib/debugLogger';
+
+/**
+ * `?debug=1` 付きで開くと localStorage に永続化され、右下に Copy / Clear ボタンを表示する。
+ * `?debug=0` で解除。Android PWA で console 取れないときの診断用。
+ */
+
+const STORAGE_KEY = '__fcm_debug_panel__';
+
+const isEnabled = (): boolean => {
+ try {
+ const params = new URLSearchParams(window.location.search);
+ const q = params.get('debug');
+ if (q === '1') {
+ localStorage.setItem(STORAGE_KEY, '1');
+ return true;
+ }
+ if (q === '0') {
+ localStorage.removeItem(STORAGE_KEY);
+ return false;
+ }
+ return localStorage.getItem(STORAGE_KEY) === '1';
+ } catch {
+ return false;
+ }
+};
+
+const copyText = async (text: string): Promise => {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+const DebugLogPanel = () => {
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ setVisible(isEnabled());
+ }, []);
+
+ if (!visible) return null;
+
+ const onCopy = async () => {
+ const logs = await readAllLogs();
+ if (!logs) {
+ toast.info('ログなし');
+ return;
+ }
+ const lines = logs.split('\n').length;
+ const ok = await copyText(logs);
+ if (ok) {
+ toast.success(`${lines} 件コピー`);
+ } else {
+ // クリップボード拒否時は prompt で見せて手動コピー
+ window.prompt('コピーしてください', logs);
+ }
+ };
+
+ const onClear = async () => {
+ await clearAllLogs();
+ toast.success('ログをクリア');
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+export { DebugLogPanel };
diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts
index 62587caa..5d64dd81 100644
--- a/frontend/src/hooks/useFcmNavigationListener.ts
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
+import { debugLog } from '@/lib/debugLogger';
/**
* firebase-messaging-sw.js の notificationclick が伝えてくる遷移先 URL を受けて
@@ -74,20 +75,28 @@ export const useFcmNavigationListener = () => {
useEffect(() => {
let cancelled = false;
- const navigateOnce = (target: string) => {
+ const navigateOnce = (target: string, source: string) => {
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
- if (target === current) return;
- if (lastNavigatedRef.current === target) return;
+ if (target === current) {
+ void debugLog('CL', 'navigateOnce skip (== current)', { target, source });
+ return;
+ }
+ if (lastNavigatedRef.current === target) {
+ void debugLog('CL', 'navigateOnce skip (== last)', { target, source });
+ return;
+ }
lastNavigatedRef.current = target;
+ void debugLog('CL', 'navigate() called', { target, source });
navigate(target);
};
const applyIntent = async () => {
const url = await readAndClearPendingIntent();
+ void debugLog('CL', 'applyIntent', { url });
if (cancelled || !url) return;
const target = parseSameOriginPath(url);
if (!target) return;
- navigateOnce(target);
+ navigateOnce(target, 'intent');
};
// 参照を effect スコープに固定: cleanup 時に navigator.serviceWorker が消えていても
@@ -95,6 +104,7 @@ export const useFcmNavigationListener = () => {
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;
@@ -102,15 +112,17 @@ export const useFcmNavigationListener = () => {
if (!target) return;
// message 経由で消化するので safety net の Cache は掃除する (二重 navigate 防止)
void clearPendingIntent();
- navigateOnce(target);
+ navigateOnce(target, 'message');
};
const onVisibility = () => {
+ void debugLog('CL', 'visibilitychange', { state: document.visibilityState });
if (document.visibilityState === 'visible') void applyIntent();
};
sw?.addEventListener('message', handler);
document.addEventListener('visibilitychange', onVisibility);
+ void debugLog('CL', 'listener mounted', { hasSw: !!sw });
void applyIntent();
return () => {
diff --git a/frontend/src/hooks/useFocusBlockOnMount.ts b/frontend/src/hooks/useFocusBlockOnMount.ts
index e6be07c1..0ae7d718 100644
--- a/frontend/src/hooks/useFocusBlockOnMount.ts
+++ b/frontend/src/hooks/useFocusBlockOnMount.ts
@@ -3,6 +3,7 @@ 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 する。
@@ -75,6 +76,13 @@ export const useFocusBlockOnMount = () => {
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;
@@ -109,6 +117,7 @@ export const useFocusBlockOnMount = () => {
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();
diff --git a/frontend/src/lib/debugLogger.ts b/frontend/src/lib/debugLogger.ts
new file mode 100644
index 00000000..1bdcbdb9
--- /dev/null
+++ b/frontend/src/lib/debugLogger.ts
@@ -0,0 +1,116 @@
+/**
+ * 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;
+
+interface LogEntry {
+ ts: number;
+ 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(), 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)}`;
+ return `[${t}] [${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
+ }
+};
From d667ea06f8c1c50f3cb771f93cf73ec96767fa27 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 08:00:13 +0000
Subject: [PATCH 08/17] =?UTF-8?q?chore(#207):=20=E8=A8=BA=E6=96=AD?=
=?UTF-8?q?=E3=83=91=E3=83=8D=E3=83=AB=E3=82=92=20gate=20=E7=84=A1?=
=?UTF-8?q?=E3=81=97=E5=B8=B8=E6=99=82=E8=A1=A8=E7=A4=BA=E3=81=AB=E5=A4=89?=
=?UTF-8?q?=E6=9B=B4=20(chore=20branch=20=E3=81=AE=E3=81=BF)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PWA install 後は URL バーが隠れて ?debug=1 を後付けしにくいので、この
診断専用ブランチでは gate を撤廃し install 直後から Copy Logs / Clear
が使える状態にする。PR #216 は DO NOT MERGE 前提なので prod 影響なし。
---
frontend/src/components/DebugLogPanel.tsx | 33 ++---------------------
1 file changed, 2 insertions(+), 31 deletions(-)
diff --git a/frontend/src/components/DebugLogPanel.tsx b/frontend/src/components/DebugLogPanel.tsx
index b9320fec..d59f3410 100644
--- a/frontend/src/components/DebugLogPanel.tsx
+++ b/frontend/src/components/DebugLogPanel.tsx
@@ -1,32 +1,11 @@
-import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { clearAllLogs, readAllLogs } from '@/lib/debugLogger';
/**
- * `?debug=1` 付きで開くと localStorage に永続化され、右下に Copy / Clear ボタンを表示する。
- * `?debug=0` で解除。Android PWA で console 取れないときの診断用。
+ * 診断ブランチ (chore/issue207_debug-logger) 専用: 右下に Copy / Clear ボタンを常時表示する。
+ * PR #216 は Draft & DO NOT MERGE 前提。gate 無しで install 直後から使える状態にしておく。
*/
-const STORAGE_KEY = '__fcm_debug_panel__';
-
-const isEnabled = (): boolean => {
- try {
- const params = new URLSearchParams(window.location.search);
- const q = params.get('debug');
- if (q === '1') {
- localStorage.setItem(STORAGE_KEY, '1');
- return true;
- }
- if (q === '0') {
- localStorage.removeItem(STORAGE_KEY);
- return false;
- }
- return localStorage.getItem(STORAGE_KEY) === '1';
- } catch {
- return false;
- }
-};
-
const copyText = async (text: string): Promise => {
try {
await navigator.clipboard.writeText(text);
@@ -37,14 +16,6 @@ const copyText = async (text: string): Promise => {
};
const DebugLogPanel = () => {
- const [visible, setVisible] = useState(false);
-
- useEffect(() => {
- setVisible(isEnabled());
- }, []);
-
- if (!visible) return null;
-
const onCopy = async () => {
const logs = await readAllLogs();
if (!logs) {
From a4ceaf6705c13c59eb8e28913b1fff9d8b38235b Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 08:24:21 +0000
Subject: [PATCH 09/17] =?UTF-8?q?chore(#207):=20=E8=A8=BA=E6=96=AD?=
=?UTF-8?q?=E3=83=AD=E3=82=B0=E5=90=84=E8=A1=8C=E3=81=AB=E3=83=93=E3=83=AB?=
=?UTF-8?q?=E3=83=89=E8=AD=98=E5=88=A5=E5=AD=90=20(DEBUG=5FLOG=5FVERSION)?=
=?UTF-8?q?=20=E3=82=92=E5=9F=8B=E3=82=81=E8=BE=BC=E3=82=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SW 更新は非同期でユーザ操作依存 (skipWaiting していない) なので、実機で
古い SW が動いてるケースを共有ログから区別できるようにする。
- lib/debugLogger.ts (client): DEBUG_LOG_VERSION 定数を追加、LogEntry に
version フィールドを追加、formatEntry で各行に埋め込み
- firebase-messaging-sw.js: 対の DEBUG_LOG_VERSION を持ち、SW script
evaluate 時に 'sw script evaluated' ログ、各 entry に version 記録
- useFcmNavigationListener 'listener mounted' に SW controller の
scriptURL と client version を含める → SW/client のバージョンずれ検知
- DebugLogPanel の Copy 時に client version / SW controller / URL /
copied at のヘッダを先頭に付ける
診断修正のたびに 2 定数を手動 bump する運用 (現在 v02)。
---
frontend/public/firebase-messaging-sw.js | 9 ++++++-
frontend/src/components/DebugLogPanel.tsx | 25 ++++++++++++-------
.../src/hooks/useFcmNavigationListener.ts | 9 +++++--
frontend/src/lib/debugLogger.ts | 19 ++++++++++++--
4 files changed, 48 insertions(+), 14 deletions(-)
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index 58896650..0870e39c 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -9,6 +9,10 @@
// --- 診断ロガー (client 側 lib/debugLogger.ts と同じ DB / store) ---
// Android PWA から console を取れない環境向け。SW から IndexedDB に書いて client 側で
// 読み出す。運用機能ではないので消しても実装挙動には影響しない。
+// DEBUG_LOG_VERSION は診断コード修正のたびに手動で bump する。client 側 debugLogger.ts の
+// DEBUG_LOG_VERSION と対で更新。ログ各行に埋め込まれるので、実機で古い SW が動いているのか
+// 新しい SW が動いているのかを共有ログから判別できる (SW 更新は非同期でユーザ操作依存なため)。
+const DEBUG_LOG_VERSION = 'v02-sw-2026-08-01';
const DEBUG_DB = 'fcm-debug-log';
const DEBUG_STORE = 'entries';
const DEBUG_MAX = 500;
@@ -30,7 +34,7 @@ const debugLog = async (tag, message, data) => {
const tx = db.transaction(DEBUG_STORE, 'readwrite');
const store = tx.objectStore(DEBUG_STORE);
const safeData = data === undefined ? null : JSON.parse(JSON.stringify(data));
- store.add({ ts: Date.now(), tag, message, data: safeData });
+ store.add({ ts: Date.now(), version: DEBUG_LOG_VERSION, tag, message, data: safeData });
const countReq = store.count();
countReq.onsuccess = () => {
const excess = countReq.result - DEBUG_MAX;
@@ -50,6 +54,9 @@ const debugLog = async (tag, message, data) => {
}
};
+// SW script が evaluate されたタイミングを残す。SW 更新のタイミング把握用。
+void debugLog('SW', 'sw script evaluated');
+
// Firebase Admin SDK の WebpushFCMOptions.link は data.FCM_MSG.fcmOptions.link に入る。
// フォアグラウンド通知 (useForegroundNotificationToast) の自前 showNotification 経由は data.link。
const extractDeepLink = notification => {
diff --git a/frontend/src/components/DebugLogPanel.tsx b/frontend/src/components/DebugLogPanel.tsx
index d59f3410..245b8ef3 100644
--- a/frontend/src/components/DebugLogPanel.tsx
+++ b/frontend/src/components/DebugLogPanel.tsx
@@ -1,5 +1,5 @@
import { toast } from 'sonner';
-import { clearAllLogs, readAllLogs } from '@/lib/debugLogger';
+import { clearAllLogs, DEBUG_LOG_VERSION, readAllLogs } from '@/lib/debugLogger';
/**
* 診断ブランチ (chore/issue207_debug-logger) 専用: 右下に Copy / Clear ボタンを常時表示する。
@@ -18,17 +18,24 @@ const copyText = async (text: string): Promise => {
const DebugLogPanel = () => {
const onCopy = async () => {
const logs = await readAllLogs();
- if (!logs) {
- toast.info('ログなし');
- return;
- }
- const lines = logs.split('\n').length;
- const ok = await copyText(logs);
+ const controllerUrl = navigator.serviceWorker?.controller?.scriptURL ?? '(no controller)';
+ const header = [
+ '=== fcm debug log ===',
+ `client version: ${DEBUG_LOG_VERSION}`,
+ `sw controller: ${controllerUrl}`,
+ `current URL: ${window.location.href}`,
+ `copied at: ${new Date().toISOString()}`,
+ '=====================',
+ ].join('\n');
+ const body = logs || '(no entries)';
+ const payload = `${header}\n${body}`;
+ const lines = body.split('\n').length;
+ const ok = await copyText(payload);
if (ok) {
- toast.success(`${lines} 件コピー`);
+ toast.success(`${lines} 件コピー (+ header)`);
} else {
// クリップボード拒否時は prompt で見せて手動コピー
- window.prompt('コピーしてください', logs);
+ window.prompt('コピーしてください', payload);
}
};
diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts
index 5d64dd81..3bceea16 100644
--- a/frontend/src/hooks/useFcmNavigationListener.ts
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
-import { debugLog } from '@/lib/debugLogger';
+import { DEBUG_LOG_VERSION, debugLog } from '@/lib/debugLogger';
/**
* firebase-messaging-sw.js の notificationclick が伝えてくる遷移先 URL を受けて
@@ -122,7 +122,12 @@ export const useFcmNavigationListener = () => {
sw?.addEventListener('message', handler);
document.addEventListener('visibilitychange', onVisibility);
- void debugLog('CL', 'listener mounted', { hasSw: !!sw });
+ void debugLog('CL', 'listener mounted', {
+ hasSw: !!sw,
+ swControllerUrl: sw?.controller?.scriptURL ?? null,
+ currentUrl: window.location.href,
+ clientVersion: DEBUG_LOG_VERSION,
+ });
void applyIntent();
return () => {
diff --git a/frontend/src/lib/debugLogger.ts b/frontend/src/lib/debugLogger.ts
index 1bdcbdb9..63c86cb7 100644
--- a/frontend/src/lib/debugLogger.ts
+++ b/frontend/src/lib/debugLogger.ts
@@ -11,8 +11,16 @@ 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 = 'v02-cl-2026-08-01';
+
interface LogEntry {
ts: number;
+ version: string;
tag: string;
message: string;
data: unknown;
@@ -54,7 +62,13 @@ export const debugLog = async (tag: string, message: string, data?: unknown): Pr
const db = await getDb();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
- const entry: LogEntry = { ts: Date.now(), tag, message, data: safeSerialize(data) };
+ const entry: LogEntry = {
+ ts: Date.now(),
+ version: DEBUG_LOG_VERSION,
+ tag,
+ message,
+ data: safeSerialize(data),
+ };
store.add(entry);
const countReq = store.count();
countReq.onsuccess = () => {
@@ -78,7 +92,8 @@ export const debugLog = async (tag: string, message: string, data?: unknown): Pr
const formatEntry = (e: LogEntry): string => {
const t = new Date(e.ts).toISOString();
const data = e.data == null ? '' : ` | ${JSON.stringify(e.data)}`;
- return `[${t}] [${e.tag}] ${e.message}${data}`;
+ const version = e.version ?? '?';
+ return `[${t}] [${version}] [${e.tag}] ${e.message}${data}`;
};
export const readAllLogs = async (): Promise => {
From c4a306e79b8ac70c4a078df64afc83ea52ab5f69 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 08:46:58 +0000
Subject: [PATCH 10/17] =?UTF-8?q?chore(#207):=20VitePWA=20precache=20?=
=?UTF-8?q?=E3=81=8B=E3=82=89=20firebase-messaging-sw.js=20=E3=82=92?=
=?UTF-8?q?=E9=99=A4=E5=A4=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
実機ログで SW ハンドラが全く発火せず Firebase SDK デフォルト click 処理
だけが動く現象が発生していたが、原因は VitePWA が firebase-messaging-sw.js
を precache していたことにあった。register 時に Chrome が cache 経由で
古い版を fetch → 古い SW が active → いくら deploy しても新版に切り替
わらない。
- vite.config.ts の workbox に globIgnores + navigateFallbackDenylist で
firebase-messaging-sw.js を除外
- firebase.json で /firebase-messaging-sw.js に Cache-Control: no-cache
ヘッダを追加。Firebase Hosting のデフォルト max-age=3600 も抑える
- 診断: DEBUG_LOG_VERSION を v03 に bump、client 側で
getRegistrations() を呼び出して全 SW の scope / active / waiting /
installing の scriptURL をログ出力
feature branch (#209) に同等の修正を反映する前に、まず diagnostic
branch で実機挙動確認する。
---
firebase.json | 24 +++++++++++++++++++
frontend/public/firebase-messaging-sw.js | 2 +-
.../src/hooks/useFcmNavigationListener.ts | 19 +++++++++++++++
frontend/vite.config.ts | 6 +++++
4 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/firebase.json b/firebase.json
index 3ec1bf94..3869e6b1 100644
--- a/firebase.json
+++ b/firebase.json
@@ -9,6 +9,14 @@
"source": "**",
"destination": "/index.html"
}
+ ],
+ "headers": [
+ {
+ "source": "/firebase-messaging-sw.js",
+ "headers": [
+ { "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
+ ]
+ }
]
},
{
@@ -20,6 +28,14 @@
"source": "**",
"destination": "/index.html"
}
+ ],
+ "headers": [
+ {
+ "source": "/firebase-messaging-sw.js",
+ "headers": [
+ { "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
+ ]
+ }
]
},
{
@@ -31,6 +47,14 @@
"source": "**",
"destination": "/index.html"
}
+ ],
+ "headers": [
+ {
+ "source": "/firebase-messaging-sw.js",
+ "headers": [
+ { "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
+ ]
+ }
]
}
]
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index 0870e39c..7a868aaa 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -12,7 +12,7 @@
// DEBUG_LOG_VERSION は診断コード修正のたびに手動で bump する。client 側 debugLogger.ts の
// DEBUG_LOG_VERSION と対で更新。ログ各行に埋め込まれるので、実機で古い SW が動いているのか
// 新しい SW が動いているのかを共有ログから判別できる (SW 更新は非同期でユーザ操作依存なため)。
-const DEBUG_LOG_VERSION = 'v02-sw-2026-08-01';
+const DEBUG_LOG_VERSION = 'v03-sw-2026-08-01';
const DEBUG_DB = 'fcm-debug-log';
const DEBUG_STORE = 'entries';
const DEBUG_MAX = 500;
diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts
index 3bceea16..d3ac16e3 100644
--- a/frontend/src/hooks/useFcmNavigationListener.ts
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -128,6 +128,25 @@ export const useFcmNavigationListener = () => {
currentUrl: window.location.href,
clientVersion: DEBUG_LOG_VERSION,
});
+ // 全 SW registration の state を吐き出す。root scope (VitePWA) と
+ // /firebase-cloud-messaging-push-scope (FCM) の new/waiting/active を可視化して、
+ // 古い FCM SW が waiting のまま残ってないか確認する。
+ void (async () => {
+ if (!sw) return;
+ try {
+ const regs = await sw.getRegistrations();
+ void debugLog('CL', 'registrations', {
+ registrations: regs.map(r => ({
+ scope: r.scope,
+ active: r.active?.scriptURL ?? null,
+ waiting: r.waiting?.scriptURL ?? null,
+ installing: r.installing?.scriptURL ?? null,
+ })),
+ });
+ } catch (err) {
+ void debugLog('CL', 'getRegistrations fail', { err: String(err) });
+ }
+ })();
void applyIntent();
return () => {
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$/],
},
}),
],
From 240d71bf0dfc92c725f2a0421e8037be3fb7d005 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 08:57:18 +0000
Subject: [PATCH 11/17] =?UTF-8?q?fix(#207):=20extractDeepLink=20=E3=82=92?=
=?UTF-8?q?=20notification.click=5Faction=20=E3=81=AB=E3=83=95=E3=82=A9?=
=?UTF-8?q?=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
実機ログで判明: Firebase Admin SDK で WebpushFCMOptions(link=...) を指定
しても、client 側の SW に届く payload では data.FCM_MSG.fcmOptions.link
ではなく data.FCM_MSG.notification.click_action に URL が入っていた
(Firebase JS SDK 12.16.0)。
現状は fcmOptions.link のみ見ていたので link が常に null になり、SW
handler が発火しても deep link 抽出で早期 return していた。実 payload
に合わせて click_action を primary、fcmOptions.link と data.link を
fallback にする。
DEBUG_LOG_VERSION を v04 に bump。
---
frontend/public/firebase-messaging-sw.js | 13 +++++++++----
frontend/src/lib/debugLogger.ts | 2 +-
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index 7a868aaa..2aa025b1 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -12,7 +12,7 @@
// DEBUG_LOG_VERSION は診断コード修正のたびに手動で bump する。client 側 debugLogger.ts の
// DEBUG_LOG_VERSION と対で更新。ログ各行に埋め込まれるので、実機で古い SW が動いているのか
// 新しい SW が動いているのかを共有ログから判別できる (SW 更新は非同期でユーザ操作依存なため)。
-const DEBUG_LOG_VERSION = 'v03-sw-2026-08-01';
+const DEBUG_LOG_VERSION = 'v04-sw-2026-08-01';
const DEBUG_DB = 'fcm-debug-log';
const DEBUG_STORE = 'entries';
const DEBUG_MAX = 500;
@@ -57,11 +57,16 @@ const debugLog = async (tag, message, data) => {
// SW script が evaluate されたタイミングを残す。SW 更新のタイミング把握用。
void debugLog('SW', 'sw script evaluated');
-// Firebase Admin SDK の WebpushFCMOptions.link は data.FCM_MSG.fcmOptions.link に入る。
-// フォアグラウンド通知 (useForegroundNotificationToast) の自前 showNotification 経由は data.link。
+// FCM payload に載る deep link の位置は複数の可能性がある:
+// - `data.FCM_MSG.notification.click_action`: Firebase Admin SDK で WebpushFCMOptions(link=...) を
+// 指定した場合、SDK 12.x が実 payload に写す形。実機ログで確認済で **これが primary**
+// - `data.FCM_MSG.fcmOptions.link`: SDK バージョンや Admin SDK 経路によってはこちらに入るとの
+// 資料あり (残しておく安全策)
+// - `data.link`: フォアグラウンド通知 (useForegroundNotificationToast) の自前 showNotification 経由
const extractDeepLink = notification => {
const data = notification?.data ?? {};
- return data?.FCM_MSG?.fcmOptions?.link ?? data?.link ?? null;
+ const fcm = data?.FCM_MSG;
+ return fcm?.notification?.click_action ?? fcm?.fcmOptions?.link ?? data?.link ?? null;
};
// PWA / ブラウザ tab 両方 push 登録している端末で、ユーザが今触ってる方を選ぶための優先度。
diff --git a/frontend/src/lib/debugLogger.ts b/frontend/src/lib/debugLogger.ts
index 63c86cb7..c6bd5e0a 100644
--- a/frontend/src/lib/debugLogger.ts
+++ b/frontend/src/lib/debugLogger.ts
@@ -16,7 +16,7 @@ const MAX_ENTRIES = 500;
* 揃えて更新。ログの各行に埋め込まれるので、共有されたログのバージョンが古い環境か新しい環境か
* を確認しやすくする。
*/
-export const DEBUG_LOG_VERSION = 'v02-cl-2026-08-01';
+export const DEBUG_LOG_VERSION = 'v04-cl-2026-08-01';
interface LogEntry {
ts: number;
From 2ec66245fb1ee751704a1bdef5130d75557d68f2 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 09:09:20 +0000
Subject: [PATCH 12/17] =?UTF-8?q?fix(#207):=20FCM=20SW=20=E3=81=AB=20skipW?=
=?UTF-8?q?aiting=20+=20clients.claim=20=E8=BF=BD=E5=8A=A0=E3=81=A7?=
=?UTF-8?q?=E5=BC=B7=E5=88=B6=E5=8D=B3=E6=99=82=E5=88=87=E6=9B=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
v04 のログで判明: staging に v04 の SW file が deploy 済みなのに実機は
v02 SW が active のまま。Chrome の SW default lifecycle だと新版は
waiting → 既存 client 閉じるまで activate 待ち、で反映されない。
- install イベントで self.skipWaiting()
- activate イベントで self.clients.claim() で既存 client も掌握
- 各イベントに diagnostic ログを付けて実機で切替タイミングを可視化
- DEBUG_LOG_VERSION を v05 に bump
---
frontend/public/firebase-messaging-sw.js | 14 +++++++++++++-
frontend/src/lib/debugLogger.ts | 2 +-
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index 2aa025b1..f8a2c490 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -12,7 +12,7 @@
// DEBUG_LOG_VERSION は診断コード修正のたびに手動で bump する。client 側 debugLogger.ts の
// DEBUG_LOG_VERSION と対で更新。ログ各行に埋め込まれるので、実機で古い SW が動いているのか
// 新しい SW が動いているのかを共有ログから判別できる (SW 更新は非同期でユーザ操作依存なため)。
-const DEBUG_LOG_VERSION = 'v04-sw-2026-08-01';
+const DEBUG_LOG_VERSION = 'v05-sw-2026-08-01';
const DEBUG_DB = 'fcm-debug-log';
const DEBUG_STORE = 'entries';
const DEBUG_MAX = 500;
@@ -57,6 +57,18 @@ const debugLog = async (tag, message, data) => {
// SW script が evaluate されたタイミングを残す。SW 更新のタイミング把握用。
void debugLog('SW', 'sw script evaluated');
+// 新版 SW の active 待ち (デフォルト挙動) だと既存 tab が全部閉じるまで古い SW が動き続ける。
+// notificationclick handler / extractDeepLink の修正が実機に届かないケースが観測されたため
+// install → skipWaiting、activate → clients.claim で強制的に即切替させる。
+self.addEventListener('install', event => {
+ void debugLog('SW', 'install (skipWaiting)');
+ event.waitUntil(self.skipWaiting());
+});
+self.addEventListener('activate', event => {
+ void debugLog('SW', 'activate (clients.claim)');
+ event.waitUntil(self.clients.claim());
+});
+
// FCM payload に載る deep link の位置は複数の可能性がある:
// - `data.FCM_MSG.notification.click_action`: Firebase Admin SDK で WebpushFCMOptions(link=...) を
// 指定した場合、SDK 12.x が実 payload に写す形。実機ログで確認済で **これが primary**
diff --git a/frontend/src/lib/debugLogger.ts b/frontend/src/lib/debugLogger.ts
index c6bd5e0a..a0c95275 100644
--- a/frontend/src/lib/debugLogger.ts
+++ b/frontend/src/lib/debugLogger.ts
@@ -16,7 +16,7 @@ const MAX_ENTRIES = 500;
* 揃えて更新。ログの各行に埋め込まれるので、共有されたログのバージョンが古い環境か新しい環境か
* を確認しやすくする。
*/
-export const DEBUG_LOG_VERSION = 'v04-cl-2026-08-01';
+export const DEBUG_LOG_VERSION = 'v05-cl-2026-08-01';
interface LogEntry {
ts: number;
From 3cc9fe79f55133e61f05d229afdaafdae4050738 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 11:56:23 +0000
Subject: [PATCH 13/17] =?UTF-8?q?fix(#207):=20client=20=E5=81=B4=E3=81=8B?=
=?UTF-8?q?=E3=82=89=20FCM=20SW=20=E3=81=AE=20update=20=E3=82=92=E5=BC=B7?=
=?UTF-8?q?=E5=88=B6=E3=83=88=E3=83=AA=E3=82=AC=E3=83=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
v05 push 後も実機は v02 SW が active のまま。原因は FCM SW が root scope
に居ないため navigation で update check されず、register() も起動 1 回
のみで、24h の定期 check を待つ状態になっていた (実機で観測)。
- useFcmNavigationListener mount 時に FCM SW の registration に対して
reg.update() を明示的に呼ぶ。Chrome に byte-diff check を促す
- updatefound / statechange イベントを listen して、新 SW が installed →
waiting になった時に SKIP_WAITING message を送る (二重の保険)
- 既に waiting 状態の SW があれば即 SKIP_WAITING を送信
- firebase-messaging-sw.js に message listener を追加。
{ type: 'SKIP_WAITING' } を受けたら self.skipWaiting() を発火
- DEBUG_LOG_VERSION を v06 に bump
これで client mount 時に FCM SW の状態が可視化され、waiting なら強制
activate 経路が動く。v02 SW を追い出す最終手段。
---
frontend/public/firebase-messaging-sw.js | 11 +++++-
.../src/hooks/useFcmNavigationListener.ts | 38 +++++++++++++++++++
frontend/src/lib/debugLogger.ts | 2 +-
3 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index f8a2c490..98e6eb64 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -12,7 +12,7 @@
// DEBUG_LOG_VERSION は診断コード修正のたびに手動で bump する。client 側 debugLogger.ts の
// DEBUG_LOG_VERSION と対で更新。ログ各行に埋め込まれるので、実機で古い SW が動いているのか
// 新しい SW が動いているのかを共有ログから判別できる (SW 更新は非同期でユーザ操作依存なため)。
-const DEBUG_LOG_VERSION = 'v05-sw-2026-08-01';
+const DEBUG_LOG_VERSION = 'v06-sw-2026-08-01';
const DEBUG_DB = 'fcm-debug-log';
const DEBUG_STORE = 'entries';
const DEBUG_MAX = 500;
@@ -68,6 +68,15 @@ self.addEventListener('activate', event => {
void debugLog('SW', 'activate (clients.claim)');
event.waitUntil(self.clients.claim());
});
+// client 側 (useFcmNavigationListener) が既に waiting 状態の SW を叩き起こすためのハンドラ。
+// 未来の SW が仮に install で skipWaiting を呼ばない実装になっても、client 側からこの経路で
+// 強制 activate に持ち込めるよう保険を挟む。
+self.addEventListener('message', event => {
+ if (event.data && event.data.type === 'SKIP_WAITING') {
+ void debugLog('SW', 'SKIP_WAITING received');
+ void self.skipWaiting();
+ }
+});
// FCM payload に載る deep link の位置は複数の可能性がある:
// - `data.FCM_MSG.notification.click_action`: Firebase Admin SDK で WebpushFCMOptions(link=...) を
diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts
index d3ac16e3..586cc318 100644
--- a/frontend/src/hooks/useFcmNavigationListener.ts
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -128,6 +128,44 @@ export const useFcmNavigationListener = () => {
currentUrl: window.location.href,
clientVersion: DEBUG_LOG_VERSION,
});
+
+ // FCM SW は root scope に居ないので navigation では update check されず、
+ // register() は起動時 1 回のみ (lib/messaging.ts の promise cache のため)。
+ // 明示的に update() を叩いて Chrome に新 SW の byte-diff check を促し、
+ // waiting になった SW があれば SKIP_WAITING message で即 activate に持ち込む。
+ void (async () => {
+ if (!sw) return;
+ try {
+ const reg = await sw.getRegistration('/firebase-cloud-messaging-push-scope');
+ if (!reg) {
+ void debugLog('CL', 'fcm registration not found');
+ return;
+ }
+ reg.addEventListener('updatefound', () => {
+ const newSw = reg.installing;
+ void debugLog('CL', 'fcm updatefound', { installingUrl: newSw?.scriptURL ?? null });
+ newSw?.addEventListener('statechange', () => {
+ void debugLog('CL', 'fcm new sw statechange', { state: newSw.state });
+ if (newSw.state === 'installed' && reg.waiting) {
+ reg.waiting.postMessage({ type: 'SKIP_WAITING' });
+ void debugLog('CL', 'sent SKIP_WAITING (post-install)');
+ }
+ });
+ });
+ await reg.update();
+ void debugLog('CL', 'fcm update() done', {
+ activeUrl: reg.active?.scriptURL ?? null,
+ waitingUrl: reg.waiting?.scriptURL ?? null,
+ installingUrl: reg.installing?.scriptURL ?? null,
+ });
+ if (reg.waiting) {
+ reg.waiting.postMessage({ type: 'SKIP_WAITING' });
+ void debugLog('CL', 'sent SKIP_WAITING (existing waiting)');
+ }
+ } catch (err) {
+ void debugLog('CL', 'fcm update fail', { err: String(err) });
+ }
+ })();
// 全 SW registration の state を吐き出す。root scope (VitePWA) と
// /firebase-cloud-messaging-push-scope (FCM) の new/waiting/active を可視化して、
// 古い FCM SW が waiting のまま残ってないか確認する。
diff --git a/frontend/src/lib/debugLogger.ts b/frontend/src/lib/debugLogger.ts
index a0c95275..c78f949b 100644
--- a/frontend/src/lib/debugLogger.ts
+++ b/frontend/src/lib/debugLogger.ts
@@ -16,7 +16,7 @@ const MAX_ENTRIES = 500;
* 揃えて更新。ログの各行に埋め込まれるので、共有されたログのバージョンが古い環境か新しい環境か
* を確認しやすくする。
*/
-export const DEBUG_LOG_VERSION = 'v05-cl-2026-08-01';
+export const DEBUG_LOG_VERSION = 'v06-cl-2026-08-01';
interface LogEntry {
ts: number;
From 8260594ba50574201da3615aee83305ed24ced60 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 12:28:32 +0000
Subject: [PATCH 14/17] =?UTF-8?q?fix(#207):=20lastNavigatedRef=20=E3=81=AE?=
=?UTF-8?q?=E6=B0=B8=E7=B6=9A=E5=8C=96=E3=83=90=E3=82=B0=E3=82=92=E6=99=82?=
=?UTF-8?q?=E9=96=93=E7=AA=93=20dedupe=20=E3=81=AB=E5=A4=89=E6=9B=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
v06 ログで判明: navigateOnce の lastNavigatedRef が unmount まで保持
されるため「一度 navigate した URL には二度と navigate できない」バグが
あった。前セッションで /trip/A?focusBlock=X に遷移後、別 trip に移動して
再度 focusBlock=X の通知タップ → 両経路 (message / intent) とも
'skip (== last)' で navigate 発火せず。
- ref の型を { url, at: performance.now() } に変更
- dedupe 判定を「同 URL かつ 1 秒以内」に限定 (NAV_DEDUPE_WINDOW_MS)
- 1 通知タップに対する message + intent の近接発火は dedupe できる
- それを超えた同 URL 再遷移は許可される
- DEBUG_LOG_VERSION を v07 に bump
---
frontend/public/firebase-messaging-sw.js | 2 +-
frontend/src/hooks/useFcmNavigationListener.ts | 18 ++++++++++++------
frontend/src/lib/debugLogger.ts | 2 +-
3 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index 98e6eb64..66229f7d 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -12,7 +12,7 @@
// DEBUG_LOG_VERSION は診断コード修正のたびに手動で bump する。client 側 debugLogger.ts の
// DEBUG_LOG_VERSION と対で更新。ログ各行に埋め込まれるので、実機で古い SW が動いているのか
// 新しい SW が動いているのかを共有ログから判別できる (SW 更新は非同期でユーザ操作依存なため)。
-const DEBUG_LOG_VERSION = 'v06-sw-2026-08-01';
+const DEBUG_LOG_VERSION = 'v07-sw-2026-08-01';
const DEBUG_DB = 'fcm-debug-log';
const DEBUG_STORE = 'entries';
const DEBUG_MAX = 500;
diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts
index 586cc318..ee31a78b 100644
--- a/frontend/src/hooks/useFcmNavigationListener.ts
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -66,11 +66,15 @@ const parseSameOriginPath = (rawUrl: string): string | null => {
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
};
+// 1 通知タップに対して message + intent の 2 経路が近接発火するのを dedupe する時間窓。
+// これ以上経過したら別イベント扱いで同 URL への遷移も許可する (通知を連続タップした時等)。
+const NAV_DEDUPE_WINDOW_MS = 1000;
+
export const useFcmNavigationListener = () => {
const navigate = useNavigate();
- // fast path (message) と safety net (cache) が同一 URL に対して二重発火するのを防ぐ。
- // 直近の遷移先を保持し、同値なら skip する。
- const lastNavigatedRef = useRef(null);
+ // 直近の遷移先とタイムスタンプ。時間窓を過ぎたら別イベントとして再遷移を許可する。
+ // 以前は unmount まで保持する ref で「一度行った URL には二度と行けない」バグがあった。
+ const lastNavRef = useRef<{ url: string; at: number } | null>(null);
useEffect(() => {
let cancelled = false;
@@ -81,11 +85,13 @@ export const useFcmNavigationListener = () => {
void debugLog('CL', 'navigateOnce skip (== current)', { target, source });
return;
}
- if (lastNavigatedRef.current === target) {
- void debugLog('CL', 'navigateOnce skip (== last)', { target, source });
+ const last = lastNavRef.current;
+ const now = performance.now();
+ if (last !== null && last.url === target && now - last.at < NAV_DEDUPE_WINDOW_MS) {
+ void debugLog('CL', 'navigateOnce skip (dedupe window)', { target, source, ageMs: now - last.at });
return;
}
- lastNavigatedRef.current = target;
+ lastNavRef.current = { url: target, at: now };
void debugLog('CL', 'navigate() called', { target, source });
navigate(target);
};
diff --git a/frontend/src/lib/debugLogger.ts b/frontend/src/lib/debugLogger.ts
index c78f949b..7a1ecc5e 100644
--- a/frontend/src/lib/debugLogger.ts
+++ b/frontend/src/lib/debugLogger.ts
@@ -16,7 +16,7 @@ const MAX_ENTRIES = 500;
* 揃えて更新。ログの各行に埋め込まれるので、共有されたログのバージョンが古い環境か新しい環境か
* を確認しやすくする。
*/
-export const DEBUG_LOG_VERSION = 'v06-cl-2026-08-01';
+export const DEBUG_LOG_VERSION = 'v07-cl-2026-08-01';
interface LogEntry {
ts: number;
From e9ef6367ace00ea91f95334d4b6fb05b85d9fc01 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 14:36:06 +0000
Subject: [PATCH 15/17] =?UTF-8?q?chore(#207):=20Round=201=20=E5=89=8A?=
=?UTF-8?q?=E6=B8=9B=20-=20Cache=20safety=20net=20/=20dedupe=20/=20update?=
=?UTF-8?q?=20=E3=83=88=E3=83=AA=E3=82=AC=E3=83=BC=E5=89=8A=E9=99=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
切り分けタスク #12 の Round 1。以下を全部剥がして最小構成にした状態を
実機で検証する。3 シナリオ (foreground タップ / background push タップ
/ タスクキル状態 → タップ) 全 pass なら「全部不要」確定、fail なら
Round 2 で bisect する。
削減対象:
- Cache Storage safety net (SW storePendingIntent + client applyIntent
経路 + visibilitychange 消化)
- lastNavRef 時間窓 dedupe (safety net 削除で経路が 1 本になり不要のはず)
- SW 側 message ハンドラ (SKIP_WAITING 受信)
- client 側 registration.update() + updatefound listener + SKIP_WAITING
送信 (初回 v02→v07 移行専用の想定)
- client 側 getRegistrations() 診断ログ (SW 更新問題解決後は不要)
残した確定 fix:
- extractDeepLink の notification.click_action フォールバック
- SW の install → skipWaiting, activate → clients.claim
- postMessage → focus / なければ openWindow fallback
- 同 URL への navigate skip
test:
- Cache safety net 関連 4 test を削除
- 「現在 URL と一致するときは navigate しない」を追加
- 208 tests all pass
DEBUG_LOG_VERSION を v08 に bump。
---
frontend/public/firebase-messaging-sw.js | 38 +---
.../hooks/useFcmNavigationListener.test.tsx | 79 +--------
.../src/hooks/useFcmNavigationListener.ts | 162 ++----------------
frontend/src/lib/debugLogger.ts | 2 +-
4 files changed, 21 insertions(+), 260 deletions(-)
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index 66229f7d..a01f9ed9 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -12,7 +12,7 @@
// DEBUG_LOG_VERSION は診断コード修正のたびに手動で bump する。client 側 debugLogger.ts の
// DEBUG_LOG_VERSION と対で更新。ログ各行に埋め込まれるので、実機で古い SW が動いているのか
// 新しい SW が動いているのかを共有ログから判別できる (SW 更新は非同期でユーザ操作依存なため)。
-const DEBUG_LOG_VERSION = 'v07-sw-2026-08-01';
+const DEBUG_LOG_VERSION = 'v08-sw-2026-08-01';
const DEBUG_DB = 'fcm-debug-log';
const DEBUG_STORE = 'entries';
const DEBUG_MAX = 500;
@@ -68,15 +68,9 @@ self.addEventListener('activate', event => {
void debugLog('SW', 'activate (clients.claim)');
event.waitUntil(self.clients.claim());
});
-// client 側 (useFcmNavigationListener) が既に waiting 状態の SW を叩き起こすためのハンドラ。
-// 未来の SW が仮に install で skipWaiting を呼ばない実装になっても、client 側からこの経路で
-// 強制 activate に持ち込めるよう保険を挟む。
-self.addEventListener('message', event => {
- if (event.data && event.data.type === 'SKIP_WAITING') {
- void debugLog('SW', 'SKIP_WAITING received');
- void self.skipWaiting();
- }
-});
+// [Round 1 削減] SKIP_WAITING message handler は client 側の
+// registration.update() 経路と対で削除。install → skipWaiting のデフォルトルートのみで
+// 更新が成立するか検証する。
// FCM payload に載る deep link の位置は複数の可能性がある:
// - `data.FCM_MSG.notification.click_action`: Firebase Admin SDK で WebpushFCMOptions(link=...) を
@@ -97,24 +91,8 @@ const pickTargetClient = clientsList => {
);
};
-// postMessage 経路が届かない状況の safety net として target URL を Cache Storage に書き残す。
-// client 側 (useFcmNavigationListener) は mount + visibilitychange のたびに読んで navigate する。
-// 起こりうる postMessage 未着ケース:
-// - PWA が Android にキルされていて matchAll 上は phantom client、focus() では OS が task を
-// 復帰させるが postMessage は event loop で drain されない
-// - cold start で client 側 message listener 登録前に SW が postMessage 送出
-const INTENT_CACHE = 'fcm-nav-intent-v1';
-// Cache API は Request URL をキーに使うため、実在しない同 origin URL を予約して衝突を避ける。
-const INTENT_KEY = new URL('/__fcm_pending_nav__', self.location.origin).href;
-
-const storePendingIntent = async url => {
- try {
- const cache = await caches.open(INTENT_CACHE);
- await cache.put(new Request(INTENT_KEY), new Response(url, { headers: { 'content-type': 'text/plain' } }));
- } catch {
- // Cache API 非対応 / QuotaExceeded 等は best effort として無視
- }
-};
+// [Round 1 削減] Cache Storage safety net (storePendingIntent) と client 側 applyIntent 経路を削除。
+// SW 更新問題 (真の原因) が解決した今、postMessage + focus + openWindow fallback だけで十分か検証する。
self.addEventListener('notificationclick', event => {
event.notification.close();
@@ -139,10 +117,6 @@ self.addEventListener('notificationclick', event => {
event.waitUntil(
(async () => {
- // storage 完了を focus/openWindow より前に保証。client 側の visibilitychange で確実に拾える。
- await storePendingIntent(targetUrl.href);
- void debugLog('SW', 'intent stored', { url: targetUrl.href });
-
const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
void debugLog('SW', 'matchAll', {
n: clientsList.length,
diff --git a/frontend/src/hooks/useFcmNavigationListener.test.tsx b/frontend/src/hooks/useFcmNavigationListener.test.tsx
index a7d6d9e8..b249720b 100644
--- a/frontend/src/hooks/useFcmNavigationListener.test.tsx
+++ b/frontend/src/hooks/useFcmNavigationListener.test.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook, waitFor } from '@testing-library/react';
+import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockNavigate = vi.fn();
@@ -26,45 +26,14 @@ const dispatchSwMessage = (data: unknown) => {
for (const listener of swListeners) listener(event);
};
-// jsdom は Cache API 未実装なのでインメモリスタブを差し込む
-const cacheEntries = new Map();
-
-const cacheStub: Cache = {
- put: async (request: RequestInfo | URL, response: Response) => {
- const key = typeof request === 'string' ? request : (request as Request).url;
- cacheEntries.set(key, await response.text());
- },
- match: async (request: RequestInfo | URL) => {
- const key = typeof request === 'string' ? request : (request as Request).url;
- const value = cacheEntries.get(key);
- return value === undefined ? undefined : new Response(value);
- },
- delete: async (request: RequestInfo | URL) => {
- const key = typeof request === 'string' ? request : (request as Request).url;
- return cacheEntries.delete(key);
- },
-} as unknown as Cache;
-
-const cachesStub: CacheStorage = {
- open: async () => cacheStub,
-} as unknown as CacheStorage;
-
-const INTENT_KEY = 'http://localhost:3000/__fcm_pending_nav__';
-
-const seedPendingIntent = (url: string) => {
- cacheEntries.set(INTENT_KEY, url);
-};
-
describe('useFcmNavigationListener', () => {
beforeEach(() => {
swListeners.clear();
- cacheEntries.clear();
mockNavigate.mockClear();
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: swStub,
});
- vi.stubGlobal('caches', cachesStub);
});
afterEach(() => {
@@ -72,7 +41,6 @@ describe('useFcmNavigationListener', () => {
configurable: true,
value: undefined,
});
- vi.unstubAllGlobals();
});
it('FCM_NAVIGATE メッセージ受信で navigate() を pathname+search+hash 付きで呼ぶ', () => {
@@ -140,52 +108,11 @@ describe('useFcmNavigationListener', () => {
expect(swListeners.size).toBe(0);
});
- it('mount 時に Cache Storage に intent があれば navigate する (postMessage 未着 fallback)', async () => {
- seedPendingIntent('http://localhost:3000/trip/xyz?focusBlock=7');
-
+ it('現在 URL と一致するときは navigate しない', () => {
renderHook(() => useFcmNavigationListener());
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledExactlyOnceWith('/trip/xyz?focusBlock=7');
- });
- expect(cacheEntries.has(INTENT_KEY)).toBe(false);
- });
-
- it('mount 時に intent が現在 URL と一致するなら navigate しない', async () => {
- seedPendingIntent(window.location.href);
-
- renderHook(() => useFcmNavigationListener());
+ dispatchSwMessage({ type: 'FCM_NAVIGATE', url: window.location.href });
- await Promise.resolve();
- await Promise.resolve();
expect(mockNavigate).not.toHaveBeenCalled();
});
-
- it('visibilitychange (visible) で cache 内 intent を消化する', async () => {
- renderHook(() => useFcmNavigationListener());
-
- seedPendingIntent('http://localhost:3000/trip/later?focusBlock=99');
- Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
- act(() => {
- document.dispatchEvent(new Event('visibilitychange'));
- });
-
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledExactlyOnceWith('/trip/later?focusBlock=99');
- });
- });
-
- it('postMessage で navigate した場合は cache 内 intent も掃除する (二重 navigate 防止)', async () => {
- seedPendingIntent('http://localhost:3000/trip/abc?focusBlock=42');
-
- renderHook(() => useFcmNavigationListener());
-
- dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'http://localhost:3000/trip/abc?focusBlock=42' });
-
- await waitFor(() => {
- expect(cacheEntries.has(INTENT_KEY)).toBe(false);
- });
- // navigate は message 経由の 1 回のみ。mount 時 applyIntent は cache が既に空になってて no-op
- expect(mockNavigate).toHaveBeenCalledTimes(1);
- });
});
diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts
index ee31a78b..ec429542 100644
--- a/frontend/src/hooks/useFcmNavigationListener.ts
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from 'react';
+import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { DEBUG_LOG_VERSION, debugLog } from '@/lib/debugLogger';
@@ -7,54 +7,11 @@ import { DEBUG_LOG_VERSION, debugLog } from '@/lib/debugLogger';
* React Router で navigate する。
* App.tsx で 1 回のみ呼ぶ (Router の内側必須)。
*
- * 経路は 2 系統:
- * - fast path: SW からの postMessage `{ type: 'FCM_NAVIGATE', url }` を message event で受ける
- * - safety net: SW が Cache Storage (INTENT_CACHE / INTENT_KEY) に書き残した URL を
- * mount + visibilitychange のたびに読む。PWA が Android にキルされて postMessage が
- * 届かないケース / cold start で listener 未登録のケースを両方救う
- *
- * 両方の経路が同じ URL に対して発火するので、直近の遷移 URL を ref に持たせて重複 navigate を防ぐ。
+ * [Round 1 の削減版] postMessage 経路のみ。
+ * 元は Cache Storage safety net / dedupe / registration.update() 経路も持っていたが、
+ * SW 更新問題が根本 fix された今、safety net が本当に必要か検証する削減。
*/
-const INTENT_CACHE = 'fcm-nav-intent-v1';
-const INTENT_KEY_PATH = '/__fcm_pending_nav__';
-
-const intentKeyUrl = () => new URL(INTENT_KEY_PATH, window.location.origin).href;
-
-const openIntentCache = async (): Promise => {
- if (typeof caches === 'undefined') return null;
- try {
- return await caches.open(INTENT_CACHE);
- } catch {
- return null;
- }
-};
-
-const readAndClearPendingIntent = async (): Promise => {
- const cache = await openIntentCache();
- if (!cache) return null;
- const key = intentKeyUrl();
- try {
- const res = await cache.match(key);
- if (!res) return null;
- const url = await res.text();
- await cache.delete(key);
- return url;
- } catch {
- return null;
- }
-};
-
-const clearPendingIntent = async (): Promise => {
- const cache = await openIntentCache();
- if (!cache) return;
- try {
- await cache.delete(intentKeyUrl());
- } catch {
- // best effort
- }
-};
-
const parseSameOriginPath = (rawUrl: string): string | null => {
let parsed: URL;
try {
@@ -66,45 +23,10 @@ const parseSameOriginPath = (rawUrl: string): string | null => {
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
};
-// 1 通知タップに対して message + intent の 2 経路が近接発火するのを dedupe する時間窓。
-// これ以上経過したら別イベント扱いで同 URL への遷移も許可する (通知を連続タップした時等)。
-const NAV_DEDUPE_WINDOW_MS = 1000;
-
export const useFcmNavigationListener = () => {
const navigate = useNavigate();
- // 直近の遷移先とタイムスタンプ。時間窓を過ぎたら別イベントとして再遷移を許可する。
- // 以前は unmount まで保持する ref で「一度行った URL には二度と行けない」バグがあった。
- const lastNavRef = useRef<{ url: string; at: number } | null>(null);
useEffect(() => {
- let cancelled = false;
-
- const navigateOnce = (target: string, source: string) => {
- const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
- if (target === current) {
- void debugLog('CL', 'navigateOnce skip (== current)', { target, source });
- return;
- }
- const last = lastNavRef.current;
- const now = performance.now();
- if (last !== null && last.url === target && now - last.at < NAV_DEDUPE_WINDOW_MS) {
- void debugLog('CL', 'navigateOnce skip (dedupe window)', { target, source, ageMs: now - last.at });
- return;
- }
- lastNavRef.current = { url: target, at: now };
- void debugLog('CL', 'navigate() called', { target, source });
- navigate(target);
- };
-
- const applyIntent = async () => {
- const url = await readAndClearPendingIntent();
- void debugLog('CL', 'applyIntent', { url });
- if (cancelled || !url) return;
- const target = parseSameOriginPath(url);
- if (!target) return;
- navigateOnce(target, 'intent');
- };
-
// 参照を effect スコープに固定: cleanup 時に navigator.serviceWorker が消えていても
// (テスト環境で差し替えると起こる) removeEventListener で crash させない。
const sw = typeof navigator !== 'undefined' ? navigator.serviceWorker : undefined;
@@ -116,18 +38,16 @@ export const useFcmNavigationListener = () => {
if (typeof rawUrl !== 'string') return;
const target = parseSameOriginPath(rawUrl);
if (!target) return;
- // message 経由で消化するので safety net の Cache は掃除する (二重 navigate 防止)
- void clearPendingIntent();
- navigateOnce(target, 'message');
- };
-
- const onVisibility = () => {
- void debugLog('CL', 'visibilitychange', { state: document.visibilityState });
- if (document.visibilityState === 'visible') void applyIntent();
+ 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);
- document.addEventListener('visibilitychange', onVisibility);
void debugLog('CL', 'listener mounted', {
hasSw: !!sw,
swControllerUrl: sw?.controller?.scriptURL ?? null,
@@ -135,68 +55,8 @@ export const useFcmNavigationListener = () => {
clientVersion: DEBUG_LOG_VERSION,
});
- // FCM SW は root scope に居ないので navigation では update check されず、
- // register() は起動時 1 回のみ (lib/messaging.ts の promise cache のため)。
- // 明示的に update() を叩いて Chrome に新 SW の byte-diff check を促し、
- // waiting になった SW があれば SKIP_WAITING message で即 activate に持ち込む。
- void (async () => {
- if (!sw) return;
- try {
- const reg = await sw.getRegistration('/firebase-cloud-messaging-push-scope');
- if (!reg) {
- void debugLog('CL', 'fcm registration not found');
- return;
- }
- reg.addEventListener('updatefound', () => {
- const newSw = reg.installing;
- void debugLog('CL', 'fcm updatefound', { installingUrl: newSw?.scriptURL ?? null });
- newSw?.addEventListener('statechange', () => {
- void debugLog('CL', 'fcm new sw statechange', { state: newSw.state });
- if (newSw.state === 'installed' && reg.waiting) {
- reg.waiting.postMessage({ type: 'SKIP_WAITING' });
- void debugLog('CL', 'sent SKIP_WAITING (post-install)');
- }
- });
- });
- await reg.update();
- void debugLog('CL', 'fcm update() done', {
- activeUrl: reg.active?.scriptURL ?? null,
- waitingUrl: reg.waiting?.scriptURL ?? null,
- installingUrl: reg.installing?.scriptURL ?? null,
- });
- if (reg.waiting) {
- reg.waiting.postMessage({ type: 'SKIP_WAITING' });
- void debugLog('CL', 'sent SKIP_WAITING (existing waiting)');
- }
- } catch (err) {
- void debugLog('CL', 'fcm update fail', { err: String(err) });
- }
- })();
- // 全 SW registration の state を吐き出す。root scope (VitePWA) と
- // /firebase-cloud-messaging-push-scope (FCM) の new/waiting/active を可視化して、
- // 古い FCM SW が waiting のまま残ってないか確認する。
- void (async () => {
- if (!sw) return;
- try {
- const regs = await sw.getRegistrations();
- void debugLog('CL', 'registrations', {
- registrations: regs.map(r => ({
- scope: r.scope,
- active: r.active?.scriptURL ?? null,
- waiting: r.waiting?.scriptURL ?? null,
- installing: r.installing?.scriptURL ?? null,
- })),
- });
- } catch (err) {
- void debugLog('CL', 'getRegistrations fail', { err: String(err) });
- }
- })();
- void applyIntent();
-
return () => {
- cancelled = true;
sw?.removeEventListener('message', handler);
- document.removeEventListener('visibilitychange', onVisibility);
};
}, [navigate]);
};
diff --git a/frontend/src/lib/debugLogger.ts b/frontend/src/lib/debugLogger.ts
index 7a1ecc5e..3ebdf32d 100644
--- a/frontend/src/lib/debugLogger.ts
+++ b/frontend/src/lib/debugLogger.ts
@@ -16,7 +16,7 @@ const MAX_ENTRIES = 500;
* 揃えて更新。ログの各行に埋め込まれるので、共有されたログのバージョンが古い環境か新しい環境か
* を確認しやすくする。
*/
-export const DEBUG_LOG_VERSION = 'v07-cl-2026-08-01';
+export const DEBUG_LOG_VERSION = 'v08-cl-2026-08-01';
interface LogEntry {
ts: number;
From 5e6015d25e12115303af650ed8778431c8fc1e35 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 15:27:48 +0000
Subject: [PATCH 16/17] =?UTF-8?q?chore(#207):=20Round=202=20-=20registrati?=
=?UTF-8?q?on.update()=20=E3=81=A0=E3=81=91=20restore=20(Cache/dedupe=20?=
=?UTF-8?q?=E5=89=8A=E9=99=A4=E7=B6=99=E7=B6=9A)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round 1 (all remove) の結果、実機で v08 SW が active にならず Firebase
SDK デフォルト notificationclick 経由の messageType: "notification-clicked"
だけ流れる状態が再発。原因は削除した registration.update() が唯一の
FCM SW 更新経路だったため。
FCM SW は root scope 外なので:
- navigation 由来の update check が起きない
- register() の byte-diff check は起動 1 回のみ (promise cache)
- Chrome の 24h 定期 check を待つしかない
明示 update() が必須。ただし PWA セッション中に何度も呼ぶと noise なので、
module-level flag で 1 回だけ発火するよう制御。
- promoteFcmSwUpdate() 追加、module-level fcmSwUpdateTriggered で dedupe
- SKIP_WAITING message 経路は復元しない (v08 の install → skipWaiting で
今後の SW 更新はカバーされる想定)
- Cache Storage safety net と時間窓 dedupe は削除継続
- DEBUG_LOG_VERSION を v09 に bump
Round 2 判定: 3 シナリオ pass なら「C だけ必要、D+E は不要」確定。
---
frontend/public/firebase-messaging-sw.js | 2 +-
.../src/hooks/useFcmNavigationListener.ts | 33 +++++++++++++++++--
frontend/src/lib/debugLogger.ts | 2 +-
3 files changed, 32 insertions(+), 5 deletions(-)
diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js
index a01f9ed9..c57f9245 100644
--- a/frontend/public/firebase-messaging-sw.js
+++ b/frontend/public/firebase-messaging-sw.js
@@ -12,7 +12,7 @@
// DEBUG_LOG_VERSION は診断コード修正のたびに手動で bump する。client 側 debugLogger.ts の
// DEBUG_LOG_VERSION と対で更新。ログ各行に埋め込まれるので、実機で古い SW が動いているのか
// 新しい SW が動いているのかを共有ログから判別できる (SW 更新は非同期でユーザ操作依存なため)。
-const DEBUG_LOG_VERSION = 'v08-sw-2026-08-01';
+const DEBUG_LOG_VERSION = 'v09-sw-2026-08-01';
const DEBUG_DB = 'fcm-debug-log';
const DEBUG_STORE = 'entries';
const DEBUG_MAX = 500;
diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts
index ec429542..f7edc5ee 100644
--- a/frontend/src/hooks/useFcmNavigationListener.ts
+++ b/frontend/src/hooks/useFcmNavigationListener.ts
@@ -7,9 +7,11 @@ import { DEBUG_LOG_VERSION, debugLog } from '@/lib/debugLogger';
* React Router で navigate する。
* App.tsx で 1 回のみ呼ぶ (Router の内側必須)。
*
- * [Round 1 の削減版] postMessage 経路のみ。
- * 元は Cache Storage safety net / dedupe / registration.update() 経路も持っていたが、
- * SW 更新問題が根本 fix された今、safety net が本当に必要か検証する削減。
+ * [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 => {
@@ -23,6 +25,30 @@ const parseSameOriginPath = (rawUrl: string): string | 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();
@@ -54,6 +80,7 @@ export const useFcmNavigationListener = () => {
currentUrl: window.location.href,
clientVersion: DEBUG_LOG_VERSION,
});
+ if (sw) void promoteFcmSwUpdate(sw);
return () => {
sw?.removeEventListener('message', handler);
diff --git a/frontend/src/lib/debugLogger.ts b/frontend/src/lib/debugLogger.ts
index 3ebdf32d..3f8ce5f5 100644
--- a/frontend/src/lib/debugLogger.ts
+++ b/frontend/src/lib/debugLogger.ts
@@ -16,7 +16,7 @@ const MAX_ENTRIES = 500;
* 揃えて更新。ログの各行に埋め込まれるので、共有されたログのバージョンが古い環境か新しい環境か
* を確認しやすくする。
*/
-export const DEBUG_LOG_VERSION = 'v08-cl-2026-08-01';
+export const DEBUG_LOG_VERSION = 'v09-cl-2026-08-01';
interface LogEntry {
ts: number;
From 5f43d184ea7db44dcf48cca30aa26e251aa4dfe3 Mon Sep 17 00:00:00 2001
From: kuu13580 <13580kuu@gmail.com>
Date: Sat, 1 Aug 2026 15:55:34 +0000
Subject: [PATCH 17/17] =?UTF-8?q?docs(#207):=20=E5=AE=9F=E6=A9=9F=E6=A4=9C?=
=?UTF-8?q?=E8=A8=BC=E3=81=A7=E5=88=A4=E6=98=8E=E3=81=97=E3=81=9F=E4=BB=95?=
=?UTF-8?q?=E6=A7=98=E3=82=92=E3=83=A1=E3=83=A2=E3=81=AB=E6=AE=8B=E3=81=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Android PWA 実機で切り分け検証した結果のうち、確定した仕様のみを
docs/memo/ に集約。SW update lifecycle / VitePWA precache / FCM payload
の deep link 位置 / Firebase Hosting cache header / task-killed 挙動 /
block DOM 描画タイミングの 6 項目。
検証ブランチ (PR #216 / DO NOT MERGE) にのみ残るため、後続で参照したい
場合は develop へ手動で cherry-pick する。
---
docs/memo/notification_deeplink_findings.md | 44 +++++++++++++++++++++
1 file changed, 44 insertions(+)
create mode 100644 docs/memo/notification_deeplink_findings.md
diff --git a/docs/memo/notification_deeplink_findings.md b/docs/memo/notification_deeplink_findings.md
new file mode 100644
index 00000000..94e93238
--- /dev/null
+++ b/docs/memo/notification_deeplink_findings.md
@@ -0,0 +1,44 @@
+# 通知タップ deep link 実機検証で判明した仕様 (Issue #207)
+
+> 本メモは検証ブランチ `chore/issue207_debug-logger` (PR #216 / DO NOT MERGE)
+> で Android PWA 実機を使って一連の切り分けを行った結果、**検証で確定した仕様**
+> のみを記録する。実装の意思決定は #209 の commit / PR description に残す。
+
+## FCM SW の update lifecycle
+
+- **FCM SW は root scope 外 (`/firebase-cloud-messaging-push-scope`) に登録される**ため、navigation 由来の SW update check が発火しない
+- `navigator.serviceWorker.register()` の byte-diff check は PWA セッション中 1 回のみ (呼び出し側の promise cache により再呼び出しされない)
+- **明示 `registration.update()` が唯一の SW 更新経路**
+- SW default lifecycle だと new SW は install → waiting のまま、既存 client が全部閉じるまで activate されない
+- install イベントで `self.skipWaiting()` を呼ぶと即 waiting をスキップして activate に遷移する
+- activate イベントで `self.clients.claim()` を呼ぶと既存 client (root scope 含む) の controller にもなれる
+
+## VitePWA precache との干渉
+
+- `workbox.globPatterns: ['**/*.{js,...}']` は **firebase-messaging-sw.js を precache 対象に取り込む**
+- 結果、Chrome の `register('/firebase-messaging-sw.js')` 時に VitePWA sw.js の fetch handler が cache 経由で古い版を返す → 新 SW が実機で永遠に active にならない
+- **対策**: `workbox.globIgnores` + `navigateFallbackDenylist` で firebase-messaging-sw.js を除外
+
+## FCM payload に載る deep link の位置
+
+- Firebase Admin SDK v13 の `WebpushFCMOptions(link=...)` を指定して送ると、SDK v12.16 の client 側では **`data.FCM_MSG.notification.click_action`** に写された payload が届く
+- foreground 通知 (`useForegroundNotificationToast` の自前 `showNotification`) 経由の場合は **`data.link`** に相対 URL が入る (別経路)
+- extract 順は `click_action` を primary、`data.link` を fallback にする
+
+## Firebase Hosting のデフォルト cache header
+
+- 静的ファイルに `cache-control: max-age=3600` (1 時間) がデフォルトで付く
+- **SW file にこれが効くと deploy 直後でも update が実機に届くまで最悪 1 時間遅延する**
+- `firebase.json` の `headers` で `/firebase-messaging-sw.js` に `Cache-Control: no-cache, no-store, must-revalidate` を指定して抑制
+
+## task-killed 状態からの通知タップ
+
+- PWA を recents から swipe out した完全終了状態で通知タップすると、SW の `matchAll` は **0 件**を返す (phantom client を返さない)
+- `clients.openWindow(url)` で PWA が起動し、**渡した URL が尊重されて focusBlock まで適用**される (Android Chrome 実機で確認)
+- postMessage 未着ケース想定の Cache Storage safety net (SW → client の遅延伝達経路) は **不要**と確認された
+
+## block DOM の描画タイミング
+
+- `ViewTripLayout` は `useBlocks(pageId).isLoading` 中は `TimelineSkeleton` を返す構造 → 該当 page への `setSelectedPageId` 直後は `data-block-id` の DOM が未存在
+- rAF ポーリングだと Skeleton → Timeline 差し替え待ちで空回りする → **MutationObserver で DOM 変更 event 単位に補足**
+- staging Cold Start では Skeleton → Timeline 差し替えまで 3 秒を超えるケースがあり、**timeout は 8 秒**確保する