// useLastState — v0.23.0 / roadmap item #29: server-side sign-in state // resume. Two responsibilities, kept in one small hook so App.jsx's // surface stays minimal: // // 1. Debounce-post the authenticated user's current route to // `PUT /api/me/last-state` on every route change (~1s debounce so // a fast click-through doesn't spray the endpoint). Anonymous // users: no-op. Best-effort: a failed POST never disrupts nav. // // 2. On the first authenticated load, read the stored `last_route` // (handed back on `/api/auth/me`) and `navigate()` to it ONCE. // Stale routes (a withdrawn RFC, a slug the user lost rights to) // are not special-cased here: navigating lands on whatever that // route renders today, and the existing routing already falls // through to the catalog/empty-state for a missing RFC. Keeping // this dumb is deliberate (see SPEC §6.2 + the #29 scope note). // // Ordering with #21 Part C (Amplitude identify): App.jsx fires // `identify` in its own effect when `me.user.id` first appears. This // hook's resume redirect is gated on `identifyReady` — App.jsx flips it // true only after the identify effect has run — so the redirect always // happens AFTER identify, preserving the identify-then-track ordering // the roadmap calls out. import { useEffect, useRef } from 'react' import { putLastState } from '../api' // ~1s debounce on the route-change POST. A frontend constant, not a // server knob — the backend takes whatever lands. const DEBOUNCE_MS = 1000 // Routes we never want to resume *to* — auth/landing surfaces that // would be nonsensical or hostile to drop a returning user onto. We // still record them (cheap, and the user may legitimately be sitting on // /docs), but the resume redirect skips them and falls through to the // default landing. Anything not listed resumes normally. const NON_RESUMABLE_PREFIXES = ['/login', '/welcome', '/invites/', '/invitations/'] function isResumable(route) { if (!route || typeof route !== 'string') return false if (route === '/') return false // "/" is already the default landing return !NON_RESUMABLE_PREFIXES.some(p => route.startsWith(p)) } /** * @param {object} opts * @param {boolean} opts.authenticated whether a viewer is signed in * @param {string} opts.pathname current location.pathname * @param {boolean} opts.identifyReady App flips true after identify fires * @param {string|null} opts.lastRoute stored route from /api/auth/me * @param {function} opts.navigate react-router navigate() */ export function useLastState({ authenticated, pathname, identifyReady, lastRoute, navigate }) { // ── 1. Debounced route-change POST ──────────────────────────────── const timerRef = useRef(null) useEffect(() => { if (!authenticated) return undefined if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => { // Best-effort — swallow failures so an offline/401 POST never // surfaces as a navigation error. putLastState(pathname).catch(() => {}) }, DEBOUNCE_MS) return () => { if (timerRef.current) clearTimeout(timerRef.current) } }, [authenticated, pathname]) // ── 2. One-time resume redirect ─────────────────────────────────── // Fires once, after identify is ready, when there's a resumable // stored route AND the user is currently sitting on the default // landing ("/"). We only redirect from "/" so we never yank a user // who deep-linked somewhere specific (or refreshed mid-RFC) back to // their last route. const resumedRef = useRef(false) useEffect(() => { if (resumedRef.current) return if (!authenticated || !identifyReady) return // Only resume when the app booted on the default landing — a hard // sign-in nav lands on "/", which is exactly the case we want. if (pathname !== '/') { resumedRef.current = true // user deep-linked; don't resume later either return } if (isResumable(lastRoute)) { resumedRef.current = true navigate(lastRoute, { replace: true }) } else { resumedRef.current = true // nothing to resume to; keep default landing } }, [authenticated, identifyReady, lastRoute, pathname, navigate]) }