From 2c6bf128e248eabb7d2aa503ce34f063f4e400b3 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:31:42 -0700 Subject: [PATCH 1/5] perf(ui): cache search keyboard navigation targets --- app/pages/search.vue | 103 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index b635065aa0..1a249e1a48 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -408,29 +408,62 @@ const exactMatchType = computed<'package' | 'org' | 'user' | null>(() => { const suggestionCount = computed(() => validatedSuggestions.value.length) const totalSelectableCount = computed(() => suggestionCount.value + resultCount.value) +const resultsContainerRef = useTemplateRef('resultsContainerRef') const isVisible = (el: HTMLElement) => el.getClientRects().length > 0 +const focusableElements = shallowRef([]) +let focusableElementsObserver: MutationObserver | null = null +let refreshFocusableElementsFrame: number | null = null /** - * Get all focusable result elements in DOM order (suggestions first, then packages) + * Cache all keyboard-focusable result elements in DOM order. + * DOM order already matches our navigation order: suggestions first, then packages. */ -function getFocusableElements(): HTMLElement[] { - const suggestions = Array.from(document.querySelectorAll('[data-suggestion-index]')) - .filter(isVisible) - .sort((a, b) => { - const aIdx = Number.parseInt(a.dataset.suggestionIndex ?? '0', 10) - const bIdx = Number.parseInt(b.dataset.suggestionIndex ?? '0', 10) - return aIdx - bIdx - }) +function refreshFocusableElements() { + const root = resultsContainerRef.value + if (!root) { + focusableElements.value = [] + return + } - const packages = Array.from(document.querySelectorAll('[data-result-index]')) - .filter(isVisible) - .sort((a, b) => { - const aIdx = Number.parseInt(a.dataset.resultIndex ?? '0', 10) - const bIdx = Number.parseInt(b.dataset.resultIndex ?? '0', 10) - return aIdx - bIdx - }) + focusableElements.value = Array.from( + root.querySelectorAll('[data-suggestion-index], [data-result-index]'), + ).filter(isVisible) +} + +function scheduleFocusableElementsRefresh() { + if (!import.meta.client) return + if (refreshFocusableElementsFrame != null) return + + refreshFocusableElementsFrame = window.requestAnimationFrame(() => { + refreshFocusableElementsFrame = null + refreshFocusableElements() + }) +} + +function stopObservingFocusableElements() { + focusableElementsObserver?.disconnect() + focusableElementsObserver = null + focusableElements.value = [] +} + +function startObservingFocusableElements() { + stopObservingFocusableElements() + + const root = resultsContainerRef.value + if (!root || !import.meta.client) return + + focusableElementsObserver = new MutationObserver(() => { + scheduleFocusableElementsRefresh() + }) + + focusableElementsObserver.observe(root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style', 'hidden', 'aria-hidden'], + }) - return [...suggestions, ...packages] + scheduleFocusableElementsRefresh() } /** @@ -472,6 +505,29 @@ watch(displayResults, newResults => { } }) +watch(resultsContainerRef, () => { + startObservingFocusableElements() +}) + +watch( + [ + suggestionCount, + resultCount, + viewMode, + paginationMode, + currentPage, + showSelectionView, + isRateLimited, + committedQuery, + ], + () => { + nextTick(() => { + scheduleFocusableElementsRefresh() + }) + }, + { flush: 'post' }, +) + /** * Focus the header search input */ @@ -511,7 +567,7 @@ function handleResultsKeydown(e: KeyboardEvent) { if (totalSelectableCount.value <= 0) return - const elements = getFocusableElements() + const elements = focusableElements.value if (elements.length === 0) return const currentIndex = elements.findIndex(el => el === document.activeElement) @@ -552,6 +608,10 @@ function handleResultsKeydown(e: KeyboardEvent) { onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown) +onMounted(() => { + startObservingFocusableElements() +}) + useSeoMeta({ title: () => `${query.value ? $t('search.title_search', { search: query.value }) : $t('search.title_packages')} - npmx`, @@ -669,6 +729,11 @@ watch( ) onBeforeUnmount(() => { + stopObservingFocusableElements() + if (refreshFocusableElementsFrame != null) { + window.cancelAnimationFrame(refreshFocusableElementsFrame) + refreshFocusableElementsFrame = null + } updateLiveRegionMobile.cancel() updateLiveRegionDesktop.cancel() }) @@ -701,7 +766,7 @@ onBeforeUnmount(() => { :view-mode="viewMode" /> -
+
Date: Wed, 25 Mar 2026 07:59:15 -0700 Subject: [PATCH 2/5] fix(search): cancel pending rAF on stop and populate cache synchronously on start (#7) Co-authored-by: trivikr <16024985+trivikr@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- app/pages/search.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/pages/search.vue b/app/pages/search.vue index 1a249e1a48..6f81f3ea8e 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -443,6 +443,10 @@ function scheduleFocusableElementsRefresh() { function stopObservingFocusableElements() { focusableElementsObserver?.disconnect() focusableElementsObserver = null + if (refreshFocusableElementsFrame != null) { + window.cancelAnimationFrame(refreshFocusableElementsFrame) + refreshFocusableElementsFrame = null + } focusableElements.value = [] } @@ -463,6 +467,9 @@ function startObservingFocusableElements() { attributeFilter: ['class', 'style', 'hidden', 'aria-hidden'], }) + // Perform an initial synchronous refresh so focusableElements is populated + // before any immediate key handling (ArrowUp/ArrowDown) occurs. + refreshFocusableElements() scheduleFocusableElementsRefresh() } From c21e5330f7924a739dd048f87eb3ec9d1f774427 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:28:33 -0700 Subject: [PATCH 3/5] fix: guard the deferred refresh after teardown --- app/pages/search.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/pages/search.vue b/app/pages/search.vue index 6f81f3ea8e..4cfe90d29f 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -529,6 +529,9 @@ watch( ], () => { nextTick(() => { + if (!resultsContainerRef.value) { + return + } scheduleFocusableElementsRefresh() }) }, From 823bc042593b65d182a3605f23ff86ff69bffb34 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:36:48 -0700 Subject: [PATCH 4/5] fix: remove redundant RAF cancellation after stopObservingFocusableElements() --- app/pages/search.vue | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index 4cfe90d29f..19afc367b7 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -740,10 +740,6 @@ watch( onBeforeUnmount(() => { stopObservingFocusableElements() - if (refreshFocusableElementsFrame != null) { - window.cancelAnimationFrame(refreshFocusableElementsFrame) - refreshFocusableElementsFrame = null - } updateLiveRegionMobile.cancel() updateLiveRegionDesktop.cancel() }) From edf1725e23d952e15b747cbc58104ee8761f2156 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:40:53 -0700 Subject: [PATCH 5/5] fix: remove redundant scheduled refresh --- app/pages/search.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index 19afc367b7..d480563b3e 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -470,7 +470,6 @@ function startObservingFocusableElements() { // Perform an initial synchronous refresh so focusableElements is populated // before any immediate key handling (ArrowUp/ArrowDown) occurs. refreshFocusableElements() - scheduleFocusableElementsRefresh() } /**