Find the cost of adding an npm package to your app's bundle size
teardown.kelinci.dev
1import { sample, sampleOne } from '@mary/array-fns';
2import { createMemo, Match, Switch } from 'solid-js';
3import * as v from 'valibot';
4
5import PackageResult from './components/package-result';
6import PackageSearchInput from './components/package-search-input';
7import { CentralExclamationTriangleSolid } from './icons/central';
8import {
9 LucideArrowDown,
10 LucideCircleAlert,
11 LucideHandHeart,
12 LucideLoader,
13 LucideScissorsLineDashed,
14} from './icons/lucide';
15import { TangledDolly } from './icons/tangled';
16import {
17 formatPackageSpecifier,
18 PACKAGE_SPECIFIER_RE,
19 parsePackageSpecifier,
20 type Registry,
21} from './lib/package-name';
22import { createQuery } from './lib/query';
23import { createDerivedSignal } from './lib/signals';
24import { useSearchParams } from './lib/use-search-params';
25import { fetchPackageManifest } from './npm/packument';
26import Button from './primitives/button';
27import Tooltip from './primitives/tooltip';
28import { RECOMMENDATIONS } from './recommendations';
29
30const isSafari = (() => {
31 const ua = navigator.userAgent;
32 return /AppleWebKit/.test(ua) && !/Chrome|Chromium/.test(ua);
33})();
34
35function App() {
36 const [params, setParams] = useSearchParams({
37 q: v.pipe(v.string(), v.regex(PACKAGE_SPECIFIER_RE)),
38 });
39
40 const parsed = createMemo(() => {
41 const q = params().q;
42 return q ? parsePackageSpecifier(q) : null;
43 });
44
45 const identity = createMemo<{ registry: Registry; name: string } | undefined>(
46 () => {
47 const p = parsed();
48 return p ? { registry: p.registry, name: p.name } : undefined;
49 },
50 undefined,
51 { equals: (a, b) => a?.registry === b?.registry && a?.name === b?.name },
52 );
53
54 const range = createMemo(() => parsed()?.range ?? 'latest');
55
56 const [query, setQuery] = createDerivedSignal(() => params().q ?? '');
57
58 const [manifest, { refetch }] = createQuery(identity, (id) => fetchPackageManifest(id.registry, id.name), {
59 keepPreviousData: false,
60 });
61
62 const recs = sample(RECOMMENDATIONS, 6)
63 .flatMap((v) => (Array.isArray(v) ? sampleOne(v) : v))
64 // oxlint-disable-next-line unicorn/no-array-sort
65 .sort();
66
67 return (
68 <div class="mx-auto flex max-w-xl flex-col gap-6 p-4">
69 <div class="flex h-12 items-center justify-between gap-1 rounded-lg border border-neutral-stroke-3 bg-neutral-background-1 px-4">
70 <div class="flex shrink-0 items-center gap-2">
71 <LucideScissorsLineDashed class="size-5 text-brand-foreground-2" />
72 <h1 class="text-base-400 font-medium">teardown</h1>
73 </div>
74
75 <div class="-mr-2 flex items-center gap-1">
76 <Tooltip content="Donate!" relationship="label" placement="bottom">
77 {(triggerProps) => (
78 <a
79 {...triggerProps}
80 target="_blank"
81 href="https://github.com/sponsors/mary-ext"
82 class="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-transparent bg-subtle-background text-neutral-foreground-2 outline-2 -outline-offset-2 outline-transparent transition duration-100 hover:bg-subtle-background-hover focus-visible:outline-compound-brand-stroke active:bg-subtle-background-pressed"
83 >
84 <LucideHandHeart class="size-4" />
85 </a>
86 )}
87 </Tooltip>
88
89 <Tooltip content="Source code on tangled.org" relationship="label" placement="bottom">
90 {(triggerProps) => (
91 <a
92 {...triggerProps}
93 target="_blank"
94 href="https://tangled.org/did:plc:ia76kvnndjutgedggx2ibrem/teardown"
95 class="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-transparent bg-subtle-background text-neutral-foreground-2 outline-2 -outline-offset-2 outline-transparent transition duration-100 hover:bg-subtle-background-hover focus-visible:outline-compound-brand-stroke active:bg-subtle-background-pressed"
96 >
97 <TangledDolly class="size-4" />
98 </a>
99 )}
100 </Tooltip>
101 </div>
102 </div>
103
104 <div class="flex min-h-0 grow flex-col gap-4 sm:px-4">
105 <PackageSearchInput
106 autofocus={/* @once */ !query()}
107 value={query()}
108 onChange={setQuery}
109 onSelect={(specifier) => setParams({ q: specifier })}
110 />
111
112 {isSafari && (
113 <div class="flex gap-2 rounded-md border border-status-warning-border-1 bg-status-warning-background-1 px-3 py-1.75">
114 <CentralExclamationTriangleSolid class="size-5 shrink-0 text-status-warning-foreground-3" />
115
116 <div class="min-w-0 grow text-base-300 text-neutral-foreground-1">
117 <span class="font-semibold">Not compatible with Safari.</span> Sorry, not sure why it doesn't
118 work there. It seems to be Rolldown and WASI related.
119 </div>
120 </div>
121 )}
122
123 <Switch>
124 <Match when={manifest()} keyed>
125 {(m) => (
126 <PackageResult
127 manifest={m}
128 range={range()}
129 onVersionChange={(version) => {
130 setParams({
131 q: formatPackageSpecifier({ registry: m.registry, name: m.name, range: version }),
132 });
133 }}
134 />
135 )}
136 </Match>
137
138 <Match when={manifest.state === 'errored'}>
139 <div class="flex flex-col items-center justify-center gap-3 py-12">
140 <LucideCircleAlert class="text-danger-foreground-1 size-5" />
141 <span class="text-base-300 text-neutral-foreground-2">{manifest.error?.message}</span>
142 <Button appearance="subtle" onClick={() => refetch()}>
143 Retry
144 </Button>
145 </div>
146 </Match>
147
148 <Match when={manifest.loading}>
149 <div class="flex flex-col items-center justify-center gap-3 py-12">
150 <LucideLoader class="size-5 animate-spin-linear text-neutral-foreground-3" />
151 <span class="text-base-300 text-neutral-foreground-2">Loading...</span>
152 </div>
153 </Match>
154
155 <Match when>
156 <div class="flex flex-col gap-4">
157 <div>
158 <p class="text-base-300 text-neutral-foreground-2">
159 Find the cost of adding an npm package to your app's bundle size. <br />
160 Makes use of <a>Rolldown</a> to bundle packages in your browser.
161 </p>
162 </div>
163
164 <div class="flex flex-col gap-3">
165 <p class="text-base-200 font-bold text-neutral-foreground-4 uppercase">Example packages</p>
166
167 {recs.map((name) => {
168 return (
169 <div class="flex items-center gap-3">
170 <LucideArrowDown class="size-4 rotate-270 text-neutral-foreground-3" />
171 <button
172 onClick={() => setParams({ q: `npm:${name}` })}
173 class="cursor-pointer text-base-300 text-brand-foreground-2 transition hover:text-brand-foreground-2-hover hover:underline active:text-brand-foreground-2-pressed"
174 >
175 {name}
176 </button>
177 </div>
178 );
179 })}
180 </div>
181 </div>
182 </Match>
183 </Switch>
184 </div>
185 </div>
186 );
187}
188
189export default App;