bada72f87e
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>
92 lines
4.3 KiB
JavaScript
92 lines
4.3 KiB
JavaScript
// 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])
|
|
}
|