fix(mobile): replay cold-start notification taps after auth bootstrap

This commit is contained in:
Yun Chan 2026-09-28 02:16:24 +09:00
parent 7a8e1e3e25
commit 9a5333c617
5 changed files with 396 additions and 22 deletions

View file

@ -0,0 +1,92 @@
import type { NotificationNavigationTarget } from './notification-contract'
/**
* What the navigation shell can currently accept. Kept as plain booleans so
* the routing policy stays independent of React, auth and navigation code.
*/
export interface NotificationNavigationReadiness {
/** The NavigationContainer is mounted and can dispatch actions. */
navigationReady: boolean
/**
* Auth and preferences bootstrap finished, a user is signed in and the
* authenticated app stack (not onboarding / password recovery) is mounted.
*/
authenticatedAppReady: boolean
}
/**
* Invitation links are the only targets reachable while signed out; every
* other notification points at account data and must wait for the
* authenticated stack.
*/
export function canNavigateToNotificationTarget(
target: NotificationNavigationTarget,
readiness: NotificationNavigationReadiness,
): boolean {
if (!readiness.navigationReady) return false
return target.name === 'InviteAccept' || readiness.authenticatedAppReady
}
/**
* A notification opened while signed out (or during a cold start) may wait
* for a login, but not forever: a stale tap must not hijack navigation long
* after the user moved on.
*/
export const PENDING_NOTIFICATION_TARGET_TTL_MS = 10 * 60 * 1000
export interface NotificationNavigationQueue {
/** Remember the latest opened notification target (latest wins). */
offer(target: NotificationNavigationTarget): void
/**
* Remove and return the pending target when the shell can navigate to it
* now; otherwise keep it for a later attempt and return null.
*/
take(readiness: NotificationNavigationReadiness): NotificationNavigationTarget | null
clear(): void
hasPending(): boolean
}
interface QueueOptions {
now?: () => number
ttlMs?: number
}
export function createNotificationNavigationQueue({
now = Date.now,
ttlMs = PENDING_NOTIFICATION_TARGET_TTL_MS,
}: QueueOptions = {}): NotificationNavigationQueue {
let pending: { target: NotificationNavigationTarget; offeredAt: number } | null = null
const dropExpired = (): void => {
if (pending !== null && now() - pending.offeredAt > ttlMs) pending = null
}
return {
offer(target) {
pending = { target, offeredAt: now() }
},
take(readiness) {
dropExpired()
if (pending === null) return null
if (!canNavigateToNotificationTarget(pending.target, readiness)) return null
const { target } = pending
pending = null
return target
},
clear() {
pending = null
},
hasPending() {
dropExpired()
return pending !== null
},
}
}
/**
* App-lifetime queue. It deliberately lives outside React: the native
* initial-notification payload is one-shot, and the root navigator can
* unmount and remount while auth/preferences bootstrap (for example when the
* preferences owner switches from the installation to the signed-in user).
*/
export const notificationNavigationQueue = createNotificationNavigationQueue()

View file

@ -0,0 +1,40 @@
/**
* Suppresses duplicate deliveries of the same opened notification (the
* initial-notification read and the opened event can both report one tap).
*
* A key is only recorded as handled once the caller confirms delivery, so a
* failed or abandoned attempt never blocks a later retry of the same tap.
*/
export interface NotificationOpenDedupe {
/** Returns false when the key was already delivered or is being delivered. */
begin(key: string): boolean
/** Mark the key as delivered. */
complete(key: string): void
/** Release an in-flight key without recording it (delivery failed). */
abort(key: string): void
}
export const NOTIFICATION_OPEN_DEDUPE_LIMIT = 100
export function createNotificationOpenDedupe(
limit = NOTIFICATION_OPEN_DEDUPE_LIMIT,
): NotificationOpenDedupe {
let handled = new Set<string>()
const inFlight = new Set<string>()
return {
begin(key) {
if (handled.has(key) || inFlight.has(key)) return false
inFlight.add(key)
return true
},
complete(key) {
inFlight.delete(key)
handled.add(key)
if (handled.size > limit) handled = new Set([key])
},
abort(key) {
inFlight.delete(key)
},
}
}

View file

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useAuth } from '../../lib/auth-context'
import { useDevice } from '../../lib/device-context'
import { savePendingInviteToken } from '../teams/pending-invite'
@ -14,9 +14,16 @@ import {
onNotificationOpened,
onPushRegistrationChanged,
} from './notification-native'
import { createNotificationOpenDedupe } from './notification-open-dedupe'
import { synchronizePushRegistration } from './notification-service'
interface NotificationRuntimeOptions {
/**
* Receives every opened notification target. It may be invoked after the
* caller re-rendered or unmounted (the native initial payload is one-shot),
* so it must accept the target even when it cannot navigate yet — e.g. by
* queueing it until the navigation shell is ready.
*/
navigate: (target: NotificationNavigationTarget) => void
onFullSyncRequired?: () => Promise<boolean>
onError?: (error: unknown) => void
@ -30,7 +37,6 @@ export function useNotificationRuntime({
const { user } = useAuth()
const { currentDevice } = useDevice()
const authRef = useRef({ user, currentDevice })
const handledRef = useRef(new Set<string>())
authRef.current = { user, currentDevice }
useEffect(() => {
@ -63,23 +69,34 @@ export function useNotificationRuntime({
}
}, [currentDevice, onError, onFullSyncRequired, user])
// The native initial notification is one-shot (the module clears it on
// read), so the open listener must not be torn down and re-registered each
// time the caller's navigate callback changes identity: a payload resolved
// by a cancelled effect would otherwise be dropped. Always hand targets to
// the latest callback instead.
const navigateRef = useRef(navigate)
const onErrorRef = useRef(onError)
const [dedupe] = useState(createNotificationOpenDedupe)
navigateRef.current = navigate
onErrorRef.current = onError
useEffect(() => {
let active = true
const handle = async (payload: Record<string, string>): Promise<void> => {
let dedupeKey: string | null = null
try {
const notification = parseNotificationPayload(payload)
const dedupeKey = `${notification.eventType}:${notification.resourceId}`
if (handledRef.current.has(dedupeKey)) return
handledRef.current.add(dedupeKey)
if (handledRef.current.size > 100) {
handledRef.current = new Set([dedupeKey])
}
const key = `${notification.eventType}:${notification.resourceId}`
if (!dedupe.begin(key)) return
dedupeKey = key
if (notification.eventType === 'team.invite.created') {
await savePendingInviteToken(notification.inviteToken)
}
if (active) navigate(notificationNavigationTarget(notification))
navigateRef.current(notificationNavigationTarget(notification))
dedupe.complete(key)
} catch (error) {
if (active) onError?.(error)
if (dedupeKey !== null) dedupe.abort(dedupeKey)
if (active) onErrorRef.current?.(error)
}
}
@ -88,12 +105,12 @@ export function useNotificationRuntime({
.then((payload) => {
if (payload !== null) return handle(payload)
})
.catch((error) => { if (active) onError?.(error) })
.catch((error) => { if (active) onErrorRef.current?.(error) })
return () => {
active = false
opened.remove()
}
}, [navigate, onError])
}, [dedupe])
useEffect(() => {
let active = true