Shop

Spend your points on upgrades

Cat Cosmetics
← Back to Blog

The Webview That Lied About Having a Touchscreen

I was trying to close a dropdown in Synth so I could start recording a meeting, and the dropdown would not close. Not “closed and reopened,” not “threw an error in the console.” Nothing happened at all. The menu item lit up under my cursor exactly the way it was supposed to, I clicked it, and it just sat there, glowing, completely unbothered by the fact that I had clicked it.

I clicked it again. Same thing. I moved the mouse away and back, watched the hover state come and go correctly, clicked a third time, and finally it worked, like nothing had ever been wrong. That inconsistency is what got under my skin. A button that never works is a bug. A button that works two times out of three, with the exact same click, on the exact same machine, felt like something else.

The setup

Synth is a Tauri desktop app, which means the whole UI is a real webview (WKWebView on macOS) wrapping a Next.js frontend, with Radix underneath most of the interactive pieces: the recording controls dropdown, the model picker, the meeting list’s context menu, dialogs for renaming a meeting or picking an export format. None of it is exotic. It’s the same component library plenty of web apps ship, just running inside a native window instead of a browser tab.

The bug didn’t care which of those components it hit. Dropdown items, select options, dialog buttons, all of them would occasionally just decline to register a click, while looking, in every visible way, like they’d registered it fine.

what the screen showed Export Summary hover, focus, highlight: all correct what actually happened Export Summary click: silently ignored no console error, no event fired
Both states came from the exact same component, the exact same click, seconds apart. Nothing in the visible UI or the console distinguished the click that worked from the one that didn't.

Everything I blamed first

1 2 3 4 5 the fix
1: hunt for an invisible overlay. 2: audit z-index across every layered component. 3: chase stale listeners left behind by re-renders. 4: force remounts with fresh keys. Orange all the way through, until the inspector accidentally handed me the actual clue.

First theory: something invisible is sitting on top of the button. This is the classic explanation for a hover-but-no-click bug, so I went looking for it first. I inspected the DOM at the exact coordinates of a failed click, checked for a stray Portal container left mounted after a dialog closed, even added a temporary outline: 2px solid red to every fixed and absolutely positioned element on the page to see if anything unexpected lit up. Nothing was there. The element under the cursor was, every single time, the button I was trying to click.

Second theory: it’s a z-index war. Radix stacks portals, popovers, and dropdowns in a way that can get genuinely tangled if you’re not careful, so I spent a while auditing every z-index value in the app, looking for two things that could plausibly be fighting over the same click. I found one legitimately sloppy value and fixed it. The bug didn’t care.

Third theory, and I remember being fairly confident about this one: stale event listeners. Synth’s recording controls re-render a lot, tied to live transcript state, and I’ve been bitten before by a useEffect that attaches a listener without cleaning up the previous one, leaving two handlers arguing over the same click. I went through every dropdown-adjacent useEffect in the app, added cleanup functions everywhere I could justify one, even where I was pretty sure it hadn’t been the problem.

Still broken. Same menus, same maddening one-in-three failure rate.

Fourth theory: fine, force a clean remount. If some stale closure was haunting a component instance, a fresh key prop would kill it and start over. I added keys tied to open/close state on the worst-offending dropdowns, shipped it, and clicked through the app for a good twenty minutes specifically trying to make it fail.

It still failed. Four separate, individually reasonable theories, each one implemented and tested, and the actual behavior of the bug hadn’t shifted by a single click. At that point I was fairly sure I’d been debugging the wrong layer of the app entirely, I just had no idea which layer was the right one.

The clue that actually meant something

I opened Safari’s Web Inspector on the WKWebView, mostly out of habit, to poke at the DOM one more time while a failure was fresh. And the bug stopped. Buttons I’d just watched fail to click, twice in a row, started working the instant the inspector was attached, and kept working for as long as it stayed open.

