diff --git a/app/components/Package/List.vue b/app/components/Package/List.vue index bf9e1c2ee..4e8f75536 100644 --- a/app/components/Package/List.vue +++ b/app/components/Package/List.vue @@ -112,6 +112,12 @@ watch( { immediate: true }, ) +// Tracks how many items came from the last new-search batch. +// Items at index < newSearchBatchSize are from the new search → no animation. +// Items at index >= newSearchBatchSize were loaded via scroll → animate with stagger. +// Using an index threshold avoids any timing dependency on nextTick / virtual list paint. +const newSearchBatchSize = shallowRef(Infinity) + // Reset scroll state when results change significantly (new search) watch( () => props.results, @@ -123,6 +129,7 @@ watch( (oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name) ) { hasScrolledToInitial.value = false + newSearchBatchSize.value = newResults.length } }, ) @@ -172,9 +179,16 @@ defineExpose({ :show-publisher="showPublisher" :index="index" :search-query="searchQuery" - class="motion-safe:animate-fade-in motion-safe:animate-fill-both" + :class=" + index >= newSearchBatchSize && + 'motion-safe:animate-fade-in motion-safe:animate-fill-both' + " + :style=" + index >= newSearchBatchSize + ? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` } + : {} + " :filters="filters" - :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" @click-keyword="emit('clickKeyword', $event)" /> @@ -224,8 +238,15 @@ defineExpose({ :show-publisher="showPublisher" :index="index" :search-query="searchQuery" - class="motion-safe:animate-fade-in motion-safe:animate-fill-both" - :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" + :class=" + index >= newSearchBatchSize && + 'motion-safe:animate-fade-in motion-safe:animate-fill-both' + " + :style=" + index >= newSearchBatchSize + ? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` } + : {} + " :filters="filters" @click-keyword="emit('clickKeyword', $event)" /> diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts index 77367a049..45bb47848 100644 --- a/app/composables/useGlobalSearch.ts +++ b/app/composables/useGlobalSearch.ts @@ -4,6 +4,8 @@ import { debounce } from 'perfect-debounce' // Pages that have their own local filter using ?q const pagesWithLocalFilter = new Set(['~username', 'org']) +const SEARCH_DEBOUNCE_MS = 100 + export function useGlobalSearch(place: 'header' | 'content' = 'content') { const { settings } = useSettings() const { searchProvider } = useSearchProvider() @@ -27,10 +29,14 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { // Syncs instantly when instantSearch is on, but only on Enter press when off const committedSearchQuery = useState('committed-search-query', () => searchQuery.value) + const commitSearchQuery = debounce((val: string) => { + committedSearchQuery.value = val + }, SEARCH_DEBOUNCE_MS) + // This is basically doing instant search as user types watch(searchQuery, val => { if (settings.value.instantSearch) { - committedSearchQuery.value = val + commitSearchQuery(val) } }) @@ -71,10 +77,11 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { }) } - const updateUrlQuery = debounce(updateUrlQueryImpl, 250) + const updateUrlQuery = debounce(updateUrlQueryImpl, SEARCH_DEBOUNCE_MS) function flushUpdateUrlQuery() { // Commit the current query when explicitly submitted (Enter pressed) + commitSearchQuery.cancel() committedSearchQuery.value = searchQuery.value // When instant search is off the debounce queue is empty, so call directly if (!settings.value.instantSearch) { diff --git a/app/pages/search.vue b/app/pages/search.vue index eb424d7dd..89b2165ba 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -354,13 +354,19 @@ const canPublishToScope = computed(() => { // Show claim prompt when valid name, available, either not connected or connected and has permission const showClaimPrompt = computed(() => { - return ( - isValidPackageName.value && - packageAvailability.value?.available === true && - packageAvailability.value.name === query.value.trim() && - (!isConnected.value || (isConnected.value && canPublishToScope.value)) && - status.value !== 'pending' - ) + if (!isValidPackageName.value) return false + if (isConnected.value && !canPublishToScope.value) return false + + const avail = packageAvailability.value + + // Confirmed: availability result matches current committed query + if (avail?.available === true && avail.name === committedQuery.value.trim()) return true + + // Pending: a new fetch is in flight — keep the claim visible if the last known + // result was "available" so it doesn't flicker until new data arrives + if (status.value === 'pending' && avail?.available === true) return true + + return false }) const claimPackageModalRef = useTemplateRef('claimPackageModalRef') @@ -711,22 +717,28 @@ onBeforeUnmount(() => { status === 'success' " > -
- -
+
+ +
+
{