v0.23.0: server-side sign-in state resume (roadmap #29)

Track each authenticated user's last-viewed route + light view state
server-side, and on next sign-in redirect them to that state, falling
back to the empty-state home only when there's no recorded state.

Backend:
- migration 022_user_session_state.sql: one row per user
  (user_id PK/FK, last_route, last_route_state JSON-as-TEXT,
  resume_enabled default 1, last_updated_at).
- PUT /api/me/last-state (require_user): upserts route + light state;
  no-ops when resume_enabled=0. Localized in the /me region.
- /api/auth/me payload now carries resume_enabled + last_route +
  decoded last_route_state (no extra round-trip).
- test_session_resume_vertical.py: auth-required, upsert/read-back,
  per-user isolation, resume_enabled=0 disable.

Frontend:
- lib/useLastState.js: debounced (~1s) route-change PUT for
  authenticated users; one-time resume redirect on sign-in, gated on
  identify having fired (preserves #21 Part C identify-then-track).
- api.js: putLastState() client call.
- App.jsx: import + call the hook; set identifyReady after identify.
  Header region untouched.

Per-user (not per-device); profile-settings opt-out toggle UI
deferred (column + default-on behavior ship now). Stored state is
route + light view state ONLY, never draft buffers — documented in
SPEC §6.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 12:18:06 -07:00
parent 7d8371dea1
commit bada72f87e
7 changed files with 459 additions and 0 deletions
+30
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { getMe, subscribeToNotifications } from './api'
import { anonymize, EVENTS, identify, track } from './lib/analytics'
import { useLastState } from './lib/useLastState'
import Catalog from './components/Catalog.jsx'
import Inbox from './components/Inbox.jsx'
import RFCView from './components/RFCView.jsx'
@@ -42,6 +43,12 @@ export default function App() {
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
// v0.23.0 / item #29 — flips true once the #21-Part-C identify effect
// has fired (or once we've confirmed there's no authenticated user to
// identify). useLastState gates its resume redirect on this so the
// redirect always happens AFTER identify, preserving identify-then-
// track ordering.
const [identifyReady, setIdentifyReady] = useState(false)
const navigate = useNavigate()
const location = useLocation()
// v0.15.0 — Page Viewed event taxonomy. We fire on every
@@ -86,6 +93,9 @@ export default function App() {
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
identify({ user_id: String(uid), properties: props })
// v0.23.0 / item #29 — identify has now fired for this sign-in;
// release useLastState's resume redirect (it waits on this).
setIdentifyReady(true)
} else if (uid == null && lastUserIdRef.current != null) {
// Sign-out edge — App-level reset is handled separately by the
// sign-out gesture that fires User Signed Out. Clear our local
@@ -94,6 +104,14 @@ export default function App() {
}
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
// v0.23.0 / item #29 — once `me` has resolved, if there's no
// authenticated user there is nothing to identify, so release the
// resume gate immediately (anonymous boots have no resume to do, but
// the hook still needs the gate resolved to be a clean no-op).
useEffect(() => {
if (me != null && !me.authenticated) setIdentifyReady(true)
}, [me])
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
@@ -107,6 +125,18 @@ export default function App() {
.finally(() => setLoading(false))
}, [])
// v0.23.0 / item #29 — server-side sign-in state resume. The hook
// debounce-posts the current route for authenticated users and, once
// identify has fired, redirects a fresh sign-in (which hard-lands on
// "/") to the user's stored last route. Anonymous users: no-op.
useLastState({
authenticated: !!me?.authenticated,
pathname: location.pathname,
identifyReady,
lastRoute: me?.authenticated ? me.user?.last_route : null,
navigate,
})
// §15.3 — subscribe to the live SSE stream for authenticated viewers
// so the badge counter and the toast surface stay in lockstep with
// the inbox. Tabs that miss an event because they were closed pick
+19
View File
@@ -25,6 +25,25 @@ export async function getMe() {
return jsonOrThrow(res)
}
// ── v0.23.0: sign-in state resume (§6.2, roadmap item #29) ───────────────
//
// The route-change hook (useLastState) debounce-posts the user's current
// route + a small bag of *light* view state here for authenticated users.
// The next sign-in reads `last_route` off `/api/auth/me` and redirects.
// Privacy: `state` carries ephemeral view state ONLY — never draft-buffer
// contents (see SPEC §6.2). Best-effort: callers ignore failures (an
// offline/401 POST must never disrupt navigation).
export async function putLastState(route, state) {
const body = { route }
if (state != null) body.state = state
const res = await fetch('/api/me/last-state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
//
// The legacy /auth/login → /auth/callback OAuth flow remains during the
+91
View File
@@ -0,0 +1,91 @@
// 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])
}