Skip to content

perf(desktop): fix tasks page rendering performance#1537

Closed
AviPeltz wants to merge 2 commits into
mainfrom
tasks-slow
Closed

perf(desktop): fix tasks page rendering performance#1537
AviPeltz wants to merge 2 commits into
mainfrom
tasks-slow

Conversation

@AviPeltz
Copy link
Copy Markdown
Collaborator

@AviPeltz AviPeltz commented Feb 17, 2026

Summary

  • Tasks page was slow due to N+1 useLiveQuery calls, eager Fuse.js index rebuilds, and unbuffered search input
  • Each AssigneeCell row fired its own live query to fetch all users — now deferred until dropdown opens
  • Fuse.js search indexes were rebuilt on every Electric sync tick — now lazily built only on first search
  • Search input had no debounce — every keystroke triggered a full table re-render synchronously

Changes

  • AssigneeCell: Gate useLiveQuery behind open state so the query only runs when the dropdown is opened (matches existing StatusCell pattern)
  • useHybridSearch: Replace useMemo-based Fuse instances with ref-based lazy initialization via ensureIndex() — indexes are only built when search() is called and invalidated by reference equality on the tasks array
  • TasksTopBar: Add 150ms debounced local state for the search input — keystrokes update the input instantly while the expensive search/filter/re-render is deferred

Test Plan

  • Open tasks page — verify it loads noticeably faster with many tasks
  • Click assignee cell — verify user dropdown still populates correctly
  • Type in search — verify input is responsive and results appear after a short delay
  • Press Escape in search — verify it clears immediately
  • Change task status via status cell dropdown — verify it still works

Summary by CodeRabbit

  • Performance Improvements

    • Faster, more responsive task search with local debouncing and cancel-on-unmount behavior.
    • More efficient search indexing for quicker results.
  • Bug Fixes / Reliability

    • Escape clears search input and triggers a reset.
    • Assignee cells now use supplied user data to reduce live queries and improve rendering.

Eliminate N+1 useLiveQuery calls in AssigneeCell by deferring the users
query until the dropdown is opened. Lazily build Fuse.js search indexes
only when a search is actually performed. Debounce the search input to
prevent synchronous table re-renders on every keystroke.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 17, 2026

📝 Walkthrough

Walkthrough

Implements a debounced local search input in TasksTopBar, refactors useHybridSearch to build Fuse indexes via refs with lazy initialization, and moves user-provisioning for assignee rendering from internal queries into the table hook (useTasksTable) so AssigneeCell receives users as a prop.

Changes

Cohort / File(s) Summary
TasksTopBar (debounced input)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx
Adds localSearch state, debounceRef, and handleSearchInput to debounce typing before calling onSearchChange. Syncs localSearch with external searchQuery when cleared, resets on Escape, and cancels pending debounce on unmount.
Search indexing refactor
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts
Replaces memoized Fuse instances with ref-based indexing: introduces exactFuseRef, fuzzyFuseRef, tasksRef, and ensureIndex() to lazily build Fuse indexes when tasks change; updates search flow to use guarded ref access and maps results with adjusted scoring and matchType.
AssigneeCell → users prop
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/AssigneeCell/AssigneeCell.tsx
Removes local user-querying logic and adds a users: SelectUser[] prop; component now renders assignee options from the provided users array.
Pass users from hook
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
Adds live query to fetch allUsers, memoizes users, includes users in columns dependencies, and passes users into AssigneeCell in table cell renderers.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I nibble keys with gentle taps and wait,

Debounced breaths slow the typing's gait.
Refs stitch indexes in a secret lair,
Assignees arrive when the table calls—aware.
Hooray for tidy hops in code and air!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change—addressing tasks page rendering performance—which aligns with the core objective of all modifications across multiple files.
Description check ✅ Passed The description provides a clear summary of the problem, detailed changes to three key files, and a test plan. It follows the template structure with all essential sections completed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch tasks-slow

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts (1)

19-20: Writing to a ref during render — safe here but worth a note.

