Jul 5, 2026
A frozen background page behind a modal overlay, the idiomatic router fix that doesn't work given nested routes, the unconventional one that does, and why an AI code reviewer flagged it as a bug anyway.
This portfolio site opens detail pages — portfolio items, articles — in a modal overlay rather than navigating to a new page. That means:
Vue Router's named views handle the overlay itself — a <RouterView name="modal"> renders alongside the default view. The hard part turned out to be keeping the background frozen.
Named views let a route definition supply two components at once: one for the background, one for the modal.
{
path: '/portfolio/:slug',
components: {
default: HomeView, // background
modal: PortfolioDetail
}
}That works as long as a modal is only ever opened from one specific page. It falls apart the moment a modal can be opened from anywhere — the home page, the portfolio listing, a link inside another modal — because the route definition would need to know the origin page in advance, and it can't.
The fix is to capture whatever component was actively rendering in the background RouterView right before the modal navigation happened, and keep rendering that captured component while the modal is open:
const frozenComponent = shallowRef<object | null>(null)
function backgroundComponent(comp: object | null, isModal: boolean): object | null {
if (!isModal && comp) frozenComponent.value = comp
return isModal ? frozenComponent.value : comp
}This is called from the RouterView v-slot, during render — that's the only place the resolved component constructor is actually available; it isn't reachable from <script setup> directly.
The obvious "cleaner" alternative is to capture the background component in a route watcher instead, reading route.matched[0].components.default. That doesn't work here: modal routes are nested children of page routes (/portfolio/:slug is a child of /portfolio), so when you navigate to a modal from the home page, route.matched only contains the portfolio route hierarchy — HomeView was never part of it. A watcher built on route.matched would capture the wrong component, or nothing. The render-time capture works precisely because it records what was actually on screen, not what the URL hierarchy implies should be there.
The same nested-route gap shows up again in a different spot. The page wrapper needs a stable :key so Vue's <Transition> doesn't treat "open a modal" as "navigate to a new page" and replay the page-enter animation underneath the overlay:
const pageKey = ref(route.path)
watch(
() => route.path,
(path) => {
if (!route.meta.modal) {
pageKey.value = path
setNonModalPath(path)
}
},
{ immediate: true },
)Same shape as the component problem: snapshot the value only when a modal isn't active, and reuse the snapshot while one is. Stripping the slug back out of the URL to derive a stable key doesn't work for the same reason route.matched didn't — it only accounts for a modal opened from its own parent page, not one opened from anywhere else. Two different pieces of state (the rendered component, the transition key), the same underlying routing gap, the same fix shape.
Writing to a ref during render is a recognized code smell — in the general case it risks render loops or state corruption. Here it's safe because:
!isModal && comp — while any modal is open, frozenComponent is completely locked and never overwritten.isModal stays true for the whole chain.App.vue to re-render — route changes, pageKey updates — is synchronous and always produces the correct comp. There's no async window where a stale component could slip through.An AI-generated code review flagged the frozenComponent write as "Significant." The report was academically correct — writing to a ref during render is an anti-pattern. But:
route.matched — doesn't work, given how the nested routes are structured.route.matched behaves with nested routes, what actually triggers a Vue re-render, and what the code was trying to do in the first place.Validating that report took longer than the theoretical bug ever would have cost in practice. The review flagged it because it pattern-matched against a known anti-pattern — not because it understood the guards or the constraints that ruled out the idiomatic fix.
"This is a code smell" and "this is a bug" are different claims. An anti-pattern is a shape; a bug is a shape plus a way to actually break. AI code review tools are good at spotting the former and much weaker at the latter, because the latter requires understanding intent — what the code is actually for, and why the obvious alternative doesn't fit. That gap doesn't go away as the tools improve at pattern recognition; it's a different kind of judgment.