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. プラットフォーム対応 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 21174839..2ceca3c3 100644 --- a/frontend/public/firebase-messaging-sw.js +++ b/frontend/public/firebase-messaging-sw.js @@ -1,6 +1,74 @@ // 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 する +// ことがあるため、後付けだと invocation されないケースがある。 + +// SW default lifecycle だと install → waiting、既存 client が全部閉じるまで activate されない。 +// FCM SW は root scope 外なので待たせるとユーザ操作なしに切替できず更新が届かない。 +self.addEventListener('install', event => { + event.waitUntil(self.skipWaiting()); +}); +self.addEventListener('activate', event => { + event.waitUntil(self.clients.claim()); +}); + +// Firebase Admin SDK の WebpushFCMOptions(link=...) は SDK 12.x で +// data.FCM_MSG.notification.click_action に写される (fcmOptions.link ではない)。 +// data.link はフォアグラウンド通知 (useForegroundNotificationToast) の自前 showNotification 経由。 +const extractDeepLink = notification => { + const data = notification?.data ?? {}; + const fcm = data?.FCM_MSG; + return fcm?.notification?.click_action ?? fcm?.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; + } + + // task-killed 状態など matchAll が 0 件のときは openWindow が唯一の起動経路。 + // Chrome は URL を尊重して PWA を起動 (実機で verify 済み)。 + 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.test.tsx b/frontend/src/hooks/useFcmNavigationListener.test.tsx new file mode 100644 index 00000000..d32fe564 --- /dev/null +++ b/frontend/src/hooks/useFcmNavigationListener.test.tsx @@ -0,0 +1,119 @@ +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); + }, + getRegistration: async () => undefined, +}; + +const dispatchSwMessage = (data: unknown) => { + const event = { data } as MessageEvent; + for (const listener of swListeners) listener(event); +}; + +describe('useFcmNavigationListener', () => { + beforeEach(() => { + swListeners.clear(); + mockNavigate.mockClear(); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: swStub, + }); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: undefined, + }); + }); + + it('FCM_NAVIGATE メッセージ受信で navigate() を pathname+search+hash 付きで呼ぶ', () => { + renderHook(() => useFcmNavigationListener()); + + dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'http://localhost:3000/trip/abc?focusBlock=42#top' }); + + expect(mockNavigate).toHaveBeenCalledExactlyOnceWith('/trip/abc?focusBlock=42#top'); + }); + + it('FCM_NAVIGATE 以外のメッセージは無視する', () => { + renderHook(() => useFcmNavigationListener()); + + dispatchSwMessage({ type: 'SOME_OTHER', url: 'http://localhost:3000/trip/xxx' }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('別 origin の URL は無視する (open-redirect 対策)', () => { + renderHook(() => useFcmNavigationListener()); + + dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'https://evil.example.com/trip/abc' }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('非 http scheme (javascript:) は同 origin にならず遷移しない', () => { + renderHook(() => useFcmNavigationListener()); + + dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 'javascript:alert(1)' }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('url が string でなければ無視する', () => { + renderHook(() => useFcmNavigationListener()); + + dispatchSwMessage({ type: 'FCM_NAVIGATE', url: 42 }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('data が undefined でも throw しない', () => { + renderHook(() => useFcmNavigationListener()); + + expect(() => dispatchSwMessage(undefined)).not.toThrow(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('unmount 時に listener を解除する', () => { + const { unmount } = renderHook(() => useFcmNavigationListener()); + + expect(swListeners.size).toBe(1); + unmount(); + expect(swListeners.size).toBe(0); + }); + + it('navigator.serviceWorker が無い環境でも throw しない', () => { + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: undefined, + }); + + expect(() => renderHook(() => useFcmNavigationListener())).not.toThrow(); + expect(swListeners.size).toBe(0); + }); + + it('現在 URL と一致するときは navigate しない', () => { + renderHook(() => useFcmNavigationListener()); + + dispatchSwMessage({ type: 'FCM_NAVIGATE', url: window.location.href }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/useFcmNavigationListener.ts b/frontend/src/hooks/useFcmNavigationListener.ts new file mode 100644 index 00000000..ba30ec3d --- /dev/null +++ b/frontend/src/hooks/useFcmNavigationListener.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +/** + * firebase-messaging-sw.js の notificationclick が伝えてくる遷移先 URL を受けて + * React Router で navigate する。App.tsx で 1 回のみ呼ぶ (Router の内側必須)。 + */ + +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}`; +}; + +// FCM SW は root scope 外なので navigation 由来の update check が発火せず、register() の +// byte-diff check も起動 1 回のみ (promise cache のため)。明示 update() が唯一の SW 更新経路。 +// PWA セッション中に 1 回だけ呼ぶよう module-level flag で dedupe する。 +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'); + await reg?.update(); + } catch { + // best effort + } +}; + +export const useFcmNavigationListener = () => { + const navigate = useNavigate(); + + useEffect(() => { + // 参照を effect スコープに固定: cleanup 時に navigator.serviceWorker が消えていても + // (テスト環境で差し替えると起こる) removeEventListener で crash させない。 + const sw = typeof navigator !== 'undefined' ? navigator.serviceWorker : undefined; + + const handler = (event: MessageEvent) => { + if (event.data?.type !== 'FCM_NAVIGATE') return; + const rawUrl = event.data.url; + if (typeof rawUrl !== 'string') return; + const target = parseSameOriginPath(rawUrl); + if (!target) return; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (target === current) return; + navigate(target); + }; + + sw?.addEventListener('message', handler); + if (sw) void promoteFcmSwUpdate(sw); + + return () => { + sw?.removeEventListener('message', handler); + }; + }, [navigate]); +}; diff --git a/frontend/src/hooks/useFocusBlockOnMount.test.tsx b/frontend/src/hooks/useFocusBlockOnMount.test.tsx new file mode 100644 index 00000000..cf024a35 --- /dev/null +++ b/frontend/src/hooks/useFocusBlockOnMount.test.tsx @@ -0,0 +1,324 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { HttpResponse, http } from 'msw'; +import type { ReactNode } from 'react'; +import { BrowserRouter, MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { selectedPageIdAtom } from '@/atoms/tripPage'; +import { useFocusBlockOnMount } from '@/hooks/useFocusBlockOnMount'; +import { appStore } from '@/lib/store'; +import { server } from '../../tests/msw/server'; + +// jsdom は scrollIntoView を提供しないので prototype に stub を生やす +const scrollIntoViewMock = vi.fn(); +Element.prototype.scrollIntoView = scrollIntoViewMock as unknown as Element['scrollIntoView']; + +const scheduleBlockJson = (id: number, pageId: number) => ({ + id, + page_id: pageId, + block_type: 'event' as const, + title: 'schedule block', + start_time: '2026-01-01T09:00:00', + end_time: '2026-01-01T10:00:00', + detail: null, + location_id: null, + location: null, +}); + +const LocationCapture = ({ onLocation }: { onLocation: (search: string) => void }) => { + const location = useLocation(); + onLocation(location.search); + return null; +}; + +const HookHost = () => { + useFocusBlockOnMount(); + return
target
; +}; + +const renderWithRouter = (initialPath: string, onLocation: (search: string) => void) => { + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + + + + + + ); + return render(, { wrapper: Wrapper }); +}; + +describe('useFocusBlockOnMount', () => { + beforeEach(() => { + appStore.set(selectedPageIdAtom, undefined); + scrollIntoViewMock.mockReset(); + }); + + it('?focusBlock が無ければ何もしない', async () => { + let currentSearch = ''; + renderWithRouter('/trip/abc', s => { + currentSearch = s; + }); + + // マウント直後に副作用が完了する + await waitFor(() => { + expect(currentSearch).toBe(''); + }); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(appStore.get(selectedPageIdAtom)).toBeUndefined(); + }); + + it('非数値の focusBlock は block fetch せず query param のみ掃除する', async () => { + let apiCalled = false; + server.use( + http.get('*/blocks/*', () => { + apiCalled = true; + return HttpResponse.json({}); + }) + ); + + let currentSearch = 'initial'; + renderWithRouter('/trip/abc?focusBlock=abc', s => { + currentSearch = s; + }); + + await waitFor(() => { + expect(currentSearch).toBe(''); + }); + expect(apiCalled).toBe(false); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(appStore.get(selectedPageIdAtom)).toBeUndefined(); + }); + + it('block ロード成功時に selectedPageId 切り替え + scrollIntoView + query param 掃除を行う', async () => { + server.use(http.get('*/blocks/42', () => HttpResponse.json(scheduleBlockJson(42, 7)))); + + let currentSearch = 'initial'; + renderWithRouter('/trip/abc?focusBlock=42', s => { + currentSearch = s; + }); + + await waitFor(() => { + expect(appStore.get(selectedPageIdAtom)).toBe(7); + }); + await waitFor(() => { + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }); + }); + await waitFor(() => { + expect(currentSearch).toBe(''); + }); + }); + + it('block が 404 の場合は selectedPageId を変えず query param のみ掃除する', async () => { + server.use(http.get('*/blocks/999', () => new HttpResponse(null, { status: 404 }))); + + let currentSearch = 'initial'; + renderWithRouter('/trip/abc?focusBlock=999', s => { + currentSearch = s; + }); + + await waitFor(() => { + expect(currentSearch).toBe(''); + }); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(appStore.get(selectedPageIdAtom)).toBeUndefined(); + }); + + it('同一マウント中に focusBlock が 42→99 と変わったら 2 回目も再処理する', async () => { + server.use( + http.get('*/blocks/42', () => HttpResponse.json(scheduleBlockJson(42, 7))), + http.get('*/blocks/99', () => HttpResponse.json(scheduleBlockJson(99, 11))) + ); + + // SW → postMessage → useFcmNavigationListener の navigate() で同一マウント中に + // URL の focusBlock が差し替わるフローを再現するため、テスト内で navigate を外に取り出す。 + const navigateHandle: { current: ((to: string) => void) | null } = { current: null }; + const NavigateHandle = () => { + navigateHandle.current = useNavigate(); + return null; + }; + + const searchRef = { current: '' }; + const captureSearch = (s: string) => { + searchRef.current = s; + }; + + // 初期 URL を含めて BrowserRouter に載せる (window.history 経由で navigate 可能) + window.history.replaceState({}, '', '/trip/abc?focusBlock=42'); + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + + + + + + + ); + + render( + <> + +
target-99
+ , + { wrapper: Wrapper } + ); + + // 1 回目: focusBlock=42 の処理完了を待つ + await waitFor(() => { + expect(appStore.get(selectedPageIdAtom)).toBe(7); + }); + await waitFor(() => { + expect(searchRef.current).toBe(''); + }); + await waitFor(() => { + expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); + }); + + // 2 回目: SPA navigate で URL に別の focusBlock を差し込む + scrollIntoViewMock.mockClear(); + act(() => { + navigateHandle.current?.('/trip/abc?focusBlock=99'); + }); + + await waitFor(() => { + expect(appStore.get(selectedPageIdAtom)).toBe(11); + }); + await waitFor(() => { + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }); + }); + await waitFor(() => { + expect(searchRef.current).toBe(''); + }); + }); + + it('focusBlock=0 は block ID として無効なので query 掃除だけ行う', async () => { + let apiCalled = false; + server.use( + http.get('*/blocks/*', () => { + apiCalled = true; + return HttpResponse.json({}); + }) + ); + + let currentSearch = 'initial'; + renderWithRouter('/trip/abc?focusBlock=0', s => { + currentSearch = s; + }); + + await waitFor(() => { + expect(currentSearch).toBe(''); + }); + expect(apiCalled).toBe(false); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(appStore.get(selectedPageIdAtom)).toBeUndefined(); + }); + + it('同じ focusBlock=42 が clearParam 後にもう一度来ても再処理する', async () => { + server.use(http.get('*/blocks/42', () => HttpResponse.json(scheduleBlockJson(42, 7)))); + + const navigateHandle: { current: ((to: string) => void) | null } = { current: null }; + const NavigateHandle = () => { + navigateHandle.current = useNavigate(); + return null; + }; + + const searchRef = { current: '' }; + const captureSearch = (s: string) => { + searchRef.current = s; + }; + + window.history.replaceState({}, '', '/trip/abc?focusBlock=42'); + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + + + + + + + ); + + render( + <> + + , + { wrapper: Wrapper } + ); + + // 1 回目: focusBlock=42 の処理完了 (page 切替 + scroll + clearParam) を待つ + await waitFor(() => { + expect(appStore.get(selectedPageIdAtom)).toBe(7); + }); + await waitFor(() => { + expect(searchRef.current).toBe(''); + }); + await waitFor(() => { + expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); + }); + + // 2 回目: URL が /trip/abc に戻った状態から、同じ block へ再通知タップを再現 + scrollIntoViewMock.mockClear(); + appStore.set(selectedPageIdAtom, undefined); + act(() => { + navigateHandle.current?.('/trip/abc?focusBlock=42'); + }); + + await waitFor(() => { + expect(appStore.get(selectedPageIdAtom)).toBe(7); + }); + await waitFor(() => { + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }); + }); + await waitFor(() => { + expect(searchRef.current).toBe(''); + }); + }); + + it('block DOM 出現前に unmount された場合は scroll しない (AbortSignal で observer/timer/rAF が解放される)', async () => { + server.use(http.get('*/blocks/42', () => HttpResponse.json(scheduleBlockJson(42, 7)))); + + // data-block-id を持たない host。waitForBlockElement が MutationObserver で待機し続ける状態を作る。 + const HookHostWithoutTarget = () => { + useFocusBlockOnMount(); + return null; + }; + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + + + + + ); + + const { unmount } = render(, { wrapper: Wrapper }); + + // block 取得 → selectedPageId 切替までは進むが、DOM に data-block-id が無く scroll 待機のまま + await waitFor(() => { + expect(appStore.get(selectedPageIdAtom)).toBe(7); + }); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + + // 待機中に unmount。以降 element を追加しても scroll は発火しないはず。 + unmount(); + const el = document.createElement('div'); + el.setAttribute('data-block-id', '42'); + document.body.appendChild(el); + + // MutationObserver が abort で解放されていることを検証するため少し待つ。 + await new Promise(resolve => setTimeout(resolve, 50)); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + + document.body.removeChild(el); + }); +}); diff --git a/frontend/src/hooks/useFocusBlockOnMount.ts b/frontend/src/hooks/useFocusBlockOnMount.ts new file mode 100644 index 00000000..1067fbd2 --- /dev/null +++ b/frontend/src/hooks/useFocusBlockOnMount.ts @@ -0,0 +1,134 @@ +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'; +// 通知タップ → 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; + +// AbortSignal で observer / timer / rAF をまとめて解放できるようにする。cleanup の taskkill 経路が +// unmount / focusBlock 変更 / StrictMode double-fire で確実に停止する。 +const waitForBlockElement = (blockId: number, maxMs: number, signal: AbortSignal): Promise => + new Promise(resolve => { + const selector = `[data-block-id="${blockId}"]`; + if (signal.aborted) return resolve(null); + + 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); + signal.removeEventListener('abort', onAbort); + resolve(el); + }; + const onAbort = () => finish(null); + + const observer = new MutationObserver(() => { + const el = document.querySelector(selector); + if (el) finish(el); + }); + observer.observe(document.body, { childList: true, subtree: true }); + timeoutId = setTimeout(() => finish(null), maxMs); + signal.addEventListener('abort', onAbort); + }); + +// layout flush を 1 フレーム待ってから scroll する。Timeline 差し替え直後は要素の位置計算が +// まだ確定していないことがあり、そのタイミングで scrollIntoView すると外れる。 +// +// rAF は明示 cancel しない (AbortSignal 対象外)。abort 後に fire するパターンは 2 つあるが、 +// いずれも実害が小さい: +// - unmount 経由の abort: element が detach 済で scrollIntoView は no-op +// - focusBlock 変化経由の abort: 元 element に一瞬 scroll した後、新しい rAF が上書き +// 逆に rAF を signal で cancel すると、clearParam() 直後の自 cleanup で成功パスの scroll +// まで潰れるため、そちらの副作用の方が大きい。 +const scrollIntoViewOnNextFrame = (el: HTMLElement) => { + requestAnimationFrame(() => { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); +}; + +// block ID は BIGSERIAL PRIMARY KEY (正の整数) のみ有効。"0" / 桁溢れ / 非数値は不正値扱いで +// query 掃除だけ行う。useBlock(0) が SWR の falsy key で fetch を止めるため、そのまま 0 を渡すと +// block も error も来ず ref も query も更新されずスタックする。 +const parseFocusBlockId = (raw: string | null): number | null => { + if (raw == null || !/^[1-9]\d*$/.test(raw)) return null; + const n = Number(raw); + return Number.isSafeInteger(n) ? n : null; +}; + +export const useFocusBlockOnMount = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const rawFocusBlock = searchParams.get(FOCUS_BLOCK_PARAM); + const focusBlockId = parseFocusBlockId(rawFocusBlock); + const hasInvalidParam = rawFocusBlock != null && focusBlockId == null; + + const setSelectedPageId = useSetAtom(selectedPageIdAtom); + const { block, error } = useBlock(focusBlockId); + // 直前の rawFocusBlock を保持し、値が変わるまで再処理をブロックする。ref なので rerender は起こさない。 + // - TripPage は SPA navigate で unmount しないので、連続通知 (42 → 99) の 2 回目を処理する + // - 消費済み記録は uncancelled 完了後 (async 内) にセット。StrictMode の double-fire で最初の async が + // cancelled になっても 2 度目で確実に完走するため + // - query 掃除後 (rawFocusBlock === null) は ref をリセット。同一 block の再通知にも応答するため + const consumedKeyRef = useRef(null); + + useEffect(() => { + if (rawFocusBlock === null) { + consumedKeyRef.current = null; + return; + } + if (consumedKeyRef.current === rawFocusBlock) return; + + const clearParam = () => + setSearchParams( + prev => { + const next = new URLSearchParams(prev); + next.delete(FOCUS_BLOCK_PARAM); + return next; + }, + { replace: true } + ); + + if (hasInvalidParam) { + consumedKeyRef.current = rawFocusBlock; + clearParam(); + return; + } + if (focusBlockId == null) return; + if (error) { + consumedKeyRef.current = rawFocusBlock; + clearParam(); + return; + } + if (!block) return; + + setSelectedPageId(block.pageId); + + const controller = new AbortController(); + (async () => { + const el = await waitForBlockElement(focusBlockId, SCROLL_WAIT_MAX_MS, controller.signal); + if (controller.signal.aborted) return; + if (el) scrollIntoViewOnNextFrame(el); + clearParam(); + consumedKeyRef.current = rawFocusBlock; + })(); + + return () => { + controller.abort(); + }; + }, [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; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fca6f338..217c1c55 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -64,6 +64,11 @@ export default defineConfig({ workbox: { navigateFallback: '/index.html', globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + // firebase-messaging-sw.js は別 scope で登録される独立 SW。VitePWA sw.js の + // precache に紛れ込むと、Chrome の register 時に fetch handler が cache 経由で + // 古い版を返し続けて更新が実機に届かなくなる。glob から除外必須。 + globIgnores: ['**/firebase-messaging-sw.js'], + navigateFallbackDenylist: [/^\/firebase-messaging-sw\.js$/], }, }), ],