Skip to content

perf(races): keep distance/year/search filters responsive#252

Merged
rootulp merged 1 commit into
mainfrom
worktree-vectorized-crunching-cocoa
Jul 16, 2026
Merged

perf(races): keep distance/year/search filters responsive#252
rootulp merged 1 commit into
mainfrom
worktree-vectorized-crunching-cocoa

Conversation

@rootulp

@rootulp rootulp commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Problem

Reddit feedback on tritimes.org:

The ui is sluggish—when I click on a brew distance filter (e.g. All -> 70.3) nothing happens for several seconds before the button state finally updates. Makes me wonder whether I actually hit the button. Button should change state right away so I know that the thing I did, did something. Throw up a loading indicator while the list is loading if needed (though it shouldn't take that long to filter the list).

Root cause

The /races page renders up to ~1,500 race cards. Clicking a distance filter called setDistance, 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. filterRaces itself is cheap (a single pass over 1,472 races) — the cost is the DOM reconciliation of the grid.

Fix

  • Filter selections (distance / year / query) stay urgent so buttons and inputs update the instant they're interacted with.
  • The expensive filtered grid is derived from useDeferredValue copies, so React paints the new button state first, then re-renders the grid in a non-blocking, interruptible background pass.
  • Extracted the grid into a memo'd RaceGrid component so toggling the pending indicator doesn't re-render all ~1,500 cards.
  • Added an "Updating…" spinner and a dimmed list while the deferred render catches up (the requested loading indicator).

Verification

  • tsc --noEmit — no new errors (the 3 pre-existing errors in athlete-shards.test.ts are unrelated and present on main).
  • eslint src/app/races/race-list.tsx — clean.
  • vitest run — all 157 tests pass.
  • next dev/races renders 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

    • Improved responsiveness when applying race filters.
    • Added visual feedback while filtered results are updating.
    • Reduced unnecessary race card re-rendering.
  • UI Improvements

    • Added a “Showing X of Y races” result count.
    • Displays an “Updating…” indicator while results refresh.

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
@rootulp rootulp self-assigned this Jul 16, 2026
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tritimes Ready Ready Preview, Comment Jul 16, 2026 4:13am

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

RaceList 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.

Changes

Race filtering and rendering

Layer / File(s) Summary
Deferred filtering state
app/src/app/races/race-list.tsx
Filter values are deferred and filtered results are memoized; isPending detects differences between current and deferred selections.
Memoized grid and pending feedback
app/src/app/races/race-list.tsx
Race cards render through RaceGrid, while the results summary and grid opacity indicate pending updates.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

  • rootulp/tritimes#231: Both changes modify the race-list filtering pipeline and filtered race results.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: improving responsiveness of race filters on the races page.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-vectorized-crunching-cocoa

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@rootulp
rootulp marked this pull request as ready for review July 16, 2026 04:12
@rootulp
rootulp merged commit f364b9a into main Jul 16, 2026
6 of 7 checks passed
@rootulp
rootulp deleted the worktree-vectorized-crunching-cocoa branch July 16, 2026 04:13

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
app/src/app/races/race-list.tsx (2)

147-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update outline-none to outline-hidden for Tailwind CSS v4.

As per library documentation for Tailwind v4, outline-none now 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 custom focus:ring-white/40, use focus:outline-hidden instead.

♻️ 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 value

Apply 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

📥 Commits

Reviewing files that changed from the base of the PR and between aeaac7e and 3695ee4.

📒 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 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.

Suggested change
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant