Skip to content

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

Open
MatteoGabriele wants to merge 20 commits intonpmx-dev:mainfrom
MatteoGabriele:fix/search-reliability
Open

refactor(ui): fetch results on submit instead of on type#1107
MatteoGabriele wants to merge 20 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 8, 2026 9:10pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 8, 2026 9:10pm
npmx-lunaria Ignored Ignored Feb 8, 2026 9:10pm

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

📝 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() (persistent, debounced updater) consumed by AppHeader, the home page and the search page. Removes the old Header/SearchBox URL-sync/debounce logic. Updates pages and header to use the new SearchBox and route search submissions to the named search route. Adds an onResponse callback option 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 accurately describes the key changes: merging two search boxes, changing update timing from typing to submit, and fixing an accessibility issue.

✏️ 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

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.

@MatteoGabriele MatteoGabriele changed the title refactor(ui): create a general search box refactor(ui): fetch results on submit instead of on type Feb 7, 2026
@MatteoGabriele
Copy link
Contributor Author

@ghostdevv I've removed the "Enter to exact" functionality since we now use Enter to actually search. What do you think?
Not sure what you meant by the throttling part, since we now work on submit instead of on-type.
thanks for your help

@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 +140 to 150
<SearchBox
v-if="!isOnHomePage"
ref="searchBoxRef"
:inputClass="isSearchExpanded ? 'w-full' : ''"
:class="{ 'max-w-md': !isSearchExpanded }"
class="max-w-sm"
compact
:model-value="searchQuery"
@update:model-value="updateSearchQuery"
@submit="handleSubmitSearch"
@focus="handleSearchFocus"
@blur="handleSearchBlur"
/>
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:

#!/bin/bash
# Find SearchBox component
fd -t f "SearchBox" --extension vue

# Find AppHeader.vue to inspect the handlers
fd -t f "AppHeader" --extension vue

Repository: npmx-dev/npmx.dev

Length of output: 118


🏁 Script executed:

#!/bin/bash
# Read SearchBox component
echo "=== SearchBox.vue ==="
cat app/components/SearchBox.vue

echo -e "\n=== AppHeader.vue ==="
cat app/components/AppHeader.vue

Repository: npmx-dev/npmx.dev

Length of output: 9164


🏁 Script executed:

#!/bin/bash
# Find usePackageSearchQuery composable to verify debouncing
fd -t f "usePackageSearchQuery" --extension ts --extension js

# Also search for where this composable is defined
rg "usePackageSearchQuery" -A 10 --type ts --type js

Repository: npmx-dev/npmx.dev

Length of output: 2015


🏁 Script executed:

#!/bin/bash
# Read the full usePackageSearchQuery composable
cat app/composables/usePackageSearchQuery.ts

Repository: npmx-dev/npmx.dev

Length of output: 541


Use the value passed by the SearchBox submit event instead of reading the debounced state.

AppHeader's handleSubmitSearch ignores the input value emitted by SearchBox and reads directly from searchQuery.value, which is updated via a debounced 400ms function. If a user types quickly and presses Enter before the debounce fires, the navigation uses a stale query value. Update handleSubmitSearch(value: string) to accept and use the value from the submit event rather than reading from state.

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

@danielroe I made a change that might resolve the issue.
While the implementation may not be perfect and could be cleaner, it works. I first want to confirm with you whether this is where we want to go.
The approach uses a shared state variable to facilitate communication between components, updating the URL only on submit or when the promise resolves. This setup enables request-on-type functionality, preventing update loops by syncing the URL 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
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