perf(races): keep distance/year/search filters responsive#252
Conversation
The /races page renders up to ~1,500 race cards. Clicking a distance filter updated both the active-button highlight and the full grid in a single synchronous render, so React couldn't paint the new button state until the entire list re-reconciled — the button appeared frozen for seconds, as reported in Reddit feedback. Keep the filter selections urgent so buttons/inputs respond instantly, and derive the expensive filtered grid from useDeferredValue copies so the heavy re-render happens in a non-blocking background pass. Extract the grid into a memoized component (so toggling the pending indicator doesn't re-render every card) and surface an "Updating…" spinner with a dimmed list while the deferred render catches up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7KxWfJiEzf58PeEWo5FRw
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughRaceList now defers and memoizes race filtering, renders cards through a memoized RaceGrid component, and displays result counts, an updating indicator, and reduced opacity while deferred results are pending. ChangesRace filtering and rendering
Estimated code review effort: 2 (Simple) | ~15 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
app/src/app/races/race-list.tsx (2)
147-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUpdate
outline-nonetooutline-hiddenfor Tailwind CSS v4.As per library documentation for Tailwind v4,
outline-nonenow only removes the outline style, which may not completely hide the browser's default focus outline. To ensure the default outline is fully removed and doesn't interfere with your customfocus:ring-white/40, usefocus:outline-hiddeninstead.♻️ Proposed fix
<input id="race-search" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Name or location" autoComplete="off" - className="px-4 py-2 rounded-full text-sm font-medium bg-white/10 text-white ring-1 ring-white/20 border-none placeholder:text-gray-500 focus:outline-none focus:ring-white/40 w-56" + className="px-4 py-2 rounded-full text-sm font-medium bg-white/10 text-white ring-1 ring-white/20 border-none placeholder:text-gray-500 focus:outline-hidden focus:ring-white/40 w-56" />🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/app/races/race-list.tsx` around lines 147 - 155, Update the input element in the race-list search control, identified by id="race-search", to use Tailwind CSS v4’s focus:outline-hidden utility instead of focus:outline-none while preserving the existing custom focus:ring-white/40 styling.
174-198: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueApply the pending opacity transition to the empty state message.
Currently, when the grid is updating (
isPending === true), the<RaceGrid />wrapper fades to 60% opacity to visually indicate the stale state. However, if the current state is empty (filtered.length === 0), the "No races match your filters" message remains at full opacity while the user changes filters, which might feel disconnected from the loading indicator.Consider moving the empty state message inside the fading wrapper so that it also receives the opacity transition.
✨ Proposed refactor
- {filtered.length === 0 && ( - <div className="py-12 text-center"> - <p className="text-gray-400">No races match your filters.</p> - <button - onClick={() => { - setDistance("All"); - setYear("All"); - setQuery(""); - }} - className="mt-3 px-4 py-2 rounded-full text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5 ring-1 ring-white/20 transition-colors" - > - Clear filters - </button> - </div> - )} - <div className={ isPending ? "opacity-60 transition-opacity duration-150" : "transition-opacity duration-150" } > - <RaceGrid races={filtered} /> + {filtered.length === 0 ? ( + <div className="py-12 text-center"> + <p className="text-gray-400">No races match your filters.</p> + <button + onClick={() => { + setDistance("All"); + setYear("All"); + setQuery(""); + }} + className="mt-3 px-4 py-2 rounded-full text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5 ring-1 ring-white/20 transition-colors" + > + Clear filters + </button> + </div> + ) : ( + <RaceGrid races={filtered} /> + )} </div>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/app/races/race-list.tsx` around lines 174 - 198, The empty-state message currently sits outside the pending-state opacity wrapper, so it does not fade while filters update. Move the `filtered.length === 0` empty-state block inside the wrapper controlled by `isPending`, while preserving the existing `RaceGrid` rendering and clear-filters behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/app/races/race-list.tsx`:
- Line 88: Memoize the years computation in the race-list component so the
mapping, Set creation, sorting, and reversing only rerun when the races input
changes. Use the existing races value as the dependency and preserve the current
descending, unique-year result.
---
Nitpick comments:
In `@app/src/app/races/race-list.tsx`:
- Around line 147-155: Update the input element in the race-list search control,
identified by id="race-search", to use Tailwind CSS v4’s focus:outline-hidden
utility instead of focus:outline-none while preserving the existing custom
focus:ring-white/40 styling.
- Around line 174-198: The empty-state message currently sits outside the
pending-state opacity wrapper, so it does not fade while filters update. Move
the `filtered.length === 0` empty-state block inside the wrapper controlled by
`isPending`, while preserving the existing `RaceGrid` rendering and
clear-filters behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: d0850ed8-4c6a-48c8-b32b-ae9dc3bf3c39
📒 Files selected for processing (1)
app/src/app/races/race-list.tsx
| const deferredYear = useDeferredValue(year); | ||
| const deferredQuery = useDeferredValue(query); | ||
|
|
||
| const years = [...new Set(races.map((r) => getYear(r.date)))].sort().reverse(); |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
Memoize the years array computation.
As per PR objectives, the goal is to keep filter selections urgent and responsive. However, years is recalculated synchronously on every render. For a list of ~1,500 races, mapping over the array, creating a Set, and sorting on every keystroke will block the main thread and cause input lag, negating the primary benefit of using useDeferredValue.
⚡ Proposed fix for performance
- const years = [...new Set(races.map((r) => getYear(r.date)))].sort().reverse();
+ const years = useMemo(() => [...new Set(races.map((r) => getYear(r.date)))].sort().reverse(), [races]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const years = [...new Set(races.map((r) => getYear(r.date)))].sort().reverse(); | |
| const years = useMemo(() => [...new Set(races.map((r) => getYear(r.date)))].sort().reverse(), [races]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/app/races/race-list.tsx` at line 88, Memoize the years computation in
the race-list component so the mapping, Set creation, sorting, and reversing
only rerun when the races input changes. Use the existing races value as the
dependency and preserve the current descending, unique-year result.
Problem
Reddit feedback on tritimes.org:
Root cause
The
/racespage renders up to ~1,500 race cards. Clicking a distance filter calledsetDistance, which updated both the active-button highlight and the full grid in a single synchronous render. React can't paint the new button state until the entire ~1,500-card list re-reconciles, so the button appeared frozen for several seconds.filterRacesitself is cheap (a single pass over 1,472 races) — the cost is the DOM reconciliation of the grid.Fix
useDeferredValuecopies, so React paints the new button state first, then re-renders the grid in a non-blocking, interruptible background pass.memo'dRaceGridcomponent so toggling the pending indicator doesn't re-render all ~1,500 cards.Verification
tsc --noEmit— no new errors (the 3 pre-existing errors inathlete-shards.test.tsare unrelated and present onmain).eslint src/app/races/race-list.tsx— clean.vitest run— all 157 tests pass.next dev—/racesrenders HTTP 200 with no runtime errors; filter selection now highlights immediately with the list updating behind an "Updating…" indicator.No linked GitHub/Linear issue — this addresses feedback from a Reddit comment.
🤖 Generated with Claude Code
https://claude.ai/code/session_01W7KxWfJiEzf58PeEWo5FRw
Summary by CodeRabbit
Performance Improvements
UI Improvements