fix(security): smooth the scan-end visual transition#351
Merged
Conversation
Reported: clicking Rescan all on SecurityPage produced a one-frame
"shake" at the moment scanning ended. Four cumulative causes, four
fixes that needed to land together to make the transition feel like
state morphing rather than a banner swap.
1. Two React commits at the busy → idle edge.
The manual-rescan branch flipped `displayBusy(false)` + `rescanInFlight(false)`
synchronously, then set `scanResult` after an `await refresh()` a
few ms later. React committed twice: once with the scan banner
already unmounted and the result banner not yet mounted (and the
EmptyState / risk-list branch possibly flipped), then once with
the result banner. The intermediate frame was empty.
Fix: defer `setDisplayBusy(false)` into the same async block as
`setScanResult` so React 18 auto-batches both into one commit. Add
a `try / catch` fallback to `riskRef.current` so a refresh failure
doesn't strand the user in a permanent busy state.
2. Geometric column-width mismatch between the two banners.
ScanBanner's right grid column was an empty `<span aria-hidden />`
(0 px); ScanResultBanner's right column was a `w-5 h-5` dismiss
button (20 px). With `grid-template-columns: auto 1fr auto` the
middle column gained 20 px of room on the swap, re-wrapping any
text near the right edge. Verified via Playwright
getBoundingClientRect: pre-fix the middle column had 646 px in
one mode and 626 px in the other; post-fix both modes resolve to
626 px (icon = 16, middle = 626, right = 20) — pixel-perfect.
Fix: width-matched placeholder `<span aria-hidden className="w-5 h-5" />`
in the scanning state.
3. Banner appearance was an instant pop.
Both banners got `animate-in fade-in duration-200` so they enter
gradually rather than snapping in.
4. The two banners were separate React components at separate JSX
positions. Even after geometric + batching fixes, React still saw
"unmount ScanBanner, mount ScanResultBanner" — two distinct DOM
subtrees swap-replacing each other. The pinging amber dot's
`animate-ping` cut mid-animation, the amber background flipped to
warm-surface in one paint frame, and the 2 px amber progress
strip vanished mid-interpolation. All cumulatively read as "the
banner just shook".
Fix: unify into a single `ScanStatusBanner` driven by a
discriminated `state: { kind: 'scanning' | 'complete' }` union.
The two old components live as conditional inner content of one
outer div. Because the JSX position and component type are the
same across the state swap, React reconciles the outer div in
place — no remount. `transition-colors duration-300` morphs the
background and border from amber to warm-surface smoothly. The
icon, text, right column all swap their inner JSX without
touching the parent box. The progress strip is rendered in BOTH
states with a `transition-[width,opacity] duration-300`; on
completion it slides to 100 % and fades to 0 in the same beat,
giving the natural "completed and dissipates" feel rather than
blinking off mid-animation.
Trade-off worth flagging: the old shape rendered both banners
independently so a result banner could persist visibly while a
follow-up auto burst started ("ACK stays put while Spool rescans the
background"). The unified ternary prioritises scanning, so a follow-
up auto burst temporarily replaces the ACK with the scanning state.
scanResult stays in component state, so when that burst finishes the
banner returns to its complete mode with the original counts intact
— the ACK is not lost, just paused. The morph between the two states
is in-place CSS now, so the auto-burst interruption itself feels
smooth.
App test suite green: 338 passed (no test relied on the two banners
coexisting). Typecheck clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Eliminates the one-frame "shake" SecurityPage produced when a manual rescan ended. The user reported it as a small but persistent visual judder that survived several iterations of the obvious fixes — the actual cause turned out to be four overlapping problems that all had to land together for the transition to feel like state morphing instead of a banner swap.
Why
After PR #350 (scan-worker enqueue burst batch) merged, manual verification on the dev archive surfaced a separate UX seam at the OTHER end of the burst — scan completion. Walked the codepath one fix at a time, with the user verifying each iteration, and converged on a four-layer fix:
displayBusy(false)+rescanInFlight(false)synchronously, then setscanResultafter anawait refresh()a few ms later. Two commits = one intermediate frame with neither banner mounted.ScanBanner's right column was 0 px (empty span);ScanResultBanner's right column was 20 px (dismiss button). Undergrid-template-columns: auto 1fr autothe middle column gained / lost 20 px across the swap. Verified via PlaywrightgetBoundingClientRect: pre-fix the middle column had 646 / 626 px in the two modes; post-fix both are 626 px (pixel-perfect).ScanBanner, mountScanResultBanner": two distinct subtrees swap-replacing each other. The pinging amber dot'sanimate-pingcut mid-animation; the amber background flipped to warm-surface in one paint frame; the 2 px amber progress strip vanished mid-interpolation. Cumulatively read as "the banner just shook".How
1. Batch the busy → idle flip
setDisplayBusy(false)is now deferred into the same async block assetScanResultso React 18 auto-batches them into one commit. Atry / catchfalls back toriskRef.currentso a refresh failure doesn't strand the user in a permanent busy state.2. Width-matched columns
Added a
<span aria-hidden className=\"inline-block w-5 h-5\" />placeholder in the scanning state. The gridauto 1fr autonow resolves to the same column widths in both modes.3. Fade-in entry
Both banner modes get
animate-in fade-in duration-200so the banner enters gradually rather than snapping in.4. Unified
ScanStatusBannerReplaced
ScanBanner+ScanResultBannerwith a singleScanStatusBannerdriven by a discriminatedstate: { kind: 'scanning' } | { kind: 'complete' }union. Because the JSX position and component type are the same across the state swap, React reconciles the outer div in place — no remount.transition-colors duration-300morphs the background + border from amber to warm-surface smoothly. The icon, text, and right column all swap their inner JSX without touching the parent box. The progress strip is rendered in BOTH states withtransition-[width,opacity] duration-300; on completion it slides to 100 % and fades to 0 in the same beat, giving the natural "completed and dissipates" feel rather than blinking off mid-animation.How it connects
ScanStatusBannerkeeps both legacydata-testidselectors (security-scan-banner,security-scan-result-banner) — they toggle based on the active state, so existing e2e specs (security-manual-rescan-ack,security-mute-refresh,security-empty-detector-settings) continue to select correctly.Verification
pnpm vitest runinpackages/app— 338 passed (no test relied on the two banners coexisting).pnpm exec tsc --noEmitinpackages/app— clean.getBoundingClientRecton the two banners' grid columns confirmed pixel-perfect alignment (16 / 626 / 20 in both modes).Notes / non-goals
scanResultis preserved in component state, so when that burst finishes the banner returns to its complete mode with the original counts intact — the ACK is paused, not lost. The morph between scanning ↔ complete is in-place CSS now, so the auto-burst interruption itself feels smooth.