Skip to content

fix(npm-stats): use official npm registry search and fix search UX#845

Merged
tannerlinsley merged 1 commit intomainfrom
taren/charming-grothendieck-f03689
Apr 22, 2026
Merged

fix(npm-stats): use official npm registry search and fix search UX#845
tannerlinsley merged 1 commit intomainfrom
taren/charming-grothendieck-f03689

Conversation

@tannerlinsley
Copy link
Copy Markdown
Member

@tannerlinsley tannerlinsley commented Apr 21, 2026

Summary

  • Replace deprecated api.npms.io with registry.npmjs.org/-/v1/search so the package picker returns current versions
  • Fix the input losing focus while typing by always mounting Command.List and giving the synthetic "Use X" item a stable key, per cmdk's recommended pattern
  • Swap the custom combine-packages modal for @radix-ui/react-dialog so it matches the look and a11y of the other modals on the site

Test plan

  • /stats/npm — type a package name; focus stays in the input throughout typing
  • Results reflect the current npm registry (compare a few packages against npmjs.com)
  • "Use X" fallback still lets you add an arbitrary package name
  • Clicking the "+" on a package pill opens the combine dialog styled like the login/search modals (overlay, rounded corners, ESC + click-outside + X close)
  • Dialog's inner PackageSearch also searches the registry and keeps focus

Summary by CodeRabbit

  • New Features

    • Added ability to type and create custom package entries directly in the NPM package search.
  • Improvements

    • Enhanced search responsiveness with improved input debouncing.
    • Refined empty-state messaging when no packages are found.
    • Upgraded package combining dialog with improved controls.

- Swap deprecated api.npms.io for registry.npmjs.org so results reflect
  current package versions
- Always mount Command.List and stabilize the synthetic "Use X" item to
  stop the input losing focus while typing
- Replace custom overlay for the combine-packages modal with
  @radix-ui/react-dialog to match the rest of the site's modals
@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 21, 2026

Deploy Preview for tanstack ready!

Name Link
🔨 Latest commit 8445536
🔍 Latest deploy log https://app.netlify.com/projects/tanstack/deploys/69e801a4b220000008705fb9
😎 Deploy Preview https://deploy-preview-845--tanstack.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 44 (🔴 down 6 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (🔴 down 9 from production)
SEO: 97 (no change from production)
PWA: 70 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

📝 Walkthrough

Walkthrough

The NPM search component was updated to use a different API endpoint (npmjs.org registry instead of npms.io), simplified query gating logic, removed label-based rendering, and introduced create/select functionality via a sentinel value. The combining package dialog was refactored to use Radix UI Dialog primitives instead of inline markup.

Changes

Cohort / File(s) Summary
NPM Search Component
src/components/npm-stats/PackageSearch.tsx
API endpoint changed from npms.io to registry.npmjs.org, JSON response parsing updated from results[].package to objects[].package. Query gating simplified with hasUsableQuery flag. Label field removed from NpmSearchResult type. Create/select behavior added via CREATE_ITEM_VALUE sentinel, allowing users to create or select typed values. List rendering refactored with conditional display of create action, updated empty-state logic, and twMerge-based Command.List class composition.
Dialog Refactoring
src/routes/stats/npm/index.tsx
Replaced inline "Combine Package Dialog" markup with Radix UI Dialog-based implementation using DialogPrimitive.Root, Portal, Overlay, Content, Title, Close, and screen-reader-only Description. Dialog state controlled via open={combiningPackage !== null} and onOpenChange callback.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~35 minutes

Poem