Assigning tasksRef.current = tasks during render is a well-known pattern for "latest value" refs, but in Concurrent React it can theoretically read a value from a discarded render. This is benign here because the ref is only consumed inside event-driven callbacks (search), never during render output. Just flagging for awareness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts`
around lines 19 - 20, The ref write to tasksRef.current during render can read
values from discarded renders in concurrent React; instead update the ref inside
an effect so the latest tasks are set after commit — e.g., replace the inline
assignment with a useEffect that sets tasksRef.current = tasks (or, if you
intentionally rely on the render-time assignment, add a clear comment above
tasksRef/useRef and where search uses it explaining it’s only read from
event-driven callbacks). Update references: tasksRef, useRef, and the search
callback to match this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx`:
- Around line 61-63: The effect that syncs localSearch to an empty parent
searchQuery must also cancel any pending debounced update so a stale
onSearchChange(oldValue) doesn't fire; inside the useEffect that checks if
(searchQuery === "") { setLocalSearch("") } also clear the debounce (e.g.,
clearTimeout(searchDebounceRef.current) or call
searchDebounceRef.current.cancel() if using lodash/debounce) and reset the ref,
ensuring no pending onSearchChange(value) will run after the parent clears the
query.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts`:
- Around line 19-20: The ref write to tasksRef.current during render can read
values from discarded renders in concurrent React; instead update the ref inside
an effect so the latest tasks are set after commit — e.g., replace the inline
assignment with a useEffect that sets tasksRef.current = tasks (or, if you
intentionally rely on the render-time assignment, add a clear comment above
tasksRef/useRef and where search uses it explaining it’s only read from
event-driven callbacks). Update references: tasksRef, useRef, and the search
callback to match this change.

Comment on lines +61 to +63
useEffect(() => {
if (searchQuery === "") setLocalSearch("");
}, [searchQuery]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Stale debounce fires after parent clears the search.

When the parent resets searchQuery to "" (e.g., on tab change), localSearch is synced to "", but a pending debounced onSearchChange(value) from a prior keystroke can still fire ~150 ms later, re-populating the search with the old value.

Cancel the timer alongside the sync:

Proposed fix
 	useEffect(() => {
-		if (searchQuery === "") setLocalSearch("");
+		if (searchQuery === "") {
+			clearTimeout(debounceRef.current);
+			setLocalSearch("");
+		}
 	}, [searchQuery]);
📝 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
useEffect(() => {
if (searchQuery === "") setLocalSearch("");
}, [searchQuery]);
useEffect(() => {
if (searchQuery === "") {
clearTimeout(debounceRef.current);
setLocalSearch("");
}
}, [searchQuery]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx`
around lines 61 - 63, The effect that syncs localSearch to an empty parent
searchQuery must also cancel any pending debounced update so a stale
onSearchChange(oldValue) doesn't fire; inside the useEffect that checks if
(searchQuery === "") { setLocalSearch("") } also clear the debounce (e.g.,
clearTimeout(searchDebounceRef.current) or call
searchDebounceRef.current.cancel() if using lodash/debounce) and reset the ref,
ensuring no pending onSearchChange(value) will run after the parent clears the
query.

Query all users once at the table level and pass them down as a prop,
eliminating the per-cell useLiveQuery entirely. Fixes the empty dropdown
flash on first open since user data is already available.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx (1)

307-328: Consider using a ref to avoid column recreation on user-list changes.

The dependency array changed from [] to [users], which is necessary for correctness since the AssigneeCell renderer closes over users. However, this means the entire column definition array is recreated whenever the users list changes, which triggers a full table re-render.

If useLiveQuery returns a new allUsers reference on every Electric sync tick (even when the actual user data is unchanged), this could partially offset the performance gains from eliminating per-row queries. You could use a ref + structural comparison to keep users referentially stable:

♻️ Optional: stabilize users reference
+ import { useRef } from "react";
+ 
+ function useStableArray<T>(arr: T[]): T[] {
+   const ref = useRef(arr);
+   if (
+     arr.length !== ref.current.length ||
+     arr.some((item, i) => item !== ref.current[i])
+   ) {
+     ref.current = arr;
+   }
+   return ref.current;
+ }

Then wrap users:

- const users = useMemo(() => allUsers ?? [], [allUsers]);
+ const users = useStableArray(useMemo(() => allUsers ?? [], [allUsers]));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx`
around lines 307 - 328, The columns are being recreated whenever the `users`
array identity changes; stabilize it by introducing a ref (e.g., `usersRef`)
that you update only when the user list actually changes (structural compare by
id/length or deep equality) and then pass `usersRef.current` into the
`AssigneeCell` renderer instead of the raw `users` array; update the column
`useMemo` dependency to `[]` (or other stable deps) so the column definitions
(e.g., the `columnHelper.accessor` that renders `AssigneeCell`) aren't recreated
on every sync tick.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx`:
- Around line 307-328: The columns are being recreated whenever the `users`
array identity changes; stabilize it by introducing a ref (e.g., `usersRef`)
that you update only when the user list actually changes (structural compare by
id/length or deep equality) and then pass `usersRef.current` into the
`AssigneeCell` renderer instead of the raw `users` array; update the column
`useMemo` dependency to `[]` (or other stable deps) so the column definitions
(e.g., the `columnHelper.accessor` that renders `AssigneeCell`) aren't recreated
on every sync tick.

@AviPeltz AviPeltz closed this Mar 7, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 7, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch
  • ⚠️ Electric Fly.io app

Thank you for your contribution! 🎉

@Kitenite Kitenite deleted the tasks-slow branch March 15, 2026 16:10
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