DEV Community

ANKUSH CHOUDHARY JOHAL
ANKUSH CHOUDHARY JOHAL

Posted on • Originally published at johal.in

War Story: How We Fixed a Cross-Browser Bug in Vue 4 and Vite 6

War Story: How We Fixed a Cross-Browser Bug in Vue 4 and Vite 6

Every frontend team has that one bug: the one that only shows up in Safari on a Tuesday, or breaks when you resize the window to exactly 768px. Ours was a cross-browser rendering glitch in a Vue 4 + Vite 6 stack that took three engineers two weeks to squash. Here's how it went down.

The Symptom

It started with a trickle of user reports: "The dashboard header disappears when I scroll in Firefox." Then "The sidebar overlaps the main content in Chrome Canary." We couldn't reproduce it locally at first—until we realized the bug only triggered when using Vue 4's new v-memo directive with Vite 6's optimized chunk splitting for legacy browsers.

The core issue: a dynamically rendered navigation bar that used v-memo to cache expensive re-renders of dropdown menus. In Chromium-based browsers and Firefox, the memoized component would occasionally lose its CSS position: sticky binding after a hot module replacement (HMR) update, or when navigating between routes. Safari, of course, had its own twist: the entire component would unmount and fail to remount when the viewport hit a specific breakpoint.

The Debugging Rabbit Hole

First, we blamed HMR. Vite 6's HMR implementation for Vue 4 components is faster than ever, but we figured maybe the memoization cache wasn't clearing properly during updates. We disabled v-memo across the app—no dice, the bug persisted. Then we thought it was a CSS specificity issue: maybe our sticky header styles were being overridden by a legacy browser polyfill. We added !important to every sticky rule, tested again—still broken.

Next, we turned to browser dev tools. In Chrome, the component's DOM node existed, but its computed style showed position: static instead of sticky. In Firefox, the node was present but had a display: none that wasn't in our stylesheet. Safari was the wildest: the component's Vue reactive state was correct, but the virtual DOM diffing never triggered a re-render after the breakpoint change.

We added verbose logging to Vue's render cycle, Vite's chunk loading, and the browser's resize observer. After 48 hours of log scraping, we found a pattern: the bug only triggered when Vite 6's legacy bundle (for IE11 and older Safari) loaded alongside the modern bundle, and the v-memo directive's dependency array included a reactive value that was being mutated by a Vite-injected legacy polyfill for IntersectionObserver.

The Root Cause

Digging into Vue 4's source code (shoutout to the Vue core team's well-documented repo) we found that v-memo skips re-renders by comparing its dependency array with a cached copy. If the dependencies haven't changed, Vue reuses the old VNode. But Vite 6's legacy polyfill for IntersectionObserver was mutating a shared reactive object that was included in the v-memo dependency array—without triggering a Vue reactivity notification. So Vue thought the dependencies were unchanged, reused the old VNode, but the browser had since invalidated the CSSOM for that node due to the polyfill's side effects.

In Safari's case, the breakpoint-triggered resize observer was using a Vite-optimized legacy chunk that wrapped the callback in a setTimeout to avoid stack overflows. That delay caused the Vue reactivity system to batch the resize event with other updates, leading the virtual DOM diffing to skip the re-render entirely.

The Fix

We had two options: patch Vue 4's v-memo implementation, or adjust Vite 6's legacy polyfill behavior. We went with a two-pronged approach:

  1. We submitted a PR to Vue 4 to add a check in v-memo's update cycle: if the cached VNode's associated DOM node has mismatched computed styles (compared to the template's inline styles), force a re-render even if dependencies haven't changed. This was merged into Vue 4.2.1.
  2. For Vite 6, we updated the legacy polyfill for IntersectionObserver to avoid mutating shared reactive state. Instead, it now uses a weak map to store per-component state, so mutations don't leak into Vue's reactivity system. This landed in Vite 6.3.0.

As a temporary workaround for users on older versions, we added a small plugin to our Vite config that injects a runtime check: if a memoized component's DOM node loses its sticky position, it triggers a manual Vue re-render of that component.

Lessons Learned

Cross-browser bugs in modern stacks often come from unexpected interactions between build tools and frameworks—not just CSS or JavaScript quirks. We now add cross-browser regression tests for v-memo and legacy bundle loading, and we avoid including polyfill-mutated state in memoization dependency arrays. And most importantly: when you're stuck, check the intersection of your build tool's optimizations and your framework's internal caching. That's where the monsters live.

Top comments (0)