Skip to content

Comments

refactor(ui): fetch results on submit instead of on type#1107

Closed
MatteoGabriele wants to merge 21 commits intonpmx-dev:mainfrom
MatteoGabriele:fix/search-reliability
Closed

refactor(ui): fetch results on submit instead of on type#1107
MatteoGabriele wants to merge 21 commits intonpmx-dev:mainfrom
MatteoGabriele:fix/search-reliability

Conversation

@MatteoGabriele
Copy link
Contributor

@MatteoGabriele MatteoGabriele commented Feb 6, 2026

  • Merges the two search boxes on the homepage and header.
  • Changes the update to occur on submit rather than on typing. This reduces requests to the registry API, minimizes router updates to the input value, which can cause issues like missing letters, and prevents empty responses when results are available.
  • Fixes a minor accessibility issue where focus could get stuck on the hidden search form button in the header.

@vercel
Copy link

vercel bot commented Feb 6, 2026

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

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 9, 2026 9:43am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 9, 2026 9:43am
npmx-lunaria Ignored Ignored Feb 9, 2026 9:43am

Request Review

@codecov
Copy link

codecov bot commented Feb 6, 2026

Codecov Report

❌ Patch coverage is 54.34783% with 21 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/SearchBox.vue 65.21% 8 Missing ⚠️
app/components/AppHeader.vue 28.57% 4 Missing and 1 partial ⚠️
app/pages/index.vue 0.00% 2 Missing and 2 partials ⚠️
app/composables/npm/useNpmSearch.ts 33.33% 2 Missing ⚠️
app/composables/usePackageSearchQuery.ts 77.77% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces the header-specific Header/SearchBox.vue with a new app/components/SearchBox.vue that exposes a public model searchQuery, emits submit, focus, and blur, and provides a focus() method. Adds a composable usePackageSearchQuery() (persisted state with debounced updater) consumed by AppHeader, the home page and the search page. Removes the old Header/SearchBox URL-sync/debounce logic. Updates AppHeader and pages to use the new SearchBox and route search submissions to the named search route. Adds an onResponse callback to useNpmSearch and wires it to update the URL from search responses. Tests and e2e specs updated to use a unified #search-box input.

Possibly related PRs

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly relates to the changeset. It describes the three main changes: merging search boxes, changing updates to submit-only, and fixing an accessibility issue.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉


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

Copy link
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: 2

🧹 Nitpick comments (2)
app/components/SearchBox.vue (1)

77-93: Remove inline focus-visible utility from the <button> element.

The button at line 79 uses focus-visible:outline-accent/70, but project convention is to rely on the global rule in main.css for buttons and selects. Based on learnings: "individual buttons or selects in Vue components should not rely on inline focus-visible utility classes like focus-visible:outline-accent/70."

♻️ Proposed fix
          <button
            type="submit"
-           class="absolute hidden `@xs`:block group inset-ie-2.5 font-mono text-sm transition-[background-color,transform] duration-200 active:scale-95 focus-visible:outline-accent/70"
+           class="absolute hidden `@xs`:block group inset-ie-2.5 font-mono text-sm transition-[background-color,transform] duration-200 active:scale-95"
app/components/AppHeader.vue (1)

33-42: Consider extracting duplicated search-navigation logic into a composable.

handleSearchSubmit is identical in both index.vue and AppHeader.vue. A shared composable (e.g., useSearchNavigation) would reduce duplication and ensure consistent behaviour if the navigation logic changes.

@danielroe
Copy link
Member

would you check the e2e tests? they test a lot of functionality that has changed, so I expect they need to be updated....

Copy link
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)
app/components/SearchBox.vue (1)

74-90: Remove inline focus-visible utility from the submit button.

The submit button applies focus-visible:outline-accent/70 as an inline utility class. The project applies focus-visible styling for button and select elements globally via main.css. Per-element inline utilities on buttons should be avoided to keep styling consistent and maintainable.

Proposed fix
          <button
            type="submit"
-           class="absolute hidden `@xs`:block group inset-ie-2.5 font-mono text-sm transition-[background-color,transform] duration-200 active:scale-95 focus-visible:outline-accent/70"
+           class="absolute hidden `@xs`:block group inset-ie-2.5 font-mono text-sm transition-[background-color,transform] duration-200 active:scale-95"

Based on learnings: "In the npmx.dev project, focus-visible styling for buttons and selects is applied globally via main.css… individual buttons or selects in Vue components should not rely on inline focus-visible utility classes like focus-visible:outline-accent/70."

Copy link
Contributor

@ghostdevv ghostdevv left a comment

Choose a reason for hiding this comment

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

I noticed that when I search for something that has an exact match, I press enter to trigger the search I see the results for half a second then I get taken to the package because of the exact match

Changes the update to occur on submit rather than on typing. This reduces requests to the registry API, minimizes router updates to the input value, which can cause issues like missing letters, and prevents empty responses when results are available.

