Select / Menu components: Typeahead silently breaks in production: build injects `/* @__PURE__ */` onto a side-effect-only IIFE, which SWC eliminates
Bug report
Current behaviour
The updateSearch IIFE inside useTypeaheadSearch is annotated with /* @__PURE__ */ in the published dist files. The annotation is incorrect — the call is invoked purely for its side effects and its return value is discarded — so a minifier that honours the annotation deletes the entire call.
The result is that searchRef.current is never written. It stays '' for the lifetime of the component, which breaks typeahead in three ways:
- Multi-character search never accumulates. Every keypress sends a fresh single-character search.
- The 1-second reset timer never exists, so
timerRefis never set. isTypingAhead(searchRef.current !== '') is permanentlyfalse, so the Space-key exemption while typing ahead never applies — Space always selects/closes instead of extending the search.
The user-visible symptom that led us here: with options Botanicals, Brats, Bbt, typing g,g,g (or any repeated character) advances focus to the next matching option on every keypress in production, whereas in development the search accumulates. Same app, same Radix version, same browser — only the build differs.
This is silent: no error, no warning. It only reproduces in a minified production build, so unit tests and dev servers never catch it.
Affected published packages
Verified by grepping the shipped dist/index.mjs for (/* @__PURE__ */ __name((function <name> — an annotated IIFE invoked for side effects:
| Package | Version | Function |
|---|---|---|
@radix-ui/react-select |
2.3.7 | updateSearch |
@radix-ui/react-menu |
2.1.24 | updateSearch |
@radix-ui/react-scroll-area |
1.2.18 | loop |
react-menu is the shared implementation behind DropdownMenu, ContextMenu, and Menubar, so those inherit the same broken typeahead.
react-scroll-area is the most severe of the three. In addUnlinkedScrollListener the annotated IIFE is the requestAnimationFrame polling loop:
// @radix-ui/[email protected] dist/index.mjs:707
(/* @__PURE__ */ __name((function loop() {
const position = { left: node.scrollLeft, top: node.scrollTop };
...
rAF = window.requestAnimationFrame(loop);
}), "loop"))();When that call is dropped, the loop never starts, so the scroll handler never fires at all.
The annotation is injected by your build, not authored
This is worth stressing because the source is correct. In packages/react/select/src/select.tsx the IIFE has no annotation:
(function updateSearch(value: string) {
searchRef.current = value;
window.clearTimeout(timerRef.current);
if (value !== '') timerRef.current = window.setTimeout(() => updateSearch(''), 1000);
})(search);All 20 /* @__PURE__ */ occurrences in select.tsx sit on React.forwardRef(...) calls, which is legitimate. Confirmed by reading sourcesContent from the shipped dist/index.mjs.map.
What happens is that esbuild's --keep-names wraps the function expression in its __name(...) helper and marks that helper call pure (correctly — __name itself is side-effect free). But the annotation lands on the callee position of the enclosing call expression:
(/* @__PURE__ */ __name((function updateSearch(value) { ... }), "updateSearch"))(search);
//^ annotation attaches here, but the call that gets dropped is the outer (…)(search)A minifier reading that annotation concludes the whole (…)(search) expression is removable.
Not all minifiers agree — SWC drops it, esbuild and terser keep it
This is why it has gone unnoticed. Given the exact shipped shape:
| Minifier | Version | Result |
|---|---|---|
| SWC | 1.15.47 | call deleted |
| esbuild | 0.28.1 | preserved |
| terser | latest | preserved |
SWC is what Next.js/Turbopack uses, so every Next.js app on a Radix Select, DropdownMenu, ContextMenu, Menubar, or ScrollArea has these broken in production.
Minimal SWC repro, independent of Radix and of __name:
// input
export function a(o) { (/* @__PURE__ */ f(function g(v){ o.x = v; }))(1); }
export function b(o) { (/* @__PURE__ */ (function g(v){ o.x = v; }))(1); }
export function c(o) { const h = /* @__PURE__ */ (function g(v){ o.x = v; }); h(1); }
// swc.minify({ compress: true, module: true })
export function a(o){} // ← side effect lost
export function b(o){} // ← side effect lost
export function c(o){o.x=1} // ← preserved (annotation not on a callee)Whether SWC's reading is defensible is arguable, but the annotation is factually wrong regardless: the expression it marks pure does have side effects, so no minifier should be blamed for acting on it.
Reproduction
Verified against a real Next.js 16.3.0 / Turbopack production build (next build), not just an isolated minifier run. From the emitted client chunk:
// dist/.../static/chunks/<hash>.js — useTypeaheadSearch, mangled
o = t.useCallback(e => { r(n.current + e) }, [r])
// ^ n = searchRef, read only; the updateSearch IIFE is absentCompare the same hook in the unminified dist/index.mjs, where searchRef.current = value is present. grep -c for the 1e3 reset timer in the production chunk returns zero matches within the typeahead hook.
react-menu in the same chunk shows the identical pattern — z.current read, never written.
Steps, if you want to reproduce from scratch:
- Render a
Selectwith three or more options starting with the same letter (e.g.Botanicals,Brats,Bbt). - Build for production with Next.js/Turbopack (or run
swc.minifyon@radix-ui/react-select/dist/index.mjs). - Open the select and press the shared initial letter repeatedly.
- Production advances focus on every press; development accumulates the search string. Typing
bbcannot reachBbtin either, but the reason differs — in production the search is never even retained.
Expected behaviour
Typeahead should behave identically in development and production: the search string accumulates across keypresses, resets after 1 second of inactivity, and isTypingAhead reflects whether a search is in flight.
Suggested fix
Remove the annotated-IIFE shape so no annotation can attach to a side-effectful call. Hoisting to a function declaration is the smallest change and survives all three minifiers (verified):
const handleTypeaheadSearch = React.useCallback(
(key: string) => {
const search = searchRef.current + key;
handleSearchChange(search);
function updateSearch(value: string) {
searchRef.current = value;
window.clearTimeout(timerRef.current);
if (value !== '') timerRef.current = window.setTimeout(() => updateSearch(''), 1000);
}
updateSearch(search);
},
[handleSearchChange],
);Both this and a plain un-annotated IIFE retain the side effect under SWC, esbuild, and terser. The same treatment applies to react-menu's updateSearch and react-scroll-area's loop.
It may also be worth auditing the build for this shape generally — (/* @__PURE__ */ __name((function …)), "…")(…) is wrong wherever it appears, and a guard in @repo/builder (or dropping --keep-names for immediately-invoked expressions) would prevent recurrence in packages beyond these three.
Environment
@radix-ui/react-select2.3.7,@radix-ui/react-menu2.1.24,@radix-ui/react-scroll-area1.2.18 (viaradix-ui1.6.7)- React 19.2.8, Next.js 16.3.0 (Turbopack), SWC 1.15.47
- Reproduced on macOS; not browser-dependent (pure string/ref logic)
Source: radix-ui/primitives