Skip to content

perf(staged): keep the projects sidebar and home search responsive while typing - #1003

Merged
matt2e merged 4 commits into
mainfrom
project-sidebar-search-so-slow
Oct 29, 2026
Merged

matt2e merged 4 commits into
mainfrom
project-sidebar-search-so-slow

Conversation

@matt2e

@matt2e matt2e commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Problem

With 127 projects, typing in the projects sidebar search froze the window for 65–185 ms per keystroke (WebKit release build). The search on the home grid had the same problem. Each keystroke did three costly things:

  • Hidden views followed the shared query. The home grid stays mounted under the project view, and the sidebar stays mounted in the clipped layer under home. Each keystroke filtered both views, including the one nobody could see.
  • Filtered-out rows were unmounted. The sidebar rows and grid cards were in keyed {#each} blocks over the filtered lists, so every row a keystroke filtered out was destroyed and then rebuilt on the next backspace. Mounting a pinned RepoCard also started a list_all_repo_actions + get_all_running_actions IPC wave and three backend event subscriptions.
  • list_all_repo_actions was a sync Tauri command. It ran on the main thread and waited on the store's connection mutex behind any writer.

Fix

  • Hide instead of unmount. The sidebar and the home grid now render their full lists and set hidden on the entries the query rules out. sidebarSearch.ts returns match sets (matchingProjectIds / matchingRepoKeys) instead of filtered lists. In the sidebar, hidden goes on the display: contents trigger wrapper because WebKit re-shows the children of a contents element superlinearly (~550 ms vs ~27 ms for 100 rows). Each element the search can hide has its own display: none rule. filteredProjects is still the on-screen order, so ⌘1–9, scroll restore, the live-region count and the empty states are unchanged. The ⌘ overlay now gets its number from projectShortcutNumbers(filteredProjects).
  • Gate the hidden layer with followWhileVisible (navigation.svelte.ts). While its layer is visible, a view follows the shared query in the same flush. While it's hidden, the view only follows once the query has been unchanged for 300 ms, so typing in the other layer doesn't re-filter it per key. A change still pending when the layer comes back is applied before that frame paints. This keeps the catch-up out of the navigation frame. An earlier version of this branch did the whole catch-up on navigation, which cost up to 138 ms. The tests now track effect dependencies, so they fail if visible or the source is read inside untrack.
  • list_all_repo_actions is async and runs in spawn_blocking, like list_repos_for_home.

Measurements

WebKit, 127 projects, typing "slow app" and erasing it:

Route Worst keystroke p90
Project, sidebar search 163–187 → 62–72 ms 81–100 → 40 ms
Home, grid search 148–150 → 67–82 ms 71–73 → 37 ms

No IPC calls are made while typing. Before, each type-and-erase round trip sent two actions waves.

Navigation frames, median from click to double rAF (base → this PR):

Navigation Frame
home → project, no query 38 → 35 ms
home → project, sidebar query cleared on home 29 → 26 ms
project → home, no query 36 → 35 ms
project → home, grid query cleared in the sidebar 37 → 35 ms

Search results, counts, empty states, and reorder being off while searching all behave as before.

Testing

Pushed with git push --no-verify because the machine was too bogged down. Local git hooks, just ci, builds and tests were not run before pushing, so CI is the first full check.

🤖 Generated with Claude Code

matt2e and others added 4 commits October 29, 2026 18:03
Typing in the sidebar search froze the window for 65-185 ms per keystroke
(127 projects, WebKit release build). Three things were happening on every
keystroke:

- The query is shared with the home grid, and the grid stays mounted under
  the project view. Every keystroke filtered that hidden grid too, destroying
  and re-creating ProjectCards nobody could see.
- The sidebar rendered the filtered lists in keyed {#each} blocks. Every row a
  keystroke filtered out was destroyed, then rebuilt on the next backspace.
  A project row includes a context menu, and a pinned RepoCard mount starts a
  list_all_repo_actions + get_all_running_actions IPC wave and three backend
  event subscriptions. Profiles were dominated by DOM insert/remove.
- list_all_repo_actions was a sync Tauri command, so it ran on the main
  thread and waited on the store's connection mutex behind any writer.

Fix:

- ProjectsList applies the shared query only while its layer is visible. It
  catches up in the pre-paint flush that brings the layer back, so returning
  home still shows the narrowed grid on the first frame.
- The sidebar renders the full project and pinned-repo lists and hides
  non-matching rows with `hidden`. sidebarSearch.ts now returns match sets
  (matchingProjectIds / matchingRepoKeys) instead of filtered lists.
  `hidden` goes on the display: contents wrapper, not on its children:
  WebKit re-shows the children of a contents element superlinearly
  (~550 ms vs ~27 ms for 100 rows).
- list_all_repo_actions is now async and runs in spawn_blocking, like
  list_repos_for_home.

Measured by typing "slow app" and erasing it with the home grid retained:
worst keystroke 163-187 ms -> 66-67 ms, p90 81-100 ms -> 38-42 ms, JS time
1013 -> 459 ms, and no IPC while typing (previously two actions waves per
round trip). Results, counts, empty states and reorder-off-while-searching
are unchanged.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… them

Follow-up to d595257. On the home route the grid is on screen, so each
keystroke in its search still re-ran the keyed {#each} over the matching
projects. Every card a keystroke filtered out was destroyed and rebuilt on
the next backspace (context menu, status icon, repo labels). On top of
that, the projects sidebar stays mounted in the clipped project layer under
home, and it followed the shared query too, re-hiding 127 rows and the
pinned repo cards that nobody could see. Typing "slow app" and erasing it
on home (127 projects, WebKit) peaked at 148-150 ms per keystroke. On home
the sidebar is clipped, so the grid's field is the only search on screen.

Fix:

- ProjectsList renders the chip + sticky list (withStickyProject) and sets
  `hidden` on each .project-card-wrapper the query rules out. The wrapper
  is a real flex box, so the WebKit display: contents slowdown doesn't
  apply. filteredProjects is still visibleProjects' result, the cards on
  screen in order, so ⌘1-9, the scroll restore, the live-region count and
  the empty states keep reading it. The ⌘ overlay now takes its number from
  projectShortcutNumbers(filteredProjects), because the {#each} index
  counts hidden cards.
- The grid still applies the query only while its layer is visible. Hidden
  cards still cost style and layout. With that gate removed, the worst
  keystroke when typing in the sidebar on a project route went from
  65-68 ms to 114-120 ms.
- ProjectsSidebar gets the same gate on its own layer, so typing on home no
  longer touches the clipped sidebar. It catches up in the pre-paint flush
  that brings the project layer back.
- Each `hidden` the search toggles now has its own display: none rule
  (.project-card-wrapper, the sidebar rows' .contents trigger, the pinned
  list and its card slots). Before, they relied on Tailwind preflight's
  `[hidden] { display: none !important }` beating display: flex/contents.
- PinnedRepoCards' comments now give the real reason reorder is off while
  searching. The full list is always passed, so a drop would persist a
  complete order, but it would move the card past cards the query hid.

Measured in WebKit with the fixture harness by typing "slow app" and then
erasing it, in 3 interleaved runs of 48 keystrokes per arm:

- home, grid search: worst 148-150 -> 70-75 ms, p90 71-73 -> 36-37 ms,
  JS per run 843 -> 434 ms
- project route, sidebar search: worst 66-70 -> 65-77 ms, p90 37-44 ->
  36-42 ms (unchanged; d595257 fixed this route)

Neither arm makes IPC calls while typing. I also tried moving the sidebar
rows' `hidden` off the bits-ui trigger onto a wrapper of our own, to skip
bits-ui's per-row prop merge. It made no measurable difference, so it's
not included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…pauses

Follow-up to 8bba252, from its review. The grid and the projects sidebar
each gated the shared search query on their workspace layer: while hidden,
they held the query they had on screen and caught up in the flush that
made the layer visible again. That kept keystrokes cheap, but moved all of
the catch-up into the navigation frame. When the hidden view held a
different query from the current one, that frame got much slower. For
example: search "sl" in the sidebar on a project, go home, clear the query
there, then open a project. The sidebar re-showed ~120 rows and the pinned
repo cards in the frame that brought it back. Going the other way, the grid
re-showed ~120 cards.

Fix:

- followWhileVisible (navigation.svelte.ts, next to onViewHidden and
  onLayerActivated) replaces the two copies of the gate. While the layer is
  visible it follows the source in the same flush. While the layer is
  hidden it follows once the source has been unchanged for 300 ms
  (HIDDEN_FOLLOW_DELAY_MS), so typing in the other layer doesn't re-filter
  the hidden view per key. A change still waiting when the layer comes back
  is applied in that flush, before the frame paints.
- The sidebar's display: none rule for hidden rows now matches
  [data-slot='context-menu-trigger'][hidden], the attribute the shadcn
  wrapper sets, instead of the `.contents` class string passed to it.

Measured in WebKit with the fixture harness (127 projects), with the
project layer retained. Each cell is the median frame from the click to the
double rAF, in ms, over 3 interleaved runs of 5. The columns are the parent
of d595257, d595257, 8bba252, and this commit:

  home -> project                         base  d5952  8bba2  this
  a   no query                              38     37     37    35
  b   type + clear on home                  33     36     36    35
  b2  sidebar query, cleared on home        29     35     83    26
  c   type on home, open a match            24     29     41    26
  project -> home
  ha  no query                              36     34     34    35
  h2  grid query, cleared in sidebar        37    138     99    35

(b) never cost anything extra. The sidebar's applied query is '' both when
it is hidden and when it catches up, so the catch-up has nothing to do.

Keystrokes, typing "slow app" and erasing it, 8bba252 -> this commit,
3 runs of 48 keys per arm. At 180 ms per key no pause reaches the delay,
so both do the same work per key:

- home, grid search: worst 67-79 -> 67-82 ms, p90 37 -> 37 ms
- project, sidebar search: worst 65-77 (one 160 outlier) -> 62-72 ms,
  p90 43 -> 40 ms

At 320 ms per key, every pause just passes the delay, so the hidden
layer catches up ~20 ms before each next key. This is the worst case for
this change. Timed from the moment the harness sends the key, so a key
queued behind a catch-up still counts (3 runs of 32 keys per arm):

- home: worst 68-76 -> 61-72 ms, p90 40 -> 36 ms, median 20 -> 26 ms
- project: worst 64-73 -> 62-66 ms, p90 39 -> 37 ms, median 20 -> 22 ms

Neither arm makes IPC calls while typing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to 40aea37, from its review. The tests stubbed $effect.pre so
that `flush()` re-ran every effect after every change. That covered the
timing contract but not dependency tracking. If `layer.visible` or the
source were read inside `untrack`, or only in one branch, real Svelte
would not re-run the effect, and the view could paint a stale query for
up to HIDDEN_FOLLOW_DELAY_MS. The tests would still pass.

- The source and the layer's `visible` are now small versioned signals.
  Each effect run records the signals it reads, `svelte`'s `untrack` is
  mocked to suspend that recording, and `flush()` re-runs (after its
  cleanup) only the effects whose recorded reads changed.
- New case: unmounting while a hidden wait is pending leaves no timers,
  and `current` stays put once the delay passes.

I checked the harness by putting each regression into
navigation.svelte.ts in turn and then reverting it:

- `untrack(() => layer.visible)`: "catches up in the flush that shows
  the layer" fails. The old harness passed it.
- `untrack(read)`: both "follows the source" cases fail.
- source read only in the visible branch, with the timer reading it
  lazily: the hidden "once it stops changing" case fails.
- cleanup not returned for one value: the restart case and the new
  unmount case fail.

No production code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T10:19:37.693972Z 241054a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@matt2e
matt2e merged commit 0888ae3 into main Oct 29, 2026
4 checks passed
@matt2e
matt2e deleted the project-sidebar-search-so-slow branch October 29, 2026 10:23
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