From a7c58effb3721f8441696b2208823f5555134306 Mon Sep 17 00:00:00 2001 From: nnecec Date: Thu, 19 Mar 2026 18:39:06 +0800 Subject: [PATCH 1/5] feat(search): enhance global search input handling and state management --- app/composables/useGlobalSearch.ts | 76 ++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts index 45bb478487..9f36b46eeb 100644 --- a/app/composables/useGlobalSearch.ts +++ b/app/composables/useGlobalSearch.ts @@ -1,4 +1,5 @@ import { normalizeSearchParam } from '#shared/utils/url' +import { nextTick } from 'vue' import { debounce } from 'perfect-debounce' // Pages that have their own local filter using ?q @@ -17,8 +18,22 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { 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('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 '' } @@ -40,13 +55,28 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { } }) - // 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 + } }, ) @@ -108,6 +138,42 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { }, }) + // 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 + }) + } + return { model: searchQueryValue, committedModel: committedSearchQuery, From da1d4a5bbc9d4e29a140389128fdc07c1fa777b1 Mon Sep 17 00:00:00 2001 From: nnecec Date: Tue, 21 Apr 2026 14:09:23 +0800 Subject: [PATCH 2/5] fix(useGlobalSearch): streamline getFocusedSearchInputValue and improve search query handling - Moved getFocusedSearchInputValue function to a more appropriate location. - Simplified logic for checking active input element and its type. - Enhanced condition to prevent overwriting search query when focused input matches URL value. - Cleaned up formatting for better readability. --- app/composables/useGlobalSearch.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts index 9f36b46eeb..13401d4c0a 100644 --- a/app/composables/useGlobalSearch.ts +++ b/app/composables/useGlobalSearch.ts @@ -6,7 +6,14 @@ import { debounce } from 'perfect-debounce' const pagesWithLocalFilter = new Set(['~username', 'org']) const SEARCH_DEBOUNCE_MS = 100 +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 +} export function useGlobalSearch(place: 'header' | 'content' = 'content') { const { settings } = useSettings() const { searchProvider } = useSearchProvider() @@ -18,14 +25,7 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { 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('search-query', () => { // Preserve fast typing before hydration (e.g. homepage autofocus search input). @@ -62,18 +62,14 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { ([routeName, urlQuery]) => { if (routeName !== 'search') return - // Never clobber in-progress typing while any search input is focused. + const value = normalizeSearchParam(urlQuery) + // Only skip when the focused input already reflects this URL value. if (import.meta.client) { - const active = document.activeElement - if ( - active instanceof HTMLInputElement && - (active.type === 'search' || active.name === 'q') - ) { + const activeValue = getFocusedSearchInputValue() + if (activeValue && activeValue === value) { return } } - - const value = normalizeSearchParam(urlQuery) if (searchQuery.value !== value) { searchQuery.value = value } From 364c842085cae95255f0e72910ef56a1bb564056 Mon Sep 17 00:00:00 2001 From: nnecec Date: Fri, 24 Apr 2026 13:22:47 +0800 Subject: [PATCH 3/5] feat(search): add data attribute for global search inputs - Added `data-global-search` attribute to search input fields in SearchBox.vue and index.vue to distinguish global search from local filters. - Updated `getFocusedSearchInputValue` function to only capture values from inputs marked with `data-global-search`, enhancing search query handling. --- app/components/Header/SearchBox.vue | 1 + app/composables/useGlobalSearch.ts | 19 +++++++++++++++---- app/pages/index.vue | 1 + 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/components/Header/SearchBox.vue b/app/components/Header/SearchBox.vue index 50ddd4f826..e9c968fc6f 100644 --- a/app/components/Header/SearchBox.vue +++ b/app/components/Header/SearchBox.vue @@ -57,6 +57,7 @@ defineExpose({ focus }) v-model="searchQuery" type="search" name="q" + data-global-search :placeholder="$t('search.placeholder')" no-correct class="w-full min-w-25 ps-7 pe-8" diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts index 13401d4c0a..9ca7a60116 100644 --- a/app/composables/useGlobalSearch.ts +++ b/app/composables/useGlobalSearch.ts @@ -6,12 +6,18 @@ import { debounce } from 'perfect-debounce' const pagesWithLocalFilter = new Set(['~username', 'org']) const SEARCH_DEBOUNCE_MS = 100 + +/** + * Returns the value of the focused global search input, if any. + * Only matches inputs explicitly marked with data-global-search attribute + * to avoid capturing page-local filter inputs. + */ const getFocusedSearchInputValue = () => { if (!import.meta.client) return '' const active = document.activeElement if (!(active instanceof HTMLInputElement)) return '' - if (active.type !== 'search' && active.name !== 'q') return '' + if (!active.hasAttribute('data-global-search')) return '' return active.value } export function useGlobalSearch(place: 'header' | 'content' = 'content') { @@ -28,15 +34,18 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { // Internally used searchQuery state const searchQuery = useState('search-query', () => { + // Skip reading focused input on pages with local filters - they use ?q for local state + if (pagesWithLocalFilter.has(route.name as string)) { + return '' + } + // Preserve fast typing before hydration (e.g. homepage autofocus search input). + // Only captures inputs with data-global-search marker attribute. const focusedInputValue = getFocusedSearchInputValue() if (focusedInputValue) { return focusedInputValue } - if (pagesWithLocalFilter.has(route.name as string)) { - return '' - } return normalizeSearchParam(route.query.q) }) @@ -159,8 +168,10 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { // On hydration, useState can reuse SSR payload (often empty), skipping initializer. // Recover fast-typed value from the focused input once on client mount. + // Skip on pages with local filters to avoid importing local ?q state. if (import.meta.client) { onMounted(() => { + if (pagesWithLocalFilter.has(route.name as string)) return const focusedInputValue = getFocusedSearchInputValue() if (!focusedInputValue) return if (searchQuery.value) return diff --git a/app/pages/index.vue b/app/pages/index.vue index 90d6e38879..632dfdab6e 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -59,6 +59,7 @@ defineOgImage('Splash.takumi', {}, { alt: () => $t('seo.home.description') }) v-model="searchQuery" type="search" name="q" + data-global-search autofocus :placeholder="$t('search.placeholder')" no-correct From 85e5754b34f0ad05d0327a5e256dfaa5c894cafd Mon Sep 17 00:00:00 2001 From: nnecec Date: Fri, 24 Apr 2026 13:33:04 +0800 Subject: [PATCH 4/5] refactor(useGlobalSearch): improve formatting and cancel in-flight updates - Enhanced code readability by formatting multi-line function calls and conditions. - Added cancellation for in-flight URL and search query updates to prevent unintended navigation and value restoration. --- app/composables/useGlobalSearch.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts index 9ca7a60116..0916ff87c5 100644 --- a/app/composables/useGlobalSearch.ts +++ b/app/composables/useGlobalSearch.ts @@ -151,6 +151,10 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { () => route.name, name => { if (name !== 'index') return + // Drop any in-flight URL/commit updates so they can't navigate + // back to /search or revive the old committed value after reset. + updateUrlQuery.cancel() + commitSearchQuery.cancel() searchQuery.value = '' committedSearchQuery.value = '' // Use nextTick so we run after the homepage has rendered. From d4b5d7494797ddee346106a1fa5f2892e4bd4dec Mon Sep 17 00:00:00 2001 From: nnecec Date: Fri, 24 Apr 2026 15:13:32 +0800 Subject: [PATCH 5/5] fix(useGlobalSearch): prevent duplicate handlers and streamline hydration recovery - Updated logic to register reset/refocus handlers only for the content instance to avoid duplicates when useGlobalSearch is called from multiple locations. - Enhanced hydration recovery by ensuring it only occurs for the content instance, improving performance and consistency. --- app/composables/useGlobalSearch.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts index 0916ff87c5..4661644588 100644 --- a/app/composables/useGlobalSearch.ts +++ b/app/composables/useGlobalSearch.ts @@ -146,7 +146,9 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { // 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) { + // Only register in one place (content instance) to avoid duplicate reset/refocus handlers + // when useGlobalSearch is called from multiple callsites (e.g., Header/SearchBox and page components). + if (import.meta.client && place === 'content') { watch( () => route.name, name => { @@ -173,7 +175,8 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { // On hydration, useState can reuse SSR payload (often empty), skipping initializer. // Recover fast-typed value from the focused input once on client mount. // Skip on pages with local filters to avoid importing local ?q state. - if (import.meta.client) { + // Only register in one place (content instance) to avoid duplicate hydration recovery. + if (import.meta.client && place === 'content') { onMounted(() => { if (pagesWithLocalFilter.has(route.name as string)) return const focusedInputValue = getFocusedSearchInputValue()