Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
2 changed files with 49 additions and 15 deletions
Showing only changes of commit c7ef5811d1 - Show all commits
@@ -83,6 +83,11 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
// subscribe(registration, vapidKey) without any await before pushManager.subscribe().
// Button is disabled until the key is ready (null = not yet loaded).
const [vapidKey, setVapidKey] = useState<string | null>(null)
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the tap
// handler has ZERO awaits between the user gesture and pushManager.subscribe().
// Any await (including navigator.serviceWorker.ready) between the tap and
// pushManager.subscribe() breaks the iOS user-gesture requirement.
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null)
const headingId = useId()
const { subscribe, permission } = usePushSubscription()
@@ -103,6 +108,20 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
})
}, [installed, permission, dismissed])
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration in a useEffect so the
// tap handler never has to await navigator.serviceWorker.ready.
// navigator.serviceWorker.ready resolves once the SW is active; doing this
// eagerly means the result is in state before the user can tap the button.
useEffect(() => {
if (!installed) return
if (permission !== 'default') return
if (dismissed) return
if (!navigator.serviceWorker) return
void navigator.serviceWorker.ready.then((reg) => {
setSwRegistration(reg)
})
}, [installed, permission, dismissed])
// Don't render when: not installed, already granted/denied, or dismissed
if (!installed) return null
if (permission !== 'default') return null
@@ -115,20 +134,21 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
}
// onClick handler — subscribe() called synchronously (iOS user-gesture requirement).
// CR-04: vapidKey is already resolved from state (pre-fetched in useEffect above).
// No await before subscribe() in this scope; we pass the key directly.
// NEW-CR-01: Both vapidKey AND swRegistration are pre-resolved in state (useEffects above).
// There is ZERO await between the tap gesture and registration.pushManager.subscribe()
// inside subscribe() — the iOS gesture gate is fully satisfied.
function handleEnableClick() {
if (loading || !vapidKey) return
if (loading || !vapidKey || !swRegistration) return
setLoading(true)
setError(null)
// Capture the key synchronously before any await — iOS gesture gate requirement.
// Capture both synchronously — no await in this scope before subscribe().
const resolvedVapidKey = vapidKey
const resolvedRegistration = swRegistration
void (async () => {
try {
const registration = await navigator.serviceWorker.ready
await subscribe(registration, resolvedVapidKey)
await subscribe(resolvedRegistration, resolvedVapidKey)
// On success: close the prompt (permission is now 'granted')
setLoading(false)
onClose?.()
@@ -273,10 +293,10 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
}}
>
{/* Primary CTA — 48px, accent color */}
{/* CR-04: disabled until vapidKey is loaded (null = pre-fetch pending) */}
{/* NEW-CR-01: disabled until BOTH vapidKey and swRegistration are ready */}
<button
onClick={handleEnableClick}
disabled={loading || !vapidKey}
disabled={loading || !vapidKey || !swRegistration}
aria-busy={loading}
style={{
background: 'var(--color-member-0, #4A90D9)',
+21 -7
View File
@@ -54,6 +54,9 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
// CR-04: pre-fetch the VAPID key into state so the toggle tap handler can call
// subscribe(registration, vapidKey) without any network await before pushManager.subscribe().
const [vapidKey, setVapidKey] = useState<string | null>(null)
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the toggle
// tap handler has ZERO awaits between the user gesture and pushManager.subscribe().
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
// Compute initial toggle on/off state per UI-SPEC toggle initial state rule:
@@ -87,6 +90,18 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
})
}, [isOpen, permission])
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the toggle
// tap handler never needs to await navigator.serviceWorker.ready. Any await
// between the tap gesture and pushManager.subscribe() breaks iOS.
useEffect(() => {
if (!isOpen) return
if (permission === 'denied') return
if (!navigator.serviceWorker) return
void navigator.serviceWorker.ready.then((reg) => {
setSwRegistration(reg)
})
}, [isOpen, permission])
if (!isOpen) return null
const handleToggle = async () => {
@@ -106,16 +121,15 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
} else {
// off → on, permission 'default': needs tap-gated subscribe with OS dialog
// The toggle click IS the tap gesture — call subscribe() directly here.
// CR-04: vapidKey was pre-fetched into state (useEffect above); pass it directly
// so there is no await-fetch between the tap and pushManager.subscribe().
if (!vapidKey) return // key not ready — this should be rare; button should show spinner
// NEW-CR-01: Both vapidKey AND swRegistration are pre-resolved into state
// (useEffects above). There is ZERO await between the tap and
// registration.pushManager.subscribe() — iOS gesture gate satisfied.
if (!vapidKey || !swRegistration) return // not ready — useEffects still resolving
const resolvedVapidKey = vapidKey
const resolvedRegistration = swRegistration
setIsTogglingOn(true)
try {
const registration = await navigator.serviceWorker?.ready
if (registration) {
await subscribe(registration, resolvedVapidKey)
}
await subscribe(resolvedRegistration, resolvedVapidKey)
} catch {
// Permission denied by OS or error — permission state will update reactively
} finally {