← All tasks

Slice J — Kill router.refresh() on unlock PROD · GATE 2

Shipped 2026-05-18. Tracked followup to Slice I — the post-Slice-I baseline still carried a ~200ms RSC roundtrip + ~820ms server page render after every PIN unlock, courtesy of router.refresh() in LockScreen.onUnlocked. Slice J takes the next bite: the shell transitions phase locally, fetches the OPEN-phase bundle from a new GET route, and skips router.refresh() entirely on the unlock hot path.

4/4 prod test · 51/51 regression green — 55/55 total. PIN-Enter → workspace topbar visible dropped from 2479ms → 1437ms avg (3 consecutive runs: 1434/1430/1448ms, ±10ms). That's a −1042ms / −42% additional win on top of Slice I's −45%, and crosses the original Slice I <1500ms target. End-to-end perceived unlock latency vs the pre-Slice-I baseline: 4403ms → 1437ms (−2966ms / −67%).

Latency measurements (PIN-Enter → workspace topbar visible)

PhaseMeasuredDelta vs prior
Pre-Slice-I baseline (sequential page.tsx + router.refresh)4403ms
Slice I shipped (Suspense streaming)2479ms re-measured−1924ms (−44%)
Slice J shipped (local transition + bundle fetch)1437ms avg (1434/1430/1448)−1042ms (−42% vs Slice I)

Architecture

AFTER SLICE I (2479ms) AFTER SLICE J (1437ms) ───────────────────── ────────────────────── PIN-Enter PIN-Enter │ │ ▼ ▼ unlockRegisterAction unlockRegisterAction │ bcrypt + 2 DAOs │ bcrypt + 2 DAOs (unchanged) │ audit via after() │ audit via after() (unchanged) │ return { ok, sessionId, │ return { ok, sessionId, │ cashierName } │ cashierName, phase, │ │ actorKind, actorId } ▼ ▼ client: router.refresh() client: setLocalPhase('open') │ │ setLocalCashier(...) │ RSC roundtrip (200ms) │ setLocalNixSessionId(...) │ server page render (820ms): │ │ - Suspense fallback streams │ fetch /cafe/api/cafe/pos/ │ immediately (workspace shell │ register/{id}/bundle │ visible) │ (fires in parallel; data │ - loadProOpenPhaseBundle │ trickles in over ~2.6s │ 12 DAOs in parallel resolves │ on prod cold isolate) │ ~200-400ms later │ ▼ │ client renders OpenPhase WORKSPACE INTERACTIVE │ shell with empty arrays. (~820ms after action) │ Workspace topbar visible │ ~50ms after action returns. ▼ WORKSPACE INTERACTIVE (~max(action ~770ms, client render ~50ms))

What shipped

lib/actions/register.ts — both unlock actions extend their return shape with phase: "preshift" | "open" + cashierActorKind + cashierActorId. No new DB calls on the critical path — only the existing bcrypt + 2-DAO verification. The action does NOT call the OPEN bundle loader (that was the Slice I Attempt 1 regression shape).

New GET route /cafe/api/cafe/pos/register/[configId]/bundle — cookie auth + module-cached pos access + tier dispatch to existing Pro/Starter loaders. Returns { tier, bundle }. Delegates to loadProOpenPhaseBundle / loadStarterOpenPhaseBundle unchanged.

lock-screens.tsx — new UnlockSuccess interface; LockScreen + PinKeypadScreen's onUnlocked + PreShiftScreen's onOpened all carry the action result so the parent shell can transition state locally.

Both lockable shells (Pro (pos-fullscreen)/.../lockable-shell.tsx + Starter starter-lockable-shell.tsx) maintain localPhase / localCashier / localNixSessionId / localBundle state. On unlock + on preshift→open: setLocal* and fire the bundle fetch. Workspace topbar visible immediately on the local render with empty arrays; the bundle fills it in over the next ~2.6s. Lock + close-shift call a parent resetToLocked callback (no router.refresh() needed — local state alone drives the UI transition; the cookie is already cleared server-side by the action).

