fix(state): persist to localStorage + explicit Disconnect (survive reload)
The Zustand store persisted to sessionStorage, which is tab-volatile — a hard reload / new tab / closing the tab dropped the whole session, including the board binding (device.connected, nodeUrl, teamId). The workshop runs on the team's own laptop, so switch the store (and the offline outbox) to localStorage: the binding + progress now survive a refresh, hard reload, navigation, and tab close. Clearing is now explicit only: a new `disconnect()` action drops the board binding (keeping team/domain/phases/ADD so they can re-bind and resume), surfaced as a "Disconnect" button on the claimed-board card. `reset()` still wipes everything. Tests: add an in-memory localStorage to setupTests (Node 22's disabled experimental localStorage shadows jsdom's) + clear it per test. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6a9fad9f10
commit
c12f40837b
@@ -10,6 +10,8 @@ export interface BoardClaimProps {
|
|||||||
connected: boolean
|
connected: boolean
|
||||||
port: string | null
|
port: string | null
|
||||||
onClaimed: (result: ClaimResult) => void
|
onClaimed: (result: ClaimResult) => void
|
||||||
|
/** Drop the binding so the team can re-bind (only on an explicit click). */
|
||||||
|
onDisconnect?: () => void
|
||||||
/** Pre-fill the code (e.g. from a ?code= param). */
|
/** Pre-fill the code (e.g. from a ?code= param). */
|
||||||
initialCode?: string
|
initialCode?: string
|
||||||
}
|
}
|
||||||
@@ -29,7 +31,7 @@ const SETUP_CMD = 'curl -fsSL https://apess.redclaw.dev/setup.sh | bash'
|
|||||||
* code on its matrix; entering that code binds the board to the team. The
|
* code on its matrix; entering that code binds the board to the team. The
|
||||||
* bearer token never touches the browser.
|
* bearer token never touches the browser.
|
||||||
*/
|
*/
|
||||||
export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, initialCode }: BoardClaimProps) {
|
export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, onDisconnect, initialCode }: BoardClaimProps) {
|
||||||
const [code, setCode] = useState(initialCode ?? '')
|
const [code, setCode] = useState(initialCode ?? '')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -37,9 +39,20 @@ export function BoardClaim({ teamId, teamName, members, connected, port, onClaim
|
|||||||
if (connected) {
|
if (connected) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3" data-testid="board-connected">
|
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3" data-testid="board-connected">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" />
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm font-medium">Board claimed</span>
|
<span className="w-2 h-2 rounded-full bg-teal animate-pulse shrink-0" />
|
||||||
|
<span className="text-sm font-medium">Board claimed</span>
|
||||||
|
</div>
|
||||||
|
{onDisconnect && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDisconnect}
|
||||||
|
className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-red-500 shrink-0"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-[10px] text-muted-foreground mt-1">{port}</div>
|
<div className="font-mono text-[10px] text-muted-foreground mt-1">{port}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const snapshot = () => projectSnapshot(useSession.getState())
|
|||||||
describe('projectSnapshot', () => {
|
describe('projectSnapshot', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
sessionStorage.clear()
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps store identity + progress into a snapshot', () => {
|
it('maps store identity + progress into a snapshot', () => {
|
||||||
@@ -31,7 +31,7 @@ describe('offline outbox', () => {
|
|||||||
let fetchMock: ReturnType<typeof vi.fn>
|
let fetchMock: ReturnType<typeof vi.fn>
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
sessionStorage.clear()
|
localStorage.clear()
|
||||||
fetchMock = vi.fn()
|
fetchMock = vi.fn()
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
})
|
})
|
||||||
@@ -40,17 +40,17 @@ describe('offline outbox', () => {
|
|||||||
it('queues a failed push and never throws', async () => {
|
it('queues a failed push and never throws', async () => {
|
||||||
fetchMock.mockRejectedValue(new Error('offline'))
|
fetchMock.mockRejectedValue(new Error('offline'))
|
||||||
await expect(safePushTeam(snapshot())).resolves.toBeUndefined()
|
await expect(safePushTeam(snapshot())).resolves.toBeUndefined()
|
||||||
expect(JSON.parse(sessionStorage.getItem('apess_outbox')!)).toHaveLength(1)
|
expect(JSON.parse(localStorage.getItem("apess_outbox")!)).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('flushes the outbox when connectivity returns', async () => {
|
it('flushes the outbox when connectivity returns', async () => {
|
||||||
fetchMock.mockRejectedValueOnce(new Error('offline'))
|
fetchMock.mockRejectedValueOnce(new Error('offline'))
|
||||||
await safePushTeam(snapshot())
|
await safePushTeam(snapshot())
|
||||||
expect(JSON.parse(sessionStorage.getItem('apess_outbox')!)).toHaveLength(1)
|
expect(JSON.parse(localStorage.getItem("apess_outbox")!)).toHaveLength(1)
|
||||||
|
|
||||||
fetchMock.mockResolvedValue({ ok: true })
|
fetchMock.mockResolvedValue({ ok: true })
|
||||||
await flushOutbox()
|
await flushOutbox()
|
||||||
expect(JSON.parse(sessionStorage.getItem('apess_outbox')!)).toHaveLength(0)
|
expect(JSON.parse(localStorage.getItem("apess_outbox")!)).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ describe('useCollectiveSync', () => {
|
|||||||
let fetchMock: ReturnType<typeof vi.fn>
|
let fetchMock: ReturnType<typeof vi.fn>
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
sessionStorage.clear()
|
localStorage.clear()
|
||||||
fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ type OutboxItem = { kind: 'team'; payload: TeamSnapshot } | { kind: 'submission'
|
|||||||
|
|
||||||
function readOutbox(): OutboxItem[] {
|
function readOutbox(): OutboxItem[] {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(sessionStorage.getItem(OUTBOX_KEY) ?? '[]') as OutboxItem[]
|
return JSON.parse(localStorage.getItem(OUTBOX_KEY) ?? '[]') as OutboxItem[]
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function writeOutbox(items: OutboxItem[]): void {
|
function writeOutbox(items: OutboxItem[]): void {
|
||||||
sessionStorage.setItem(OUTBOX_KEY, JSON.stringify(items))
|
localStorage.setItem(OUTBOX_KEY, JSON.stringify(items))
|
||||||
}
|
}
|
||||||
function enqueue(item: OutboxItem): void {
|
function enqueue(item: OutboxItem): void {
|
||||||
// collapse duplicate team pushes — only the latest snapshot matters
|
// collapse duplicate team pushes — only the latest snapshot matters
|
||||||
@@ -83,7 +83,7 @@ export interface UseCollectiveSyncOptions {
|
|||||||
* Best-effort, offline-first sync of this team's state to the collective.
|
* Best-effort, offline-first sync of this team's state to the collective.
|
||||||
* Mounted once near the router root. Pushes on phase changes and submission,
|
* Mounted once near the router root. Pushes on phase changes and submission,
|
||||||
* throttles stat-driven churn, and never blocks the participant flow — failed
|
* throttles stat-driven churn, and never blocks the participant flow — failed
|
||||||
* pushes go to a sessionStorage outbox and retry on interval / `online`.
|
* pushes go to a localStorage outbox and retry on interval / `online`.
|
||||||
*/
|
*/
|
||||||
export function useCollectiveSync(opts: UseCollectiveSyncOptions = {}): void {
|
export function useCollectiveSync(opts: UseCollectiveSyncOptions = {}): void {
|
||||||
const throttleMs = opts.throttleMs ?? 8000
|
const throttleMs = opts.throttleMs ?? 8000
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function TeamRegistration() {
|
|||||||
const device = useSession((s) => s.device)
|
const device = useSession((s) => s.device)
|
||||||
const setTeam = useSession((s) => s.setTeam)
|
const setTeam = useSession((s) => s.setTeam)
|
||||||
const setDevice = useSession((s) => s.setDevice)
|
const setDevice = useSession((s) => s.setDevice)
|
||||||
|
const disconnect = useSession((s) => s.disconnect)
|
||||||
const resumeTeam = useSession((s) => s.resumeTeam)
|
const resumeTeam = useSession((s) => s.resumeTeam)
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ export function TeamRegistration() {
|
|||||||
connected={device.connected}
|
connected={device.connected}
|
||||||
port={device.port}
|
port={device.port}
|
||||||
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
||||||
|
onDisconnect={disconnect}
|
||||||
onClaimed={(r) => {
|
onClaimed={(r) => {
|
||||||
// Resume (a lost-browser re-claim): adopt the board's canonical
|
// Resume (a lost-browser re-claim): adopt the board's canonical
|
||||||
// team + restore its progress instead of keeping this fresh id.
|
// team + restore its progress instead of keeping this fresh id.
|
||||||
|
|||||||
@@ -2,6 +2,32 @@ import '@testing-library/jest-dom/vitest'
|
|||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from '@testing-library/react'
|
||||||
import { afterEach } from 'vitest'
|
import { afterEach } from 'vitest'
|
||||||
|
|
||||||
|
// The store persists to localStorage, but Node 22's experimental (disabled)
|
||||||
|
// localStorage shadows jsdom's — give tests a real in-memory implementation.
|
||||||
|
class MemStorage implements Storage {
|
||||||
|
private m = new Map<string, string>()
|
||||||
|
get length() {
|
||||||
|
return this.m.size
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.m.clear()
|
||||||
|
}
|
||||||
|
getItem(k: string) {
|
||||||
|
return this.m.get(k) ?? null
|
||||||
|
}
|
||||||
|
key(i: number) {
|
||||||
|
return [...this.m.keys()][i] ?? null
|
||||||
|
}
|
||||||
|
removeItem(k: string) {
|
||||||
|
this.m.delete(k)
|
||||||
|
}
|
||||||
|
setItem(k: string, v: string) {
|
||||||
|
this.m.set(k, String(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', { value: new MemStorage(), configurable: true })
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
|
|||||||
+11
-1
@@ -82,6 +82,8 @@ export interface SessionState {
|
|||||||
recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void
|
recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void
|
||||||
setAddLayer: <K extends keyof AddLayers>(key: K, value: AddLayers[K]) => void
|
setAddLayer: <K extends keyof AddLayers>(key: K, value: AddLayers[K]) => void
|
||||||
setSubmission: (s: Submission) => void
|
setSubmission: (s: Submission) => void
|
||||||
|
/** Drop the board binding (keep the team's work) so they can re-bind. */
|
||||||
|
disconnect: () => void
|
||||||
/** Adopt a canonical team on a resume (lost-browser re-claim): switch identity
|
/** Adopt a canonical team on a resume (lost-browser re-claim): switch identity
|
||||||
* and restore name/members/phases/stats so we don't clobber synced progress. */
|
* and restore name/members/phases/stats so we don't clobber synced progress. */
|
||||||
resumeTeam: (snap: {
|
resumeTeam: (snap: {
|
||||||
@@ -123,6 +125,9 @@ export const useSession = create<SessionState>()(
|
|||||||
})),
|
})),
|
||||||
setAddLayer: (key, value) => set((s) => ({ add: { ...s.add, [key]: value } })),
|
setAddLayer: (key, value) => set((s) => ({ add: { ...s.add, [key]: value } })),
|
||||||
setSubmission: (submission) => set({ submission }),
|
setSubmission: (submission) => set({ submission }),
|
||||||
|
// Explicit "disconnect": drop the board binding but KEEP the team's work
|
||||||
|
// (name, members, domain, phases, ADD). They can re-bind and resume.
|
||||||
|
disconnect: () => set({ device: { connected: false, port: null, uptimeS: 0, nodeUrl: null } }),
|
||||||
resumeTeam: (snap) =>
|
resumeTeam: (snap) =>
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
teamId: snap.id,
|
teamId: snap.id,
|
||||||
@@ -135,7 +140,12 @@ export const useSession = create<SessionState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'apess_state',
|
name: 'apess_state',
|
||||||
storage: createJSONStorage(() => sessionStorage),
|
// localStorage (not sessionStorage): the workshop runs on the team's own
|
||||||
|
// laptop, so their board binding + progress must survive a page refresh,
|
||||||
|
// a hard reload, navigating away, and even closing/reopening the tab —
|
||||||
|
// until they explicitly hit Disconnect (or Reset). sessionStorage was
|
||||||
|
// tab-volatile and dropped the connection on a hard reload.
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
version: 3,
|
version: 3,
|
||||||
// v1 held a different shape (add.L1 was an object, no `domain`) — too stale
|
// v1 held a different shape (add.L1 was an object, no `domain`) — too stale
|
||||||
// to salvage, so reset. From v2 on we merge over `initial` so newly-added
|
// to salvage, so reset. From v2 on we merge over `initial` so newly-added
|
||||||
|
|||||||
Reference in New Issue
Block a user