Skip to content

Search bar performance improvements#4072

Open
general-adhoc wants to merge 2 commits into
mainfrom
fix-search-enter-debounce
Open

Search bar performance improvements#4072
general-adhoc wants to merge 2 commits into
mainfrom
fix-search-enter-debounce

Conversation

@general-adhoc

@general-adhoc general-adhoc commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Improve global search bar performance

Summary

I noticed the search bars felt laggy across multiple screens (originally partner network search). Root caused it to the search bar component which impacted all 20 usages of search-box.tsx (19 of SearchBoxPersisted one of SearchBox directly)

Separating this out from the partner network search PR as this has a larger potential blast radius than just partner search.

Two Problems

  1. Pressing enter on any search bar after entering a search term still waited out the 500ms default debounce duration (i.e., enter key did not flush debounced content)
  2. committing a search triggers a server round-trip (RSC re-render) that produced no data because the current SearchBoxPersisted views render results from client SWR fetches.

Note: several components were worse with additional non-shallow write (tags, folders, domains, and admin network)

This caused an up to 500ms debounce delay + server round trip for RSC re-render BEFORE the client would begin fetching data, leading to noticeable lag.

BEFORE:
type search term + enter -> wait 500 ms -> router.push -> RSC round-trip (wasted) -> SWR fetch -> render

AFTER:
type search term + enter -> history.pushState -> SWR fetch -> render

