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.
<1500ms target. End-to-end perceived unlock
latency vs the pre-Slice-I baseline: 4403ms → 1437ms (−2966ms / −67%).
| Phase | Measured | Delta 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) |
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.tsx — defaultBeginningCash +
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.
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.
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.
Raw: measurement.json · result.json
| test-phase1-prod.mjs (route + SSO checks; first parallel run had 1 flake — re-ran solo green) | 11/11 |
| test-phase2-sso-outdoor-prod.mjs | 6/6 |
| test-phase2-cafe-multishop-prod.mjs | 6/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 |
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)
router.refresh(). The
cashierOptions + webUser in props stay from the
initial server render — if a manager edits the cashier list mid-shift while a register
stays open, the new cashier won't show up until the next full page reload. Acceptable
tradeoff (rare change, easy workaround).