🐰 A search that bounces with precision,
From one API to another in mission,
With sentinels guiding the way,
And dialogs styled for the day,
The packages now sing in creation's permission! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: switching to the official npm registry search API and improving the search UX with better focus handling and dialog styling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch taren/charming-grothendieck-f03689

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/npm-stats/PackageSearch.tsx`:
- Around line 53-77: The UI is using debouncedInputValue for create/empty-state
logic which can show stale info; keep the debounce only for the network request
(use debouncedInputValue for useQuery and its enabled flag) but derive UI state
from the live inputValue: replace trimmedInput = debouncedInputValue.trim() with
trimmedInput = inputValue.trim(), compute showCreateItem using trimmedInput and
searchResults (which still come from searchQuery), and compute the
empty-list/“no packages found” condition from inputValue.trim() as well so the
create/empty UI reflects the immediate user input while queries remain
debounced.
- Around line 57-67: The queryFn currently assumes a successful npm registry
response and maps response.json() directly, so HTTP 4xx/5xx responses are
treated as empty results; modify the queryFn used in PackageSearch (the async
fetch block that builds the registry URL) to check response.ok and, if false,
throw an Error including response.status and response.statusText (or
response.text()) so the react-query hook sets searchQuery.isError; then update
the component render logic to detect searchQuery.isError and render an explicit
error state/message instead of falling back to the empty-results path; apply the
same change to the other queryFn instance referenced around lines 129–137.

In `@src/routes/stats/npm/index.tsx`:
- Around line 618-620: The close control using DialogPrimitive.Close that
renders only the X icon is missing an accessible name; update the
DialogPrimitive.Close element to include an accessible label (e.g., add
aria-label="Close dialog") or include visually hidden text next to the X icon so
screen readers get a reliable name—modify the DialogPrimitive.Close usage that
wraps the X component to include the aria-label or hidden label text.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c2715b81-418c-4aba-a41d-53ed27851f67

📥 Commits

Reviewing files that changed from the base of the PR and between eb49d0e and 8445536.

📒 Files selected for processing (2)
  • src/components/npm-stats/PackageSearch.tsx
  • src/routes/stats/npm/index.tsx

Comment on lines +53 to +77
const hasUsableQuery = debouncedInputValue.length > 2

const searchQuery = useQuery({
queryKey: ['npm-search', debouncedInputValue],
queryFn: async () => {
if (!debouncedInputValue || debouncedInputValue.length <= 2)
return [] as Array<NpmSearchResult>

const response = await fetch(
`https://api.npms.io/v2/search?q=${encodeURIComponent(
`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(
debouncedInputValue,
)}&size=10`,
)
const data = (await response.json()) as {
results: Array<{ package: NpmSearchResult }>
objects: Array<{ package: NpmSearchResult }>
}
return data.results.map((r) => r.package)
return data.objects.map((r) => r.package)
},
enabled: debouncedInputValue.length > 2,
enabled: hasUsableQuery,
placeholderData: keepPreviousData,
})

const results = React.useMemo(() => {
const hasInputValue = searchQuery.data?.find(
(d) => d.name === debouncedInputValue,
)

return [
...(hasInputValue
? []
: [
{
name: debouncedInputValue,
label: `Use "${debouncedInputValue}"`,
},
]),
...(searchQuery.data ?? []),
]
}, [searchQuery.data, debouncedInputValue])

const handleInputChange = (value: string) => {
setInputValue(value)
}
const searchResults = hasUsableQuery ? (searchQuery.data ?? []) : []
const trimmedInput = debouncedInputValue.trim()
const showCreateItem =
hasUsableQuery &&
trimmedInput.length > 0 &&
!searchResults.some((d) => d.name === trimmedInput)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the live input for the create item and list state.

trimmedInput comes from the debounced value, so Line 80 can submit the previous term if the user presses Enter before the debounce settles. The same split state also makes Line 133 briefly show “No packages found” right after the third keystroke. Keep the debounce on the request only; derive create/empty-state UI from the current inputValue.

