DEV Community

Ted
Ted

Posted on • Originally published at tedagentic.com

framer-motion v12 Broke My UI — And My Monitoring Never Saw It

I had 22 cron jobs running — ranking checks, error guards, page watchers, RAM monitors, Telegram briefings. None of them fired.

A framer-motion v12 upgrade had been silently blocking navigation on all browsers and crashing the app entirely on Safari. I found it by accident.

What broke

Two symptoms, discovered in the same session:

1. Safari crash. One page wouldn't load at all. Users saw: "Something went wrong. Please refresh the page to continue." Refreshing didn't help.

2. Click navigation broken on all browsers. Interactive cards did nothing when clicked — they hovered correctly, scaled visually, but the click never fired.

The error message came from the React error boundary in App.tsx:

render() {
  if (this.state.hasError) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <h2>Something went wrong</h2>
        <p>Please refresh the page to continue.</p>
      </div>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Error boundaries are good practice — they prevent blank screens. But they swallow the actual error. Nothing in any log showed what crashed or when it started.

Root cause: framer-motion v12 + WAAPI

framer-motion v12 switched its animation engine to the Web Animations API (WAAPI) by default. This broke things in two distinct ways:

Safari crash — Safari's WAAPI implementation is incomplete. Certain element.animate() calls throw in Safari where Chrome and Firefox handle them fine. The unhandled error cascades into the error boundary, which hides the page behind the generic message.

Click blocking on all browsers — When motion.div runs a whileHover animation via WAAPI, the animation layer sits in the pointer event path and intercepts clicks before they reach the underlying <Link>. The hover works. The click doesn't.

Both problems came from the same pattern:

<Link to="/some/route">
  <motion.div whileHover={{ scale: 1.02 }}>
    ...card content...
  </motion.div>
</Link>
Enter fullscreen mode Exit fullscreen mode

The fix

Replace whileHover on elements inside <Link> with CSS-only hover. Same visual result, no JS animation in the pointer event path:

// Before
<motion.div whileHover={{ scale: 1.02 }}>

// After
<div className="transition-all duration-200 hover:scale-[1.02]">
Enter fullscreen mode Exit fullscreen mode

Entrance animations (initial / animate) were left in place — fade and slide transitions work correctly via WAAPI on all browsers including Safari. Only whileHover inside interactive elements is the problem.

Two files changed, ~10 lines. Navigation worked on first deploy.

Why monitoring missed it

Here's what my stack was watching:

┌─────────────────────────────────────────────────────┐
│              WHAT I WAS MONITORING                  │
├─────────────────────────────────────────────────────┤
│  GSC Rankings      impressions, CTR, position drops │
│  Index Status      crawl errors, 4xx, deindex risk  │
│  Page Speed        Core Web Vitals, load time       │
│  Server Health     RAM, disk, process uptime        │
│  Telegram alerts   morning briefing, threshold hits │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│              WHAT I WASN'T MONITORING               │
├─────────────────────────────────────────────────────┤
│  Does the page actually render without JS errors?   │
│  Do navigation links work when clicked?             │
│  Is the error boundary showing instead of content?  │
└─────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

GSC data lags 48–72 hours minimum. A broken page can still show stable impressions for days because crawlers hit the prerendered HTML shell — the static content is fine. The JS crash only happens when React hydrates in a real browser, which Googlebot doesn't fully replicate.

The gap is synthetic monitoring — an automated browser that visits pages, clicks things, and asserts on the result. I had zero of that.

Closing the gap with Playwright

Playwright is a headless browser automation library. Script it to visit URLs, interact with elements, and assert on what it finds.

The new monitoring layer looks like this:

┌──────────────┐     every 2h      ┌─────────────────────┐
│   cron job   │ ───────────────▶  │  ui_monitor.js      │
└──────────────┘                   │                     │
                                   │  Playwright opens   │
                                   │  real Chromium      │
                                   │                     │
                                   │  7 checks:          │
                                   │  • page loads       │
                                   │  • no error boundary│
                                   │  • clicks navigate  │
                                   └────────┬────────────┘
                                            │
                                   pass     │     fail
                                            │
                                   ─────────┼──────────▶  Telegram alert
                                            │
                                          log
Enter fullscreen mode Exit fullscreen mode

The 7 checks:

PASS: Homepage loads
PASS: Key blog page loads
PASS: Interactive guide loads
PASS: Navigation card click — route A
PASS: Navigation card click — route B
PASS: Laws page loads
PASS: Blog index loads
Enter fullscreen mode Exit fullscreen mode

Each check opens a fresh Chromium context, loads the page, and verifies behaviour — not just HTTP 200, but that the error boundary isn't visible and that clicking a card navigates to the correct route.

Telegram alert on failure:

🚨 UI Monitor — 1 check(s) failed

❌ Navigation card click — route A
   Error boundary visible after click

⏰ 2026-05-12T14:00:00.000Z
Enter fullscreen mode Exit fullscreen mode

One practical detail: the app had an age-verification modal that blocks all pointer events on first visit. The monitor bypasses it by injecting a localStorage key before page load via addInitScript:

await page.addInitScript(() => {
  window.localStorage.setItem('age-verified', 'true');
});
Enter fullscreen mode Exit fullscreen mode

Don't try to click through first-visit gates in tests — just set the state the app checks.

Cron entry:

0 */2 * * * node /path/to/ui_monitor.js >> /path/to/ui_monitor.log 2>&1
Enter fullscreen mode Exit fullscreen mode

What this doesn't cover yet

  • WebKit engine — Playwright Chromium doesn't replicate Safari's JS engine. A WebKit-specific crash would still go undetected. Playwright has a webkit browser option; that's next.
  • Authenticated flows — forms and gated pages aren't tested
  • Performance regressions — slow pages aren't flagged

Full coverage isn't the goal at this stage. The goal was to go from zero synthetic monitoring to something. Seven checks every 2 hours is a meaningful step change.

Takeaway

External monitoring  →  what crawlers see
Synthetic monitoring →  what users experience
Enter fullscreen mode Exit fullscreen mode

They are not the same thing. A page can be indexed, ranking, and receiving impressions while being completely broken for every human who visits it.

Adding Playwright took an afternoon. The cron runs automatically. The next time a dependency ships a breaking change — and it will — I'll know within 2 hours instead of weeks.

This incident pushed me to think about monitoring gaps more broadly — if UI monitoring wasn't there, what else wasn't? The answer was search visibility, which led to wiring GSC directly to Telegram before the site had a single organic click.

Top comments (0)