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 秒**確保する