Reproduce

  1. go to any search bar (try links - https://app.dub.co/{{your-workspace}}/links, quickly type something and press enter)
  2. you'll notice the debounce + RSC re-render before the app starts your search (you can also see the _rsc network request - see below for example screenshot)

Solution

  • flush debounce on user enter
  • added a new opt-in shallow?: boolean on the shared queryParams hook - this updates URL via history API instead of router. This avoids triggering navigation, RSC round trips. 'useSearchParams' still reacts and SWR refetches.
  • SearchBoxPersisted opts in to shallow by default. This is safe across all current usages.
  • Cleaned up redundant onChangeDebounced handlers
  • added regression test ensuring shallow write uses history api and not router
  • BEHAVIOR CHANGE: queryParams skips when resulting URL is unchanged - prevent wasted same-URL navigation. This should be safe, want to call out.
image

Summary by CodeRabbit

  • New Features

    • Search filtering can now use shallow URL updates to feel more immediate and avoid full navigation.
    • Search inputs now more reliably apply typed queries when pressing Enter.
  • Bug Fixes

    • Persisted search now keeps the displayed value and URL in sync without interrupting active typing, and clears pagination when updating the query.
    • Improved handling of clearing/changing search to update results more consistently.
  • Tests

    • Added coverage for shallow routing behavior in URL updates.

@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Jun 24, 2026 5:40am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a shallow?: boolean option to useRouterStuff.queryParams for history-based URL updates and no-op skipping. SearchBoxPersisted now manages its own persisted search updates. Several callers drop manual debounced URL wiring, and tests cover the new routing paths.

Changes

Shallow routing and SearchBox URL self-management

Layer / File(s) Summary
useRouterStuff shallow routing and no-op guard
packages/ui/src/hooks/use-router-stuff.ts
Adds shallow?: boolean to queryParams options. Computes newPath before navigating and returns early if it matches the current path. When shallow is true, updates the URL with history.pushState/replaceState and returns without invoking the Next.js router.
SearchBoxPersisted URL self-management and Enter flush
apps/web/ui/shared/search-box.tsx
SearchBoxPersisted now calls shallow queryParams internally when the debounced value changes (also deleting page), and syncs the displayed value from the URL only when the user is not actively editing. Removes onChangeDebounced from the public prop interface. SearchBox flushes the debounced callback on Enter keypress, guarded against IME composition.
Caller simplification
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/page-client.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/links/folders/page-client.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/links/tags/page-client.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events-modal.tsx, apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/page.tsx
Domains, folders, and tags pages remove manual onChangeDebounced callbacks and queryParams usage from SearchBoxPersisted. Campaign events modal drops local useDebouncedCallback and wires onChangeDebounced directly on SearchBox. Network admin page adds shallow: true to its queryParams call.
Shallow routing tests
apps/web/tests/ui/use-router-stuff.test.ts
New test suite mocks next/navigation and spies on window.history. Covers: shallow+replace uses replaceState, shallow+push uses pushState, non-shallow triggers router navigation, and no-op URLs skip all writes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • dubinc/dub#3659: Both PRs modify queryParams call sites in useRouterStuff — that PR adds { scroll: false } to filter handlers, this PR adds shallow: true and internalizes the pattern inside SearchBoxPersisted.

Suggested reviewers

  • steven-tey

Poem

🐇 A shallow hop, no page reload,
The URL updates, light as a feather!
Enter key flushes, search is bestowed,
No more callback wires tangled together.
History writes where the router once strode —
hop hop, the bunny keeps state together! 🌿

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is related to the changes, but it's too broad to show the specific routing and search-box behavior updates. Use a more specific title, e.g. "Add shallow URL updates and flush debounced search on Enter".
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
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.

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

✨ 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 fix-search-enter-debounce

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.

@general-adhoc general-adhoc force-pushed the fix-search-enter-debounce branch from 9f3dc69 to eb6fc81 Compare June 24, 2026 05:28
@general-adhoc general-adhoc marked this pull request as ready for review June 24, 2026 05:29
@general-adhoc

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

any regressions?

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get the PR diff summary
gh pr view 4072 --repo dubinc/dub --json files,additions,deletions,changedFiles

echo "=== PR Files Changed ==="
gh pr diff 4072 --repo dubinc/dub --name-only

echo "=== Full Diff ==="
gh pr diff 4072 --repo dubinc/dub

Length of output: 18111



@general-adhoc Here is my full review of PR #4072.


Overall Assessment

The PR correctly addresses both root causes (debounce flush on Enter, RSC round-trips). The fixes are well-reasoned and the new test suite validates the core invariant. A few things worth reviewing before merging:


🔴 Potential Regression: scroll: false Silently Ignored with shallow: true

File: packages/ui/src/hooks/use-router-stuff.ts
File: apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/page.tsx

The shallow branch returns early before the scroll-preservation logic runs:

if (shallow) {
  if (typeof window !== "undefined") {
    if (replace) window.history.replaceState({}, "", newPath);
    else window.history.pushState({}, "", newPath);
  }
  return;   // ← scroll preservation never reached
}

// Nested overflow container scroll is not preserved by Next's `scroll: false`
if (scroll === false && typeof document !== "undefined") { ... }

The admin network page passes both shallow: true and scroll: false:

queryParams({
  del: ["networkStatus", "country", "page"],
  scroll: false,
  shallow: true,   // ← scroll: false is now a no-op
});

For history.pushState, the browser natively doesn't scroll to top, so this may be OK in practice — but it is silently discarded. A comment or explicit note that scroll is ignored when shallow: true would prevent future surprises.


🟡 Behavioral Change: Global No-Op Guard Applies to All queryParams Callers

File: packages/ui/src/hooks/use-router-stuff.ts (Lines 92–96)

const currentQuery = searchParams.toString();
const currentPath = `${pathname}${currentQuery.length > 0 ? `?${currentQuery}` : ""}`;
if (newPath === currentPath) return;

This guard runs for all queryParams calls — not just shallow ones. Any code that previously relied on a same-URL navigation having a side effect (e.g., triggering an RSC refresh, resetting scroll to top intentionally, or forcing a re-render by re-pushing the same URL) will now silently no-op. The PR calls this out as an intentional behavior change, but it has a blast radius of all 20 SearchBoxPersisted usages plus any direct queryParams callers.


🟡 Reliability of history.pushStateuseSearchParams Reactivity

File: packages/ui/src/hooks/use-router-stuff.ts

The shallow path bypasses router.push/router.replace entirely and calls window.history.pushState directly. The claim that useSearchParams still reacts depends on Next.js App Router monkey-patching history.pushState internally — which it does today, but is an undocumented implementation detail. A comment citing this assumption would help future maintainers understand the coupling:

// Next.js App Router intercepts history.pushState/replaceState and
// notifies useSearchParams subscribers — so SWR keys still update
// without triggering a Server Component re-render.
if (shallow) {
  ...
}

🟢 Bug Fixes Confirmed Correct

Operator precedence bug (two instances):

// Before (parsed as: get() ?? ("" !== value))
if (searchParams.get(urlParam) ?? "" !== value)
if ((search ?? "" !== value) && value === debouncedValue)

// After (correct)
if ((searchParams.get(urlParam) ?? "") !== value)
if ((search ?? "") !== value && value === debouncedValue)

These were real bugs — the first caused the URL-update useEffect to fire on nearly every render; the second could cause unexpected value resets on back/forward navigation.

setDebouncedValue in back/forward sync:

// Before: only setValue — debouncedValue effect would fire and push the old value back
setValue(search ?? "");

// After: both in sync — prevents a navigation loop
setValue(search ?? "");
setDebouncedValue(search ?? "");

This was a genuine pre-existing bug; the fix is correct.

debounced.cancel() on clear:

onClick={() => {
  debounced.cancel();   // ← prevents pending debounce from firing after clear
  onChange("");
  onChangeDebounced?.("");
}}

Without the cancel, a pending debounce callback could fire after the clear and re-write the search param. Correct fix.

Enter key flush + IME guard:

if (e.key === "Enter" && !e.nativeEvent.isComposing) {
  debounced.flush();
}

The isComposing guard is important for CJK input methods and is correctly included.


🟡 SearchBoxPersisted — Consumers Not Updated in This PR

The onChangeDebounced prop was removed from SearchBoxPersisted's type. The PR updates 5 of the ~20 usages. Any remaining consumer that still passes onChangeDebounced to SearchBoxPersisted will get a TypeScript error at compile time (not a silent runtime regression), but worth confirming CI is clean.


🟢 Tests

The 4 test cases in use-router-stuff.test.ts directly cover the critical invariants:

  • Shallow + replace → history.replaceState, never router
  • Shallow + push → history.pushState, never router
  • Non-shallow → router.push, never history API
  • Same-URL no-op → no navigation, no history write

Good coverage of the core behavioral contract.


Summary

Area Status
Enter key flushes debounce ✅ Correct
Clear cancels pending debounce ✅ Correct
Operator precedence bug fixes ✅ Genuine fixes
Back/forward loop fix (setDebouncedValue) ✅ Genuine fix
Shallow routing skips RSC round-trip ✅ Works (Next.js patches pushState)
scroll: false silently ignored with shallow: true ⚠️ Benign in practice, should document
Global no-op guard behavioral change ⚠️ Intentional but broad blast radius — worth monitoring
Removed onChangeDebounced from SearchBoxPersisted ⚠️ TS compile will catch stragglers; confirm CI passes
Test coverage ✅ Covers the key invariants
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/ui/src/hooks/use-router-stuff.ts (1)

99-109: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document or enforce that scroll is ignored in shallow mode.

The early return on Lines 100-109 skips the scroll-preservation branch entirely, so callers can now pass { scroll: false, shallow: true } and get different semantics from the router path. Either handle scroll before the shallow return or make these options mutually exclusive to avoid a misleading API.

🤖 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 `@packages/ui/src/hooks/use-router-stuff.ts` around lines 99 - 109, The
shallow-navigation branch in use-router-stuff’s router helper currently returns
before the scroll-preservation logic, so scroll is silently ignored when shallow
is true. Update the behavior in the same routing path so that `scroll` is either
explicitly handled before the shallow early return or documented/enforced as
incompatible with shallow mode, and make sure the semantics in the router helper
match the public API expectations for `scroll` and `shallow`.
🤖 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 `@apps/web/tests/ui/use-router-stuff.test.ts`:
- Around line 29-39: The renderQueryParams helper mounts a React root via
createRoot but never cleans it up, which can leak mounted trees across tests.
Update renderQueryParams to keep a reference to the root returned by createRoot,
and make sure it is unmounted and the container is removed after use; use the
Probe component and useRouterStuff hook as the key places to locate the setup.
If needed, wrap the helper’s mounting logic so cleanup runs reliably even when
assertions fail.

---

Nitpick comments:
In `@packages/ui/src/hooks/use-router-stuff.ts`:
- Around line 99-109: The shallow-navigation branch in use-router-stuff’s router
helper currently returns before the scroll-preservation logic, so scroll is
silently ignored when shallow is true. Update the behavior in the same routing
path so that `scroll` is either explicitly handled before the shallow early
return or documented/enforced as incompatible with shallow mode, and make sure
the semantics in the router helper match the public API expectations for
`scroll` and `shallow`.
🪄 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

Run ID: 19177d4d-c7b9-465c-8aeb-a2b4b88e108d

📥 Commits

Reviewing files that changed from the base of the PR and between 83b9bec and eb6fc81.

📒 Files selected for processing (8)
  • apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/page.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events-modal.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/page-client.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/links/folders/page-client.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/links/tags/page-client.tsx
  • apps/web/tests/ui/use-router-stuff.test.ts
  • apps/web/ui/shared/search-box.tsx
  • packages/ui/src/hooks/use-router-stuff.ts

Comment on lines +29 to +39
function renderQueryParams() {
let api: ReturnType<typeof useRouterStuff> | undefined;
function Probe() {
api = useRouterStuff();
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
flushSync(() => createRoot(container).render(createElement(Probe)));
if (!api) throw new Error("hook did not render");
return api;

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unmount the React root to keep tests isolated.

renderQueryParams creates and mounts a root but never unmounts/removes it. Over multiple tests, this leaks mounted trees and can cause cross-test interference once the hook grows effects.

Suggested fix
 function renderQueryParams() {
   let api: ReturnType<typeof useRouterStuff> | undefined;
   function Probe() {
     api = useRouterStuff();
     return null;
   }
   const container = document.createElement("div");
   document.body.appendChild(container);
-  flushSync(() => createRoot(container).render(createElement(Probe)));
+  const root = createRoot(container);
+  flushSync(() => root.render(createElement(Probe)));
   if (!api) throw new Error("hook did not render");
-  return api;
+  return {
+    ...api,
+    cleanup: () => {
+      root.unmount();
+      container.remove();
+    },
+  };
 }
🤖 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 `@apps/web/tests/ui/use-router-stuff.test.ts` around lines 29 - 39, The
renderQueryParams helper mounts a React root via createRoot but never cleans it
up, which can leak mounted trees across tests. Update renderQueryParams to keep
a reference to the root returned by createRoot, and make sure it is unmounted
and the container is removed after use; use the Probe component and
useRouterStuff hook as the key places to locate the setup. If needed, wrap the
helper’s mounting logic so cleanup runs reliably even when assertions fail.

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