TLDR:
el.blur()inside a CDP eval silently no-ops when the tab lacks real OS focus. Dispatchnew Event('blur')instead — or watch your commit handler disappear and your next test inherit a ghost.
the setup
I built a simple gym-tracking PWA (Progressive Web App, basically a web app you can install on your phone like a native app). M/W/F, log the lifts, done.
The v9 update added tap-to-type: tap a weight cell, an input appears inline, type the number, blur to commit, input collapses. Clean little pattern.
I wanted to verify it through Arc CDP — my Chrome DevTools Protocol (CDP) setup for driving Arc browser programmatically from scripts. I use it constantly for browser verification.
what broke
I ran the sequence inside a CDP eval:
- Click the cell
inp.focus()- Set
inp.value = '185' inp.blur()
Nothing committed. The input just… sat there.
So I moved on to the next test — click a different cell.
What I got back was BIZARRE.
The new cell opened, but the old input was still in the DOM. Still had its stale listeners registered. Still had its stale cancel state. Everything from the previous eval was still alive in there, and it was contaminating everything I tried next.
I immediately suspected the app.
Maybe the blur handler had a timing issue. Maybe React wasn't picking up the value change. Maybe I needed to fire a synthetic input event before the blur…
None of it. The app was completely fine.
the real problem
Here's what was actually happening.
When arc-cdp eval runs, it executes JS via Runtime.evaluate inside a tab that does not have real OS focus.
Arc is sitting there. The tab is technically active. But the system-level focus belongs to whatever window is in front — Terminal, Claude Code, whatever I'm actually typing in.
So when I called inp.focus() inside the eval, it looked like it worked.
But the tab never received real system focus.
Which means when I called inp.blur() right after… nothing. No blur event. No handler. No commit. The input just orphaned itself in the DOM — listeners and all — and waited to ruin my next test.
the fix
Replace inp.blur() with:
inp.dispatchEvent(new Event('blur'))
Dispatches the event directly. The listener fires regardless of whether the tab has real focus. Commit handler runs. Input collapses. DOM is clean.
(React note: new Event('blur') still won't reach onBlur in React 17+. React delegates that to focusout — you need new FocusEvent('focusout', {bubbles: true}). Bit me on a different project the next week. Its own adventure.)
why this keeps mattering
The deeper rule I keep learning:
CDP tabs are real tabs, carrying real OS state you didn't set and don't own.
Focus is one face of that.
A week later, I was testing hover styles on a client's site and got a background-size of 99.9965% on a link at "rest" — the CSS rule said 0%. I started chasing a phantom gradient bug.
Turned out my mouse cursor was parked on the card. The :hover state was real. The CDP screenshot was honest. I was the variable.
Same lesson. Different scar.
So the rule I write down now: when a synthetic-interaction test returns impossible results, suspect leftover DOM state from the previous eval before you blame the app. Re-run the failing case on a fresh element in isolation first.
That's it. That's the whole thing.
P.S. The app shipped. Tap-to-type works great. The tracker has no idea any of this happened.