💡 Suggested fix
-  const hasUsableQuery = debouncedInputValue.length > 2
+  const trimmedInput = inputValue.trim()
+  const debouncedTrimmedInput = debouncedInputValue.trim()
+  const hasUsableQuery = debouncedTrimmedInput.length > 2

   const searchQuery = useQuery({
-    queryKey: ['npm-search', debouncedInputValue],
+    queryKey: ['npm-search', debouncedTrimmedInput],
     queryFn: async () => {
       const response = await fetch(
         `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(
-          debouncedInputValue,
+          debouncedTrimmedInput,
         )}&size=10`,
       )
@@
-  const trimmedInput = debouncedInputValue.trim()
+  const isDebouncing = trimmedInput !== debouncedTrimmedInput
   const showCreateItem =
-    hasUsableQuery &&
+    trimmedInput.length > 2 &&
     trimmedInput.length > 0 &&
     !searchResults.some((d) => d.name === trimmedInput)
@@
-          {inputValue.length < 3 ? (
+          {trimmedInput.length < 3 ? (
             <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
               Keep typing to search...
             </div>
-          ) : searchQuery.isLoading ? (
+          ) : isDebouncing || searchQuery.isLoading ? (
             <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
               Searching...
             </div>

Also applies to: 80-82, 125-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/npm-stats/PackageSearch.tsx` around lines 53 - 77, The UI is
using debouncedInputValue for create/empty-state logic which can show stale
info; keep the debounce only for the network request (use debouncedInputValue
for useQuery and its enabled flag) but derive UI state from the live inputValue:
replace trimmedInput = debouncedInputValue.trim() with trimmedInput =
inputValue.trim(), compute showCreateItem using trimmedInput and searchResults
(which still come from searchQuery), and compute the empty-list/“no packages
found” condition from inputValue.trim() as well so the create/empty UI reflects
the immediate user input while queries remain debounced.

Comment on lines 57 to 67
queryFn: async () => {
if (!debouncedInputValue || debouncedInputValue.length <= 2)
return [] as Array<NpmSearchResult>

const response = await fetch(
`https://api.npms.io/v2/search?q=${encodeURIComponent(
`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(
debouncedInputValue,
)}&size=10`,
)
const data = (await response.json()) as {
results: Array<{ package: NpmSearchResult }>
objects: Array<{ package: NpmSearchResult }>
}
return data.results.map((r) => r.package)
return data.objects.map((r) => r.package)
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle registry failures explicitly.

Line 66 assumes a successful npm payload, but 4xx/5xx responses will put the query into an error state. The list then falls through to the empty-results path, which turns outages or rate limits into “No packages found”. Check response.ok and render an error state when searchQuery.isError is true.

🛡️ Suggested fix
   const searchQuery = useQuery({
     queryKey: ['npm-search', debouncedInputValue],
     queryFn: async () => {
       const response = await fetch(
         `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(
           debouncedInputValue,
         )}&size=10`,
       )
+      if (!response.ok) {
+        throw new Error(`npm search failed: ${response.status}`)
+      }
       const data = (await response.json()) as {
         objects: Array<{ package: NpmSearchResult }>
       }
-      return data.objects.map((r) => r.package)
+      return data.objects?.map((r) => r.package) ?? []
     },
@@
-          ) : !searchResults.length && !showCreateItem ? (
+          ) : searchQuery.isError ? (
+            <div className="px-3 py-2 text-sm text-red-600 dark:text-red-400">
+              Search failed. Try again.
+            </div>
+          ) : !searchResults.length && !showCreateItem ? (
             <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
               No packages found
             </div>

Also applies to: 129-137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/npm-stats/PackageSearch.tsx` around lines 57 - 67, The queryFn
currently assumes a successful npm registry response and maps response.json()
directly, so HTTP 4xx/5xx responses are treated as empty results; modify the
queryFn used in PackageSearch (the async fetch block that builds the registry
URL) to check response.ok and, if false, throw an Error including
response.status and response.statusText (or response.text()) so the react-query
hook sets searchQuery.isError; then update the component render logic to detect
searchQuery.isError and render an explicit error state/message instead of
falling back to the empty-results path; apply the same change to the other
queryFn instance referenced around lines 129–137.

Comment on lines +618 to +620
<DialogPrimitive.Close className="rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500">
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</DialogPrimitive.Close>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add an accessible name to the close button.

This is an icon-only control right now, so assistive tech does not get a reliable label. Add aria-label="Close dialog" or visually hidden text.

♿ Suggested fix
-                  <DialogPrimitive.Close className="rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500">
+                  <DialogPrimitive.Close
+                    aria-label="Close dialog"
+                    className="rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500"
+                  >
                     <X className="w-4 h-4 sm:w-5 sm:h-5" />
                   </DialogPrimitive.Close>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<DialogPrimitive.Close className="rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500">
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</DialogPrimitive.Close>
<DialogPrimitive.Close
aria-label="Close dialog"
className="rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500"
>
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</DialogPrimitive.Close>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/stats/npm/index.tsx` around lines 618 - 620, The close control
using DialogPrimitive.Close that renders only the X icon is missing an
accessible name; update the DialogPrimitive.Close element to include an
accessible label (e.g., add aria-label="Close dialog") or include visually
hidden text next to the X icon so screen readers get a reliable name—modify the
DialogPrimitive.Close usage that wraps the X component to include the aria-label
or hidden label text.

@tannerlinsley tannerlinsley merged commit 5d690c7 into main Apr 22, 2026
8 checks passed
@tannerlinsley tannerlinsley deleted the taren/charming-grothendieck-f03689 branch April 22, 2026 22:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant