
Dejank
- 21 installs
- 30 repo stars
- Updated March 27, 2026
- gbasin/dejank
Detect and diagnose visual jank in React UIs through static analysis of flicker-prone code patterns and runtime investigation of symptoms like flash, layout shift, or remount churn.
About
Finds visual instability in React and browser UIs using a static-analysis mode for pre-merge sweeps and a runtime mode that classifies symptoms and picks the lightest investigation path. A developer uses it when a UI flickers, blinks, shifts layout, or feels rebuilt.
- Two modes: static pattern analysis and runtime symptom investigation
- Classifies symptoms into flash, jump, stutter, pop-in, and whole-pane-rebuilt buckets
Dejank by the numbers
- 21 all-time installs (skills.sh)
- Ranked #393 of 596 Debugging skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gbasin/dejank --skill dejankAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 21 |
|---|---|
| repo stars | ★ 30 |
| Last updated | March 27, 2026 |
| Repository | gbasin/dejank ↗ |
What it does
Detect and diagnose visual jank in React UIs through static analysis of flicker-prone code patterns and runtime investigation of symptoms like flash, layout shift, or remount churn.
Files
Find and fix visual instability in React and browser UIs. This skill operates in two modes depending on what is known at invocation time.
Decision Tree
Determine the mode before doing any work:
Is there a specific visual complaint? (The user describes something they can see: flicker, blink, flash, jump, stutter, pop-in, scroll reset, focus loss, "the whole pane feels rebuilt.")
- Yes -- go to Mode B: Runtime Investigation
- No (general review, PR check, preventive sweep, or just a file/directory target) -- go to Mode A: Static Analysis
If unsure, start with Mode A. Its findings often explain the complaint without runtime tooling.
---
Mode A -- Static Analysis
Use when: reviewing a PR, scanning changed files, doing a preventive sweep, or no specific symptom is reported.
1. Read references/static-patterns.md for the full pattern catalog. 2. Read references/scan-process.md for scope rules, severity rubric, output format, and guardrails. 3. Identify scope: changed files in the current branch (git diff --name-only against base), or the user-specified target and its direct imports. 4. Scan each React component against all patterns. 5. Report findings grouped by component, ranked by severity.
Stop here unless a finding warrants runtime confirmation or the user escalates.
---
Mode B -- Runtime Investigation
Use when: the user reports something visible -- flicker, blink, flash, jump, snap, stutter, pop-in, scroll reset, focus loss, or "the whole pane feels rebuilt."
Step 1 -- Classify the symptom
| Bucket | Symptoms |
|---|---|
flash / blink | subtree replaced, hidden, or briefly emptied |
jump / snap | layout shifts, size changes, scroll resets |
sticky / stutter | long tasks, frame gaps, render storms |
pop in | post-paint correction from effects, fonts, images, async state |
whole pane rebuilt | keyed remount or identity loss |
Anchor each finding: Which stable surface lost identity, visibility, position, or continuity during this interaction?
Step 2 -- Pick the lightest investigation path
- Reproducible, need first-pass evidence: references/playwright-and-probe.md
- Smells like remount or rerender churn: references/react-path.md
- Smells like layout, paint, or compositor: references/browser-path.md
- Intermittent or prod-only: references/field-path.md
- Need the tool decision matrix or budgets: references/tooling-and-signals.md
Do not start with a heavyweight trace unless the lighter pass fails to explain the symptom.
Step 3 -- Escalate
Each reference file contains its own escalation rules. Move to the next layer only when the current one is insufficient.
Done Criteria
- You can state the likely cause AND either provide a code fix or identify what telemetry to add.
- Max escalation depth: 2 layers. If the second investigation path doesn't explain the symptom, report what you found and what remains unknown. Do not keep escalating.
---
Hybrid: Static + Runtime
When a runtime complaint maps to a static pattern (e.g., "it flashes on load" and you find effect-driven state initialization), run Mode A on the relevant files first. If the static finding explains the symptom, fix it without runtime tooling. If not, proceed to Mode B.
---
Output
Mode A: Findings grouped by component. Each finding includes pattern name, severity, what the user sees, code location, and a specific fix (not generic advice). End with a severity summary. Full format in references/scan-process.md.
Mode B: Report in this order:
1. Interaction: what was tested 2. Classification: remount, layout, loading, post-paint, or paint/composite 3. Evidence: concrete metrics, trace signals, or selector replacement findings 4. Likely cause: highest-confidence explanation 5. Next action: code fix, deeper trace, or telemetry
If you create a reusable probe or report during the audit, keep it opt-in unless the repo already budgets against it.
Scope
This skill covers the narrow band of issues that are visual, temporal, and caused by React's render model or browser rendering -- things that make a UI feel janky even when everything else is correct.
It does NOT cover runtime performance (slow renders, memory leaks), accessibility, design quality, or error handling. Defer those to the appropriate specialized skill.
interface:
display_name: "Dejank"
short_description: "Detect and diagnose visual jank in React UIs"
default_prompt: "Use $dejank to scan for visual instability patterns or diagnose a specific rendering complaint in this frontend."
dejank
Detect and diagnose visual jank in React UIs. Static analysis of anti-patterns and runtime investigation of specific symptoms.
Install
npx skills add gbasin/dejank --all -gWhat it does
React's commit model means state changes paint across multiple frames. Intermediate states leak into what the user sees as flicker, layout shift, or flash. These issues don't throw errors, pass tests, and satisfy type-checkers — but users feel them.
This skill operates in two modes based on a decision tree:
Mode A — Static Analysis (pre-merge review, no specific complaint)
Scans React components against known anti-patterns that produce visual jank:
| # | Pattern | Severity |
|---|---|---|
| 1 | Effect-driven state initialization | Critical |
| 2 | Derived state in effects | High |
| 3 | Chained effect cascade | High |
| 4 | Conditional mount/unmount | High |
| 5 | Unstable key props | High |
| 6 | Unsized async content (layout shift) | Critical |
| 7 | Skeleton/placeholder absence | Medium |
| 8 | Suspense boundary flash | Medium |
| 9 | Hydration mismatch sources | Critical (SSR) |
| 10 | Layout thrashing in event handlers | Medium |
| 11 | Animating layout properties | Medium |
| 12 | Missing startTransition for expensive updates | Low |
| 13 | Ref callback remount | Medium |
| 14 | Z-index / stacking context flash | Low |
| 15 | Font flash (FOUT/FOIT) | High |
| 16 | Unstable context value | High |
| 17 | Component defined inside component | Critical |
| 18 | Async waterfall in component tree | High |
Mode B — Runtime Investigation (user reports a specific visual symptom)
Classifies the symptom, then routes to the lightest investigation path that can explain it:
- Playwright probe for reproducible first-pass evidence
- React DevTools for remount/rerender churn
- Chrome Performance for layout, paint, compositor issues
- Production telemetry for intermittent/prod-only problems
Structure
SKILL.md Decision tree entrypoint
agents/openai.yaml Codex UI metadata
references/
static-patterns.md Anti-pattern catalog
scan-process.md Scope, severity, output format, guardrails
playwright-and-probe.md Runtime: Playwright probe path
react-path.md Runtime: React DevTools investigation
browser-path.md Runtime: Chrome Performance investigation
field-path.md Runtime: production telemetry
tooling-and-signals.md Tool decision matrix + budgetsBrowser Path
Use this path when the symptom looks like layout movement, repaint, compositing, or general browser work rather than a pure React identity problem.
Diagnostic Workflow
Step 1 -- Live Metrics (zero setup)
Open Chrome DevTools Performance panel. The landing page shows live Core Web Vitals (CLS, INP, LCP) updating in real time as you interact. Color-coded cards indicate good/needs-improvement/poor. If CLS or INP lights up during the suspect interaction, you have a starting point.
Step 2 -- Record a Trace
1. Performance panel > Record (Cmd+E) 2. Perform the interaction you want to measure 3. Stop recording 4. Check the Insights sidebar first -- it auto-surfaces curated findings:
- "Layout shift culprits" -- identifies the worst shift cluster and the affected DOM elements
- "Forced reflow" -- highlights layout thrashing (>30ms forced reflows)
- "Render-blocking requests" -- resources delaying paint
Step 3 -- Diagnose by Symptom
For layout shifts / jump / snap:
- Look at the Layout Shift Regions overlay (Rendering tab > check "Layout Shift Regions")
- In the trace, expand the "Experience" track to see layout shift entries
- Use the Layout Instability API
sourcesarray to identify which element shifted: entry.sources[0].node-- the shifted elemententry.sources[0].previousRect/currentRect-- where it was and where it went- Heuristic: the element immediately preceding the shifted element is most likely the cause
For sluggish interaction / stutter:
- Find the interaction in the Interactions track
- Check the three INP sub-parts:
- Input delay -- main thread was blocked before handler ran (look for preceding long tasks)
- Processing duration -- event handler itself is slow (look at script attribution)
- Presentation delay -- rendering work after handler (look for large style/layout recalcs)
- Click "Local duration" to see Long Animation Frame (LoAF) data with per-script attribution
For paint/repaint storms:
- Enable Paint Flashing (Rendering tab) -- green highlights show areas being repainted
- Large green areas on every frame indicate excessive paint work
- Check if affected elements can use
will-change: transformorcontain: paintto isolate paint regions
For compositor/layer issues:
- Open the Layers panel (Cmd+Shift+P > "Show Layers")
- 3D view shows all compositor layers with dimensions, compositing reasons, memory cost, and paint count
- Excessive layers waste GPU memory; too few layers force main-thread paint
Key APIs for Deeper Diagnosis
Long Animation Frames (LoAF) -- replaces Long Tasks
Available Chrome 123+. Strictly superior to the Long Tasks API for attribution.
Each LoAF entry provides:
duration,renderStart,styleAndLayoutStart,blockingDurationscripts[]withsourceURL,sourceFunctionName,sourceCharPosition,forcedStyleAndLayoutDuration
forcedStyleAndLayoutDuration on script entries directly identifies layout thrashing -- the time a script spent forcing synchronous reflows.
Layout Instability API -- element-level shift attribution
Each LayoutShift entry's sources array contains up to 5 shifted elements, sorted by impact area. Each has node, previousRect, currentRect. An all-zero rect means the element entered/left the viewport.
CSS Techniques for Prevention
content-visibility: auto
Skips layout, style, and paint for off-screen elements entirely. Up to 7x faster initial render on content-heavy pages.
.list-item {
content-visibility: auto;
contain-intrinsic-size: auto 300px; /* prevents scrollbar jitter */
}Baseline browser support since September 2025 (Chrome 85+, Firefox 125+, Safari 18+).
CSS contain
contain: content(layout + paint) -- safe default for independent components (cards, list items, modals). Limits the blast radius of layout changes.contain: strict(size + layout + paint) -- only when you explicitly set dimensions.
Compositor-safe animations
Only transform and opacity run on the compositor thread (GPU-accelerated, no main-thread involvement). Everything else triggers layout or paint.
- Use
scale()instead of animatingwidth/height - Use
translate()instead of animatingtop/left - In Tailwind: use
transition-transformortransition-opacity, nottransition-all - Toggle
will-changebefore/after animations; do not leave it permanently set
Forced Reflow Triggers (Curated)
Reading any of these forces the browser to synchronously compute layout:
Box metrics: offsetLeft/Top/Width/Height, clientLeft/Top/Width/Height, getBoundingClientRect(), getClientRects() Scroll: scrollWidth/Height, scrollLeft/Top, scrollIntoView() Computed style: getComputedStyle() (for dimensions, positioning, transforms, grid properties) Content: innerText (computes visible text) Focus: focus(), select()
If you read any of these then write to the DOM in the same synchronous block, you force a reflow. Batch all reads first, then all writes.
Full list: Paul Irish's "What forces layout/reflow" (https://gist.github.com/paulirish/5d52fb081b3570c81e3a)
Rendering Tab Quick Reference
| Tool | Overlay Color | What It Shows |
|---|---|---|
| Paint Flashing | Green | Areas being repainted |
| Layout Shift Regions | Purple | Areas where layout shifts occur |
| Layer Borders | Orange/cyan | Compositor layer boundaries |
| Frame Rendering Stats | Top-right | Real-time FPS, GPU raster, memory |
| Scrolling Performance Issues | Highlighted | Scroll listeners harming perf |
Agent Browser Tools
See tooling-and-signals.md § Agent Browser Tools for chrome-cdp vs dev-browser guidance.
Escalation
If the selector-level probe already proved a stable surface was replaced, stay on the React path first.
If the probe shows instability but not the cause, or if the issue looks like geometry or paint behavior, use this browser path. If symptoms remain unclear after a Performance trace, use chrome://tracing with devtools.timeline,blink.user_timing categories for frame-level detail.
Field Path
Use this path when the issue is intermittent, route-specific, or only visible in production.
Rules
- Add telemetry before guessing.
- Do not claim a local fix solved a prod-only problem without field evidence.
- Keep local repros and field metrics separate.
- If the issue never reproduces locally, optimize for better production attribution rather than deeper local speculation.
web-vitals Attribution Build
Import from web-vitals/attribution (adds ~1.5KB brotli). Each metric callback gains an attribution property with diagnostic data.
CLS Attribution
Log these fields to identify what shifted:
attribution.largestShiftTarget-- CSS selector of the first element in the largest shiftattribution.largestShiftValue-- score of the single largest shiftattribution.largestShiftTime-- when it happenedattribution.loadState-- document loading state at the timeattribution.largestShiftSource.previousRect/currentRect-- element position before/after
Aggregation strategy: Rank largestShiftTarget selectors across all users to find the elements affecting the most sessions.
INP Attribution
Log these fields to identify slow interactions:
attribution.interactionTarget-- CSS selector of the element the user interacted withattribution.interactionType--'pointer'or'keyboard'attribution.inputDelay-- time blocked before handler ranattribution.processingDuration-- time in event handlersattribution.presentationDelay-- time from handler end to next paintattribution.longAnimationFrameEntries-- LoAF entries intersecting the interaction (Chrome 123+), with per-script attribution
Reporting
Report on visibilitychange to hidden -- this is the only reliable "final value" signal. Use navigator.sendBeacon() for delivery during page unload.
Long Animation Frames (LoAF)
Chrome 123+. Successor to Long Tasks API. Measures entire animation frames >50ms and provides per-script attribution:
scripts[].sourceURL,sourceFunctionName,sourceCharPosition-- which code caused the long framescripts[].forcedStyleAndLayoutDuration-- time spent on forced reflows within that script
LoAF tells you which script caused the jank, not just that a long frame occurred. Integrate with INP attribution (web-vitals provides longAnimationFrameEntries on INP callbacks) for full correlation.
Feature-detect: PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')
CrUX (Chrome User Experience Report)
Use CrUX as a smoke detector -- it tells you which pages have problems. Then drill into your own RUM for element-level attribution.
- CrUX API provides URL-level and origin-level p75 data for CLS, INP, LCP
- Thresholds: CLS good <= 0.1, INP good <= 200ms
- Updated daily, 28-day rolling window
- Does NOT tell you which elements shifted -- that requires your own telemetry
Stable Element Identification
CSS selectors in telemetry break when class names contain build hashes (CSS modules, Tailwind JIT). Mitigation:
- Add stable
idattributes ordata-attributes to key UI surfaces - SpeedCurve walks up the DOM tree and stops at the first
idordata-sctrackattribute - Ensure your critical surfaces have stable selectors that survive deploys
Non-CLS Jank Telemetry
CLS only captures layout shifts. It misses flicker, remount churn, Suspense flashes, and theme pop-in. For these:
- React Profiler API: Wrap key subtrees in
<Profiler onRender={callback}>. Log whenphase === "mount"after initial load (unexpected remount) or whenactualDurationexceeds a threshold. - MutationObserver on stable containers: Count mutations per animation frame. Rapid add/remove cycles are a flicker signature.
- Suspense fallback mount logging: Wrap Suspense fallbacks in a component that logs when it mounts. If it mounts for <100ms, that's a flash.
Tool Landscape
| Tool | CLS Attribution | INP Attribution | LoAF | Session Replay |
|---|---|---|---|---|
| web-vitals (self-hosted) | Yes (full) | Yes (full) | Yes (via INP) | No |
| Vercel Speed Insights | Element-level | Element-level | No | No |
| Sentry | Score only (no element) | Element + subparts | No | Yes |
| Datadog RUM | Element-level | Element + subparts | Yes (SDK v6+) | Yes |
| DebugBear | Element + frequency | Element + subparts | Yes | No |
| SpeedCurve | Element-level | Element + subparts | Yes | No |
Sentry captures CLS as a numeric value but not largestShiftTarget or sources -- supplement with the web-vitals attribution build if you need element-level CLS diagnosis. Sentry Session Replay correlates Web Vital breadcrumbs with video playback, which can identify shifting elements visually.
Playwright and Probe
Use this as the default reproducible path.
Goal
Replay one interaction and capture enough evidence to decide whether the problem is identity loss, layout instability, or browser work.
Steps
1. Reproduce in the production build. 2. Name one interaction precisely. 3. Identify the stable surfaces that should not lose continuity. 4. Prefer an existing local render-stability probe or trace harness if the repo already has one. 5. If no probe exists, add the smallest possible one that records:
- layout shifts (with element attribution via
entry.sources) - long animation frames (LoAF, Chrome 123+) -- fall back to long tasks for Firefox/WebKit
- frame gaps
- DOM mutation churn
- selector-level replacement, detach, flicker, empty-visible frames, and scroll jumps
6. Save a JSON or text artifact that can be compared after a fix.
Stable Surface Examples
Track selectors for surfaces that should feel continuous:
- main layout
- active editor
- transcript
- sidebar
- proof rail
- modal body
The question is not "did the DOM change?" The question is "did this stable surface lose continuity?"
Probe Signals
PerformanceObserver Entry Types
`layout-shift`: Observe with { type: 'layout-shift', buffered: true }. Each entry has:
value-- shift scorehadRecentInput-- true if within 500ms of user input (exclude from CLS)sources-- up to 5 shifted elements withnode,previousRect,currentRect
Capture sources[0]?.node tag name or selector in the report to go from "CLS is 0.25" to "this div shifted 170px because the image above it loaded without dimensions."
`long-animation-frame` (LoAF, Chrome 123+): Observe with { type: 'long-animation-frame', buffered: true }. Each entry provides:
duration,blockingDuration,renderStart,styleAndLayoutStartscripts[]withsourceURL,sourceFunctionName,forcedStyleAndLayoutDuration
Strictly superior to longtask -- tells you which script caused the jank. Feature-detect: PerformanceObserver.supportedEntryTypes.includes('long-animation-frame'). Fall back to longtask when unavailable.
`event` (for INP, optional): Observe with { type: 'event', buffered: true, durationThreshold: 104 }. Each entry gives processingStart, processingEnd, duration, interactionId. Measures input-to-paint latency per interaction.
`element` (optional): Add elementtiming="name" attribute to critical DOM elements, then observe { type: 'element', buffered: true }. Gives the render timestamp of specific elements -- useful for measuring when a card or panel actually paints.
Signal Interpretation
- Low CLS + selector replacement -> likely remount or identity problem
- Flicker or empty-visible frames -> hidden/show or async swap problem
- Scroll jump -> reset or subtree replacement problem
- LoAF with high
forcedStyleAndLayoutDuration-> layout thrashing in scripts - LoAF with high
blockingDuration-> main-thread pressure - Frame gaps without long tasks -> rendering pressure (large style/layout recalc)
Measurement Pitfalls
- CLS finalization: Lab CLS may under-report because the page never "hides." For interaction-scoped measurement (start/stop around one action), this is fine -- you measure shifts during the window, not lifetime CLS.
- INP requires real input events: Playwright's
click()andtype()generate real input events and triggereventtiming entries.page.evaluate(() => button.click())does NOT -- it bypasses the input pipeline. - LCP requires foreground visibility: LCP will not report in Playwright unless the page is in the foreground and some interaction occurs.
- `toHaveScreenshot()` cannot catch flicker: It waits for the screenshot to stabilize before comparing. By design, it misses transient intermediate states.
CDP Escalation Layer
Playwright exposes full Chrome DevTools Protocol via page.context().newCDPSession(page). Use for deeper signals when the standard probe is insufficient:
`Performance.getMetrics`: Take delta snapshots before/after an interaction. Returns cumulative counters including LayoutCount, RecalcStyleCount, RecalcStyleDuration -- tells you how many layout recalculations happened.
`Page.startScreencast`: Captures individual rendered frames as images. Diff consecutive frames with pixelmatch to detect GPU/paint-level flicker that DOM-level probes miss. Use { format: 'png', quality: 80, everyNthFrame: 1 }.
Tracing: Tracing.start with devtools.timeline,blink.user_timing categories captures the same events Chrome DevTools Performance panel shows. Produces large data volumes -- use only as escalation.
Browser Launch Flags for Accurate Measurement
--disable-background-timer-throttling--disable-backgrounding-occluded-windows--disable-renderer-backgrounding
These prevent Chrome from throttling timers and rendering when the window is not focused.
Agent Browser Tools
See tooling-and-signals.md § Agent Browser Tools for chrome-cdp vs dev-browser guidance. For probe work, prefer dev-browser (Playwright-based, scripted sequences, persistent pages).
Escalation Rule
If the probe explains the symptom, inspect source and fix it.
If the probe shows instability but not the cause:
- Go to react-path.md when the issue smells like remount or rerender churn
- Go to browser-path.md when the issue smells like layout, paint, or compositor work
React Path
Use this path when the UI feels rebuilt, a pane blinks, focus is lost, or a component seems to rerender or remount too often.
Inspect First
Look for these patterns before adding tools:
- Unstable or over-broad
keyusage - A large subtree keyed on session, route, timestamp, or step
- Conditional branch swaps that change component identity
- Loading states that replace populated content during refetch
useEffectmount-time correction that visibly changes state after first paint- Parent or context updates that force large subtrees to rerender
- Component defined inside another component's render body (full remount every parent render)
React Compiler Check
Before deep investigation, check if the suspect component is compiled:
- DevTools sparkle badge: Successfully compiled components show a sparkle icon in React DevTools. Absent badge means the compiler bailed out -- the component loses automatic memoization and may exhibit instability that surrounding compiled components do not.
- Enable `react-hooks/todo` as error in ESLint: This surfaces silent compilation failures. One team found 100+ components silently un-optimized.
- Common bail-out causes: destructuring + mutating props, complex try/catch blocks, ESLint suppression comments on any hook rule.
Tool Escalation
Tier 1 -- Zero setup
React Scan browser extension (Chrome Web Store). Hooks into React's fiber tree to visually highlight components as they render. Shows unnecessary renders, "Memoizable" tags, FPS drops during interactions. Use getReport() for programmatic render count data.
Tier 2 -- Dev build
React Performance Tracks (React 19.2+, Chrome DevTools Performance panel). Shows:
- Four priority subtracks (Blocking, Transition, Suspense, Idle) with Update/Render/Commit/Effects phases
- Components flamegraph with yellow Mount and Unmount badges -- a component that unmounts then mounts is a remount
- Changed Props inspection (dev builds) -- click any render entry to see exactly which props changed
- Effect duration flamegraph -- shows effect execution time and which effects triggered further updates
- Cascading update detection -- shows which component scheduled an update during render
This replaces the old standalone Profiler tab for most render stability investigations.
Tier 3 -- Code instrumentation
`<Profiler>` component for programmatic detection:
phaseparameter distinguishes"mount"/"update"/"nested-update""nested-update"= a layout effect triggered a state change, causing a second commit before paint (cascading update)- Two
"mount"phases for the same id in a short window = unexpected remount - Wire into E2E probes for CI-level render budget enforcement
react-render-tracker for fiber-level mount/update/unmount event logs across the full component tree over time.
Tier 4 -- Specialized
why-did-you-render for deep prop/state equality checks. Shows exactly what changed in context/state/hooks using deep-equality comparison. Incompatible with React Compiler -- do not use in compiler-enabled projects. Use React Scan instead.
Relevant React 19 APIs
`useEffectEvent` (stable in React 19.2): Extracts non-reactive logic from effects. Use when an effect re-synchronizes unnecessarily because its callback references frequently-changing values (e.g., theme, locale). The wrapped callback always reads the latest values but does not cause the effect to re-run.
`<Activity>` (React 19.2): For "whole pane feels rebuilt" symptoms on show/hide toggles. <Activity mode="hidden"> unmounts effects and deprioritizes rendering while preserving React state and DOM. Performance Tracks show Reconnect/Disconnect events for Activity components.
`startTransition`: Prevents Suspense boundaries from re-showing fallbacks for already-revealed content. Use when navigating between views triggers a brief fallback flash.
What To Prove
Try to answer one of these:
- This subtree rerendered more often than expected
- This subtree remounted instead of updating in place
- This loading or effect path replaced visible content
- This effect triggered a cascading update (
nested-updatephase)
When you can point to the identity boundary, the fix is usually clearer than when you start from generic performance language.
Strict Mode Note
Always reproduce against a production build. React Strict Mode double-invokes component bodies and effects in dev, inflating all render/commit counts 2x. However, develop with Strict Mode on -- its double effect execution catches non-idempotent effects that are often the same effects causing mount-time jank.
Scan Process and Output Format
How to execute a Mode A (static analysis) scan and report findings.
Scope
If no target is specified, scan files changed in the current branch (git diff --name-only against the base branch). If a target is given, scan that file/directory and its direct imports.
Direct importers: Also consider scanning files that import the changed files (one level up). If a parent component's layout depends on a changed child's dimensions, the child change can cause layout shift in the parent. Skip transitive dependents -- layout shift is almost always local (parent-child).
Shared layout components: If a changed file is a layout primitive (<Stack>, <Card>, <Skeleton>, etc.), expand scope to all direct importers since any sizing change affects every consumer.
Process
1. Identify scope: Changed files, or target files + direct imports + direct importers. 2. Size check: Count .tsx/.jsx files in scope (excluding node_modules/, test files *.test.*, and type-only files).
- ≤ 15 component files: Read all of them. Skip to step 4.
- > 15 component files: Use the grep sweep in step 3 to prioritize reads.
3. Grep sweep (large scopes only): Run the signatures in the table below against all files in scope. Rank files by match count -- files hitting multiple signatures are highest priority. Deep-read all files with matches plus any the user specifically called out. Files with zero signature matches get deferred -- note them in the report as "grep-scanned only." 4. Read and analyze: For each file being deep-read, identify React component boundaries by pattern-matching on source text. Do not try to invoke an AST parser. Reserve AST-based tooling (dependency-cruiser, importree) for scope resolution only. 5. Follow local imports one level deep: For each deep-read file, check its import statements. If it imports a local component or context provider not already in scope, read that file too. This catches:
- Context providers with unstable values (pattern #16)
- Utility components with layout-affecting behavior
- Shared hooks that set state in effects
Do not chase imports into node_modules or beyond one level. 6. React Compiler check: Look for components that may have been silently skipped by the React Compiler (ESLint suppression comments on hook rules, complex try/catch, destructure-then-mutate patterns). These lose automatic memoization and are higher risk for render instability. 7. Pattern match: Check each component against ALL patterns in static-patterns.md. 8. Assess severity:
- Critical: Almost always produces visible jank regardless of conditions.
- High: Produces jank under normal usage conditions (user interaction, data loading).
- Medium: Produces jank under specific conditions (slow device, large dataset, rapid interaction).
- Low: Could produce jank, context-dependent -- note the condition.
9. Note unavoidable patterns: Some patterns (e.g., textarea auto-resize via write-then-read) are the standard approach with no better alternative. Still note them as "acknowledged, no actionable fix" so the report is complete and the user knows the pattern was seen and evaluated, not overlooked. 10. Skip false positives: See guardrails below.
Grep Signatures for Pre-Scan
Run these against .tsx/.jsx files in scope. Matches identify candidate files for deep reading.
High-confidence (match strongly suggests a finding):
| Signature | Pattern |
|---|---|
transition-all | #11 Animating layout properties |
key={Math.random or key={Date.now | #5 Unstable keys |
.Provider value={{ | #16 Unstable context value |
Candidate (match warrants deep reading to confirm):
| Signature | Pattern |
|---|---|
useEffect | #1 Effect-driven init, #2 Derived state, #3 Chained cascade |
<img (check for missing width/height/aspect-) | #6 Unsized async content |
<Suspense or React.lazy | #8 Suspense boundary flash |
typeof window or localStorage or sessionStorage | #9 Hydration mismatch (SSR only) |
offsetHeight or getBoundingClientRect or getComputedStyle | #10 Layout thrashing |
ref={( | #13 Ref callback remount |
@font-face or fonts.googleapis | #15 Font flash |
Require deep reading (no reliable grep signature):
| Pattern | Why |
|---|---|
| #4 Conditional mount/unmount | Most conditional renders are fine -- context needed |
| #7 Skeleton/placeholder absence | Structural comparison between loading and loaded states |
| #12 Missing startTransition | Requires understanding render cost |
| #14 Z-index/stacking context flash | Requires understanding conditional styling |
| #17 Component defined inside component | Requires understanding scope nesting |
| #18 Async waterfall | Requires understanding data dependency chain |
What to Skip (React Compiler Territory)
Do not duplicate what the React Compiler and its ESLint rules already handle:
setStateduring render (caught byreact-hooks/set-state-in-render)- Manual
useMemo/useCallbacksuggestions for non-jank-producing code (compiler auto-memoizes) - Unsafe ref access during render (caught by
react-hooks/refs)
Focus on patterns the compiler does NOT address: layout shift from missing dimensions, useEffect vs useLayoutEffect for DOM measurements, missing loading states, conditional rendering of large blocks without space reservation, font flash, async waterfalls.
Output Format
Group findings by component/file, not by pattern category. For each finding:
### [ComponentName] -- path/to/file.tsx:L##
**[Pattern Name]** (Severity)
What the user sees: [one sentence describing the visual impact]
Code: [the specific lines]
Fix: [the specific fix for this instance -- not generic advice, actual code]Lead with severity and visual impact. Include the specific code snippet. Provide a concrete fix. Distinguish "must fix" from "consider fixing" -- mixing these erodes trust.
End with a summary:
## Summary
- Critical: N findings (must fix -- users definitely see these)
- High: N findings (should fix -- users likely see these)
- Medium: N findings (consider fixing -- visible under certain conditions)
- Low: N findings (optional -- minor or context-dependent)
- Files grep-scanned only: N (if applicable -- list them for transparency)Guardrails
Do not:
- Skip files in scope. For scopes ≤ 15 files, every component file must be read. For larger scopes, every file must be at least grep-scanned. If you didn't read or scan it, you can't clear it.
- Flag every conditional render as jank -- most are fine. Only flag when the component has significant visual height, the condition toggles during user interaction, and there is no reserved space.
- Suggest
useMemoeverywhere as a blanket fix -- only where it prevents visible jank. With React Compiler, manual memoization is increasingly unnecessary. - Ignore the "what the user sees" test -- if you cannot articulate the visual impact, it is probably not jank.
- Recommend
useLayoutEffectuniversally -- it blocks paint and creates its own problems if overused. - Miss
transition-allin Tailwind -- it is the single most common source of animation jank in Tailwind projects. - Flag patterns inside generated code,
node_modules, or vendor directories. - Flag
useEffectwith empty deps as jank when no DOM measurement or state setter is involved. - Silently omit "standard but unavoidable" patterns like textarea auto-resize. Note them as acknowledged.
Static Pattern Catalog
Scan for ALL of the following. Each pattern includes what to look for in the source and why it causes jank.
---
1. Effect-Driven State Initialization (Critical)
What to find: useEffect or useEffect(..., []) whose body calls a state setter with a value different from the initial useState default.
// BAD: renders once with `false`, then immediately re-renders with `true`
const [isDark, setIsDark] = useState(false);
useEffect(() => {
setIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
}, []);What the user sees: 1-2 frames of light theme before snapping to dark. A "flash."
Fixes (in order of preference): 1. Compute the correct initial value inline: useState(() => window.matchMedia(...).matches) 2. If it must be an effect (e.g., reading a ref), use useLayoutEffect -- it fires before paint 3. For external stores, use useSyncExternalStore which avoids the double-render entirely
Special case: When the initial state is read from localStorage, sessionStorage, document.cookie, or similar browser APIs -- same pattern, same fix (lazy initializer).
Verify: The useState initializer computes the correct value directly. The useEffect that called the setter is removed or converted to useLayoutEffect if a ref read is required.
---
2. Derived State in Effects (High)
What to find: useEffect that watches prop/state and sets another piece of state that could be computed during render.
// BAD: every time `items` changes, renders twice -- once stale, once correct
const [filtered, setFiltered] = useState([]);
useEffect(() => {
setFiltered(items.filter(i => i.active));
}, [items]);What the user sees: A single frame of stale data before the filtered list updates.
Fix: Compute during render -- const filtered = useMemo(() => items.filter(...), [items]) -- or just inline it if cheap.
Verify: The useEffect + setState pair is gone, replaced by inline computation or useMemo. The derived value updates in the same render as its source.
---
3. Chained Effect Cascade (High)
What to find: Two or more useEffect hooks where one sets state that triggers another. The chain effect A -> setState -> re-render -> effect B -> setState -> re-render produces multiple intermediate frames.
What the user sees: Cascading visual updates -- elements shifting, appearing, or changing in sequence over 2-4 frames instead of all at once.
Fix: Collapse into a single state update. Use useReducer if the logic is complex, or compute derived values during render. If effects are truly independent and order doesn't matter, they can stay separate -- but if B depends on A's state, they should be one unit.
Verify: The chain of effects is collapsed. A single state change produces one render cycle, not multiple sequential re-renders.
---
4. Conditional Mount/Unmount (High)
What to find: JSX that toggles component existence based on state/props.
// BAD: component unmounts and remounts -- animation restarts, state resets
{isOpen && <Panel />}
{showSidebar ? <Sidebar /> : null}When this is jank: When the condition toggles rapidly or during transitions. The unmount/remount cycle means:
- CSS enter animations replay
- Component internal state resets
- A frame of "nothing" may be painted between unmount and remount
When this is fine: Lazy rendering of expensive components that aren't needed. Modal open/close where you want state reset.
Fixes (in order of preference): 1. React 19.2+: Use <Activity mode={isOpen ? "visible" : "hidden"}> -- preserves state, hides DOM, destroys effects, deprioritizes hidden updates. The first-party solution for coarse-grained show/hide. 2. Use CSS to hide instead of unmounting:
// STABLE: component stays mounted, no remount flash
<Panel className={isOpen ? 'visible' : 'hidden'} />
<Panel style={{ display: isOpen ? 'block' : 'none' }} />
<Panel hidden={!isOpen} />Judgment call: Flag this as a finding but note that not every conditional render is a bug -- flag specifically when the condition is tied to state that changes during user interaction (hover, focus, animation, toggle).
Verify: The component stays mounted across the toggle. Internal state persists and CSS enter animations do not replay.
---
5. Unstable Key Props (High)
What to find: key prop set to a value that changes when it shouldn't -- Math.random(), Date.now(), uuid() called inline, or array index on a list that reorders.
// BAD: new key every render = full unmount/remount every frame
<Panel key={Math.random()} />
<Item key={`${item.id}-${Date.now()}`} />What the user sees: Component flashes/blinks on every re-render. Animations restart. Input fields lose focus.
Fix: Use a stable identifier. If using index, ensure the list never reorders or filters.
Verify: The key prop uses a stable identifier. Grep for the old unstable expression to confirm it is removed.
---
6. Unsized Async Content -- Layout Shift (Critical)
What to find: <img>, <video>, <iframe>, or any container that loads async content without explicit dimensions.
// BAD: content below the image jumps when it loads
<img src={url} />
<img src={url} className="w-full" /> // width set, but no height/aspect-ratio
// STABLE
<img src={url} width={640} height={480} />
<img src={url} className="w-full aspect-video" />
<div className="aspect-[4/3]"><img src={url} className="object-cover" /></div>What the user sees: Content below the element jumps/shifts when the async content arrives.
Fix: Set explicit width+height attributes, or use CSS aspect-ratio. For dynamic content, use a skeleton placeholder with the correct dimensions.
Verify: The element has explicit width+height attributes or CSS aspect-ratio. Content below does not shift when the async content loads.
---
7. Skeleton/Placeholder Absence (Medium)
What to find: Components that render loading states as structurally different from their loaded state -- different height, different layout, or just a spinner.
// BAD: loading state is 40px tall, loaded state is 400px tall
if (loading) return <Spinner />;
return <DataTable rows={data} />;What the user sees: Layout jumps when data arrives because the loading placeholder was a different size.
Fix: Use a skeleton that matches the loaded component's dimensions. Or use min-height on the container.
Verify: Loading and loaded states have matching outer dimensions. The layout does not jump when data arrives.
---
8. Suspense Boundary Flash (Medium)
What to find: <Suspense> with a fallback that can resolve within a few frames (~100ms), causing the fallback to flash briefly before content appears.
Also find: React.lazy() imports without any Suspense boundary (these will throw, but before that they cause a blank flash).
What the user sees: A loading spinner/skeleton blinks for a split second.
Fix: Wrap the state update that triggers the Suspense in startTransition -- React will keep showing the old UI while the new content loads, avoiding the flash. For short fetches, set a minimum delay on the loading state display.
Verify: Navigate to the Suspense-wrapped content. The previous UI stays visible during short loads instead of flashing a fallback.
---
9. Hydration Mismatch Sources (Critical for SSR/SSG)
What to find: Client-only values used in JSX during initial render:
window.*,document.*,navigator.*localStorage.*,sessionStorage.*Date.now(),new Date()(if value differs server/client)Math.random()typeof window !== 'undefined'used to conditionally render different JSX
// BAD: server renders null, client renders the component -> flash
{typeof window !== 'undefined' && <ClientOnlyWidget />}What the user sees: Content briefly appears as the server-rendered version, then flickers as React hydration replaces it with the client version.
Fix: Use a hydration-safe pattern -- useEffect to set a mounted flag, then conditionally render. Or use the framework's client-only wrapper (next/dynamic with ssr: false, Remix ClientOnly).
SSR detection: Check for next.config.*, remix.config.*, astro.config.*, or exports like getServerSideProps, generateStaticParams, loader, getStaticProps in route files. Also check for hydrateRoot or renderToPipeableStream in entry files. If none found, skip this pattern -- the project is a pure client-side SPA.
Verify: Server and client render identical initial HTML. No React hydration warnings appear in the browser console. The client-only content uses a hydration-safe pattern (useEffect + mounted flag, or framework client-only wrapper).
---
10. Layout Thrashing in Event Handlers (Medium)
What to find: Reading layout properties (offsetHeight, offsetWidth, getBoundingClientRect(), scrollTop, clientHeight, getComputedStyle()) then immediately writing to the DOM or setting state in the same synchronous block.
// BAD: read -> write -> read -> write forces multiple reflows
function handleResize() {
const h = element.offsetHeight; // read (forces layout)
element.style.height = h * 2; // write (invalidates layout)
const w = element.offsetWidth; // read (forces layout AGAIN)
element.style.width = w * 2; // write
}What the user sees: Sluggish animations, dropped frames during scroll/resize.
Fix: Batch all reads, then batch all writes. Or use requestAnimationFrame. Or better -- use CSS for the layout logic so the browser handles it.
Verify: DOM reads and writes are batched separately. No forced reflow warnings appear in the Chrome Performance panel during the interaction.
---
11. Animating Layout Properties (Medium)
What to find: CSS transitions or animations on width, height, top, left, right, bottom, margin, padding, border-width, font-size. In Tailwind: transition-all is a common culprit.
/* BAD: triggers layout recalculation every frame */
.panel { transition: height 0.3s, width 0.3s; }
/* FAST: only composite properties -- GPU-accelerated */
.panel { transition: transform 0.3s, opacity 0.3s; }What the user sees: Janky/stuttery animations, especially on lower-powered devices.
Fix: Animate only transform and opacity. Use scale() instead of width/height changes, translate() instead of top/left. If you must animate layout properties, add will-change and accept the trade-off.
Tailwind-specific: Flag transition-all -- it transitions every property including layout ones. Use transition-transform, transition-opacity, or transition-colors instead.
Verify: Animations target only transform and opacity. No layout recalculations per frame in the Performance panel. In Tailwind, transition-all is replaced with a specific transition utility.
---
12. Missing startTransition for Expensive Updates (Low)
What to find: State updates that trigger expensive re-renders (large lists, heavy computation) without being wrapped in startTransition.
What the user sees: UI freezes momentarily -- input feels laggy, buttons feel unresponsive.
Fix: Wrap non-urgent updates in startTransition to let React yield to the browser for paint between renders.
Verify: The UI remains responsive during the update. Typing or clicking does not feel laggy while the expensive re-render is in progress.
---
13. Ref Callback Remount (Medium -- reduced with React Compiler)
What to find: Inline ref callbacks that create a new function identity every render.
// BAD: new function every render -> ref detaches and reattaches
<div ref={(node) => { /* measure or focus */ }} />What the user sees: Focus loss, measurement jitter, or elements re-initializing.
Fix: Memoize the ref callback with useCallback, or use a ref object (useRef).
React 19 notes:
forwardRefis deprecated -- refs are now passed as regular props.- React Compiler auto-memoizes inline ref callbacks, reducing relevance in compiled projects. Check for the DevTools sparkle badge -- if the component is compiled, this pattern is likely handled.
- Ref callbacks can now return a cleanup function (like
useEffect). React calls the cleanup on unmount instead of calling the ref withnull.
Verify: The ref callback has stable identity (memoized with useCallback or using useRef). Focus and measurements are not disrupted on re-render.
---
14. Z-Index / Stacking Context Flash (Low)
What to find: Elements that create a new stacking context conditionally (position, z-index, transform, opacity < 1, will-change toggled via state), causing content to visually "pop" in front of or behind other content for a frame.
What the user sees: An element briefly appears above/below where it should be.
Fix: Keep the stacking context stable -- apply position: relative and z-index unconditionally if the element participates in stacking.
Verify: Stacking context properties (position, z-index) are applied unconditionally. The element does not visually pop between layers during state changes.
---
15. Font Flash -- FOUT/FOIT (High)
What to find: Custom font loading without preloading or font-display control. In Next.js: using <link href="fonts.googleapis.com/..."> instead of next/font.
What the user sees: Text renders in a fallback font then swaps to the custom font (FOUT -- Flash of Unstyled Text), or text is invisible until the font loads (FOIT -- Flash of Invisible Text). Both cause layout shift when glyph metrics differ.
Fixes: 1. Use font-display: optional -- if the font isn't cached, skip it entirely (zero flash) 2. Preload critical fonts: <link rel="preload" href="font.woff2" as="font" crossorigin> 3. Use font metric overrides (size-adjust, ascent-override, descent-override) to match fallback glyph metrics 4. In Next.js: use next/font which handles preloading and metric overrides automatically
Verify: Hard-refresh the page. Text renders in the final font immediately (or the fallback is visually identical via metric overrides). No visible font swap or layout shift from glyph metric differences.
---
16. Unstable Context Value (High)
What to find: A Context.Provider whose value prop is a new object/array literal on every render.
// BAD: new object every render -> all consumers re-render every time parent renders
<ThemeContext.Provider value={{ theme, toggleTheme }}>What the user sees: Unrelated parts of the UI re-render and potentially flicker when a parent component updates, even if the context value hasn't meaningfully changed.
Fix: Memoize the value:
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
<ThemeContext.Provider value={value}>Note: React Compiler auto-memoization mitigates this but does not eliminate all cases. @eslint-react/no-unstable-context-value catches it statically.
Verify: The value prop is memoized. Context consumer components do not re-render when the provider's parent updates but the context value has not changed.
---
17. Component Defined Inside Component (Critical)
What to find: A function component defined inside another component's render body.
function Parent() {
// BAD: ChildPanel is a new component type every render -> full remount every time
function ChildPanel() {
return <div>{/* ... */}</div>;
}
return <ChildPanel />;
}What the user sees: The inner component fully remounts on every parent render -- all state is destroyed, DOM is rebuilt, animations restart. Visually identical to an unstable key.
Fix: Move the component definition outside the parent function. If it needs parent scope, pass values as props.
Verify: The inner component is defined outside the parent function. It retains state across parent re-renders and does not remount.
---
18. Async Waterfall in Component Tree (High)
What to find: Sequential data fetching in nested components where a child cannot start fetching until its parent renders with data.
// BAD: waterfall -- parent fetches, renders child, child fetches, renders grandchild
function Dashboard() {
const { data: user } = useQuery('user');
if (!user) return <Spinner />;
return <UserProjects userId={user.id} />; // fetches after parent resolves
}What the user sees: Cascading loading states -- each level adds a full round-trip of latency before the next level can even start. Content pops in section by section.
Fixes: 1. Hoist fetches to the top level and fetch in parallel 2. Use React Server Components with parallel data loading 3. Use framework-level data loading (loader in Remix/React Router, generateMetadata/fetch in Next.js Server Components) 4. Use <Suspense> with parallel use() or useSuspenseQuery calls
Verify: Open the browser Network tab and trigger the data loading. Fetches that were previously sequential now start in parallel (overlapping in the waterfall view).
Tooling and Signals
Use this reference when choosing the next debugging layer or when setting up a layered stability workflow.
Decision Matrix
| Symptom shape | Start with | Escalate to | Why |
|---|---|---|---|
| Stable pane blinks or feels rebuilt | Playwright + selector-level probe | React Performance Tracks | Identity loss and remounts are usually easier to prove than paints |
| Layout jumps or scroll resets | Playwright + layout/scroll probe | Chrome Performance + Rendering | CLS and geometry changes need browser timing |
| Stutter or delayed feedback | Playwright + LoAF probe | Chrome Performance | LoAF script attribution identifies the blocking code |
| Looks fine locally but breaks in prod | Existing probe if reproducible | web-vitals attribution + LoAF | Field-only issues need telemetry |
| Unsure whether React or browser | Playwright + selector-level probe | React Performance Tracks or Chrome based on first signals | Cheap evidence should pick the branch |
What Each Tool Is Good For
Static Analysis / Linting
Use as a pre-merge check. Catches root causes before they become runtime symptoms.
Best for:
- Detecting known jank-producing code patterns before merge
- CI enforcement with zero runtime cost
- Catching React Compiler bail-outs (
react-hooks/todorule)
Key tools:
eslint-plugin-react-hooks(v6.1+) -- official, includes React Compiler validation ruleseslint-plugin-react-perf-- inline object/array/function literals in JSX that defeat memoization@eslint-react/eslint-plugin-- 4-7x faster, covers hooks, RSC, DOM, namingoxlint-- 50-100x faster pre-pass with 700+ built-in rules
Weak at:
- Layout shift from missing dimensions (no mainstream ESLint rule for
<img>without width/height outside Next.js) - Runtime-dependent patterns (timing, device speed, network conditions)
Playwright + Local Probe
Use as the default reproducible layer.
Best for:
- Scripted repro and CI artifacts
- Selector replacement and flicker detection
- Layout shift with element attribution (via
sourcesarray) - LoAF with per-script attribution (Chrome 123+, falls back to long tasks)
- DOM mutation churn summaries
Weak at:
- Exact React commit attribution
- Paint invalidation regions
- Layer/compositor detail
React Performance Tracks (React 19.2+)
Use when the problem smells like rerender or remount churn. Shows in Chrome DevTools Performance panel.
Best for:
- Mount/unmount badges per component (remount = unmount then mount)
- Cascading update detection (which component scheduled an update during render)
- Changed props inspection (dev builds)
- Effect duration flamegraph
- Four priority subtracks (Blocking, Transition, Suspense, Idle)
Weak at:
- Browser paint/compositor analysis
- Production parity unless profiling builds are enabled
React Scan
Use as a fast visual rerender detector. Browser extension works on any React site.
Best for:
- Visual highlighting of rendering components
- "Memoizable" tags for components that would benefit from React.memo
- FPS drop detection during interactions
- Programmatic render budgets via
getReport()for CI enforcement
Weak at:
- Render timing (only counts, not durations)
- Layout shift, paint, compositor detail
- Production use (unsafe outside dev)
why-did-you-render
Use for deep prop/state equality analysis when you need to know exactly what changed.
Best for:
- Identifying when a component rerenders with semantically identical props/state
- Deep-equality comparison that catches new-object-every-render patterns
Weak at:
- Incompatible with React Compiler -- do not use in compiler-enabled projects
- Production realism
- Browser rendering detail
Chrome DevTools Performance + Rendering
Use when the problem smells like layout, paint, or compositing.
Best for:
- Screenshots per frame
- Layout shift clusters with element attribution (Insights sidebar)
- INP 3-phase breakdown (input delay / processing / presentation)
- LoAF integration in console (script attribution for slow interactions)
- Forced reflow detection (auto-surfaced in Insights)
- Paint instrumentation and Layers panel
Weak at:
- Fast automation loops unless you already have a stable repro
Visual Regression Testing
Use for screenshot-based stability checks in CI.
Best for:
- Detecting visual regressions between builds
- Element-level or full-page comparison
- Baseline management
Key tools:
- Playwright
toHaveScreenshot()-- built-in, free, uses pixelmatch - Chromatic -- Storybook-centric, component-level
- Percy (BrowserStack) -- cross-browser, AI review agent
Weak at:
- Cannot catch transient flicker (
toHaveScreenshot()waits for stability before comparing) - Requires baseline maintenance
web-vitals Attribution
Use when the issue is intermittent, route-specific, or only visible in production.
Best for:
- CLS with element-level attribution (
largestShiftTarget) - INP with 3 subparts + LoAF script entries
- LCP with 4-phase breakdown
- Tying bad metrics to pages, routes, or UI states
Weak at:
- Proving a single local visual glitch
- Non-CLS jank (flicker, remount churn, Suspense flash)
Layered Workflow
| Layer | When | Tools | What it catches |
|---|---|---|---|
| Static (every commit) | CI | ESLint + React Compiler rules + oxlint | Root cause patterns |
| Dev-time (local iteration) | Dev | React Scan + Performance Tracks | Rerender storms, remounts |
| Lab/CI (every PR or nightly) | CI | Playwright probe + toHaveScreenshot() | CLS, flicker, frame gaps, visual regression |
| Field (production) | Prod | web-vitals attribution + LoAF + RUM | Real-user CLS, INP, intermittent issues |
Suggested Starting Budgets
Use report-only first. Promote these to assertions only after the interaction is stable.
| Signal | Budget | Context |
|---|---|---|
| Non-input layout shift (CLS) | < 0.02 | Google's field "good" is 0.1 at p75; per-interaction budget is intentionally stricter |
| INP per interaction | < 200ms | Google's "good" threshold; aspirational: < 100ms |
| Long animation frames > 50ms | 0 preferred, <= 1 tolerated | With LoAF, also log sourceURL for attribution |
| Tracked selector flicker | 0 | Any flicker on a stable surface is a finding |
| Tracked selector replacement on stable panes | 0 | Identity loss on a stable surface is a finding |
| Max frame gap | < 75ms | ~4 dropped frames at 60fps |
If the app intentionally resets a full workspace, mark that interaction as a controlled remount instead of pretending it is stable.
Agent Browser Tools
When you need to interact with a live browser during investigation:
- chrome-cdp (
npx skills add pasky/chrome-cdp-skill --all -g): Quick inspection of a tab the user already has open. Screenshots, JS eval, accessibility snapshots. Lightweight -- no server, no Playwright. Use for one-shot inspection. - [dev-browser](https://github.com/SawyerHood/dev-browser) (
npx skills add sawyerhood/dev-browser --all -g): Full Playwright-based automation with persistent named pages. Use for scripted interaction sequences, PerformanceObserver injection, request interception, headless CI runs. Thepageobject is a standard Playwright Page.
Pick chrome-cdp for "look at this tab," dev-browser for scripted multi-step investigation.