Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 32 additions & 24 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const updateUrlPage = debounce((page: number) => {
}, 500)

const { model: searchQuery, provider: searchProvider } = useGlobalSearch()
const query = computed(() => searchQuery.value)
const query = computed(() => searchQuery.value.trim().replace(/!$/, ''))

// Track if page just loaded (for hiding "Searching..." during view transition)
const hasInteracted = shallowRef(false)
Expand Down Expand Up @@ -424,28 +424,34 @@ async function navigateToPackage(packageName: string) {
// Track the input value when user pressed Enter (for navigating when results arrive)
const pendingEnterQuery = shallowRef<string | null>(null)

// Watch for results to navigate when Enter was pressed before results arrived
watch(displayResults, results => {
if (!pendingEnterQuery.value) return
// Watch for results to navigate when Enter was pressed before results arrived,
// or for "I'm feeling lucky" redirection when the query ends with "!" and there is the exact match.
watch(
displayResults,
results => {
const rawQuery = normalizeSearchParam(route.query.q)
const isFeelingLucky = rawQuery.endsWith('!')

// Check if input is still focused (user hasn't started navigating or clicked elsewhere)
if (document.activeElement?.tagName !== 'INPUT') {
pendingEnterQuery.value = null
return
}
if (!pendingEnterQuery.value && !isFeelingLucky) return

// 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) {
pendingEnterQuery.value = null
navigateToPackage(firstResult.package.name)
}
})
// For manual Enter, check if input is still focused (user hasn't started navigating or clicked elsewhere)
if (pendingEnterQuery.value && document.activeElement?.tagName !== 'INPUT') {
pendingEnterQuery.value = null
return
}

const target = pendingEnterQuery.value || rawQuery.replace(/!$/, '')
if (!target) return

// Navigate if first result matches the target query
const firstResult = results[0]
if (firstResult?.package.name === target) {
pendingEnterQuery.value = null
navigateToPackage(firstResult.package.name)
}
},
{ immediate: true },
)
Comment on lines +427 to +454
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

Case-sensitive comparison may prevent redirect for mixed-case input.

Line 448 performs a strict === comparison between firstResult?.package.name and target. However, npm package names are case-insensitive, and the visibleResults reordering logic (line 89) already uses .toLowerCase() when finding exact matches. If a user types Nuxt!, target becomes "Nuxt" while the actual package name is "nuxt", so the redirect silently fails.

The same issue exists in the Enter-key handler at line 467.

Suggested fix
-    const target = pendingEnterQuery.value || rawQuery.replace(/!$/, '')
+    const target = (pendingEnterQuery.value || rawQuery.replace(/!$/, '')).toLowerCase()
     if (!target) return

     // Navigate if first result matches the target query
     const firstResult = results[0]
-    if (firstResult?.package.name === target) {
+    if (firstResult?.package.name.toLowerCase() === target) {

And in the Enter-key handler:

-    const cleanedInputValue = inputValue.replace(/!$/, '')
+    const cleanedInputValue = inputValue.replace(/!$/, '').toLowerCase()

     // Check if first result matches the input value exactly
     const firstResult = displayResults.value[0]
-    if (firstResult?.package.name === cleanedInputValue) {
+    if (firstResult?.package.name.toLowerCase() === cleanedInputValue) {


function handleResultsKeydown(e: KeyboardEvent) {
// If the active element is an input, navigate to exact match or wait for results
Expand All @@ -454,15 +460,17 @@ function handleResultsKeydown(e: KeyboardEvent) {
const inputValue = (document.activeElement as HTMLInputElement).value.trim()
if (!inputValue) return

const cleanedInputValue = inputValue.replace(/!$/, '')

// Check if first result matches the input value exactly
const firstResult = displayResults.value[0]
if (firstResult?.package.name === inputValue) {
if (firstResult?.package.name === cleanedInputValue) {
pendingEnterQuery.value = null
return navigateToPackage(firstResult.package.name)
}

// No match yet - store input value, watcher will handle navigation when results arrive
pendingEnterQuery.value = inputValue
// No match yet - store cleaned input value, watcher will handle navigation when results arrive
pendingEnterQuery.value = cleanedInputValue
return
}

Expand Down
14 changes: 14 additions & 0 deletions test/e2e/search-feeling-lucky.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect, test } from './test-utils'

test.describe('Search "I\'m Feeling Lucky" Redirect', () => {
test('direct URL access with "!" should redirect to package', async ({ page, goto }) => {
await goto('/search?q=nuxt!', { waitUntil: 'hydration' })
await expect(page).toHaveURL(/\/package\/nuxt$/, { timeout: 15000 })
})

test('normal search query (without "!") should not redirect', async ({ page, goto }) => {
await goto('/search?q=nuxt', { waitUntil: 'hydration' })
await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ timeout: 15000 })
await expect(page).toHaveURL(/\/search\?q=nuxt/)
})
})
Loading