Skip to content
Open
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
76 changes: 71 additions & 5 deletions app/composables/useGlobalSearch.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { normalizeSearchParam } from '#shared/utils/url'
import { nextTick } from 'vue'
import { debounce } from 'perfect-debounce'

// Pages that have their own local filter using ?q
const pagesWithLocalFilter = new Set(['~username', 'org'])

export function useGlobalSearch(place: 'header' | 'content' = 'content') {

Check warning on line 8 in app/composables/useGlobalSearch.ts

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

eslint-plugin-unicorn(consistent-function-scoping)

Function `getFocusedSearchInputValue` does not capture any variables from its parent scope
const { settings } = useSettings()
const { searchProvider } = useSearchProvider()
const searchProviderValue = computed(() => {
Expand All @@ -15,8 +16,22 @@

const router = useRouter()
const route = useRoute()
const getFocusedSearchInputValue = () => {
if (!import.meta.client) return ''

const active = document.activeElement
if (!(active instanceof HTMLInputElement)) return ''
if (active.type !== 'search' && active.name !== 'q') return ''
return active.value
}
// Internally used searchQuery state
const searchQuery = useState<string>('search-query', () => {
// Preserve fast typing before hydration (e.g. homepage autofocus search input).
const focusedInputValue = getFocusedSearchInputValue()
if (focusedInputValue) {
return focusedInputValue
}

if (pagesWithLocalFilter.has(route.name as string)) {
return ''
}
Expand All @@ -34,13 +49,28 @@
}
})

// clean search input when navigating away from search page
// Sync URL query to input state only on search page.
// On other pages (e.g. home), keep the user's in-progress typing untouched.
watch(
() => route.query.q,
urlQuery => {
() => [route.name, route.query.q] as const,
([routeName, urlQuery]) => {
if (routeName !== 'search') return

// Never clobber in-progress typing while any search input is focused.
if (import.meta.client) {
const active = document.activeElement
if (
active instanceof HTMLInputElement &&
(active.type === 'search' || active.name === 'q')
) {
return
}
}

const value = normalizeSearchParam(urlQuery)
if (!value) searchQuery.value = ''
if (!searchQuery.value) searchQuery.value = value
if (searchQuery.value !== value) {
searchQuery.value = value
}
Comment on lines +55 to +73
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

Do not suppress every URL synchronisation while a search box is focused.

This also skips legitimate /search?q=... history or programmatic changes. If focus stays in the input, searchQuery never updates, committedSearchQuery stays stale too, and there is no later resynchronisation on blur.

💡 Safer guard
-      // Never clobber in-progress typing while any search input is focused.
-      if (import.meta.client) {
-        const active = document.activeElement
-        if (
-          active instanceof HTMLInputElement &&
-          (active.type === 'search' || active.name === 'q')
-        ) {
-          return
-        }
-      }
-
       const value = normalizeSearchParam(urlQuery)
+      // Only skip when the focused input already reflects this URL value.
+      if (import.meta.client) {
+        const activeValue = getFocusedSearchInputValue()
+        if (activeValue && activeValue === value) {
+          return
+        }
+      }
       if (searchQuery.value !== value) {
         searchQuery.value = value
       }

},
)

Expand Down Expand Up @@ -101,6 +131,42 @@
},
})

// When navigating back to the homepage (e.g. via logo click from /search),
// reset the global search state so the home input starts fresh and re-focus
// the dedicated home search input.
if (import.meta.client) {
watch(
() => route.name,
name => {
if (name !== 'index') return
searchQuery.value = ''
committedSearchQuery.value = ''
// Use nextTick so we run after the homepage has rendered.
nextTick(() => {
const homeInput = document.getElementById('home-search')
if (homeInput instanceof HTMLInputElement) {
homeInput.focus()
homeInput.select()
}
})
},
{ flush: 'post' },
)
}

// On hydration, useState can reuse SSR payload (often empty), skipping initializer.
// Recover fast-typed value from the focused input once on client mount.
if (import.meta.client) {
onMounted(() => {
const focusedInputValue = getFocusedSearchInputValue()
if (!focusedInputValue) return
if (searchQuery.value) return

// Use model setter path to preserve instant-search behavior.
searchQueryValue.value = focusedInputValue
})
Comment on lines +137 to +167
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

Guard the mounted recovery after an intentional home reset.

These blocks can fight each other on the /search/ logo-click path. The index watcher clears the shared state, but the next mount can still read the previously focused header input via getFocusedSearchInputValue(); writing that back through searchQueryValue restores the old query and, with instant search enabled, can immediately navigate back to /search. A one-shot shared flag is needed so the mounted recovery is skipped for the same navigation that intentionally resets the home field.

}

return {
model: searchQueryValue,
committedModel: committedSearchQuery,
Expand Down
Loading