page.tsxdefaultBeginningCash + cashierOptions always loaded (phase-independent), so local LOCKED↔PRESHIFT transitions land with the same data a server render would have provided.

Safety fallback — if the bundle fetch returns non-2xx or throws, the shell falls back to router.refresh(). The old server-rendered path is the rescue, so a misbehaving route can't strand the cashier on an empty workspace.

The journey — three commits, two unstuck bugs

Commit 1 6d932ae — initial shape: actions return phase, new bundle route, both shells maintain localPhase + fetch bundle on unlock, no router.refresh() on unlock. First prod measurement: 2447ms (essentially baseline). Then a more granular diagnostic test surfaced that PIN-Enter→topbar was actually 819ms — but the official harness measured 2447ms because of run-to-run variance (cold isolate on the first measurement). Re-run measured 1424ms.

Bug surfaced: Lock + close-shift round-trip test failed (pos-lock-screen not visible after Lock click). Root cause: server's props.phase was already "locked" from server's perspective the whole time (we never refreshed after the local unlock), so the parent's serverPhase sync rule had nothing to detect when router.refresh() landed back — localPhase stayed "open".

Commit 2 47610c9 — added resetToLocked callback that LockableShell passes to OpenPhase. OpenPhase calls it from lock + close-shift handlers before router.refresh(). Partial fix — Run 1 still failed, Run 2 passed. Race-y.

Diagnosis: router.refresh() puts React in a transition state. State updates inside transitions are deferred — including the setLocalPhase("locked") from resetToLocked. The LockScreen render kept getting deferred behind the refresh's RSC fetch.

Commit 3 16e4263 — dropped router.refresh() from lock + close-shift handlers entirely. resetToLocked alone drives the UI transition (server-side cookie is already cleared by the action). Three consecutive runs passed 4/4 at 1434/1430/1448ms.

Lessons logged

React transitions defer state updates. router.refresh() in Next.js App Router wraps the refresh in a transition (so the UI doesn't flicker during the re-fetch). State updates fired in the same event handler after router.refresh() can be deferred behind the transition, making them race-y. When you need state to update synchronously for a UI transition, call setState first and avoid calling router.refresh() in the same handler unless you genuinely need fresh server data.

"Server says same → no signal" trap. If you maintain client-side overrides of server-derived state and the server's value never changed (because you skipped the refresh on the local transition), a sync rule that watches for changes won't fire when you eventually call router.refresh() — the server still reports the same value. The fix is explicit reset in the handler, not a passive watcher.

Run-to-run variance on cold isolates. The first prod measurement (2447ms) looked like a complete no-op, but the architecture was actually working (diagnostic showed 819ms PIN-Enter→topbar). Worker cold-start was masking the win. Three consecutive runs after warm-up landed 1430±10ms stable. When a perf change looks like a no-op, always re-run before reverting.

Checks — 55/55 prod test

Raw: measurement.json · result.json

Regression sweep — 51/51 green

test-phase1-prod.mjs (route + SSO checks; first parallel run had 1 flake — re-ran solo green)11/11
test-phase2-sso-outdoor-prod.mjs6/6
test-phase2-cafe-multishop-prod.mjs6/6
test-m1-prod.mjs (shop scoping)10/10
test-r7-prod.mjs (dashboard + manager-live)14/14
test-r8-prod.mjs (auth/security trio)4/4

Files (1 new + 5 modified, no migration, no new dep)

New: app/api/cafe/pos/register/[configId]/bundle/route.ts
Modified: lib/actions/register.ts · app/(pos-fullscreen)/pos/register/[configId]/page.tsx · app/(authed)/pos/_components/lock-screens.tsx · app/(pos-fullscreen)/pos/register/[configId]/lockable-shell.tsx · app/(authed)/pos/_components/starter-lockable-shell.tsx

Commits: 6d932ae (initial) · 47610c9 (resetToLocked) · 16e4263 (drop router.refresh from lock paths)

Operator follow-ups (not blocking the ship)