@@ -65,6 +65,10 @@ export function useNpmSearch(
6565
6666 const isLoadingMore = shallowRef ( false )
6767
68+ // Track rate limit errors separately for better UX
69+ // Using ref instead of shallowRef to ensure reactivity triggers properly
70+ const isRateLimited = ref ( false )
71+
6872 // Standard (non-incremental) search implementation
6973 let lastSearch : NpmSearchResponse | undefined = undefined
7074
@@ -74,34 +78,64 @@ export function useNpmSearch(
7478 const q = toValue ( query )
7579
7680 if ( ! q . trim ( ) ) {
81+ isRateLimited . value = false
7782 return emptySearchResponse
7883 }
7984
8085 const opts = toValue ( options )
8186
8287 // This only runs for initial load or query changes
83- // Reset cache for new query
88+ // Reset cache for new query (but don't reset rate limit yet - only on success)
8489 cache . value = null
8590
8691 const params = new URLSearchParams ( )
8792 params . set ( 'text' , q )
8893 // Use requested size for initial fetch
8994 params . set ( 'size' , String ( opts . size ?? 25 ) )
9095
91- if ( q . length === 1 ) {
92- const encodedName = encodePackageName ( q )
93- const [ { data : pkg , isStale } , { data : downloads } ] = await Promise . all ( [
94- $npmRegistry < Packument > ( `/${ encodedName } ` , { signal } ) ,
95- $npmApi < NpmDownloadCount > ( `/downloads/point/last-week/${ encodedName } ` , {
96- signal,
97- } ) ,
98- ] )
99-
100- if ( ! pkg ) {
101- return emptySearchResponse
96+ try {
97+ if ( q . length === 1 ) {
98+ const encodedName = encodePackageName ( q )
99+ const [ { data : pkg , isStale } , { data : downloads } ] = await Promise . all ( [
100+ $npmRegistry < Packument > ( `/${ encodedName } ` , { signal } ) ,
101+ $npmApi < NpmDownloadCount > ( `/downloads/point/last-week/${ encodedName } ` , {
102+ signal,
103+ } ) ,
104+ ] )
105+
106+ if ( ! pkg ) {
107+ return emptySearchResponse
108+ }
109+
110+ const result = packumentToSearchResult ( pkg , downloads ?. downloads )
111+
112+ // If query changed/outdated, return empty search response
113+ if ( q !== toValue ( query ) ) {
114+ return emptySearchResponse
115+ }
116+
117+ cache . value = {
118+ query : q ,
119+ objects : [ result ] ,
120+ total : 1 ,
121+ }
122+
123+ // Success - clear rate limit flag
124+ isRateLimited . value = false
125+
126+ return {
127+ objects : [ result ] ,
128+ total : 1 ,
129+ isStale,
130+ time : new Date ( ) . toISOString ( ) ,
131+ }
102132 }
103133
104- const result = packumentToSearchResult ( pkg , downloads ?. downloads )
134+ const { data : response , isStale } = await $npmRegistry < NpmSearchResponse > (
135+ `/-/v1/search?${ params . toString ( ) } ` ,
136+ { signal } ,
137+ 60 ,
138+ )
105139
106140 // If query changed/outdated, return empty search response
107141 if ( q !== toValue ( query ) ) {
@@ -110,36 +144,27 @@ export function useNpmSearch(
110144
111145 cache . value = {
112146 query : q ,
113- objects : [ result ] ,
114- total : 1 ,
115- }
116-
117- return {
118- objects : [ result ] ,
119- total : 1 ,
120- isStale,
121- time : new Date ( ) . toISOString ( ) ,
147+ objects : response . objects ,
148+ total : response . total ,
122149 }
123- }
124150
125- const { data : response , isStale } = await $npmRegistry < NpmSearchResponse > (
126- `/-/v1/search?${ params . toString ( ) } ` ,
127- { signal } ,
128- 60 ,
129- )
151+ // Success - clear rate limit flag
152+ isRateLimited . value = false
130153
131- // If query changed/outdated, return empty search response
132- if ( q !== toValue ( query ) ) {
133- return emptySearchResponse
134- }
154+ return { ...response , isStale }
155+ } catch ( error : unknown ) {
156+ // Detect rate limit errors. npm's 429 response doesn't include CORS headers,
157+ // so the browser reports "Failed to fetch" instead of the actual status code.
158+ const errorMessage = ( error as { message ?: string } ) ?. message || String ( error )
159+ const isRateLimitError =
160+ errorMessage . includes ( 'Failed to fetch' ) || errorMessage . includes ( '429' )
135161
136- cache . value = {
137- query : q ,
138- objects : response . objects ,
139- total : response . total ,
162+ if ( isRateLimitError ) {
163+ isRateLimited . value = true
164+ return emptySearchResponse
165+ }
166+ throw error
140167 }
141-
142- return { ...response , isStale }
143168 } ,
144169 { default : ( ) => lastSearch || emptySearchResponse } ,
145170 )
@@ -260,5 +285,7 @@ export function useNpmSearch(
260285 hasMore,
261286 /** Manually fetch more results up to target size (incremental mode only) */
262287 fetchMore,
288+ /** Whether the search was rate limited by npm (429 error) */
289+ isRateLimited : readonly ( isRateLimited ) ,
263290 }
264291}
0 commit comments