DEV Community

Daniel Pertu
Daniel Pertu

Posted on

I nearly deleted four live components, because my audit grep only matched double quotes

I wanted to know which of our copied-in UI primitives were actually used, so I wrote the obvious loop:

for f in components/ui/*.tsx; do
  n=$(basename "$f" .tsx)
  echo "$n: $(grep -rl "components/ui/$n\"" --include='*.tsx' app components | wc -l)"
done
Enter fullscreen mode Exit fullscreen mode

It told me four files had zero importers: progress, separator, tabs, textarea. Dead code, 150 lines, three of them dragging a Radix package along for the ride. An easy win, and I was one command away from taking it.

The trailing " in that pattern is the bug. It only matches from "@/components/ui/progress". Our codebase also writes from '@/components/ui/progress', and the files that use those four primitives all happen to use single quotes.

The real numbers

Rerun with components/ui/$n['\"] and nothing is dead:

primitive lines importers
button 56 38
card 79 21
label 26 17
input 25 16
skeleton 15 11
badge 36 10
dialog 122 7
select 160 5
textarea 22 4
dropdown-menu 200 3
separator 31 2
progress 31 1
tabs 66 1
toast 128 1
toaster 35 1

Fifteen files, 1,032 lines, zero orphans. progress and tabs each have exactly one importer, and it is the same component, a session detail modal that imports both with single quotes. toast is imported once by toaster, and toaster is imported once by the root layout, which is a chain rather than a lonely file.

Why the quoting is a coin flip

Across app, components, lib, utils and hooks there are 589 double-quoted from "..." specifiers and 570 single-quoted ones. Seventeen individual files contain both. A .prettierrc has been in the repository since the first commit and it says:

{ "singleQuote": true, "semi": false, "tabWidth": 2 }
Enter fullscreen mode Exit fullscreen mode

There is no CI workflow in this repository, so format:check runs nowhere, and format has never been run across the tree. The config is an intention, not a fact about the files, which is exactly the state in which a one-character grep becomes a load-bearing part of your reasoning.

The lesson I actually took from this is not "configure Prettier". It is that an audit over source text has to be wrong in the safe direction. A grep that under-reports usage tells you to delete something that is used. If you must guess, guess that code is alive: run the pattern that matches too much, then read the hits.

A better check for this particular question needs no greps at all. TypeScript already knows:

npx tsc --noEmit   # after deleting the file, see what breaks
Enter fullscreen mode Exit fullscreen mode

Delete-and-compile is slower per file and right every time.

The shape underneath

With the counts fixed, the structure of this directory is worth a look. Nine Radix packages are in dependencies, and each is imported by exactly one file:

@radix-ui/react-dialog         -> components/ui/dialog.tsx
@radix-ui/react-dropdown-menu  -> components/ui/dropdown-menu.tsx
@radix-ui/react-label          -> components/ui/label.tsx
@radix-ui/react-progress       -> components/ui/progress.tsx
@radix-ui/react-select         -> components/ui/select.tsx
@radix-ui/react-separator      -> components/ui/separator.tsx
@radix-ui/react-slot           -> components/ui/button.tsx
@radix-ui/react-tabs           -> components/ui/tabs.tsx
@radix-ui/react-toast          -> components/ui/toast.tsx
Enter fullscreen mode Exit fullscreen mode

That is the shadcn bargain working as designed. The dependency graph is a star: nine packages reach nine wrapper files, and the 54 other files that import a primitive only ever see our wrappers. When Radix changes an API, the blast radius is one file per package. It also means these primitives are our code, not a library, so "is this used" is a question only our own grep can answer, which is how the mistake above was possible in the first place.

The distribution is lopsided in the way you would expect, and more lopsided than I would have guessed. button at 38 importers is a third of the whole app, and dropdown-menu at 200 lines is the largest primitive in the directory for three call sites.

Which of the 15 a visitor can actually reach

Here is the part that matters for anyone shipping a marketing site and an app from one Next.js project. Of the fifteen primitives, the public pages reach button, input and label, plus dropdown-menu through the site header and toaster through the root layout. The other ten, including the 122-line dialog and the 160-line select, exist only behind the dashboard.

That split is observable from outside. Load pub-trivia.app/tools/qr-code-generator, which is a real form built from exactly those three primitives: a Label, an Input and a Button that generates a QR code in your browser with no account and no server round trip. Then open the Network tab, filter to JS, and search the delivered chunks for SelectTrigger or DialogOverlay. Nineteen files, 1,131,866 bytes of JavaScript before compression, and zero hits for either. Searching for DropdownMenuTrigger does hit, three times, because the header menu is on every page.

Two more pages to look at, with their own reason for being there:

  • pub-trivia.app/tools/round-planner is the same three primitives again, this time doing arithmetic on question counts and timings. If you want to see how far Input and Label go before you need a form library, these two pages are the whole answer.
  • pub-trivia.app/features is where the dropdown-menu primitive earns its 200 lines: the nav menu in the header is the only consumer of it a visitor ever touches.

If you run this audit on your own components/ui, run it twice with two quoting patterns and compare. If the two runs disagree, the disagreement is the interesting output, not the counts.

Top comments (0)