That’s a strange thing to notice, because it isn’t really a fix. Nothing about opening a devtools panel should change whether a click event fires. But it was the first piece of information in this whole chase that pointed somewhere specific: not at my component tree, not at a stray CSS rule, but at something happening underneath the DOM entirely, in however WebKit itself was tracking input state. Attaching the inspector was clearly resetting something. I just needed to find out what.

What was actually happening

The something turned out to be navigator.maxTouchPoints, or more precisely, WKWebView’s answer to it. On macOS, Tauri’s webview can misreport itself as touch-capable, even on a machine with no touchscreen anywhere near it, a mouse-and-trackpad Mac like the one I was testing on. There’s an open, still-unresolved discussion about exactly this on Tauri’s GitHub (tauri-apps/tauri discussion #11957), which is where I finally found language for what I was looking at, though not a fix.

Radix doesn’t just ignore that touch-capable signal. Its DismissableLayer, the piece responsible for closing dropdowns, popovers, and dialogs when you interact outside them, registers real touchstart and touchend handlers defensively, on the reasonable assumption that a touch-capable device might send a tap instead of a click. On a real touchscreen this is exactly the right thing to do. On a Mac that’s lying about having one, it means Radix is listening for a touch sequence that will never legitimately occur, but that the webview is apparently still capable of half-generating on its own, as a phantom event tied to something in its internal pointer state.

WKWebView: navigator.maxTouchPoints falsely reports touch support Radix DismissableLayer: registers touchstart / touchend defensively phantom touchend fires, no finger ever touched anything treated as the tail end of a touch interaction real mouse click cancelled before it reaches the button
Nothing in this chain is a bug on its own. Radix's touch handling is correct behavior for a real touchscreen; the webview's false signal is what turns it against a mouse-only Mac.

Opening the Web Inspector “fixed” it because attaching it forces WebKit to reset its input state, clearing out whatever half-formed touch sequence was sitting there. That’s a diagnostic accident, not a resolution. The moment the inspector closed, the phantom touch capability came right back, and so did the failed clicks.

The fix

Synth is desktop-only. It runs on a Mac, with a mouse or a trackpad, never a touchscreen, so touch events have exactly zero legitimate use anywhere in the app. Rather than trying to convince Radix or WebKit to behave, I just made sure no touch listener could ever get registered in the first place, on macOS specifically:

const TOUCH_EVENT_TYPES = new Set(['touchstart', 'touchend', 'touchmove', 'touchcancel']);

if (typeof window !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent)) {
  const originalAddEventListener = EventTarget.prototype.addEventListener;

  EventTarget.prototype.addEventListener = function (type, listener, options) {
    if (TOUCH_EVENT_TYPES.has(type)) return;
    return originalAddEventListener.call(this, type, listener, options);
  };
}

That’s the entire file. It monkey-patches addEventListener so any attempt to register one of the four touch event types is silently dropped, gated behind a macOS check so it’s a true no-op everywhere else. The only thing that mattered beyond the patch itself was import order: it has to run before Radix ever gets a chance to attach its own handlers, so it goes at the very top of layout.tsx, above everything else, evaluated the moment the module loads, well before any component mounts.

I clicked through every dropdown, dialog, and select in the app after that, on purpose, dozens of times, trying to reproduce the one-in-three failure. Nothing. It just worked, every time, the boring way a button is supposed to.

Anyway

The strangest part of this whole thing is how little is written about it anywhere. The Tauri discussion describes the exact symptom, other people clearly hitting the same wall, but as of this fix it has no accepted resolution sitting on it. I don’t think this bug is rare so much as it’s easy to give up on, because “opening devtools fixes it” is such an unsatisfying, seemingly unreproducible clue that it’s tempting to write off as a fluke instead of chasing it to an actual cause. It wasn’t a fluke. It was the one piece of the app quietly telling the truth about where the problem lived, if I’d been willing to listen to it three theories earlier.