it would be nice if it was throttled (as you're typing it searches every N seconds), but I understand that it may be tricky with the whole navigation between pages and such.

Comment on lines 38 to 41
await navigateTo({
name: 'search',
query: { q: searchQuery.value },
})
Copy link
Contributor

Choose a reason for hiding this comment

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

what's the difference in nuxt between the router.push that was happening before, and this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this case, I think either option would work.
Previously, this component had no route handling, so I copy-pasted the function from the homepage, as they serve the same purpose. I didn't check whether it was navigateTo or router.push, since they essentially perform the same action. I believe navigateTo is a helper function that can also be used on the server side.

@ghostdevv
Copy link
Contributor

ghostdevv commented Feb 7, 2026

I just wonder if we can't solve the search issue without sacrificing on enter to exact or the results updating as you type... like the registry requests is annoying but can be fine tuned/cached I think and is worth it for the user experience, and the other issues should be solvable 🤔 wdyt?

Not sure what you meant by the throttling part, since we now work on submit instead of on-type.

just as an alternative to debounce which has a nicer UX - but it only matters if we can fix the underlying problems

@MatteoGabriele
Copy link
Contributor Author

MatteoGabriele commented Feb 7, 2026

@ghostdevv I have re-implemented an update-on-type feature, but instead of using a watcher, I am now debouncing the search input update so it is not linked to the route update. It seems to be working better, avoiding the weird update loop we had before. I also re-enabled the enter key functionality.

Let me know what you think of this version.

PS: The index still uses the Enter key to prevent accidental page change while typing, which could cause loss of focus or characters, or a disruptive experience.

@MatteoGabriele
Copy link
Contributor Author

I'm actually finding issues again with the update-on-type. I will revert my last commit.

Copy link
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

Comment on lines +5 to 13
async function handleSearchSubmit() {
if (!searchQuery.value) {
return
}

async function search() {
const query = searchQuery.value.trim()
if (!query) return
await navigateTo({
path: '/search',
query: query ? { q: query } : undefined,
name: 'search',
query: { q: searchQuery.value },
})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Trim whitespace-only queries before navigating.

Right now " " is treated as a valid query and will still navigate. Consider normalising to avoid empty/whitespace submissions (and mirror the same behaviour in the header submit handler for consistency).

Suggested tweak
 async function handleSearchSubmit() {
-  if (!searchQuery.value) {
+  const query = searchQuery.value.trim()
+  if (!query) {
     return
   }

   await navigateTo({
     name: 'search',
-    query: { q: searchQuery.value },
+    query: { q: query },
   })
 }
📝 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
async function handleSearchSubmit() {
if (!searchQuery.value) {
return
}
async function search() {
const query = searchQuery.value.trim()
if (!query) return
await navigateTo({
path: '/search',
query: query ? { q: query } : undefined,
name: 'search',
query: { q: searchQuery.value },
})
async function handleSearchSubmit() {
const query = searchQuery.value.trim()
if (!query) {
return
}
await navigateTo({
name: 'search',
query: { q: query },
})

Copy link
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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/pages/search.vue (1)

567-573: ⚠️ Potential issue | 🟡 Minor

Remove debug console.log statement.

This appears to be a debug artifact that should be removed before merging.

Proposed fix
   // Navigate if first result matches the query that was entered
   const firstResult = results[0]
-  // eslint-disable-next-line no-console
-  console.log('[search] watcher fired', {
-    pending: pendingEnterQuery.value,
-    firstResult: firstResult?.package.name,
-  })
   if (firstResult?.package.name === pendingEnterQuery.value) {

Comment on lines +1 to +21
import { debounce } from 'perfect-debounce'

export function usePackageSearchQuery() {
const searchQuery = useState<string>('package_search_query', () => '')

const route = useRoute()
onMounted(() => {
if (route.query.q) {
searchQuery.value = normalizeSearchParam(route.query.q)
}
})

const updateSearchQuery = debounce((newSearchQuery: string) => {
searchQuery.value = newSearchQuery
}, 400)

return {
searchQuery,
updateSearchQuery,
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for normalizeSearchParam definition and usage
rg "normalizeSearchParam" --type=ts --type=vue -B2 -A2

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Check the actual imports in usePackageSearchQuery.ts
cat app/composables/usePackageSearchQuery.ts

Repository: npmx-dev/npmx.dev

Length of output: 541


🏁 Script executed:

# Check nuxt.config for auto-import configuration
fd "nuxt.config" -type f | xargs cat

Repository: npmx-dev/npmx.dev

Length of output: 231


🏁 Script executed:

# Search for normalizeSearchParam definition
rg "normalizeSearchParam" -A3 -B3

Repository: npmx-dev/npmx.dev

Length of output: 5756


🏁 Script executed:

# Find nuxt.config files
find . -name "nuxt.config*" -type f

Repository: npmx-dev/npmx.dev

Length of output: 99


🏁 Script executed:

# Check for utils/url.ts where normalizeSearchParam might be defined
find app -name "url.ts" -o -name "url.js"

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check nuxt.config for auto-import configuration
cat -n nuxt.config.ts

Repository: npmx-dev/npmx.dev

Length of output: 9567


🏁 Script executed:

# Check the useStructuredFilters composable which also uses normalizeSearchParam
cat app/composables/useStructuredFilters.ts

Repository: npmx-dev/npmx.dev

Length of output: 14869


Add missing import for normalizeSearchParam.

The composable uses normalizeSearchParam (line 8) without importing it. Add import { normalizeSearchParam } from '#shared/utils/url' at the top of the file.

@MatteoGabriele
Copy link
Contributor Author

MatteoGabriele commented Feb 8, 2026

@ghostdevv I made a change that might resolve the issue. The code might needs some cleaning, but I wanted to hear your thoughts first.

I'm using a shared state variable, updating the URL only on submit or when the promise resolves. The input field is no longer connected to the URL preventing update loops by syncing only once on landing.

wdyt?

Copy link
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

Comment on lines 196 to 201
onSuccess: data => {
router.replace({
name: 'search',
query: { ...route.query, q: data.query },
})
},
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect useNpmSearch for stale-response handling
find . -name "useNpmSearch.ts" -o -name "useNpmSearch.js" 2>/dev/null

Repository: npmx-dev/npmx.dev

Length of output: 98


🏁 Script executed:

#!/bin/bash
# Check the context around lines 196-201 in search.vue
cat -n app/pages/search.vue | sed -n '180,220p'

Repository: npmx-dev/npmx.dev

Length of output: 1470


🏁 Script executed:

#!/bin/bash
# Look for stale-response handling patterns (requestId, abort, cancellation)
rg -n "useNpmSearch|onSuccess|requestId|abort|cancel" app/ --type ts --type vue -A 3 -B 1 | head -100

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

#!/bin/bash
cat -n app/composables/npm/useNpmSearch.ts

Repository: npmx-dev/npmx.dev

Length of output: 12899


Guard URL updates against out-of-order search responses.

onResponse updates the route with data.query. If requests overlap and responses arrive out of order, this can revert the URL to an older query. useNpmSearch invokes the callback before its stale-response check, so no built-in protection prevents this. Please add a guard.

🛠️ Optional guard
   onResponse: data => {
+    if (data.query !== query.value) return
     router.replace({
       name: 'search',
       query: { ...route.query, q: data.query },
     })
   },

Copy link
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/composables/npm/useNpmSearch.ts (2)

136-148: ⚠️ Potential issue | 🟡 Minor

Same timing concern applies here, plus callback fires before !pkg check.

The callback is invoked before both the package existence check (line 138) and the stale query check (line 145). This means the URL could be updated even when no package exists or when the response is stale.

Consider moving the callback after both checks to ensure it only fires for valid, current responses.

Proposed fix
           $npmApi<NpmDownloadCount>(`/downloads/point/last-week/${encodedName}`, {
             signal,
           }),
         ])

-        opts.onResponse?.({ query: q })
-
         if (!pkg) {
           return emptySearchResponse
         }

         const result = packumentToSearchResult(pkg, downloads?.downloads)

         // If query changed/outdated, return empty search response
         if (q !== toValue(query)) {
           return emptySearchResponse
         }

+        opts.onResponse?.({ query: q })
+
         cache.value = {

173-179: ⚠️ Potential issue | 🟡 Minor

Consistent timing issue with onResponse before stale check.

Same concern as the other paths—moving the callback after the stale query check (line 176) would prevent URL updates for discarded responses.

Proposed fix
         const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>(
           `/-/v1/search?${params.toString()}`,
           { signal },
           60,
         )

-        opts.onResponse?.({ query: q })
-
         // If query changed/outdated, return empty search response
         if (q !== toValue(query)) {
           return emptySearchResponse
         }

+        opts.onResponse?.({ query: q })
+
         cache.value = {

Comment on lines +103 to 107
opts.onResponse?.({ query: q })

if (q !== toValue(query)) {
return emptySearchResponse
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

onResponse invoked before stale query check may cause URL desync.

The callback fires before verifying q !== toValue(query). If the user types quickly, the callback updates the URL with an outdated query, then the response is discarded as stale—leaving the URL out of sync with the actual search input.

Consider moving the callback invocation after the stale check, or rename to onResponseReceived if the current timing is intentional.

Proposed fix
         const response = await searchAlgolia(q, {
           size: opts.size ?? 25,
         })

-        opts.onResponse?.({ query: q })
-
         if (q !== toValue(query)) {
           return emptySearchResponse
         }

+        opts.onResponse?.({ query: q })
+
         isRateLimited.value = false
📝 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
opts.onResponse?.({ query: q })
if (q !== toValue(query)) {
return emptySearchResponse
}
if (q !== toValue(query)) {
return emptySearchResponse
}
opts.onResponse?.({ query: q })
isRateLimited.value = false

@serhalp serhalp added the blocked label Feb 9, 2026
@MatteoGabriele
Copy link
Contributor Author

I'm going to close this PR because the merge conflicts are getting larger and larger, and I might have found a way to fix the update-on-type issue. I also don't want to tie this decision to the input component refactor.
I'm going to open a new PR with only smaller changes to the search logic.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants